diff --git a/Cargo.lock b/Cargo.lock index 636bff28..18e4a71d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1537,6 +1537,32 @@ dependencies = [ "uuid", ] +[[package]] +name = "aws-sdk-bedrock" +version = "1.150.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81186bb96a4e98ff93f7b4336deec0afc1f90ca282099fc5395fcf5de6c0389d" +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-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand 2.5.0", + "http 0.2.12", + "http 1.5.0", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-bedrockruntime" version = "1.138.0" @@ -5647,6 +5673,7 @@ dependencies = [ "async-trait", "aws-config", "aws-credential-types", + "aws-sdk-bedrock", "aws-sdk-bedrockruntime", "aws-sdk-sts", "aws-smithy-types", @@ -12848,9 +12875,9 @@ dependencies = [ [[package]] name = "rig-bedrock" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10e8ee8d206e78398eca2db97cb0cf27f43c903a99c60a730bc7a0cfeaf3ee83" +checksum = "9a40dec6d2833da0f482f97f105b846b46b82b8ab904b78f2365e263e016f09d" dependencies = [ "async-stream", "aws-config", @@ -12864,14 +12891,15 @@ dependencies = [ "serde_json", "tokio", "tracing", + "tracing-futures", "uuid", ] [[package]] name = "rig-core" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8731dd5532b3a12ce1613af73073fb2051ef750f50c504778c21d55ae933cac" +checksum = "35f5520515ae8f6851adcbc6fde9eea8e96f657418c062e16c82cd81cce44e8e" dependencies = [ "as-any", "async-stream", @@ -12903,16 +12931,14 @@ dependencies = [ [[package]] name = "rig-derive" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e98dde7a4e59e083e7396126ee4c83498c5bff605d126654e67815fa230a78" +checksum = "eb868fcebdf3ba425e3afad2e4926bb6d9e1188a856843b00bcee2e15c07424f" dependencies = [ "convert_case 0.11.0", - "indoc", "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "serde_json", "syn 2.0.119", ] diff --git a/Cargo.toml b/Cargo.toml index 7794aa9c..df0520b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,6 +139,7 @@ async-task = "4.2.0" async-trait = "0.1.89" async-fs = "2.1.2" aws-sdk-bedrockruntime = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] } +aws-sdk-bedrock = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] } aws-smithy-http-client = { version = "1", features = ["test-util"] } backtrace = "0.3.76" base64 = "0.22" @@ -261,8 +262,8 @@ reqwest = { version = "0.13", features = [ "stream", ] } reqwest-eventsource = { package = "aha-reqwest-eventsource", version = "0.1" } -rig-core = "=0.40.0" -rig-bedrock = "=0.40.0" +rig-core = "=0.41.0" +rig-bedrock = "=0.41.0" resvg = "0.47.0" rust-embed = { version = "8.7.0", features = ["include-exclude"] } rustc-hash = "2.1.1" diff --git a/app/Cargo.toml b/app/Cargo.toml index 847d639f..1cfed747 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -328,6 +328,7 @@ tracing-subscriber.workspace = true # AWS SDK (loading credentials for BYO LLM) aws-config = { version = "1.8.16", features = ["credentials-login"] } aws-credential-types = "1" +aws-sdk-bedrock.workspace = true aws-sdk-bedrockruntime.workspace = true aws-sdk-sts = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] } aws-smithy-types = "1" diff --git a/app/src/ai/acp/runtime_model.rs b/app/src/ai/acp/runtime_model.rs index a5559278..65fc308f 100644 --- a/app/src/ai/acp/runtime_model.rs +++ b/app/src/ai/acp/runtime_model.rs @@ -86,17 +86,12 @@ impl AcpRuntimeModel { Ok(manager) } - pub(crate) fn discovery_config(settings: &AISettings) -> Result { - let agent_id = if settings.acp_agent_id.value().trim().is_empty() { - "codex" - } else { - settings.acp_agent_id.value().trim() - }; - let launch = crate::ai::acp::resolve_acp_launch( - agent_id, - settings.acp_agent_command.value(), - settings.acp_agent_args.value(), - )?; + pub(crate) fn discovery_config_for_values( + agent_id: &str, + command: &str, + args: &[String], + ) -> Result { + let launch = crate::ai::acp::resolve_acp_launch(agent_id, command, args)?; Ok(AcpManagerConfig::new(launch)) } diff --git a/app/src/ai/bedrock/discovery.rs b/app/src/ai/bedrock/discovery.rs new file mode 100644 index 00000000..91523fd2 --- /dev/null +++ b/app/src/ai/bedrock/discovery.rs @@ -0,0 +1,193 @@ +//! AWS Bedrock control-plane discovery. +//! +//! The foundation-model catalog is only a candidate list. Every candidate is +//! checked with `GetFoundationModelAvailability` before it is offered to the +//! user or persisted in Galaxy settings. + +use aws_config::BehaviorVersion; +use aws_sdk_bedrock::Client; +use aws_sdk_bedrockruntime::config::Region; + +use super::client::{BedrockClientConfig, BedrockError}; +use crate::settings::ai::BedrockModelConfig; + +pub async fn discover_available_models( + config: BedrockClientConfig, +) -> Result, String> { + 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}"))?; + + let mut models = Vec::new(); + for summary in catalog.model_summaries() { + let model_id = summary.model_id(); + let availability = match client + .get_foundation_model_availability() + .model_id(model_id) + .send() + .await + { + Ok(availability) => availability, + Err(error) => { + log::debug!( + "[bedrock] Availability check failed for {model_id}; excluding model: {error}" + ); + continue; + } + }; + + 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(), + ); + continue; + } + + let display_name = summary + .model_name() + .map(str::to_owned) + .unwrap_or_else(|| prettify_model_id(model_id)); + let vision_supported = summary + .input_modalities() + .iter() + .any(|modality| modality.as_str() == "IMAGE"); + + models.push(BedrockModelConfig { + model_id: model_id.to_owned(), + display_name, + vision_supported, + use_rig: false, + }); + } + + 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) +} + +fn model_availability_is_usable( + agreement_status: Option<&str>, + authorization_status: &str, + entitlement_status: &str, + region_status: &str, +) -> bool { + agreement_status == Some("AVAILABLE") + && authorization_status == "AUTHORIZED" + && entitlement_status == "AVAILABLE" + && region_status == "AVAILABLE" +} + +async fn load_aws_config( + config: &BedrockClientConfig, +) -> Result { + let sdk_config = match config.auth_method { + 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" { + loader = loader.profile_name(&config.profile); + } + if !config.region.is_empty() { + loader = loader.region(Region::new(config.region.clone())); + } + loader.load().await + } + crate::settings::ai::BedrockAuthMethod::StaticKeys => { + if config.access_key_id.is_empty() || config.secret_access_key.is_empty() { + return Err(BedrockError::CredentialsNotConfigured); + } + let credentials = aws_credential_types::Credentials::new( + &config.access_key_id, + &config.secret_access_key, + config.session_token.clone(), + None, + "galaxy-bedrock-discovery", + ); + let mut loader = + aws_config::defaults(BehaviorVersion::latest()).credentials_provider(credentials); + loader = loader.region(Region::new(if config.region.is_empty() { + "us-east-1".to_string() + } else { + config.region.clone() + })); + loader.load().await + } + }; + + if sdk_config.region().is_none() { + return Err(BedrockError::RegionNotConfigured); + } + Ok(sdk_config) +} + +fn prettify_model_id(model_id: &str) -> String { + model_id + .rsplit('.') + .next() + .unwrap_or(model_id) + .replace(['-', ':'], " ") +} + +#[cfg(test)] +mod tests { + use super::model_availability_is_usable; + + #[test] + fn requires_every_availability_status() { + assert!(model_availability_is_usable( + Some("AVAILABLE"), + "AUTHORIZED", + "AVAILABLE", + "AVAILABLE", + )); + assert!(!model_availability_is_usable( + None, + "AUTHORIZED", + "AVAILABLE", + "AVAILABLE", + )); + assert!(!model_availability_is_usable( + Some("AVAILABLE"), + "NOT_AUTHORIZED", + "AVAILABLE", + "AVAILABLE", + )); + assert!(!model_availability_is_usable( + Some("AVAILABLE"), + "AUTHORIZED", + "NOT_AVAILABLE", + "AVAILABLE", + )); + assert!(!model_availability_is_usable( + Some("AVAILABLE"), + "AUTHORIZED", + "AVAILABLE", + "NOT_AVAILABLE", + )); + } +} diff --git a/app/src/ai/bedrock/mod.rs b/app/src/ai/bedrock/mod.rs index 38c975a6..4ebfe0a3 100644 --- a/app/src/ai/bedrock/mod.rs +++ b/app/src/ai/bedrock/mod.rs @@ -2,6 +2,7 @@ pub mod client; pub mod convert; pub mod crash_log; pub mod diagnostic; +pub mod discovery; pub mod external_config; pub mod models; pub mod request_translator; diff --git a/app/src/ai/bedrock/models.rs b/app/src/ai/bedrock/models.rs index acbc5abd..af73d25b 100644 --- a/app/src/ai/bedrock/models.rs +++ b/app/src/ai/bedrock/models.rs @@ -1,156 +1,7 @@ #![allow(dead_code)] -use super::external_config::ExternalBedrockConfig; use crate::settings::ai::BedrockModelConfig; -pub struct DefaultModel { - pub model_id: &'static str, - pub display_name: &'static str, - pub vision_supported: bool, - pub context_size: u32, -} - -pub const DEFAULT_BEDROCK_MODELS: &[DefaultModel] = &[ - DefaultModel { - model_id: "us.anthropic.claude-opus-4-6-v1[1m]", - display_name: "Claude Opus 4.6 (1M)", - vision_supported: true, - context_size: 1_000_000, - }, - DefaultModel { - model_id: "us.anthropic.claude-sonnet-4-6[1m]", - display_name: "Claude Sonnet 4.6 (1M)", - vision_supported: true, - context_size: 1_000_000, - }, - DefaultModel { - model_id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", - display_name: "Claude Sonnet 4.5", - vision_supported: true, - context_size: 200_000, - }, - DefaultModel { - model_id: "us.anthropic.claude-sonnet-4-20250514-v1:0", - display_name: "Claude Sonnet 4", - vision_supported: true, - context_size: 200_000, - }, - DefaultModel { - model_id: "us.anthropic.claude-3-sonnet-20240229-v1:0", - display_name: "Claude 3 Sonnet", - vision_supported: true, - context_size: 200_000, - }, - DefaultModel { - model_id: "global.anthropic.claude-sonnet-4-6", - display_name: "Claude Sonnet 4.6 (Global)", - vision_supported: true, - context_size: 200_000, - }, - DefaultModel { - model_id: "global.anthropic.claude-sonnet-4-5-20250929-v1:0", - display_name: "Claude Sonnet 4.5 (Global)", - vision_supported: true, - context_size: 200_000, - }, - DefaultModel { - model_id: "global.anthropic.claude-sonnet-4-20250514-v1:0", - display_name: "Claude Sonnet 4 (Global)", - vision_supported: true, - context_size: 200_000, - }, - DefaultModel { - model_id: "us.anthropic.claude-opus-4-5-20251101-v1:0", - display_name: "Claude Opus 4.5", - vision_supported: true, - context_size: 200_000, - }, - DefaultModel { - model_id: "us.anthropic.claude-opus-4-1-20250805-v1:0", - display_name: "Claude Opus 4.1", - vision_supported: true, - context_size: 200_000, - }, - DefaultModel { - model_id: "global.anthropic.claude-opus-4-6-v1", - display_name: "Claude Opus 4.6 (Global)", - vision_supported: true, - context_size: 200_000, - }, - DefaultModel { - model_id: "global.anthropic.claude-opus-4-5-20251101-v1:0", - display_name: "Claude Opus 4.5 (Global)", - vision_supported: true, - context_size: 200_000, - }, - DefaultModel { - model_id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", - display_name: "Claude Haiku 4.5", - vision_supported: true, - context_size: 200_000, - }, - DefaultModel { - model_id: "us.anthropic.claude-3-haiku-20240307-v1:0", - display_name: "Claude 3 Haiku", - vision_supported: true, - context_size: 200_000, - }, - DefaultModel { - model_id: "us.anthropic.claude-3-5-haiku-20241022-v1:0", - display_name: "Claude 3.5 Haiku", - vision_supported: true, - context_size: 200_000, - }, - DefaultModel { - model_id: "global.anthropic.claude-haiku-4-5-20251001-v1:0", - display_name: "Claude Haiku 4.5 (Global)", - vision_supported: true, - context_size: 200_000, - }, -]; - -pub fn get_effective_models(user_models: &[BedrockModelConfig]) -> Vec { - if !user_models.is_empty() { - return user_models.to_vec(); - } - - // Fall back to models from external configs (Claude Code / OpenCode) - let external = ExternalBedrockConfig::load(); - if !external.models.is_empty() { - log::info!( - "[bedrock] Using {} model(s) from external config", - external.models.len() - ); - // Merge external models with defaults so the user still sees all defaults - let mut models = external.models; - let defaults: Vec = DEFAULT_BEDROCK_MODELS - .iter() - .map(|m| BedrockModelConfig { - model_id: m.model_id.to_string(), - display_name: m.display_name.to_string(), - vision_supported: m.vision_supported, - use_rig: false, - }) - .collect(); - for default in defaults { - if !models.iter().any(|m| m.model_id == default.model_id) { - models.push(default); - } - } - return models; - } - - DEFAULT_BEDROCK_MODELS - .iter() - .map(|m| BedrockModelConfig { - model_id: m.model_id.to_string(), - display_name: m.display_name.to_string(), - vision_supported: m.vision_supported, - use_rig: false, - }) - .collect() -} - pub fn configured_model_uses_rig( selected_model_id: &str, configured_models: &[BedrockModelConfig], diff --git a/app/src/ai/bedrock/models_tests.rs b/app/src/ai/bedrock/models_tests.rs index c283a0a3..55f927ae 100644 --- a/app/src/ai/bedrock/models_tests.rs +++ b/app/src/ai/bedrock/models_tests.rs @@ -78,27 +78,6 @@ fn test_cross_region_prefix_unknown_region() { ); } -#[test] -fn test_get_effective_models_empty_returns_defaults() { - let models = get_effective_models(&[]); - assert_eq!(models.len(), DEFAULT_BEDROCK_MODELS.len()); - assert_eq!(models[0].model_id, "us.anthropic.claude-opus-4-6-v1[1m]"); - assert_eq!(models[0].display_name, "Claude Opus 4.6 (1M)"); -} - -#[test] -fn test_get_effective_models_custom_overrides() { - let custom = vec![BedrockModelConfig { - model_id: "custom.model-v1:0".to_string(), - display_name: "Custom Model".to_string(), - vision_supported: false, - use_rig: true, - }]; - let models = get_effective_models(&custom); - assert_eq!(models.len(), 1); - assert_eq!(models[0].model_id, "custom.model-v1:0"); -} - #[test] fn test_cross_region_prefix_skips_arn() { let arn = "arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/fma5bsw4oxhy"; diff --git a/app/src/ai/blocklist/controller/response_stream.rs b/app/src/ai/blocklist/controller/response_stream.rs index 9eb745c1..f6395947 100644 --- a/app/src/ai/blocklist/controller/response_stream.rs +++ b/app/src/ai/blocklist/controller/response_stream.rs @@ -227,9 +227,14 @@ impl ResponseStream { let llm_prefs = LLMPreferences::as_ref(ctx); if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) { return ProviderConfig::OpenAI(OpenAIClientConfig { + kind: client_config.kind, base_url: client_config.base_url.clone(), api_key: client_config.api_key.clone(), - model: Some(model_id.to_string()), + model: client_config + .model + .clone() + .or_else(|| Some(model_id.to_string())), + reasoning_effort: client_config.reasoning_effort.clone(), max_input_tokens: client_config.max_input_tokens, max_output_tokens: client_config.max_output_tokens, use_rig: client_config.use_rig, diff --git a/app/src/ai/chatgpt_auth.rs b/app/src/ai/chatgpt_auth.rs new file mode 100644 index 00000000..49311455 --- /dev/null +++ b/app/src/ai/chatgpt_auth.rs @@ -0,0 +1,101 @@ +//! ChatGPT subscription OAuth state used by the AI settings page. + +use async_channel::unbounded; +use galaxy_agent_rig::{ChatGPTDeviceCode, ChatGPTSubscriptionClient}; +use galaxyui::{Entity, ModelContext, SingletonEntity}; + +/// Current state of the local ChatGPT subscription connection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ChatGPTAuthState { + NotConnected, + Connecting, + AwaitingDeviceCode { + verification_uri: String, + user_code: String, + }, + Connected, + Failed(String), +} + +enum ChatGPTAuthEvent { + DeviceCode(ChatGPTDeviceCode), + Completed(Result<(), String>), +} + +#[derive(Clone, Debug)] +pub(crate) enum ChatGPTAuthModelEvent { + StateChanged, +} + +/// Coordinates Rig's device authorization flow with Galaxy UI. +pub(crate) struct ChatGPTAuthModel { + state: ChatGPTAuthState, +} + +impl ChatGPTAuthModel { + pub(crate) fn new() -> Self { + Self { + state: ChatGPTAuthState::NotConnected, + } + } + + pub(crate) fn state(&self) -> &ChatGPTAuthState { + &self.state + } + + pub(crate) fn connect(&mut self, ctx: &mut ModelContext) { + if matches!( + self.state, + ChatGPTAuthState::Connecting | ChatGPTAuthState::AwaitingDeviceCode { .. } + ) { + return; + } + + self.state = ChatGPTAuthState::Connecting; + ctx.emit(ChatGPTAuthModelEvent::StateChanged); + + let (event_tx, event_rx) = unbounded(); + let device_code_tx = event_tx.clone(); + let _ = ctx.spawn_stream_local( + event_rx, + |model, event, ctx| { + match event { + ChatGPTAuthEvent::DeviceCode(code) => { + model.state = ChatGPTAuthState::AwaitingDeviceCode { + verification_uri: code.verification_uri, + user_code: code.user_code, + }; + } + ChatGPTAuthEvent::Completed(result) => { + model.state = match result { + Ok(()) => ChatGPTAuthState::Connected, + Err(error) => ChatGPTAuthState::Failed(error), + }; + } + } + ctx.emit(ChatGPTAuthModelEvent::StateChanged); + }, + |_, _| {}, + ); + + let _ = ctx.spawn( + async move { + let result = + match ChatGPTSubscriptionClient::with_device_code_handler(move |code| { + let _ = device_code_tx.try_send(ChatGPTAuthEvent::DeviceCode(code)); + }) { + Ok(client) => client.authorize().await, + Err(error) => Err(error), + }; + let _ = event_tx.send(ChatGPTAuthEvent::Completed(result)).await; + }, + |_, _, _| {}, + ); + } +} + +impl Entity for ChatGPTAuthModel { + type Event = ChatGPTAuthModelEvent; +} + +impl SingletonEntity for ChatGPTAuthModel {} diff --git a/app/src/ai/crosscheck/reviewer.rs b/app/src/ai/crosscheck/reviewer.rs index 07cfef74..36b314ba 100644 --- a/app/src/ai/crosscheck/reviewer.rs +++ b/app/src/ai/crosscheck/reviewer.rs @@ -14,6 +14,7 @@ use crate::ai::agent::conversation::AIConversationId; use crate::ai::llms::LLMPreferences; use crate::ai::openai::client::{OpenAIClient, OpenAIClientConfig}; use crate::ai::provider::ProviderConfig; +use crate::settings::OpenAIProviderKind; use crate::AISettings; /// Maximum default iterations if the setting is somehow zero. @@ -163,9 +164,14 @@ impl CrosscheckReviewer { let llm_prefs = LLMPreferences::as_ref(ctx); if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) { return ProviderConfig::OpenAI(OpenAIClientConfig { + kind: client_config.kind, base_url: client_config.base_url.clone(), api_key: client_config.api_key.clone(), - model: Some(model_id.to_string()), + model: client_config + .model + .clone() + .or_else(|| Some(model_id.to_string())), + reasoning_effort: client_config.reasoning_effort.clone(), max_input_tokens: client_config.max_input_tokens, max_output_tokens: Some(REVIEWER_MAX_OUTPUT_TOKENS), use_rig: client_config.use_rig, @@ -217,6 +223,26 @@ impl CrosscheckReviewer { provider_config: ProviderConfig, ) -> Result { match provider_config { + ProviderConfig::OpenAI(config) + if config.kind == OpenAIProviderKind::ChatGPTSubscription => + { + let runtime = galaxy_agent_rig::ChatGPTSubscriptionRuntime::new( + galaxy_agent_rig::ChatGPTSubscriptionRuntimeConfig { + model: config.model.unwrap_or(model_id), + reasoning_effort: config.reasoning_effort, + max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)), + auth_file: None, + }, + ); + runtime + .complete_text( + prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(), + format!( + "Please review the following agent output:\n\n---\n\n{agent_output}" + ), + ) + .await + } ProviderConfig::OpenAI(config) => { Self::invoke_via_openai(agent_output, model_id, config).await } diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 20636ee5..c6a4ac42 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -17,14 +17,16 @@ use warp_multi_agent_api as api; use super::custom_model_routers::{self, CustomModelRouter, ModelConfigError}; use super::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::acp::{acp_launch_fingerprint, acp_selection_identity}; -use crate::ai::bedrock::models::get_effective_models; use crate::auth::auth_manager::{AuthManager, AuthManagerEvent}; use crate::auth::AuthStateProvider; use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind}; #[cfg(not(target_family = "wasm"))] use crate::persistence::model::{AcpConversationData, AgentBackend}; use crate::server::server_api::ServerApiProvider; -use crate::settings::{AcpConfigValueSettings, BedrockModelConfig, OpenAIModelConfig}; +use crate::settings::{ + AcpConfigValueSettings, BedrockModelConfig, OpenAIModelConfig, OpenAIProviderConfig, + OpenAIProviderKind, +}; use crate::user_config::{WarpConfig, WarpConfigUpdateEvent}; use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; use crate::{report_error, AISettings}; @@ -659,6 +661,7 @@ impl LLMPreferences { | AISettingsChangedEvent::OpenAIProviders { .. } | AISettingsChangedEvent::AcpAgents { .. } | AISettingsChangedEvent::AcpAgentId { .. } + | AISettingsChangedEvent::BedrockModels { .. } ) { me.inject_bedrock_models(ctx); me.inject_openai_models(ctx); @@ -670,6 +673,11 @@ 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); + } // Safety: ensure the default model is still present in choices. // If all provider models were removed, the default_id would dangle. me.ensure_default_model_present(); @@ -709,8 +717,8 @@ impl LLMPreferences { #[cfg(not(target_family = "wasm"))] { - Self::ensure_default_models_in_settings(ctx); - me.inject_bedrock_models(ctx); + Self::ensure_default_chatgpt_models_in_settings(ctx); + me.refresh_bedrock_models(ctx); me.inject_openai_models(ctx); me.ensure_default_model_present(); me.fetch_openai_models_from_endpoint(ctx); @@ -720,39 +728,85 @@ impl LLMPreferences { } #[cfg(not(target_family = "wasm"))] - fn ensure_default_models_in_settings(ctx: &mut ModelContext) { - use crate::ai::bedrock::models::DEFAULT_BEDROCK_MODELS; + fn ensure_default_chatgpt_models_in_settings(ctx: &mut ModelContext) { + let mut providers = AISettings::as_ref(ctx).openai_providers.value().clone(); + let default_chatgpt_models = crate::settings::ai::default_chatgpt_provider().models; + let mut providers_changed = false; + for provider in &mut providers { + if provider.kind != OpenAIProviderKind::ChatGPTSubscription { + continue; + } - let settings = AISettings::as_ref(ctx); - let mut current_models: Vec = settings.bedrock_models.value().clone(); + for default_model in &default_chatgpt_models { + if !provider + .models + .iter() + .any(|model| model.model_id == default_model.model_id) + { + provider.models.push(default_model.clone()); + providers_changed = true; + } + } - let existing_ids: std::collections::HashSet = - current_models.iter().map(|m| m.model_id.clone()).collect(); - - let mut added = false; - for default in DEFAULT_BEDROCK_MODELS { - if !existing_ids.contains(default.model_id as &str) { - current_models.push(BedrockModelConfig { - model_id: default.model_id.to_string(), - display_name: default.display_name.to_string(), - vision_supported: default.vision_supported, - use_rig: false, - }); - added = true; + for model in &mut provider.models { + if !model.reasoning_efforts.is_empty() { + continue; + } + if let Some(default_model) = default_chatgpt_models + .iter() + .find(|default_model| default_model.model_id == model.model_id) + { + if !default_model.reasoning_efforts.is_empty() { + model.reasoning_efforts = default_model.reasoning_efforts.clone(); + providers_changed = true; + } + } } } - - if added { - log::info!( - "[bedrock] Added missing default models to settings — now {} total", - current_models.len() - ); + if providers_changed { AISettings::handle(ctx).update(ctx, |settings, ctx| { - let _ = settings.bedrock_models.set_value(current_models, ctx); + let _ = settings.openai_providers.set_value(providers, ctx); }); } } + #[cfg(not(target_family = "wasm"))] + fn refresh_bedrock_models(&mut self, ctx: &mut ModelContext) { + let settings = AISettings::as_ref(ctx); + if !*settings.bedrock_enabled.value() { + return; + } + let config = crate::ai::bedrock::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(), + use_rig: false, + }; + + let _ = ctx.spawn( + async move { crate::ai::bedrock::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); + } + Err(error) => { + log::debug!("[bedrock] Startup model discovery unavailable: {error}"); + } + }, + ); + } + #[cfg(not(target_family = "wasm"))] fn inject_bedrock_models(&mut self, ctx: &AppContext) { // Galaxy's runtime inventory is rebuilt exclusively from enabled local @@ -768,7 +822,9 @@ impl LLMPreferences { return; } - let user_models: Vec = settings.bedrock_models.value().clone(); + // Bedrock models are populated only by the control-plane discovery + // flow. Never fall back to a static catalog or external config here. + let discovered_models: Vec = settings.bedrock_models.value().clone(); let region = settings.bedrock_region.value().clone(); let cross_region = *settings.bedrock_cross_region_inference.value(); @@ -777,7 +833,7 @@ impl LLMPreferences { let external_config = ExternalBedrockConfig::load(); let require_1h_cache = external_config.enable_prompt_caching_1h; - let mut effective = get_effective_models(&user_models); + let mut effective = discovered_models; // Filter out models that don't support 1-hour caching if required if require_1h_cache { @@ -943,8 +999,15 @@ impl LLMPreferences { return; } - let mut provider_entries: Vec<(String, String, Option, Vec)> = - Vec::new(); + type OpenAIProviderEntry = ( + String, + OpenAIProviderKind, + bool, + String, + Option, + Vec, + ); + let mut provider_entries: Vec = Vec::new(); let configured_models = settings.openai_models.value().clone(); let single_provider_models = if configured_models.is_empty() { @@ -968,7 +1031,14 @@ impl LLMPreferences { } else { "LiteLLM".to_string() }; - provider_entries.push((name, base_url, api_key, single_provider_models)); + provider_entries.push(( + name, + OpenAIProviderKind::OpenAICompatible, + true, + base_url, + api_key, + single_provider_models, + )); } provider_entries.extend( @@ -977,11 +1047,17 @@ impl LLMPreferences { .value() .iter() .filter_map(|provider| { - if provider.base_url.trim().is_empty() || provider.models.is_empty() { + if !provider.enabled + || (provider.kind == OpenAIProviderKind::OpenAICompatible + && provider.base_url.trim().is_empty()) + || provider.models.is_empty() + { return None; } Some(( provider.name.clone(), + provider.kind, + provider.enabled, provider.base_url.clone(), provider.api_key.clone(), provider.models.clone(), @@ -995,58 +1071,93 @@ impl LLMPreferences { let mut total_injected = 0; let mut seen_model_ids: HashSet = HashSet::new(); - for (provider_name, base_url, api_key, models) in provider_entries { + for (provider_name, provider_kind, provider_enabled, base_url, api_key, models) in + provider_entries + { + if !provider_enabled { + continue; + } for model in &models { + if !model.enabled { + continue; + } if !seen_model_ids.insert(model.model_id.clone()) { continue; } - // Register the routing entry - let client_config = OpenAIClientConfig { - base_url: base_url.clone(), - api_key: api_key.clone(), - model: None, // filled per-request from model_id - max_input_tokens: Some(openai_model_context_size(model)), - max_output_tokens: model.max_output_tokens, - use_rig: model.use_rig, - supports_system_messages: model.supports_system_messages(), - }; - self.openai_provider_routing - .insert(model.model_id.clone(), client_config); + let reasoning_efforts: Vec> = + if provider_kind == OpenAIProviderKind::ChatGPTSubscription { + // Keep the base model as the provider-default mode, then expose each + // explicitly supported effort as a separate selectable variant. + std::iter::once(None) + .chain(model.reasoning_efforts.iter().map(Some)) + .collect() + } else { + vec![None] + }; - let llm_info = LLMInfo { - id: LLMId::from(model.model_id.as_str()), - display_name: model.display_name.clone(), - base_model_name: model.display_name.clone(), - reasoning_level: None, - usage_metadata: LLMUsageMetadata { - request_multiplier: 1, - credit_multiplier: None, - }, - description: Some(provider_name.clone()), - disable_reason: None, - vision_supported: model.vision_supported, - spec: None, - provider: LLMProvider::LiteLLM, - host_configs: HashMap::from([( - LLMModelHost::DirectApi, - RoutingHostConfig { - enabled: true, - model_routing_host: LLMModelHost::DirectApi, + for reasoning_effort in reasoning_efforts { + let reasoning_effort = reasoning_effort.cloned(); + let model_key = reasoning_effort.as_deref().map_or_else( + || model.model_id.clone(), + |effort| openai_model_variant_id(&model.model_id, effort), + ); + + // Register the routing entry. Reasoning variants keep the provider's + // actual model ID while using their synthetic key only for selection. + let client_config = OpenAIClientConfig { + kind: provider_kind, + base_url: base_url.clone(), + api_key: api_key.clone(), + model: Some(model.model_id.clone()), + reasoning_effort: reasoning_effort.clone(), + max_input_tokens: Some(openai_model_context_size(model)), + max_output_tokens: model.max_output_tokens, + use_rig: model.use_rig + || provider_kind == OpenAIProviderKind::ChatGPTSubscription, + supports_system_messages: model.supports_system_messages(), + }; + self.openai_provider_routing + .insert(model_key.clone(), client_config); + + let display_name = reasoning_effort.as_deref().map_or_else( + || model.display_name.clone(), + |effort| format!("{} ({effort})", model.display_name), + ); + let llm_info = LLMInfo { + id: LLMId::from(model_key.as_str()), + display_name, + base_model_name: model.display_name.clone(), + reasoning_level: reasoning_effort, + usage_metadata: LLMUsageMetadata { + request_multiplier: 1, + credit_multiplier: None, }, - )]), - discount_percentage: None, - context_window: openai_model_context_window(model), - }; - self.models_by_feature - .agent_mode - .choices - .push(llm_info.clone()); - self.models_by_feature.coding.choices.push(llm_info.clone()); - if let Some(ref mut cli) = self.models_by_feature.cli_agent { - cli.choices.push(llm_info); + description: Some(provider_name.clone()), + disable_reason: None, + vision_supported: model.vision_supported, + spec: None, + provider: LLMProvider::LiteLLM, + host_configs: HashMap::from([( + LLMModelHost::DirectApi, + RoutingHostConfig { + enabled: true, + model_routing_host: LLMModelHost::DirectApi, + }, + )]), + discount_percentage: None, + context_window: openai_model_context_window(model), + }; + self.models_by_feature + .agent_mode + .choices + .push(llm_info.clone()); + self.models_by_feature.coding.choices.push(llm_info.clone()); + if let Some(ref mut cli) = self.models_by_feature.cli_agent { + cli.choices.push(llm_info); + } + total_injected += 1; } - total_injected += 1; } } @@ -1438,6 +1549,43 @@ impl LLMPreferences { ); } + /// Discovers models for a provider draft without persisting or injecting it. + /// + /// The provider setup modal uses this to keep configuration changes atomic + /// until the user clicks Save. + #[cfg(not(target_family = "wasm"))] + pub(crate) async fn discover_openai_provider_models( + provider: OpenAIProviderConfig, + ) -> Result, String> { + if provider.base_url.trim().is_empty() { + return Err("Enter a provider URL before testing the connection.".to_string()); + } + + let base_url = provider.base_url.trim_end_matches('/').to_string(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|error| format!("Could not create the provider client: {error}"))?; + let api_key = provider.api_key.as_deref().filter(|key| !key.is_empty()); + + let models = if let Some(models) = + fetch_from_litellm_model_info(&base_url, api_key, &client).await + { + models + } else { + fetch_from_openai_models(&base_url, api_key, &client).await + }; + + if models.is_empty() { + return Err( + "The provider responded, but no models were found at /model/info or /models." + .to_string(), + ); + } + + Ok(models) + } + /// Returns the `LLMInfo` for the base LLM to be used for an Agent Mode request. pub fn get_active_base_model<'a>( &'a self, @@ -2228,7 +2376,7 @@ fn openai_model_context_size(model: &OpenAIModelConfig) -> u32 { /// Merges endpoint metadata into a provider's configured models without /// discarding local routing choices or manually configured models. #[cfg(not(target_family = "wasm"))] -fn merge_discovered_provider_models( +pub(crate) fn merge_discovered_provider_models( existing_models: &[OpenAIModelConfig], discovered_models: Vec, ) -> Vec { @@ -2245,6 +2393,7 @@ fn merge_discovered_provider_models( .find(|model| model.model_id == discovered.model_id) { discovered.display_name = existing.display_name.clone(); + discovered.enabled = existing.enabled; discovered.use_rig = existing.use_rig; if existing.supports_system_messages.is_some() { discovered.supports_system_messages = existing.supports_system_messages; @@ -2271,6 +2420,11 @@ fn merge_discovered_provider_models( merged } +#[cfg(not(target_family = "wasm"))] +fn openai_model_variant_id(model_id: &str, reasoning_effort: &str) -> String { + format!("{model_id}::reasoning::{reasoning_effort}") +} + #[cfg(not(target_family = "wasm"))] fn openai_model_context_window(model: &OpenAIModelConfig) -> LLMContextWindow { let context_size = openai_model_context_size(model); @@ -2400,6 +2554,8 @@ async fn fetch_from_litellm_model_info( } else { model_info["supports_system_messages"].as_bool() }, + reasoning_efforts: Vec::new(), + enabled: true, }) }) .collect(); @@ -2527,6 +2683,8 @@ async fn fetch_from_openai_models( } else { m["supports_system_messages"].as_bool() }, + reasoning_efforts: Vec::new(), + enabled: true, }) }) .collect(); diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index 9101ae41..f4bd2ce7 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -152,6 +152,8 @@ fn openai_model(model_id: &str) -> OpenAIModelConfig { provider: None, use_rig: false, supports_system_messages: None, + reasoning_efforts: Vec::new(), + enabled: true, } } @@ -556,6 +558,60 @@ fn disabled_providers_do_not_leave_models_in_the_runtime_inventory() { }); } +#[test] +fn chatgpt_reasoning_modes_route_to_the_base_model_with_effort_metadata() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + AISettings::handle(&app).update(&mut app, |settings, ctx| { + settings + .bedrock_enabled + .set_value(false, ctx) + .expect("Bedrock setting should update"); + settings + .acp_enabled + .set_value(false, ctx) + .expect("ACP setting should update"); + settings + .openai_enabled + .set_value(true, ctx) + .expect("OpenAI setting should update"); + settings + .openai_models + .set_value(Vec::new(), ctx) + .expect("OpenAI model setting should update"); + settings + .openai_providers + .set_value(vec![crate::settings::ai::default_chatgpt_provider()], ctx) + .expect("OpenAI provider setting should update"); + }); + + let mut preferences = empty_preferences(); + app.read(|ctx| preferences.inject_openai_models(ctx)); + + let mode_id = "gpt-5.4::reasoning::high"; + let mode = preferences + .models_by_feature + .agent_mode + .choices + .iter() + .find(|model| model.id.as_str() == mode_id) + .expect("GPT-5.4 high mode should be available"); + assert_eq!(mode.reasoning_level.as_deref(), Some("high")); + let routing = preferences + .openai_client_config_for_model(mode_id) + .expect("reasoning mode should have a routing entry"); + assert_eq!(routing.model.as_deref(), Some("gpt-5.4")); + assert_eq!(routing.reasoning_effort.as_deref(), Some("high")); + + let ultra_id = "gpt-5.6-sol::reasoning::ultra"; + let ultra_routing = preferences + .openai_client_config_for_model(ultra_id) + .expect("GPT-5.6 Sol ultra mode should have a routing entry"); + assert_eq!(ultra_routing.model.as_deref(), Some("gpt-5.6-sol")); + assert_eq!(ultra_routing.reasoning_effort.as_deref(), Some("ultra")); + }); +} + #[test] fn provider_discovery_enables_rig_for_new_models_and_keeps_manual_models() { let manual = openai_model("manual-model"); diff --git a/app/src/ai/mcp/templatable_manager/native.rs b/app/src/ai/mcp/templatable_manager/native.rs index 05418b68..2de511d9 100644 --- a/app/src/ai/mcp/templatable_manager/native.rs +++ b/app/src/ai/mcp/templatable_manager/native.rs @@ -41,6 +41,7 @@ use crate::cloud_object::{ CloudObjectUuidLookup as _, GenericStringObjectFormat, JsonObjectType, Space, }; use crate::drive::CloudObjectTypeAndId; +use crate::local_object_repository::{local_owner, LocalObjectRepository}; use crate::persistence::{ database_file_path_for_scope, establish_ro_connection, ModelEvent, PersistenceScope, }; @@ -499,6 +500,19 @@ impl TemplatableMCPServerManager { initiated_by: InitiatedBy, ctx: &mut ModelContext, ) { + if matches!(space, Space::Personal) { + let client_id = ClientId::default(); + LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| { + repository.create_templatable_mcp_server_with_id( + SyncId::ClientId(client_id), + templatable_mcp_server, + ctx, + ); + }); + self.fetch_cloud_servers(ctx); + return; + } + let owner = UserWorkspaces::as_ref(ctx).space_to_owner(space, ctx); if let Some(owner) = owner { let update_manager = UpdateManager::handle(ctx); @@ -527,9 +541,24 @@ impl TemplatableMCPServerManager { template_server: TemplatableMCPServer, ctx: &mut ModelContext, ) { - let cloud_templatable_mcp_server = - self.get_cloud_templatable_mcp_server(template_server.uuid); + let cloud_templatable_mcp_server = self + .get_cloud_templatable_mcp_server(template_server.uuid) + .cloned(); if let Some(cloud_templatable_mcp_server) = cloud_templatable_mcp_server { + if cloud_templatable_mcp_server.permissions.owner == local_owner() + && cloud_templatable_mcp_server.id.into_client().is_some() + { + LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| { + repository.update_templatable_mcp_server( + template_server.uuid, + template_server, + ctx, + ); + }); + self.fetch_cloud_servers(ctx); + return; + } + let update_manager = UpdateManager::handle(ctx); update_manager.update(ctx, |update_manager, ctx| { update_manager.update_templatable_mcp_server( @@ -553,6 +582,16 @@ impl TemplatableMCPServerManager { let cloud_templatable_mcp_server = self.get_cloud_templatable_mcp_server(uuid); if let Some(cloud_templatable_mcp_server) = cloud_templatable_mcp_server { + if cloud_templatable_mcp_server.permissions.owner == local_owner() + && cloud_templatable_mcp_server.id.into_client().is_some() + { + LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| { + repository.delete_templatable_mcp_server(uuid, ctx); + }); + self.fetch_cloud_servers(ctx); + return; + } + let cloud_object_type_and_id = CloudObjectTypeAndId::GenericStringObject { object_type: GenericStringObjectFormat::Json(JsonObjectType::TemplatableMCPServer), id: cloud_templatable_mcp_server.id, @@ -1425,6 +1464,11 @@ impl TemplatableMCPServerManager { let cloud_templatable_mcp_server = self.get_cloud_templatable_mcp_server(template_uuid); if let Some(cloud_templatable_mcp_server) = cloud_templatable_mcp_server { + if cloud_templatable_mcp_server.permissions.owner == local_owner() + && cloud_templatable_mcp_server.id.into_client().is_some() + { + return true; + } let auth_state = AuthStateProvider::as_ref(ctx).get(); let current_team = UserWorkspaces::as_ref(ctx).current_team(); @@ -1443,6 +1487,11 @@ impl TemplatableMCPServerManager { pub fn is_author(&self, template_uuid: Uuid, ctx: &AppContext) -> bool { let cloud_templatable_mcp_server = self.get_cloud_templatable_mcp_server(template_uuid); if let Some(cloud_templatable_mcp_server) = cloud_templatable_mcp_server { + if cloud_templatable_mcp_server.permissions.owner == local_owner() + && cloud_templatable_mcp_server.id.into_client().is_some() + { + return true; + } let auth_state = AuthStateProvider::as_ref(ctx).get(); cloud_templatable_mcp_server.metadata().creator_uid == auth_state.user_id().map(|user_id| user_id.as_string()) diff --git a/app/src/ai/mod.rs b/app/src/ai/mod.rs index 93c68252..dba69676 100644 --- a/app/src/ai/mod.rs +++ b/app/src/ai/mod.rs @@ -24,6 +24,8 @@ pub mod bedrock; pub(crate) mod bedrock_credentials; pub(crate) mod block_context; pub(crate) mod blocklist; +#[cfg(not(target_family = "wasm"))] +pub(crate) mod chatgpt_auth; #[cfg(any(feature = "local_fs", not(target_family = "wasm")))] pub(crate) mod codebase_auto_indexing; pub mod control_code_parser; @@ -80,6 +82,8 @@ pub(crate) use ai::paths; pub fn init(app: &mut AppContext) { #[cfg(not(target_family = "wasm"))] app.add_singleton_model(acp::AcpRuntimeModel::new); + #[cfg(not(target_family = "wasm"))] + app.add_singleton_model(|_| chatgpt_auth::ChatGPTAuthModel::new()); blocklist::keyboard_navigable_buttons::init(app); blocklist::block::number_shortcut_buttons::init(app); blocklist::toggleable_items::init(app); diff --git a/app/src/ai/openai/client.rs b/app/src/ai/openai/client.rs index 2828faa1..c4d00501 100644 --- a/app/src/ai/openai/client.rs +++ b/app/src/ai/openai/client.rs @@ -4,11 +4,15 @@ use bytes::Bytes; use futures::Stream; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; +use crate::settings::OpenAIProviderKind; + #[derive(Clone, Debug)] pub struct OpenAIClientConfig { + pub kind: OpenAIProviderKind, pub base_url: String, pub api_key: Option, pub model: Option, + pub reasoning_effort: Option, pub max_input_tokens: Option, pub max_output_tokens: Option, pub use_rig: bool, diff --git a/app/src/ai/runtime/rig.rs b/app/src/ai/runtime/rig.rs index a759202c..61edc6a5 100644 --- a/app/src/ai/runtime/rig.rs +++ b/app/src/ai/runtime/rig.rs @@ -7,7 +7,10 @@ use galaxy_agent_core::{ turn_control, AgentError, AgentEvent, AgentRuntime, MessageContent, MessageRole, ToolCall, ToolCallDecision, ToolEvent, ToolPolicy, ToolResult, TurnCommand, }; -use galaxy_agent_rig::{OpenAICompatibleRuntime, OpenAICompatibleRuntimeConfig}; +use galaxy_agent_rig::{ + ChatGPTSubscriptionRuntime, ChatGPTSubscriptionRuntimeConfig, OpenAICompatibleRuntime, + OpenAICompatibleRuntimeConfig, +}; use uuid::Uuid; use warp_multi_agent_api::ToolType; @@ -24,6 +27,7 @@ use crate::ai::openai::client::OpenAIClientConfig; use crate::ai::provider::types::{ContentPart, ConversationMessage}; use crate::ai::runtime::{RuntimeResponseConfig, RuntimeResponseTranslator}; use crate::server::server_api::AIApiError; +use crate::settings::OpenAIProviderKind; pub(crate) fn rig_openai_response_stream( config: OpenAIClientConfig, @@ -35,21 +39,41 @@ pub(crate) fn rig_openai_response_stream( let skill_path_origin = params.session_context.skill_path_origin(); let prepared = prepare_rig_turn(&config, params, supported_tools, supported_cli_agent_tools); let model_id = prepared.request.model.as_str().to_string(); - let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig { - base_url: config.base_url, - api_key: config.api_key, - model: model_id.clone(), - max_output_tokens: config.max_output_tokens.map(u64::from), - supports_system_messages: config.supports_system_messages, - }); - rig_response_stream( - runtime, - prepared, - skill_path_origin, - config.max_input_tokens, - "rig_openai_compatible", - cancellation_rx, - ) + match config.kind { + OpenAIProviderKind::OpenAICompatible => { + let runtime = OpenAICompatibleRuntime::new(OpenAICompatibleRuntimeConfig { + base_url: config.base_url, + api_key: config.api_key, + model: model_id.clone(), + max_output_tokens: config.max_output_tokens.map(u64::from), + supports_system_messages: config.supports_system_messages, + }); + rig_response_stream( + runtime, + prepared, + skill_path_origin, + config.max_input_tokens, + "rig_openai_compatible", + cancellation_rx, + ) + } + OpenAIProviderKind::ChatGPTSubscription => { + let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig { + model: model_id, + reasoning_effort: config.reasoning_effort, + max_output_tokens: config.max_output_tokens.map(u64::from), + auth_file: None, + }); + rig_response_stream( + runtime, + prepared, + skill_path_origin, + config.max_input_tokens, + "rig_chatgpt_subscription", + cancellation_rx, + ) + } + } } pub(crate) async fn rig_bedrock_response_stream( diff --git a/app/src/ai/runtime/rig_request.rs b/app/src/ai/runtime/rig_request.rs index 9b828675..ab63a8c6 100644 --- a/app/src/ai/runtime/rig_request.rs +++ b/app/src/ai/runtime/rig_request.rs @@ -113,7 +113,15 @@ fn prepare_rig_turn_for_provider( supported_tools } }; - let (tools, mcp_tool_aliases) = tool_definitions(&available_tools, mcp_context.as_ref()); + let (mut tools, mcp_tool_aliases) = tool_definitions(&available_tools, mcp_context.as_ref()); + if matches!(mode, RigRequestMode::Cli) { + // History recall cannot advance a running command and is handled inline by the Rig + // adapter (without producing a client action that can trigger another turn). Keeping it + // in the CLI tool list lets the model spend its entire monitor turn recalling the prior + // snapshot instead of scheduling `read_shell_command_output`, so make polling the only + // way to inspect the active command here. + tools.retain(|tool| tool.name != "recall_tool_history"); + } let system_prompt = build_system_prompt(&input, &tools, &global_rules, mode); let mut new_messages = input_messages(input, tool_results); @@ -406,6 +414,16 @@ enum RigRequestMode { fn request_mode(inputs: &[AIAgentInput]) -> RigRequestMode { for input in inputs { + // A direct-provider follow-up carries an LRC snapshot as an action result rather than + // as a user query with `running_command`. Treat that result as a CLI-monitor turn so the + // request receives the dedicated polling instructions and CLI tool set. Without this, + // the model sees a generic tool-result turn and may stop after inspecting the snapshot + // (or call history recall) instead of scheduling the next output read. + if let AIAgentInput::ActionResult { result, .. } = input { + if result.result.triggers_server_subagent() { + return RigRequestMode::Cli; + } + } if matches!( input, AIAgentInput::UserQuery { @@ -719,7 +737,7 @@ fn build_system_prompt( "## Orchestration Mode\nDelegate only independent, bounded work where parallelism materially helps, then synthesize the results.\n\n", ), RigRequestMode::Cli => prompt.push_str( - "## Running Command Monitor\nMonitor the existing command by its command ID. Never start a duplicate command. Poll briefly, respect stop conditions, and report only verified outcomes.\n\n", + "## Running Command Monitor\nThis turn concerns a running or just-finished shell command. Act as its dedicated monitor while still following the user's steering messages. Use the command ID from the tool result for every read/write operation. If the result says the command finished, report its outcome and stop polling. Otherwise, poll with `read_shell_command_output` and use short delays. Never choose a poll interval that crosses a user-specified deadline or stop condition. When an explicit stop condition is met, call `interrupt_shell_command` immediately, then poll briefly to verify the outcome. Never start a duplicate command merely to check its state, and never report completion while a result says it is still running.\n\n", ), } prompt.push_str("## Available Tools\n"); diff --git a/app/src/ai/runtime/rig_request_tests.rs b/app/src/ai/runtime/rig_request_tests.rs index 79260e77..66077cab 100644 --- a/app/src/ai/runtime/rig_request_tests.rs +++ b/app/src/ai/runtime/rig_request_tests.rs @@ -9,8 +9,10 @@ use warp_multi_agent_api::ToolType; use super::{input_messages, prepare_bedrock_rig_turn, prepare_rig_turn, tool_definitions}; use crate::ai::agent::api::RequestParams; +use crate::ai::agent::task::TaskId; use crate::ai::agent::{ - AIAgentContext, AIAgentInput, AnyFileContent, FileContext, MCPContext, MCPServer, UserQueryMode, + AIAgentActionId, AIAgentActionResult, AIAgentActionResultType, AIAgentContext, AIAgentInput, + AnyFileContent, FileContext, MCPContext, MCPServer, RequestCommandOutputResult, UserQueryMode, }; use crate::ai::llms::LLMId; use crate::ai::openai::client::OpenAIClientConfig; @@ -18,9 +20,11 @@ use crate::ai::skills::SkillDescriptor; fn config() -> OpenAIClientConfig { OpenAIClientConfig { + kind: crate::settings::OpenAIProviderKind::OpenAICompatible, base_url: "http://localhost:4000/v1".to_string(), api_key: None, model: Some("provider-model".to_string()), + reasoning_effort: None, max_input_tokens: Some(128_000), max_output_tokens: Some(8_192), use_rig: true, @@ -131,6 +135,85 @@ fn rig_prompt_requires_follow_through_without_manual_continue_prompts() { assert!(prompt.contains("After each tool result, choose and perform the next necessary step")); } +#[test] +fn lrc_snapshot_follow_up_uses_the_cli_monitor_prompt_and_tools() { + let block_id: galaxy_terminal::model::BlockId = "precmd-lrc-test".to_string().into(); + let result = AIAgentActionResult { + id: AIAgentActionId::from("run-call".to_owned()), + task_id: TaskId::new("task".to_owned()), + result: AIAgentActionResultType::RequestCommandOutput( + RequestCommandOutputResult::LongRunningCommandSnapshot { + block_id: block_id.clone(), + command: "bash loop.sh".to_string(), + grid_contents: "Running for 2 seconds...".to_string(), + cursor: String::new(), + is_alt_screen_active: false, + }, + ), + }; + let snapshot_tool_result = ToolResult { + call_id: "run-call".to_string(), + content: result.result.model_content(), + status: ToolResultStatus::Success, + }; + let mut params = RequestParams::new_for_test(); + params.message_history = vec![galaxy_agent_core::ConversationMessage { + role: MessageRole::Assistant, + content: MessageContent::ToolUse { + tool_use_id: "run-call".to_string(), + name: "run_shell_command".to_string(), + input: serde_json::json!({ + "command": "bash loop.sh", + "wait_until_complete": false, + }), + }, + }]; + params.input = vec![AIAgentInput::ActionResult { + result, + context: Arc::from([]), + }]; + params.tool_results = vec![snapshot_tool_result]; + + let prepared = prepare_rig_turn( + &config(), + params, + vec![ToolType::RunShellCommand], + vec![ToolType::ReadShellCommandOutput], + ); + let prompt = prepared.request.system_prompt.expect("system prompt"); + + assert!(prompt.contains("## Running Command Monitor")); + assert!(prompt.contains("poll with `read_shell_command_output`")); + assert!(prepared + .request + .tools + .iter() + .any(|tool| tool.name == "read_shell_command_output")); + assert!(!prepared + .request + .tools + .iter() + .any(|tool| tool.name == "recall_tool_history")); + assert!(prepared + .request + .messages + .iter() + .any(|message| match &message.content { + MessageContent::ToolResult { content, .. } => { + content.contains("Command ID: precmd-lrc-test") + && content.contains("Continue monitoring with `read_shell_command_output`") + } + MessageContent::MultiPart(parts) => parts.iter().any(|part| { + matches!( + part, + ContentPart::ToolResult { content, .. } + if content.contains("Command ID: precmd-lrc-test") + ) + }), + _ => false, + })); +} + #[test] fn rig_prompt_requires_matching_project_skills_to_be_read_before_action() { let skill_path = LocalOrRemotePath::Local(PathBuf::from( diff --git a/app/src/local_object_repository.rs b/app/src/local_object_repository.rs index 5136b9f1..484bc9a7 100644 --- a/app/src/local_object_repository.rs +++ b/app/src/local_object_repository.rs @@ -8,6 +8,9 @@ use crate::ai::execution_profiles::{ AIExecutionProfile, CloudAIExecutionProfile, CloudAIExecutionProfileModel, }; use crate::ai::facts::{AIFact, CloudAIFact, CloudAIFactModel}; +use crate::ai::mcp::templatable::{ + CloudTemplatableMCPServer, CloudTemplatableMCPServerModel, TemplatableMCPServer, +}; use crate::auth::UserUid; use crate::cloud_object::model::generic_string_model::GenericStringObjectId; use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent}; @@ -413,6 +416,60 @@ impl LocalObjectRepository { Some(duplicate_id) } + pub fn templatable_mcp_server( + &self, + uuid: uuid::Uuid, + app: &AppContext, + ) -> Option { + CloudModel::as_ref(app) + .get_all_objects_of_type::() + .find(|server| server.model().string_model.uuid == uuid) + .cloned() + } + + pub fn create_templatable_mcp_server_with_id( + &mut self, + id: SyncId, + server: TemplatableMCPServer, + ctx: &mut ModelContext, + ) { + self.upsert_templatable_mcp_server( + GenericCloudObject::new( + id, + CloudTemplatableMCPServerModel::new(server), + locally_saved_metadata(None), + local_permissions(), + ), + ctx, + ); + } + + pub fn update_templatable_mcp_server( + &mut self, + uuid: uuid::Uuid, + server: TemplatableMCPServer, + ctx: &mut ModelContext, + ) -> bool { + let Some(mut object) = self.templatable_mcp_server(uuid, ctx) else { + return false; + }; + object.set_model(CloudTemplatableMCPServerModel::new(server)); + set_locally_saved_metadata(&mut object.metadata); + self.upsert_templatable_mcp_server(object, ctx); + true + } + + pub fn delete_templatable_mcp_server( + &mut self, + uuid: uuid::Uuid, + ctx: &mut ModelContext, + ) -> bool { + let Some(object) = self.templatable_mcp_server(uuid, ctx) else { + return false; + }; + self.delete_local_object(object.id, ObjectIdType::GenericStringObject, ctx) + } + pub fn create_workflow_with_id( &mut self, id: SyncId, @@ -514,6 +571,19 @@ impl LocalObjectRepository { }); } + fn upsert_templatable_mcp_server( + &self, + object: CloudTemplatableMCPServer, + ctx: &mut ModelContext, + ) { + CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| { + cloud_model.upsert_local_object(object.clone(), ctx); + }); + self.save(ModelEvent::UpsertGenericStringObject { + object: Box::new(object), + }); + } + fn upsert_notebook(&self, notebook: CloudNotebook, ctx: &mut ModelContext) { CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| { cloud_model.upsert_local_object(notebook.clone(), ctx); diff --git a/app/src/local_object_repository_tests.rs b/app/src/local_object_repository_tests.rs index 40d068a3..8d43f2f6 100644 --- a/app/src/local_object_repository_tests.rs +++ b/app/src/local_object_repository_tests.rs @@ -5,6 +5,7 @@ use galaxyui::App; use super::*; use crate::ai::execution_profiles::{AIExecutionProfile, ActionPermission}; use crate::ai::facts::AIMemory; +use crate::ai::mcp::templatable::TemplatableMCPServer; use crate::cloud_object::model::generic_string_model::CloudStringObject; use crate::env_vars::{EnvVar, EnvVarCollection, EnvVarValue}; use crate::notebooks::CloudNotebookModel; @@ -281,6 +282,63 @@ fn create_update_duplicate_trash_and_delete_env_var_collection_are_local() { }); } +#[test] +fn create_update_and_delete_mcp_config_are_local_and_persisted() { + App::test((), |mut app| async move { + let receiver = initialize_app(&mut app); + let repository = LocalObjectRepository::handle(&app); + let id = SyncId::ClientId(ClientId::new()); + let uuid = uuid::Uuid::new_v4(); + let server = TemplatableMCPServer { + uuid, + name: "Local MCP".to_string(), + ..Default::default() + }; + + repository.update(&mut app, |repository, ctx| { + repository.create_templatable_mcp_server_with_id(id, server, ctx); + }); + assert!(matches!( + receiver.recv().unwrap(), + ModelEvent::UpsertGenericStringObject { .. } + )); + repository.read(&app, |repository, app| { + let server = repository + .templatable_mcp_server(uuid, app) + .expect("created MCP config"); + assert_eq!(server.id, id); + assert_eq!(server.model().string_model.name, "Local MCP"); + }); + + let updated = repository.update(&mut app, |repository, ctx| { + repository.update_templatable_mcp_server( + uuid, + TemplatableMCPServer { + uuid, + name: "Updated MCP".to_string(), + ..Default::default() + }, + ctx, + ) + }); + assert!(updated); + assert!(matches!( + receiver.recv().unwrap(), + ModelEvent::UpsertGenericStringObject { .. } + )); + + let deleted = repository.update(&mut app, |repository, ctx| { + repository.delete_templatable_mcp_server(uuid, ctx) + }); + assert!(deleted); + assert!(matches!( + receiver.recv().unwrap(), + ModelEvent::DeleteObjects { ids } + if ids == vec![(id, ObjectIdType::GenericStringObject)] + )); + }); +} + #[test] fn create_update_and_delete_rule_are_local_and_persisted() { App::test((), |mut app| async move { diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index 2d60883e..e7e3ffa4 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -841,6 +841,10 @@ fn default_context_size() -> u32 { 200_000 } +fn default_enabled() -> bool { + true +} + /// Configuration for a single OpenAI-compatible (LiteLLM) model. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] #[schemars(description = "Configuration for a single OpenAI-compatible model (e.g. via LiteLLM).")] @@ -889,6 +893,14 @@ pub struct OpenAIModelConfig { description = "Whether this endpoint accepts system-role messages. Set false for ChatGPT-backed LiteLLM models that reject them." )] pub supports_system_messages: Option, + #[serde(default)] + #[schemars( + description = "Reasoning effort modes supported by this model when using the ChatGPT subscription provider." + )] + pub reasoning_efforts: Vec, + #[serde(default = "default_enabled")] + #[schemars(description = "Whether this model is enabled for the model picker.")] + pub enabled: bool, } impl settings_value::SettingsValue for OpenAIModelConfig {} @@ -902,6 +914,20 @@ impl OpenAIModelConfig { } } +/// The protocol and authentication used by an OpenAI model provider. +#[derive( + Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum OpenAIProviderKind { + /// A regular OpenAI-compatible `/chat/completions` endpoint. + #[serde(alias = "openai")] + #[default] + OpenAICompatible, + /// The ChatGPT subscription backend, authenticated with ChatGPT OAuth. + ChatGPTSubscription, +} + /// Configuration for a single OpenAI-compatible provider endpoint. /// /// Multiple providers can be configured simultaneously (e.g. LiteLLM for cloud models, @@ -911,6 +937,12 @@ impl OpenAIModelConfig { description = "Configuration for an OpenAI-compatible provider endpoint (e.g. LiteLLM, Ollama, vLLM)." )] pub struct OpenAIProviderConfig { + #[serde(default)] + #[schemars(description = "Provider protocol and authentication kind.")] + pub kind: OpenAIProviderKind, + #[serde(default = "default_enabled")] + #[schemars(description = "Whether this provider is enabled for AI requests.")] + pub enabled: bool, #[schemars(description = "Display name for this provider (shown in model picker).")] pub name: String, #[schemars(description = "Base URL for the OpenAI-compatible API endpoint.")] @@ -928,25 +960,97 @@ impl settings_value::SettingsValue for OpenAIProviderConfig {} const INITIAL_LITELLM_BASE_URL: &str = "https://ai.ryserve.net/v1"; const INITIAL_RIG_MODEL_ID: &str = "codex-gpt-5.6-sol-xhigh"; -fn default_openai_providers() -> Vec { - vec![OpenAIProviderConfig { - name: "LiteLLM (ai.ryserve.net)".to_string(), - base_url: INITIAL_LITELLM_BASE_URL.to_string(), - // Credentials are deliberately never committed. Set this locally in - // ~/.galaxy/settings.toml before sending a request. - api_key: None, - models: vec![OpenAIModelConfig { - model_id: INITIAL_RIG_MODEL_ID.to_string(), - display_name: "Codex GPT-5.6 SOL (xhigh)".to_string(), +fn default_chatgpt_models() -> Vec { + // The ChatGPT OAuth backend does not expose a model-listing capability through Rig, + // so keep this catalog small and explicit. Reasoning variants are expanded into + // selectable LLM entries when the provider is injected into the runtime inventory. + [ + ( + "gpt-5.6-sol", + "GPT-5.6 Sol", + vec!["low", "medium", "high", "xhigh", "max", "ultra"], + ), + ( + "gpt-5.6-terra", + "GPT-5.6 Terra", + vec!["low", "medium", "high", "xhigh", "max", "ultra"], + ), + ( + "gpt-5.6-luna", + "GPT-5.6 Luna", + vec!["low", "medium", "high", "xhigh", "max", "ultra"], + ), + ("gpt-5.4", "GPT-5.4", vec!["low", "medium", "high", "xhigh"]), + ( + "gpt-5.4-pro", + "GPT-5.4 Pro", + vec!["medium", "high", "xhigh"], + ), + ( + "gpt-5.3-codex", + "GPT-5.3 Codex", + vec!["low", "medium", "high", "xhigh"], + ), + ("gpt-5.3-codex-spark", "GPT-5.3 Codex Spark", vec![]), + ("gpt-5.3-instant", "GPT-5.3 Instant", vec![]), + ("gpt-5.3-chat-latest", "GPT-5.3 Chat Latest", vec![]), + ] + .into_iter() + .map( + |(model_id, display_name, reasoning_efforts)| OpenAIModelConfig { + model_id: model_id.to_string(), + display_name: display_name.to_string(), vision_supported: false, context_size: default_context_size(), max_input_tokens: None, max_output_tokens: None, provider: Some("openai".to_string()), use_rig: true, - supports_system_messages: Some(false), - }], - }] + supports_system_messages: Some(true), + reasoning_efforts: reasoning_efforts.into_iter().map(str::to_string).collect(), + enabled: true, + }, + ) + .collect() +} + +pub(crate) fn default_chatgpt_provider() -> OpenAIProviderConfig { + OpenAIProviderConfig { + kind: OpenAIProviderKind::ChatGPTSubscription, + enabled: true, + name: "ChatGPT Subscription".to_string(), + base_url: String::new(), + api_key: None, + models: default_chatgpt_models(), + } +} + +fn default_openai_providers() -> Vec { + vec![ + OpenAIProviderConfig { + kind: OpenAIProviderKind::OpenAICompatible, + enabled: true, + name: "LiteLLM (ai.ryserve.net)".to_string(), + base_url: INITIAL_LITELLM_BASE_URL.to_string(), + // Credentials are deliberately never committed. Set this locally in + // ~/.galaxy/settings.toml before sending a request. + api_key: None, + models: vec![OpenAIModelConfig { + model_id: INITIAL_RIG_MODEL_ID.to_string(), + display_name: "Codex GPT-5.6 SOL (xhigh)".to_string(), + vision_supported: false, + context_size: default_context_size(), + max_input_tokens: None, + max_output_tokens: None, + provider: Some("openai".to_string()), + use_rig: true, + supports_system_messages: Some(false), + reasoning_efforts: Vec::new(), + enabled: true, + }], + }, + default_chatgpt_provider(), + ] } /// Cached metadata and runtime session options for an ACP agent. @@ -1349,6 +1453,17 @@ define_settings_group!(AISettings, settings: [ description: "Identifier for the local Agent Client Protocol agent preset.", feature_flag: FeatureFlag::AgentClientProtocol, } + // Friendly name shown for the configured ACP provider card. + acp_connection_name: AcpConnectionName { + type: String, + default: "ACP agent runtime".to_string(), + supported_platforms: SupportedPlatforms::OR(SupportedPlatforms::MAC.into(), SupportedPlatforms::LINUX.into()), + sync_to_cloud: SyncToCloud::Never, + private: false, + toml_path: "ai.acp.connection_name", + description: "Friendly name for the configured ACP agent runtime.", + feature_flag: FeatureFlag::AgentClientProtocol, + } // Executable used to launch the configured local ACP agent. acp_agent_command: AcpAgentCommand { type: String, @@ -1394,6 +1509,16 @@ define_settings_group!(AISettings, settings: [ } // Authentication method for Bedrock: "profile", "static_keys", or "sso". bedrock_auth_method: BedrockAuthMethod, + // Friendly name shown for the configured Bedrock provider card. + bedrock_connection_name: BedrockConnectionName { + type: String, + default: "AWS Bedrock".to_string(), + supported_platforms: SupportedPlatforms::DESKTOP, + sync_to_cloud: SyncToCloud::Never, + private: false, + toml_path: "ai.bedrock.connection_name", + description: "Friendly name for the configured AWS Bedrock connection.", + } // AWS profile name to use when auth_method is Profile or SSO. bedrock_profile: BedrockProfile { type: String, @@ -1424,7 +1549,7 @@ define_settings_group!(AISettings, settings: [ toml_path: "ai.bedrock.cross_region_inference", description: "Whether to automatically add cross-region inference prefixes to model IDs.", } - // Custom Bedrock model configurations. + // Cached Bedrock models that passed foundation-model availability checks. bedrock_models: BedrockModels { type: Vec, default: Vec::new(), @@ -1432,7 +1557,7 @@ define_settings_group!(AISettings, settings: [ sync_to_cloud: SyncToCloud::Never, private: false, toml_path: "ai.bedrock.models", - description: "Custom AWS Bedrock model configurations.", + description: "AWS Bedrock models discovered as authorized and available in the configured region.", } // Whether to automatically run the login command when Bedrock credentials expire. bedrock_auto_login: BedrockAutoLogin { diff --git a/app/src/settings/ai_tests.rs b/app/src/settings/ai_tests.rs index a4e997e1..47c65c33 100644 --- a/app/src/settings/ai_tests.rs +++ b/app/src/settings/ai_tests.rs @@ -349,8 +349,9 @@ fn test_toolbar_command_map_roundtrip() { fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() { let providers = default_openai_providers(); - assert_eq!(providers.len(), 1); + assert_eq!(providers.len(), 2); let provider = &providers[0]; + assert_eq!(provider.kind, OpenAIProviderKind::OpenAICompatible); assert_eq!(provider.base_url, INITIAL_LITELLM_BASE_URL); assert_eq!(provider.api_key, None); assert_eq!(provider.models.len(), 1); @@ -359,6 +360,67 @@ fn initial_litellm_provider_maps_codex_model_to_rig_without_a_committed_key() { assert!(model.use_rig); assert_eq!(model.supports_system_messages, Some(false)); assert!(!model.supports_system_messages()); + + let chatgpt = &providers[1]; + assert_eq!(chatgpt.kind, OpenAIProviderKind::ChatGPTSubscription); + assert_eq!(chatgpt.name, "ChatGPT Subscription"); + assert!(chatgpt.base_url.is_empty()); + assert!(chatgpt.api_key.is_none()); + assert!(chatgpt + .models + .iter() + .any(|model| model.model_id == "gpt-5.4-pro")); + + let sol = chatgpt + .models + .iter() + .find(|model| model.model_id == "gpt-5.6-sol") + .expect("GPT-5.6 Sol should be in the ChatGPT catalog"); + assert_eq!(sol.reasoning_efforts.len(), 6); + assert!(sol.reasoning_efforts.iter().any(|effort| effort == "max")); + assert!(sol.reasoning_efforts.iter().any(|effort| effort == "ultra")); + + let luna = chatgpt + .models + .iter() + .find(|model| model.model_id == "gpt-5.6-luna") + .expect("GPT-5.6 Luna should be in the ChatGPT catalog"); + assert!(luna.reasoning_efforts.iter().any(|effort| effort == "max")); + assert!(luna + .reasoning_efforts + .iter() + .any(|effort| effort == "ultra")); + + let terra = chatgpt + .models + .iter() + .find(|model| model.model_id == "gpt-5.6-terra") + .expect("GPT-5.6 Terra should be in the ChatGPT catalog"); + assert!(terra.reasoning_efforts.iter().any(|effort| effort == "max")); + assert!(terra + .reasoning_efforts + .iter() + .any(|effort| effort == "ultra")); + + let gpt_54 = chatgpt + .models + .iter() + .find(|model| model.model_id == "gpt-5.4") + .expect("GPT-5.4 should be in the ChatGPT catalog"); + assert_eq!( + gpt_54.reasoning_efforts, + vec!["low", "medium", "high", "xhigh"] + .into_iter() + .map(str::to_string) + .collect::>() + ); + + let instant = chatgpt + .models + .iter() + .find(|model| model.model_id == "gpt-5.3-instant") + .expect("GPT-5.3 Instant should be in the ChatGPT catalog"); + assert!(instant.reasoning_efforts.is_empty()); } #[test] diff --git a/app/src/settings_view/ai_page.rs b/app/src/settings_view/ai_page.rs index 09b8d921..e02e4e38 100644 --- a/app/src/settings_view/ai_page.rs +++ b/app/src/settings_view/ai_page.rs @@ -9,12 +9,13 @@ use galaxy_core::ui::color::ContrastingColor; use galaxy_core::ui::theme::color::internal_colors; use galaxy_core::ui::theme::Fill as ThemeFill; use galaxy_editor::editor::NavigationKey; +use galaxyui::clipboard::ClipboardContent; use galaxyui::elements::{ Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Empty, Expanded, Fill, Flex, FormattedTextElement, HighlightedHyperlink, Hoverable, HyperlinkLens, HyperlinkUrl, MainAxisAlignment, MainAxisSize, MouseStateHandle, - OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Shrinkable, Stack, - Text, + OffsetPositioning, Padding, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, + Shrinkable, Stack, Text, }; use galaxyui::fonts::{Properties, Weight}; use galaxyui::keymap::{ContextPredicate, FixedBinding, Keystroke}; @@ -34,6 +35,10 @@ use settings::{Setting, ToggleableSetting}; use strum::IntoEnumIterator; use super::execution_profile_view::{ExecutionProfileView, ExecutionProfileViewEvent}; +use super::provider_setup_modal::{ + AcpProviderDraft, BedrockProviderDraft, ProviderSetupModalBody, ProviderSetupModalBodyEvent, + ProviderSetupModalState, +}; use super::set_default_model_modal::{SetDefaultModelModalBody, SetDefaultModelModalBodyEvent}; use super::settings_page::{ build_sub_header, build_toggle_element, render_body_item_label, @@ -53,6 +58,8 @@ use crate::ai::blocklist::agent_view::agent_input_footer::editor::{ AgentToolbarEditorMode, AgentToolbarInlineEditor, }; use crate::ai::blocklist::BlocklistAIPermissions; +#[cfg(not(target_family = "wasm"))] +use crate::ai::chatgpt_auth::{ChatGPTAuthModel, ChatGPTAuthModelEvent, ChatGPTAuthState}; use crate::ai::execution_profiles::model_menu_items::available_model_menu_items; #[cfg(not(target_family = "wasm"))] use crate::ai::execution_profiles::profiles::{ @@ -79,7 +86,7 @@ use crate::editor::{ TextColors, }; use crate::modal::{Modal, ModalEvent, ModalViewState}; -use crate::settings::ai::BedrockAuthMethod; +use crate::settings::ai::OpenAIProviderKind; use crate::settings::{ AIAutoDetectionEnabled, AICommandDenylist, AISettingsChangedEvent, AcpEnabled, AgentModeCodingPermissionsType, AgentModeCommandExecutionDenylist, @@ -687,8 +694,9 @@ pub struct AISettingsPageView { // Profile views profile_views: Vec>, add_profile_button: ViewHandle, + provider_setup_modal_state: ProviderSetupModalState, #[cfg(not(target_family = "wasm"))] - refresh_acp_button: ViewHandle, + provider_setup_modal_body: ViewHandle, // Custom model router views (gated on FeatureFlag::CustomModelRouters) #[cfg(feature = "local_fs")] @@ -699,15 +707,30 @@ pub struct AISettingsPageView { impl AISettingsPageView { #[cfg(not(target_family = "wasm"))] - fn refresh_acp_discovery(&mut self, ctx: &mut ViewContext) { - let (config, agent_id) = { - let settings = AISettings::as_ref(ctx); - let Ok(config) = crate::ai::acp::AcpRuntimeModel::discovery_config(settings) else { - log::warn!("Could not resolve ACP launch configuration for discovery"); - return; - }; - (config, settings.acp_agent_id.value().clone()) + fn refresh_acp_discovery_for_draft( + &mut self, + draft: &AcpProviderDraft, + ctx: &mut ViewContext, + ) { + let Ok(config) = crate::ai::acp::AcpRuntimeModel::discovery_config_for_values( + &draft.agent_id, + &draft.command, + &draft.args, + ) else { + log::warn!("Could not resolve ACP launch configuration for discovery"); + return; }; + self.start_acp_discovery(config, draft.agent_id.clone(), ctx); + } + + #[cfg(not(target_family = "wasm"))] + fn start_acp_discovery( + &mut self, + config: galaxy_acp::AcpManagerConfig, + agent_id: String, + ctx: &mut ViewContext, + ) { + let provider_setup_modal_body = self.provider_setup_modal_body.clone(); /* * The settings borrow must end before updating the runtime singleton. */ @@ -719,6 +742,10 @@ impl AISettingsPageView { Ok(manager) => manager, Err(error) => { log::warn!("Could not start ACP discovery: {error}"); + let error_text = error.to_string(); + provider_setup_modal_body.update(ctx, |body, ctx| { + body.finish_acp_discovery(Err(error_text), ctx); + }); return; } }; @@ -751,6 +778,9 @@ impl AISettingsPageView { crate::ai::acp::AcpRuntimeModel::handle(ctx).update(ctx, |runtime, ctx| { runtime.finish_discovery_success(option_count, ctx); }); + provider_setup_modal_body.update(ctx, |body, ctx| { + body.finish_acp_discovery(Ok(()), ctx); + }); } Err(error) => { log::warn!("ACP discovery failed: {error}"); @@ -768,7 +798,10 @@ impl AISettingsPageView { } }); crate::ai::acp::AcpRuntimeModel::handle(ctx).update(ctx, |runtime, ctx| { - runtime.finish_discovery_failure(error_text, ctx); + runtime.finish_discovery_failure(error_text.clone(), ctx); + }); + provider_setup_modal_body.update(ctx, |body, ctx| { + body.finish_acp_discovery(Err(error_text), ctx); }); } } @@ -1752,19 +1785,49 @@ impl AISettingsPageView { }) }); - #[cfg(not(target_family = "wasm"))] - let refresh_acp_button = ctx.add_typed_action_view(|_| { - ActionButton::new("Discover ACP options", SecondaryTheme) - .with_size(ButtonSize::Small) - .on_click(|ctx| { - ctx.dispatch_typed_action(AISettingsPageAction::RefreshAcpDiscovery) - }) - }); - add_profile_button.update(ctx, |button, ctx| { button.set_disabled(!is_any_ai_enabled, ctx); }); + let provider_setup_body = ctx.add_typed_action_view(ProviderSetupModalBody::new); + ctx.subscribe_to_view(&provider_setup_body, |me, _, event, ctx| match event { + ProviderSetupModalBodyEvent::Close => me.close_provider_setup_modal(ctx), + ProviderSetupModalBodyEvent::RequestAcpDiscovery(draft) => { + #[cfg(not(target_family = "wasm"))] + me.refresh_acp_discovery_for_draft(draft, ctx); + } + ProviderSetupModalBodyEvent::SaveOpenAI { + editing_index, + provider, + } => me.save_provider_setup(*editing_index, provider.clone(), ctx), + ProviderSetupModalBodyEvent::SaveBedrock(draft) => { + me.save_bedrock_provider(draft.clone(), ctx) + } + ProviderSetupModalBodyEvent::SaveAcp(draft) => me.save_acp_provider(draft.clone(), ctx), + }); + let provider_setup_modal_view = ctx.add_typed_action_view(|ctx| { + Modal::new( + Some("Add model provider".to_string()), + provider_setup_body.clone(), + ctx, + ) + .with_modal_style(UiComponentStyles { + width: Some(640.), + height: Some(600.), + ..Default::default() + }) + .with_body_style(UiComponentStyles { + height: Some(530.), + ..Default::default() + }) + .with_dismiss_on_click() + }); + ctx.subscribe_to_view(&provider_setup_modal_view, |me, _, event, ctx| { + if matches!(event, ModalEvent::Close) { + me.close_provider_setup_modal(ctx); + } + }); + let agent_toolbar_inline_editor = ctx.add_typed_action_view(|ctx| { AgentToolbarInlineEditor::new(AgentToolbarEditorMode::AgentView, ctx) }); @@ -1869,8 +1932,9 @@ impl AISettingsPageView { conversation_layout_dropdown, profile_views, add_profile_button, + provider_setup_modal_state: ModalViewState::new(provider_setup_modal_view), #[cfg(not(target_family = "wasm"))] - refresh_acp_button, + provider_setup_modal_body: provider_setup_body, #[cfg(feature = "local_fs")] router_views, #[cfg(feature = "local_fs")] @@ -1892,7 +1956,172 @@ impl AISettingsPageView { } pub fn get_modal_content(&self, _app: &AppContext) -> Option> { - None + self.provider_setup_modal_state + .is_open() + .then(|| self.provider_setup_modal_state.render()) + } + + fn open_provider_setup_modal( + &mut self, + editing_index: Option, + ctx: &mut ViewContext, + ) { + let body = self + .provider_setup_modal_state + .view + .as_ref(ctx) + .body() + .clone(); + body.update(ctx, |body, ctx| match editing_index { + Some(index) => { + let Some(provider) = AISettings::as_ref(ctx) + .openai_providers + .value() + .get(index) + .cloned() + else { + return; + }; + body.begin_edit(index, provider, ctx); + } + None => body.begin_create(ctx), + }); + self.provider_setup_modal_state.open(); + self.provider_setup_modal_state + .view + .update(ctx, |modal, ctx| { + modal.set_title(Some(if editing_index.is_some() { + "Edit model provider".to_string() + } else { + "Add model provider".to_string() + })); + ctx.notify(); + }); + ctx.emit(AISettingsPageEvent::ShowModal); + } + + fn open_bedrock_setup_modal(&mut self, ctx: &mut ViewContext) { + let settings = AISettings::as_ref(ctx); + let draft = BedrockProviderDraft { + name: settings.bedrock_connection_name.value().clone(), + auth_method: *settings.bedrock_auth_method.value(), + profile: settings.bedrock_profile.value().clone(), + region: settings.bedrock_region.value().clone(), + cross_region_inference: *settings.bedrock_cross_region_inference.value(), + auto_login: *settings.bedrock_auto_login.value(), + auth_refresh_command: settings.bedrock_auth_refresh_command.value().clone(), + access_key_id: settings.bedrock_access_key_id.value().clone(), + secret_access_key: settings.bedrock_secret_access_key.value().clone(), + models: settings.bedrock_models.value().clone(), + }; + let body = self + .provider_setup_modal_state + .view + .as_ref(ctx) + .body() + .clone(); + body.update(ctx, |body, ctx| body.begin_edit_bedrock(draft.clone(), ctx)); + self.provider_setup_modal_state.open(); + self.provider_setup_modal_state + .view + .update(ctx, |modal, ctx| { + modal.set_title(Some("Edit AWS Bedrock provider".to_string())); + ctx.notify(); + }); + ctx.emit(AISettingsPageEvent::ShowModal); + } + + fn open_acp_setup_modal(&mut self, ctx: &mut ViewContext) { + let settings = AISettings::as_ref(ctx); + let draft = AcpProviderDraft { + name: settings.acp_connection_name.value().clone(), + agent_id: settings.acp_agent_id.value().clone(), + command: settings.acp_agent_command.value().clone(), + args: settings.acp_agent_args.value().clone(), + }; + let body = self + .provider_setup_modal_state + .view + .as_ref(ctx) + .body() + .clone(); + body.update(ctx, |body, ctx| body.begin_edit_acp(draft.clone(), ctx)); + self.provider_setup_modal_state.open(); + self.provider_setup_modal_state + .view + .update(ctx, |modal, ctx| { + modal.set_title(Some("Edit ACP provider".to_string())); + ctx.notify(); + }); + ctx.emit(AISettingsPageEvent::ShowModal); + } + + fn close_provider_setup_modal(&mut self, ctx: &mut ViewContext) { + self.provider_setup_modal_state.close(); + ctx.emit(AISettingsPageEvent::HideModal); + } + + fn save_provider_setup( + &mut self, + editing_index: Option, + mut provider: OpenAIProviderConfig, + ctx: &mut ViewContext, + ) { + AISettings::handle(ctx).update(ctx, |settings, ctx| { + let mut providers = settings.openai_providers.value().clone(); + match editing_index { + Some(index) => { + if let Some(existing) = providers.get_mut(index) { + provider.enabled = existing.enabled; + *existing = provider.clone(); + } + } + None => providers.push(provider.clone()), + } + report_if_error!(settings.openai_providers.set_value(providers, ctx)); + }); + self.close_provider_setup_modal(ctx); + self.rebuild_active_subpage(ctx); + } + + fn save_bedrock_provider(&mut self, draft: BedrockProviderDraft, ctx: &mut ViewContext) { + AISettings::handle(ctx).update(ctx, |settings, ctx| { + report_if_error!(settings.bedrock_enabled.set_value(true, ctx)); + report_if_error!(settings + .bedrock_auth_method + .set_value(draft.auth_method, ctx)); + report_if_error!(settings.bedrock_profile.set_value(draft.profile, ctx)); + report_if_error!(settings.bedrock_region.set_value(draft.region, ctx)); + report_if_error!(settings + .bedrock_cross_region_inference + .set_value(draft.cross_region_inference, ctx)); + report_if_error!(settings.bedrock_auto_login.set_value(draft.auto_login, ctx)); + report_if_error!(settings + .bedrock_auth_refresh_command + .set_value(draft.auth_refresh_command, ctx)); + report_if_error!(settings + .bedrock_access_key_id + .set_value(draft.access_key_id, ctx)); + report_if_error!(settings + .bedrock_secret_access_key + .set_value(draft.secret_access_key, ctx)); + report_if_error!(settings.bedrock_connection_name.set_value(draft.name, ctx)); + report_if_error!(settings.bedrock_models.set_value(draft.models, ctx)); + }); + self.close_provider_setup_modal(ctx); + self.rebuild_active_subpage(ctx); + } + + fn save_acp_provider(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext) { + AISettings::handle(ctx).update(ctx, |settings, ctx| { + report_if_error!(settings.acp_enabled.set_value(true, ctx)); + report_if_error!(settings.acp_agent_id.set_value(draft.agent_id, ctx)); + report_if_error!(settings.acp_agent_command.set_value(draft.command, ctx)); + report_if_error!(settings.acp_agent_args.set_value(draft.args, ctx)); + report_if_error!(settings.acp_connection_name.set_value(draft.name, ctx)); + }); + self.close_provider_setup_modal(ctx); + self.rebuild_active_subpage(ctx); } /// Set the active subpage and rebuild the widget list to show only relevant widgets. @@ -1972,9 +2201,6 @@ impl AISettingsPageView { } widgets.push(Box::new(CloudHandoffWidget::default())); widgets.push(Box::new(CLIAgentWidget::default())); - if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() { - widgets.push(Box::new(AgentRuntimeSettingsWidget::new(ctx))); - } widgets.push(Box::new(AgentAttributionWidget::default())); widgets.push(Box::new(OtherAIWidget::default())); } @@ -2033,11 +2259,7 @@ impl AISettingsPageView { } Some(AISubpage::Models) => { widgets.push(Box::new(ModelsOverviewWidget)); - widgets.push(Box::new(OpenAISettingsWidget::new(ctx))); - widgets.push(Box::new(BedrockSettingsWidget::new(ctx))); - if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() { - widgets.push(Box::new(AgentRuntimeSettingsWidget::new(ctx))); - } + widgets.push(Box::new(OpenAIProviderSettingsWidget::new(ctx))); let title: Option<&str> = None; return (PageType::new_uncategorized(widgets, title), None); } @@ -2796,19 +3018,21 @@ pub enum AISettingsPageAction { RemoveFromMCPDenylist(uuid::Uuid), CreateProfile, SignupAnonymousUser, - ToggleBedrockAutoLogin, ToggleBedrockEnabled, - RefreshAwsBedrock, - SetBedrockAuthMethod(BedrockAuthMethod), - SetBedrockProfile(String), - ToggleBedrockCrossRegionInference, - ToggleBedrockModelRig(usize), ToggleOpenAIEnabled, + ConnectChatGPTSubscription, + OpenChatGPTDevicePage, + CopyChatGPTDeviceCode, ToggleAcpEnabled, - RefreshAcpDiscovery, FetchOpenAIProviderModels(usize), AddOpenAIProvider, + EditOpenAIProvider(usize), + ToggleOpenAIProviderEnabled(usize), RemoveOpenAIProvider(usize), + EditBedrockProvider, + RemoveBedrockProvider, + EditAcpProvider, + RemoveAcpProvider, ToggleFileBasedMcp, ToggleIncludeAgentCommandsInHistory, ToggleAgentAttribution, @@ -3511,12 +3735,6 @@ impl TypedActionView for AISettingsPageView { AISettingsPageAction::SignupAnonymousUser => { ctx.emit(AISettingsPageEvent::SignupAnonymousUser); } - AISettingsPageAction::ToggleBedrockAutoLogin => { - AISettings::handle(ctx).update(ctx, |settings, ctx| { - report_if_error!(settings.bedrock_auto_login.toggle_and_save_value(ctx)); - }); - ctx.notify(); - } AISettingsPageAction::ToggleCloudAgentComputerUse => { AISettings::handle(ctx).update(ctx, |settings, ctx| { report_if_error!(settings @@ -3533,50 +3751,62 @@ impl TypedActionView for AISettingsPageView { } 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(); } - AISettingsPageAction::RefreshAwsBedrock => { - // Discovery removed — models are configured via settings.toml - } - AISettingsPageAction::SetBedrockAuthMethod(method) => { - AISettings::handle(ctx).update(ctx, |settings, ctx| { - report_if_error!(settings.bedrock_auth_method.set_value(*method, ctx)); - }); - ctx.notify(); - } - AISettingsPageAction::SetBedrockProfile(profile) => { - AISettings::handle(ctx).update(ctx, |settings, ctx| { - report_if_error!(settings.bedrock_profile.set_value(profile.clone(), ctx)); - }); - ctx.notify(); - } - AISettingsPageAction::ToggleBedrockCrossRegionInference => { - AISettings::handle(ctx).update(ctx, |settings, ctx| { - report_if_error!(settings - .bedrock_cross_region_inference - .toggle_and_save_value(ctx)); - }); - ctx.notify(); - } - AISettingsPageAction::ToggleBedrockModelRig(index) => { - AISettings::handle(ctx).update(ctx, |settings, ctx| { - let mut models = settings.bedrock_models.value().clone(); - let Some(model) = models.get_mut(*index) else { - return; - }; - model.use_rig = !model.use_rig; - report_if_error!(settings.bedrock_models.set_value(models, ctx)); - }); - ctx.notify(); - } AISettingsPageAction::ToggleOpenAIEnabled => { AISettings::handle(ctx).update(ctx, |settings, ctx| { report_if_error!(settings.openai_enabled.toggle_and_save_value(ctx)); }); ctx.notify(); } + AISettingsPageAction::ConnectChatGPTSubscription => { + #[cfg(not(target_family = "wasm"))] + ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx)); + ctx.notify(); + } + AISettingsPageAction::OpenChatGPTDevicePage => + { + #[cfg(not(target_family = "wasm"))] + if let ChatGPTAuthState::AwaitingDeviceCode { + verification_uri, .. + } = ChatGPTAuthModel::as_ref(ctx).state() + { + ctx.open_url(verification_uri); + } + } + AISettingsPageAction::CopyChatGPTDeviceCode => { + #[cfg(not(target_family = "wasm"))] + let user_code = match ChatGPTAuthModel::as_ref(ctx).state() { + ChatGPTAuthState::AwaitingDeviceCode { user_code, .. } => { + Some(user_code.clone()) + } + ChatGPTAuthState::NotConnected + | ChatGPTAuthState::Connecting + | ChatGPTAuthState::Connected + | ChatGPTAuthState::Failed(_) => None, + }; + #[cfg(target_family = "wasm")] + let user_code: Option = None; + if let Some(user_code) = user_code { + ctx.clipboard() + .write(ClipboardContent::plain_text(user_code)); + let window_id = ctx.window_id(); + ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + crate::view_components::DismissibleToast::success( + "ChatGPT device code copied.".to_string(), + ), + window_id, + ctx, + ); + }); + } + } AISettingsPageAction::ToggleAcpEnabled => { if cfg!(unix) { AISettings::handle(ctx).update(ctx, |settings, ctx| { @@ -3585,26 +3815,23 @@ impl TypedActionView for AISettingsPageView { ctx.notify(); } } - AISettingsPageAction::RefreshAcpDiscovery => { - #[cfg(not(target_family = "wasm"))] - self.refresh_acp_discovery(ctx); - } AISettingsPageAction::FetchOpenAIProviderModels(provider_index) => { self.fetch_openai_provider_models(*provider_index, ctx); } AISettingsPageAction::AddOpenAIProvider => { + self.open_provider_setup_modal(None, ctx); + } + AISettingsPageAction::EditOpenAIProvider(provider_index) => { + self.open_provider_setup_modal(Some(*provider_index), ctx); + } + AISettingsPageAction::ToggleOpenAIProviderEnabled(provider_index) => { AISettings::handle(ctx).update(ctx, |settings, ctx| { let mut providers = settings.openai_providers.value().clone(); - let provider_number = providers.len() + 1; - providers.push(OpenAIProviderConfig { - name: format!("Provider {provider_number}"), - base_url: "http://localhost:4000/v1".to_string(), - api_key: None, - models: Vec::new(), - }); + if let Some(provider) = providers.get_mut(*provider_index) { + provider.enabled = !provider.enabled; + } report_if_error!(settings.openai_providers.set_value(providers, ctx)); }); - self.rebuild_active_subpage(ctx); } AISettingsPageAction::RemoveOpenAIProvider(provider_index) => { AISettings::handle(ctx).update(ctx, |settings, ctx| { @@ -3616,6 +3843,51 @@ impl TypedActionView for AISettingsPageView { }); self.rebuild_active_subpage(ctx); } + AISettingsPageAction::EditBedrockProvider => { + self.open_bedrock_setup_modal(ctx); + } + AISettingsPageAction::RemoveBedrockProvider => { + AISettings::handle(ctx).update(ctx, |settings, ctx| { + report_if_error!(settings.bedrock_enabled.set_value(false, ctx)); + report_if_error!(settings + .bedrock_auth_method + .set_value(crate::settings::ai::BedrockAuthMethod::default(), ctx)); + report_if_error!(settings + .bedrock_profile + .set_value("default".to_string(), ctx)); + report_if_error!(settings.bedrock_region.set_value(String::new(), ctx)); + report_if_error!(settings.bedrock_cross_region_inference.set_value(true, ctx)); + report_if_error!(settings.bedrock_auto_login.set_value(true, ctx)); + report_if_error!(settings + .bedrock_auth_refresh_command + .set_value("aws sso login".to_string(), ctx)); + report_if_error!(settings.bedrock_access_key_id.set_value(String::new(), ctx)); + report_if_error!(settings + .bedrock_secret_access_key + .set_value(String::new(), ctx)); + report_if_error!(settings.bedrock_models.set_value(Vec::new(), ctx)); + report_if_error!(settings + .bedrock_connection_name + .set_value("AWS Bedrock".to_string(), ctx,)); + }); + self.rebuild_active_subpage(ctx); + } + AISettingsPageAction::EditAcpProvider => { + self.open_acp_setup_modal(ctx); + } + AISettingsPageAction::RemoveAcpProvider => { + AISettings::handle(ctx).update(ctx, |settings, ctx| { + report_if_error!(settings.acp_enabled.set_value(false, ctx)); + report_if_error!(settings.acp_agent_id.set_value("codex".to_string(), ctx)); + report_if_error!(settings.acp_agent_command.set_value(String::new(), ctx)); + report_if_error!(settings.acp_agent_args.set_value(Vec::new(), ctx)); + report_if_error!(settings + .acp_connection_name + .set_value("ACP agent runtime".to_string(), ctx,)); + report_if_error!(settings.acp_agents.set_value(Vec::new(), ctx)); + }); + self.rebuild_active_subpage(ctx); + } AISettingsPageAction::ToggleFileBasedMcp => { AISettings::handle(ctx).update(ctx, |settings, ctx| { report_if_error!(settings.file_based_mcp_enabled.toggle_and_save_value(ctx)); @@ -7307,699 +7579,302 @@ impl SettingsWidget for ModelsOverviewWidget { } } -struct BedrockSettingsWidget { - enabled_toggle: SwitchStateHandle, - auto_login_toggle: SwitchStateHandle, - auth_method_dropdown: ViewHandle>, - profile_dropdown: ViewHandle>, - region_editor: ViewHandle, - auth_refresh_command_editor: ViewHandle, - access_key_editor: ViewHandle, - secret_key_editor: ViewHandle, - model_rig_toggles: RefCell>, +struct OpenAIProviderCardState { + enabled_switch: SwitchStateHandle, + edit_button: ViewHandle, + remove_button: ViewHandle, } -impl BedrockSettingsWidget { +struct OpenAIProviderSettingsWidget { + enabled_toggle: SwitchStateHandle, + bedrock_enabled_toggle: SwitchStateHandle, + acp_enabled_toggle: SwitchStateHandle, + bedrock_edit_button: ViewHandle, + bedrock_remove_button: ViewHandle, + acp_edit_button: ViewHandle, + acp_remove_button: ViewHandle, + add_provider_button: ViewHandle, + provider_cards: Vec, +} + +impl OpenAIProviderSettingsWidget { fn new(ctx: &mut ViewContext<::View>) -> Self { - let ai_settings = AISettings::as_ref(ctx); - let _is_enabled = *ai_settings.bedrock_enabled.value(); - - let region_val = ai_settings.bedrock_region.value().clone(); - let auth_cmd_val = ai_settings.bedrock_auth_refresh_command.value().clone(); - let access_key_val = ai_settings.bedrock_access_key_id.value().clone(); - let secret_key_val = ai_settings.bedrock_secret_access_key.value().clone(); - let bedrock_model_count = ai_settings.bedrock_models.value().len(); - - let auth_method_dropdown = ctx.add_typed_action_view(|ctx| { - let mut dropdown = Dropdown::new(ctx); - let methods = [ - BedrockAuthMethod::Profile, - BedrockAuthMethod::StaticKeys, - BedrockAuthMethod::Sso, - ]; - let current = *AISettings::as_ref(ctx).bedrock_auth_method.value(); - let selected_index = methods.iter().position(|m| *m == current).unwrap_or(0); - dropdown.add_items( - methods - .into_iter() - .map(|m| { - DropdownItem::new( - m.display_name(), - AISettingsPageAction::SetBedrockAuthMethod(m), - ) + let providers = AISettings::as_ref(ctx).openai_providers.value().clone(); + let provider_cards = providers + .iter() + .enumerate() + .map(|(index, _)| OpenAIProviderCardState { + enabled_switch: SwitchStateHandle::default(), + edit_button: ctx.add_typed_action_view(move |_| { + ActionButton::new("Edit", SecondaryTheme).on_click(move |ctx| { + ctx.dispatch_typed_action(AISettingsPageAction::EditOpenAIProvider(index)); }) - .collect(), - ctx, - ); - dropdown.set_selected_by_index(selected_index, ctx); - dropdown - }); - - let profile_dropdown = ctx.add_typed_action_view(|ctx| { - use crate::ai::bedrock::external_config::list_aws_profiles; - - let mut dropdown = Dropdown::new(ctx); - let profiles = list_aws_profiles(); - let current_profile = AISettings::as_ref(ctx).bedrock_profile.value().clone(); - let items: Vec<_> = profiles - .iter() - .map(|p| { - DropdownItem::new( - p.as_str(), - AISettingsPageAction::SetBedrockProfile(p.clone()), - ) + }), + remove_button: ctx.add_typed_action_view(move |_| { + ActionButton::new("Delete", DangerSecondaryTheme).on_click(move |ctx| { + ctx.dispatch_typed_action(AISettingsPageAction::RemoveOpenAIProvider( + index, + )); + }) + }), + }) + .collect(); + let add_provider_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Add provider", SecondaryTheme) + .with_icon(Icon::Plus) + .on_click(|ctx| { + ctx.dispatch_typed_action(AISettingsPageAction::AddOpenAIProvider); }) - .collect(); - let selected_index = profiles - .iter() - .position(|p| *p == current_profile) - .unwrap_or(0); - dropdown.add_items(items, ctx); - if !profiles.is_empty() { - dropdown.set_selected_by_index(selected_index, ctx); - } - dropdown }); - - let region_editor = ctx.add_typed_action_view(move |ctx| { - let appearance = Appearance::as_ref(ctx); - let options = SingleLineEditorOptions { - is_password: false, - text: TextOptions { - font_size_override: Some(appearance.ui_font_size()), - font_family_override: Some(appearance.monospace_font_family()), - text_colors_override: Some(TextColors { - default_color: appearance.theme().active_ui_text_color(), - disabled_color: appearance.theme().disabled_ui_text_color(), - hint_color: appearance.theme().disabled_ui_text_color(), - }), - ..Default::default() - }, - ..Default::default() - }; - let mut editor = EditorView::single_line(options, ctx); - editor.set_placeholder_text("auto-detect from profile", ctx); - editor.set_buffer_text(®ion_val, ctx); - editor + let bedrock_edit_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Edit", SecondaryTheme).on_click(|ctx| { + ctx.dispatch_typed_action(AISettingsPageAction::EditBedrockProvider); + }) }); - ctx.subscribe_to_view(®ion_editor, |_, editor, event, ctx| { - if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) { - let value = editor.as_ref(ctx).buffer_text(ctx); - AISettings::handle(ctx).update(ctx, |settings, ctx| { - let _ = settings.bedrock_region.set_value(value, ctx); - }); - } + let bedrock_remove_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Delete", DangerSecondaryTheme).on_click(|ctx| { + ctx.dispatch_typed_action(AISettingsPageAction::RemoveBedrockProvider); + }) }); - - let auth_refresh_command_editor = ctx.add_typed_action_view(move |ctx| { - let appearance = Appearance::as_ref(ctx); - let options = SingleLineEditorOptions { - is_password: false, - text: TextOptions { - font_size_override: Some(appearance.ui_font_size()), - font_family_override: Some(appearance.monospace_font_family()), - text_colors_override: Some(TextColors { - default_color: appearance.theme().active_ui_text_color(), - disabled_color: appearance.theme().disabled_ui_text_color(), - hint_color: appearance.theme().disabled_ui_text_color(), - }), - ..Default::default() - }, - ..Default::default() - }; - let mut editor = EditorView::single_line(options, ctx); - editor.set_placeholder_text("aws sso login", ctx); - editor.set_buffer_text(&auth_cmd_val, ctx); - editor + let acp_edit_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Edit", SecondaryTheme).on_click(|ctx| { + ctx.dispatch_typed_action(AISettingsPageAction::EditAcpProvider); + }) }); - ctx.subscribe_to_view(&auth_refresh_command_editor, |_, editor, event, ctx| { - if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) { - let buffer_text = editor.as_ref(ctx).buffer_text(ctx); - let value = if buffer_text.trim().is_empty() { - "aws sso login".to_string() - } else { - buffer_text - }; - AISettings::handle(ctx).update(ctx, |settings, ctx| { - let _ = settings.bedrock_auth_refresh_command.set_value(value, ctx); - }); - } - }); - - let access_key_editor = ctx.add_typed_action_view(move |ctx| { - let appearance = Appearance::as_ref(ctx); - let options = SingleLineEditorOptions { - is_password: false, - text: TextOptions { - font_size_override: Some(appearance.ui_font_size()), - font_family_override: Some(appearance.monospace_font_family()), - text_colors_override: Some(TextColors { - default_color: appearance.theme().active_ui_text_color(), - disabled_color: appearance.theme().disabled_ui_text_color(), - hint_color: appearance.theme().disabled_ui_text_color(), - }), - ..Default::default() - }, - ..Default::default() - }; - let mut editor = EditorView::single_line(options, ctx); - editor.set_placeholder_text("AKIA...", ctx); - editor.set_buffer_text(&access_key_val, ctx); - editor - }); - ctx.subscribe_to_view(&access_key_editor, |_, editor, event, ctx| { - if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) { - let value = editor.as_ref(ctx).buffer_text(ctx); - AISettings::handle(ctx).update(ctx, |settings, ctx| { - let _ = settings.bedrock_access_key_id.set_value(value, ctx); - }); - } - }); - - let secret_key_editor = ctx.add_typed_action_view(move |ctx| { - let appearance = Appearance::as_ref(ctx); - let options = SingleLineEditorOptions { - is_password: true, - text: TextOptions { - font_size_override: Some(appearance.ui_font_size()), - font_family_override: Some(appearance.monospace_font_family()), - text_colors_override: Some(TextColors { - default_color: appearance.theme().active_ui_text_color(), - disabled_color: appearance.theme().disabled_ui_text_color(), - hint_color: appearance.theme().disabled_ui_text_color(), - }), - ..Default::default() - }, - ..Default::default() - }; - let mut editor = EditorView::single_line(options, ctx); - editor.set_placeholder_text("wJalr...", ctx); - editor.set_buffer_text(&secret_key_val, ctx); - editor - }); - ctx.subscribe_to_view(&secret_key_editor, |_, editor, event, ctx| { - if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) { - let value = editor.as_ref(ctx).buffer_text(ctx); - AISettings::handle(ctx).update(ctx, |settings, ctx| { - let _ = settings.bedrock_secret_access_key.set_value(value, ctx); - }); - } - }); - - let profile_dropdown_clone = profile_dropdown.clone(); - let region_editor_clone = region_editor.clone(); - let auth_refresh_command_editor_clone = auth_refresh_command_editor.clone(); - let access_key_editor_clone = access_key_editor.clone(); - let secret_key_editor_clone = secret_key_editor.clone(); - ctx.subscribe_to_model(&AISettings::handle(ctx), move |_, _, event, ctx| { - if matches!(event, AISettingsChangedEvent::BedrockEnabled { .. }) { - let is_enabled = *AISettings::as_ref(ctx).bedrock_enabled.value(); - profile_dropdown_clone.update(ctx, |dropdown, ctx| { - if is_enabled { - dropdown.set_enabled(ctx); - } else { - dropdown.set_disabled(ctx); - } - }); - AISettingsPageView::update_editor_interaction_state( - region_editor_clone.clone(), - is_enabled, - ctx, - ); - AISettingsPageView::update_editor_interaction_state( - auth_refresh_command_editor_clone.clone(), - is_enabled, - ctx, - ); - AISettingsPageView::update_editor_interaction_state( - access_key_editor_clone.clone(), - is_enabled, - ctx, - ); - AISettingsPageView::update_editor_interaction_state( - secret_key_editor_clone.clone(), - is_enabled, - ctx, - ); - ctx.notify(); - } + let acp_remove_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Delete", DangerSecondaryTheme).on_click(|ctx| { + ctx.dispatch_typed_action(AISettingsPageAction::RemoveAcpProvider); + }) }); Self { enabled_toggle: SwitchStateHandle::default(), - auto_login_toggle: SwitchStateHandle::default(), - auth_method_dropdown, - profile_dropdown, - region_editor, - auth_refresh_command_editor, - access_key_editor, - secret_key_editor, - model_rig_toggles: RefCell::new( - (0..bedrock_model_count) - .map(|_| SwitchStateHandle::default()) - .collect(), - ), + bedrock_enabled_toggle: SwitchStateHandle::default(), + acp_enabled_toggle: SwitchStateHandle::default(), + bedrock_edit_button, + bedrock_remove_button, + acp_edit_button, + acp_remove_button, + add_provider_button, + provider_cards, } } - fn render_input( + fn provider_status(provider: &OpenAIProviderConfig) -> String { + if !provider.enabled { + return "Disabled".to_string(); + } + + let enabled_models = provider.models.iter().filter(|model| model.enabled).count(); + if enabled_models == 0 { + "No models enabled".to_string() + } else { + format!( + "{enabled_models} enabled model{}", + if enabled_models == 1 { "" } else { "s" } + ) + } + } + + fn provider_type(provider: &OpenAIProviderConfig) -> &'static str { + match provider.kind { + OpenAIProviderKind::OpenAICompatible => "OpenAI-compatible API", + OpenAIProviderKind::ChatGPTSubscription => "ChatGPT subscription", + } + } + + fn render_provider_card( + &self, + provider_index: usize, + provider: &OpenAIProviderConfig, appearance: &Appearance, - label: &'static str, - editor: ViewHandle, - is_enabled: bool, - app: &AppContext, ) -> Box { - let padding = Some(Coords { - top: 10., - bottom: 10., - left: 16., - right: 16., - }); - let editor_style = UiComponentStyles { - padding, - background: Some(appearance.theme().surface_2().into()), - ..Default::default() + let Some(card_state) = self.provider_cards.get(provider_index) else { + return Empty::new().finish(); }; + let summary = format!( + "{} model{} available · {}", + provider.models.len(), + if provider.models.len() == 1 { "" } else { "s" }, + Self::provider_status(provider), + ); - let label = Text::new_inline(label, appearance.ui_font_family(), CONTENT_FONT_SIZE) - .with_color(styles::header_font_color(is_enabled, app).into()) + let title = Text::new( + provider.name.clone(), + appearance.ui_font_family(), + appearance.header_font_size(), + ) + .with_color(appearance.theme().active_ui_text_color().into()) + .with_style(Properties::default().weight(Weight::Bold)) + .finish(); + let provider_type = Text::new( + Self::provider_type(provider), + appearance.ui_font_family(), + CONTENT_FONT_SIZE, + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .finish(); + let summary = Text::new(summary, appearance.ui_font_family(), CONTENT_FONT_SIZE) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) .finish(); - let input = appearance - .ui_builder() - .text_input(editor) - .with_style(editor_style) - .build() - .finish(); - - Flex::column() + let actions = Flex::row() .with_spacing(8.) - .with_child(label) - .with_child(input) - .finish() + .with_child(ChildView::new(&card_state.edit_button).finish()) + .with_child(ChildView::new(&card_state.remove_button).finish()) + .finish(); + let header = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + Flex::column() + .with_spacing(4.) + .with_child(title) + .with_child(provider_type) + .finish(), + ) + .with_child(actions) + .finish(); + let toggle = appearance + .ui_builder() + .switch(card_state.enabled_switch.clone()) + .check(provider.enabled) + .build() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(AISettingsPageAction::ToggleOpenAIProviderEnabled( + provider_index, + )); + }) + .finish(); + let enablement = Flex::row() + .with_spacing(8.) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(toggle) + .with_child( + Text::new( + if provider.enabled { + "Enabled" + } else { + "Disabled" + }, + appearance.ui_font_family(), + CONTENT_FONT_SIZE, + ) + .with_color(appearance.theme().active_ui_text_color().into()) + .finish(), + ) + .finish(); + + Container::new( + Flex::column() + .with_spacing(12.) + .with_child(header) + .with_child(summary) + .with_child(enablement) + .finish(), + ) + .with_padding(Padding::uniform(16.)) + .with_background(appearance.theme().surface_1()) + .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) + .finish() + } + + #[allow(clippy::too_many_arguments)] + fn render_builtin_provider_card( + &self, + title: &str, + description: &'static str, + enabled: bool, + toggle: SwitchStateHandle, + action: AISettingsPageAction, + edit_button: &ViewHandle, + remove_button: &ViewHandle, + appearance: &Appearance, + ) -> Box { + let toggle = appearance + .ui_builder() + .switch(toggle) + .check(enabled) + .build() + .on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone())) + .finish(); + let header = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + Flex::column() + .with_spacing(4.) + .with_child( + Text::new( + title.to_string(), + appearance.ui_font_family(), + appearance.header_font_size(), + ) + .with_color(appearance.theme().active_ui_text_color().into()) + .with_style(Properties::default().weight(Weight::Bold)) + .finish(), + ) + .with_child( + Text::new(description, appearance.ui_font_family(), CONTENT_FONT_SIZE) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ) + .finish(), + ) + .with_child( + Flex::row() + .with_spacing(8.) + .with_child(ChildView::new(edit_button).finish()) + .with_child(ChildView::new(remove_button).finish()) + .finish(), + ) + .finish(); + Container::new( + Flex::column() + .with_spacing(12.) + .with_child(header) + .with_child( + Flex::row() + .with_spacing(8.) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(toggle) + .with_child( + Text::new( + if enabled { "Enabled" } else { "Disabled" }, + appearance.ui_font_family(), + CONTENT_FONT_SIZE, + ) + .with_color(appearance.theme().active_ui_text_color().into()) + .finish(), + ) + .finish(), + ) + .finish(), + ) + .with_padding(Padding::uniform(16.)) + .with_background(appearance.theme().surface_1()) + .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) + .finish() } } -impl SettingsWidget for BedrockSettingsWidget { +impl SettingsWidget for OpenAIProviderSettingsWidget { type View = AISettingsPageView; fn search_terms(&self) -> &str { - "aws bedrock amazon credentials login profile region sso static keys" + "openai chatgpt pro subscription litellm custom provider endpoint api key models" } fn should_render(&self, _app: &AppContext) -> bool { true } - fn render( - &self, - _view: &Self::View, - appearance: &Appearance, - app: &AppContext, - ) -> Box { - let ai_settings = AISettings::as_ref(app); - let is_enabled = *ai_settings.bedrock_enabled.value(); - let auth_method = *ai_settings.bedrock_auth_method.value(); - let auto_login = *ai_settings.bedrock_auto_login.value(); - - let mut column = Flex::column().with_spacing(16.); - - column.add_child(build_sub_header(appearance, "AWS Bedrock", None).finish()); - - let has_aws_env = std::env::vars_os().any(|(k, _)| k.to_string_lossy().starts_with("AWS_")); - - if has_aws_env { - column.add_child(render_warning_box( - WarningBoxConfig::new("You have AWS environment variables defined, which may override these settings."), - appearance, - )); - } - - column.add_child(render_ai_setting_toggle::( - "Enable AWS Bedrock", - AISettingsPageAction::ToggleBedrockEnabled, - is_enabled, - true, - self.enabled_toggle.clone(), - &RefCell::new(HashMap::new()), - app, - )); - column.add_child(render_ai_setting_description( - "Route AI requests directly through AWS Bedrock using your own credentials.", - true, - app, - )); - - column.add_child(render_separator(appearance)); - - let auth_label = Text::new_inline( - "Authentication Method", - appearance.ui_font_family(), - CONTENT_FONT_SIZE, - ) - .with_color(styles::header_font_color(is_enabled, app).into()) - .finish(); - column.add_child( - Flex::column() - .with_spacing(8.) - .with_child(auth_label) - .with_child(ChildView::new(&self.auth_method_dropdown).finish()) - .finish(), - ); - - match auth_method { - BedrockAuthMethod::Profile | BedrockAuthMethod::Sso => { - let profile_label = Text::new_inline( - "AWS Profile", - appearance.ui_font_family(), - CONTENT_FONT_SIZE, - ) - .with_color(styles::header_font_color(is_enabled, app).into()) - .finish(); - column.add_child( - Flex::column() - .with_spacing(8.) - .with_child(profile_label) - .with_child(ChildView::new(&self.profile_dropdown).finish()) - .finish(), - ); - if auth_method == BedrockAuthMethod::Sso { - column.add_child(Self::render_input( - appearance, - "Login Command", - self.auth_refresh_command_editor.clone(), - is_enabled, - app, - )); - - column.add_child( - Flex::column() - .with_child(render_ai_setting_toggle::( - "Auto-run login on expiry", - AISettingsPageAction::ToggleBedrockAutoLogin, - auto_login, - is_enabled, - self.auto_login_toggle.clone(), - &RefCell::new(HashMap::new()), - app, - )) - .with_child(render_ai_setting_description( - "Automatically run the login command when credentials expire.", - is_enabled, - app, - )) - .finish(), - ); - } - } - BedrockAuthMethod::StaticKeys => { - column.add_child(Self::render_input( - appearance, - "Access Key ID", - self.access_key_editor.clone(), - is_enabled, - app, - )); - column.add_child(Self::render_input( - appearance, - "Secret Access Key", - self.secret_key_editor.clone(), - is_enabled, - app, - )); - } - } - - column.add_child(render_separator(appearance)); - - column.add_child(Self::render_input( - appearance, - "Region", - self.region_editor.clone(), - is_enabled, - app, - )); - column.add_child(render_ai_setting_description( - "Leave empty to auto-detect from your AWS profile/config.", - is_enabled, - app, - )); - - column.add_child(render_separator(appearance)); - - let configured_models: Vec<_> = ai_settings.bedrock_models.value().clone(); - if !configured_models.is_empty() { - let description = format!( - "{} model{} configured via settings.toml.", - configured_models.len(), - if configured_models.len() == 1 { - "" - } else { - "s" - } - ); - column.add_child(render_ai_setting_description(description, is_enabled, app)); - column.add_child(build_sub_header(appearance, "Bedrock runtime", None).finish()); - column.add_child(render_ai_setting_description( - "Opt individual Bedrock models into the shared Rig runtime. Models left off continue through the compatibility runtime; one-hour prompt-cache TTL requests always fall back automatically.", - is_enabled, - app, - )); - - let toggle_handles = { - let mut toggles = self.model_rig_toggles.borrow_mut(); - while toggles.len() < configured_models.len() { - toggles.push(SwitchStateHandle::default()); - } - toggles.clone() - }; - for (index, model) in configured_models.iter().enumerate() { - let toggle = appearance - .ui_builder() - .switch(toggle_handles[index].clone()) - .check(model.use_rig) - .with_disabled(!is_enabled) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(AISettingsPageAction::ToggleBedrockModelRig( - index, - )); - }) - .finish(); - column.add_child(build_toggle_element( - render_body_item_label::( - format!("{} — Rig", model.display_name), - Some(styles::header_font_color(is_enabled, app)), - None, - LocalOnlyIconState::Hidden, - ToggleState::Enabled, - appearance, - ), - toggle, - appearance, - None, - )); - } - } else { - column.add_child(render_ai_setting_description( - "No models configured. Add models to ~/.galaxy/settings.toml under [ai.bedrock].", - is_enabled, - app, - )); - } - - column.finish() - } -} - -struct AgentRuntimeSettingsWidget { - enabled_toggle: SwitchStateHandle, - agent_id_editor: ViewHandle, - command_editor: ViewHandle, - args_editor: ViewHandle, - #[cfg(not(target_family = "wasm"))] - refresh_button: ViewHandle, -} - -impl AgentRuntimeSettingsWidget { - fn new(ctx: &mut ViewContext<::View>) -> Self { - let settings = AISettings::as_ref(ctx); - let is_enabled = *settings.acp_enabled.value(); - let agent_id = settings.acp_agent_id.value().clone(); - let command = settings.acp_agent_command.value().clone(); - let args = serde_json::to_string(settings.acp_agent_args.value()) - .unwrap_or_else(|_| "[]".to_owned()); - - let agent_id_editor = Self::editor(agent_id, "codex or opencode", false, ctx); - ctx.subscribe_to_view(&agent_id_editor, |_, editor, event, ctx| { - if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) { - let value = editor.as_ref(ctx).buffer_text(ctx); - if !value.trim().is_empty() { - AISettings::handle(ctx).update(ctx, |settings, ctx| { - report_if_error!(settings.acp_agent_id.set_value(value, ctx)); - }); - } - } - }); - - let command_editor = Self::editor( - command, - "Leave empty to use the version-pinned preset", - false, - ctx, - ); - ctx.subscribe_to_view(&command_editor, |_, editor, event, ctx| { - if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) { - let value = editor.as_ref(ctx).buffer_text(ctx); - AISettings::handle(ctx).update(ctx, |settings, ctx| { - report_if_error!(settings.acp_agent_command.set_value(value, ctx)); - }); - } - }); - - let args_editor = Self::editor(args, r#"["arg1", "arg2"]"#, false, ctx); - ctx.subscribe_to_view(&args_editor, |_, editor, event, ctx| { - if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) { - let value = editor.as_ref(ctx).buffer_text(ctx); - match serde_json::from_str::>(&value) { - Ok(args) => { - AISettings::handle(ctx).update(ctx, |settings, ctx| { - report_if_error!(settings.acp_agent_args.set_value(args, ctx)); - }); - } - Err(error) => { - log::warn!("ACP agent arguments must be a JSON string array: {error}"); - let saved_args = - serde_json::to_string(AISettings::as_ref(ctx).acp_agent_args.value()) - .unwrap_or_else(|_| "[]".to_owned()); - editor.update(ctx, |editor, ctx| { - editor.system_reset_buffer_text(&saved_args, ctx); - }); - } - } - } - }); - - #[cfg(not(target_family = "wasm"))] - let refresh_button = ctx.add_typed_action_view(|_| { - ActionButton::new("Discover ACP options", SecondaryTheme) - .with_size(ButtonSize::Small) - .on_click(|ctx| { - ctx.dispatch_typed_action(AISettingsPageAction::RefreshAcpDiscovery); - }) - }); - - for editor in [ - agent_id_editor.clone(), - command_editor.clone(), - args_editor.clone(), - ] { - AISettingsPageView::update_editor_interaction_state(editor, is_enabled, ctx); - } - - let agent_id_editor_clone = agent_id_editor.clone(); - let command_editor_clone = command_editor.clone(); - let args_editor_clone = args_editor.clone(); - ctx.subscribe_to_model(&AISettings::handle(ctx), move |_, _, event, ctx| { - if matches!(event, AISettingsChangedEvent::AcpEnabled { .. }) { - let is_enabled = *AISettings::as_ref(ctx).acp_enabled.value(); - for editor in [ - agent_id_editor_clone.clone(), - command_editor_clone.clone(), - args_editor_clone.clone(), - ] { - AISettingsPageView::update_editor_interaction_state(editor, is_enabled, ctx); - } - ctx.notify(); - } - }); - - Self { - enabled_toggle: SwitchStateHandle::default(), - agent_id_editor, - command_editor, - args_editor, - #[cfg(not(target_family = "wasm"))] - refresh_button, - } - } - - fn editor( - text: String, - placeholder: &'static str, - is_password: bool, - ctx: &mut ViewContext, - ) -> ViewHandle { - ctx.add_typed_action_view(move |ctx| { - let appearance = Appearance::as_ref(ctx); - let options = SingleLineEditorOptions { - is_password, - text: TextOptions { - font_size_override: Some(appearance.ui_font_size()), - font_family_override: Some(appearance.monospace_font_family()), - text_colors_override: Some(TextColors { - default_color: appearance.theme().active_ui_text_color(), - disabled_color: appearance.theme().disabled_ui_text_color(), - hint_color: appearance.theme().disabled_ui_text_color(), - }), - ..Default::default() - }, - ..Default::default() - }; - let mut editor = EditorView::single_line(options, ctx); - editor.set_placeholder_text(placeholder, ctx); - editor.set_buffer_text(&text, ctx); - editor - }) - } - - fn render_input( - appearance: &Appearance, - label: &'static str, - editor: ViewHandle, - is_enabled: bool, - app: &AppContext, - ) -> Box { - let style = UiComponentStyles { - padding: Some(Coords { - top: 10., - bottom: 10., - left: 16., - right: 16., - }), - background: Some(appearance.theme().surface_2().into()), - ..Default::default() - }; - Flex::column() - .with_spacing(8.) - .with_child( - Text::new_inline(label, appearance.ui_font_family(), CONTENT_FONT_SIZE) - .with_color(styles::header_font_color(is_enabled, app).into()) - .finish(), - ) - .with_child( - appearance - .ui_builder() - .text_input(editor) - .with_style(style) - .build() - .finish(), - ) - .finish() - } -} - -impl SettingsWidget for AgentRuntimeSettingsWidget { - type View = AISettingsPageView; - - fn search_terms(&self) -> &str { - "agent runtime acp agent client protocol codex opencode subscription local" - } - - fn should_render(&self, _app: &AppContext) -> bool { - cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() - } - fn render( &self, _view: &Self::View, @@ -8007,343 +7882,19 @@ impl SettingsWidget for AgentRuntimeSettingsWidget { app: &AppContext, ) -> Box { let settings = AISettings::as_ref(app); - let is_enabled = *settings.acp_enabled.value(); - let mut column = Flex::column().with_spacing(16.); - - #[cfg(not(target_family = "wasm"))] - let discovery_state = crate::ai::acp::AcpRuntimeModel::as_ref(app) - .discovery_state() - .clone(); - - column.add_child(build_sub_header(appearance, "Agent runtimes", None).finish()); - column.add_child(render_ai_setting_description( - "Connect session-oriented coding agents through the Agent Client Protocol. Agent runtimes are not model providers: Galaxy supplies the workspace UI and safety boundary while the agent owns its model loop and session.", - true, - app, - )); - column.add_child(render_ai_setting_toggle::( - "Enable ACP agent runtime", - AISettingsPageAction::ToggleAcpEnabled, - is_enabled, - true, - self.enabled_toggle.clone(), - &RefCell::new(HashMap::new()), - app, - )); - column.add_child(render_ai_setting_description( - "ACP agents own their model and login. The Codex preset prefers its advertised ChatGPT sign-in, while custom agents use their first advertised ACP authentication method; tokens remain owned by the agent. Galaxy keeps the native transcript, cancellation, image uploads, and pane-pinned Galaxy Control tools.", - true, - app, - )); - column.add_child(render_ai_setting_description( - "The built-in Codex adapter starts in read-only mode. Galaxy currently denies adapter-native read, search, edit, delete, move, execute, fetch, and uncategorized permission requests because ACP’s broad categories do not carry enough command, path, or MCP identity to enforce detailed allowlists safely. Agent thinking remains available. Pane-pinned Galaxy tools are exposed only when the active execution profile permits them. Custom ACP agents must honor the protocol’s permission contract.", - true, - app, - )); - column.add_child(render_separator(appearance)); - column.add_child(Self::render_input( - appearance, - "Agent preset", - self.agent_id_editor.clone(), - is_enabled, - app, - )); - column.add_child(render_ai_setting_description( - "Use “codex” for the pinned Codex ACP adapter or “opencode” for OpenCode. Codex can launch through npx or Bun. Galaxy prefers an installed OpenCode binary; its package fallback requires npx/Node.js.", - is_enabled, - app, - )); - column.add_child(Self::render_input( - appearance, - "Custom executable (optional)", - self.command_editor.clone(), - is_enabled, - app, - )); - column.add_child(Self::render_input( - appearance, - "Custom arguments (JSON array)", - self.args_editor.clone(), - is_enabled, - app, - )); - column.add_child(render_ai_setting_description( - "ACP agents are trusted local programs. Custom arguments apply only when a custom executable is set; built-in presets ignore them. Galaxy removes inherited environment values outside a small runtime allowlist, and custom arguments are stored as plain-text settings. Only configure executables you trust, and never place API keys or access tokens in their arguments. Existing ACP sessions refuse to run after the effective executable, preset version, arguments, environment, or authentication selection changes; restore that configuration or start a new conversation.", - is_enabled, - app, - )); - #[cfg(not(target_family = "wasm"))] - column.add_child(self.refresh_button.as_ref(app).render(app)); - #[cfg(not(target_family = "wasm"))] - match discovery_state { - crate::ai::acp::AcpDiscoveryState::Idle => {} - crate::ai::acp::AcpDiscoveryState::Running => { - column.add_child(render_ai_setting_description( - "Discovering ACP configuration options...", - is_enabled, - app, - )); - } - crate::ai::acp::AcpDiscoveryState::Succeeded { option_count } => { - column.add_child(render_ai_setting_description( - format!("ACP discovery succeeded with {option_count} configuration option(s)."), - is_enabled, - app, - )); - } - crate::ai::acp::AcpDiscoveryState::Failed { message } => { - column.add_child(render_ai_setting_description( - format!("ACP discovery failed: {message}. Cached options were retained."), - is_enabled, - app, - )); - } - } - let discovered = settings.acp_agents.value(); - if let Some(agent) = discovered - .iter() - .find(|agent| agent.id.eq_ignore_ascii_case(settings.acp_agent_id.value())) - { - if let Some(timestamp) = &agent.discovery_timestamp { - column.add_child(render_ai_setting_description( - format!("Last ACP discovery attempt: {timestamp}"), - is_enabled, - app, - )); - } - if let Some(source) = &agent.discovery_source { - column.add_child(render_ai_setting_description( - format!("Discovery source: {source}"), - is_enabled, - app, - )); - } - if let Some(error) = &agent.discovery_error { - column.add_child(render_ai_setting_description( - format!("Last discovery error: {error}. Cached options are retained."), - is_enabled, - app, - )); - } - column.add_child(render_ai_setting_description( - format!("Discovered {} ACP configuration option(s) for {}. Options are refreshed from the running agent and cached in settings.toml.", agent.config_options.len(), agent.name), - is_enabled, - app, - )); - } - column.finish() - } -} - -struct OpenAIProviderEditor { - name_editor: ViewHandle, - base_url_editor: ViewHandle, - api_key_editor: ViewHandle, - fetch_button: MouseStateHandle, - remove_button: MouseStateHandle, -} - -struct OpenAISettingsWidget { - enabled_toggle: SwitchStateHandle, - provider_editors: Vec, - add_provider_button: MouseStateHandle, -} - -impl OpenAISettingsWidget { - fn create_editor( - value: String, - placeholder: &'static str, - is_password: bool, - ctx: &mut ViewContext<::View>, - ) -> ViewHandle { - ctx.add_typed_action_view(move |ctx| { - let appearance = Appearance::as_ref(ctx); - let options = SingleLineEditorOptions { - is_password, - text: TextOptions { - font_size_override: Some(appearance.ui_font_size()), - font_family_override: Some(appearance.monospace_font_family()), - text_colors_override: Some(TextColors { - default_color: appearance.theme().active_ui_text_color(), - disabled_color: appearance.theme().disabled_ui_text_color(), - hint_color: appearance.theme().disabled_ui_text_color(), - }), - ..Default::default() - }, - ..Default::default() - }; - let mut editor = EditorView::single_line(options, ctx); - editor.set_placeholder_text(placeholder, ctx); - editor.set_buffer_text(&value, ctx); - editor - }) - } - - fn new(ctx: &mut ViewContext<::View>) -> Self { - let providers = AISettings::as_ref(ctx).openai_providers.value().clone(); - let is_enabled = *AISettings::as_ref(ctx).openai_enabled.value(); - let mut provider_editors = Vec::with_capacity(providers.len()); - - for (provider_index, provider) in providers.into_iter().enumerate() { - let name_editor = Self::create_editor(provider.name, "Provider name", false, ctx); - ctx.subscribe_to_view(&name_editor, move |_, editor, event, ctx| { - if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) { - let value = editor.as_ref(ctx).buffer_text(ctx); - AISettings::handle(ctx).update(ctx, |settings, ctx| { - let mut providers = settings.openai_providers.value().clone(); - if let Some(provider) = providers.get_mut(provider_index) { - provider.name = value; - report_if_error!(settings.openai_providers.set_value(providers, ctx)); - } - }); - } - }); - - let base_url_editor = - Self::create_editor(provider.base_url, "http://localhost:4000/v1", false, ctx); - ctx.subscribe_to_view(&base_url_editor, move |_, editor, event, ctx| { - if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) { - let value = editor.as_ref(ctx).buffer_text(ctx); - AISettings::handle(ctx).update(ctx, |settings, ctx| { - let mut providers = settings.openai_providers.value().clone(); - if let Some(provider) = providers.get_mut(provider_index) { - provider.base_url = value; - report_if_error!(settings.openai_providers.set_value(providers, ctx)); - } - }); - } - }); - - let api_key_editor = Self::create_editor( - provider.api_key.unwrap_or_default(), - "sk-... (optional)", - true, - ctx, - ); - ctx.subscribe_to_view(&api_key_editor, move |_, editor, event, ctx| { - if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) { - let value = editor.as_ref(ctx).buffer_text(ctx); - AISettings::handle(ctx).update(ctx, |settings, ctx| { - let mut providers = settings.openai_providers.value().clone(); - if let Some(provider) = providers.get_mut(provider_index) { - provider.api_key = (!value.is_empty()).then_some(value); - report_if_error!(settings.openai_providers.set_value(providers, ctx)); - } - }); - } - }); - - for editor in [&name_editor, &base_url_editor, &api_key_editor] { - AISettingsPageView::update_editor_interaction_state( - editor.clone(), - is_enabled, - ctx, - ); - } - - provider_editors.push(OpenAIProviderEditor { - name_editor, - base_url_editor, - api_key_editor, - fetch_button: MouseStateHandle::default(), - remove_button: MouseStateHandle::default(), - }); - } - - let editor_handles = provider_editors - .iter() - .flat_map(|provider| { - [ - provider.name_editor.clone(), - provider.base_url_editor.clone(), - provider.api_key_editor.clone(), - ] - }) - .collect::>(); - ctx.subscribe_to_model(&AISettings::handle(ctx), move |_, _, event, ctx| { - if matches!(event, AISettingsChangedEvent::OpenAIEnabled { .. }) { - let is_enabled = *AISettings::as_ref(ctx).openai_enabled.value(); - for editor in &editor_handles { - AISettingsPageView::update_editor_interaction_state( - editor.clone(), - is_enabled, - ctx, - ); - } - ctx.notify(); - } - }); - - Self { - enabled_toggle: SwitchStateHandle::default(), - provider_editors, - add_provider_button: MouseStateHandle::default(), - } - } - - fn render_input( - appearance: &Appearance, - label: &'static str, - editor: ViewHandle, - is_enabled: bool, - app: &AppContext, - ) -> Box { - let padding = Some(Coords { - top: 10., - bottom: 10., - left: 16., - right: 16., - }); - let editor_style = UiComponentStyles { - padding, - background: Some(appearance.theme().surface_2().into()), - ..Default::default() - }; - - let label = Text::new_inline(label, appearance.ui_font_family(), CONTENT_FONT_SIZE) - .with_color(styles::header_font_color(is_enabled, app).into()) - .finish(); - - let input = appearance - .ui_builder() - .text_input(editor) - .with_style(editor_style) - .build() - .finish(); - - Flex::column() - .with_spacing(8.) - .with_child(label) - .with_child(input) - .finish() - } -} - -impl SettingsWidget for OpenAISettingsWidget { - type View = AISettingsPageView; - - fn search_terms(&self) -> &str { - "openai litellm custom provider endpoint api key models" - } - - fn should_render(&self, _app: &AppContext) -> bool { - true - } - - fn render( - &self, - _view: &Self::View, - appearance: &Appearance, - app: &AppContext, - ) -> Box { - let ai_settings = AISettings::as_ref(app); - let is_enabled = *ai_settings.openai_enabled.value(); + let is_enabled = *settings.openai_enabled.value(); + let providers = settings.openai_providers.value(); let mut column = Flex::column().with_spacing(16.); - - column - .add_child(build_sub_header(appearance, "OpenAI-compatible providers", None).finish()); - + column.add_child( + Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(build_sub_header(appearance, "Model providers", None).finish()) + .with_child(ChildView::new(&self.add_provider_button).finish()) + .finish(), + ); column.add_child(render_ai_setting_toggle::( "Enable model providers", AISettingsPageAction::ToggleOpenAIEnabled, @@ -8354,185 +7905,46 @@ impl SettingsWidget for OpenAISettingsWidget { app, )); column.add_child(render_ai_setting_description( - "Route configured LiteLLM, Ollama, LM Studio, vLLM, and other OpenAI-compatible models through Galaxy's provider registry.", + "Connect a ChatGPT subscription, OpenAI-compatible endpoint, AWS Bedrock account, or ACP agent runtime. Each provider can be enabled independently.", true, app, )); - if ai_settings.openai_providers.value().is_empty() { - column.add_child(render_ai_setting_description( - "No providers configured. Add a provider to connect a local or private OpenAI-compatible endpoint.", - is_enabled, - app, - )); - } - - for (provider_index, provider) in ai_settings.openai_providers.value().iter().enumerate() { - let Some(editors) = self.provider_editors.get(provider_index) else { - continue; - }; - - column.add_child(render_separator(appearance)); - column.add_child( - build_sub_header( - appearance, - format!("Provider {}: {}", provider_index + 1, provider.name), - None, - ) - .finish(), - ); - column.add_child(Self::render_input( - appearance, - "Name", - editors.name_editor.clone(), - is_enabled, - app, - )); - column.add_child(Self::render_input( - appearance, - "Base URL", - editors.base_url_editor.clone(), - is_enabled, - app, - )); - column.add_child(Self::render_input( - appearance, - "API Key", - editors.api_key_editor.clone(), - is_enabled, - app, - )); - column.add_child(render_ai_setting_description( - "The API key is optional, stored only in ~/.galaxy/settings.toml, and never synced to the cloud.", - is_enabled, - app, - )); - - let fetch_button = appearance - .ui_builder() - .button(ButtonVariant::Secondary, editors.fetch_button.clone()) - .with_text_label("Discover Models".to_owned()); - let fetch_button = if !is_enabled || provider.base_url.trim().is_empty() { - fetch_button.disabled().build().finish() - } else { - fetch_button - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(AISettingsPageAction::FetchOpenAIProviderModels( - provider_index, - )); - }) - .finish() - }; - - let remove_button = appearance - .ui_builder() - .button(ButtonVariant::Error, editors.remove_button.clone()) - .with_text_label("Remove Provider".to_owned()) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(AISettingsPageAction::RemoveOpenAIProvider( - provider_index, - )); - }) - .finish(); - column.add_child( - Flex::row() - .with_spacing(8.) - .with_child(fetch_button) - .with_child(remove_button) - .finish(), - ); - - let model_names = provider - .models - .iter() - .take(5) - .map(|model| model.display_name.as_str()) - .join(", "); - let overflow = provider.models.len().saturating_sub(5); - let overflow = if overflow > 0 { - format!(" (+{overflow} more)") - } else { - String::new() - }; - let models_description = if provider.models.is_empty() { - "No models configured. Discover models from this endpoint.".to_string() - } else { - format!( - "{} model{}: {model_names}{overflow}", - provider.models.len(), - if provider.models.len() == 1 { "" } else { "s" }, - ) - }; - column.add_child(render_ai_setting_description( - models_description, - is_enabled, - app, - )); - } - - column.add_child(render_separator(appearance)); - let add_provider_button = appearance - .ui_builder() - .button(ButtonVariant::Secondary, self.add_provider_button.clone()) - .with_text_label("Add Provider".to_owned()) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(AISettingsPageAction::AddOpenAIProvider); - }) - .finish(); - column.add_child(add_provider_button); - column.add_child(render_ai_setting_description( - "Model discovery only contacts an endpoint when you click Discover Models.", - is_enabled, - app, + column.add_child(self.render_builtin_provider_card( + settings.bedrock_connection_name.value().as_str(), + "Use AWS credentials to access Bedrock foundation models directly.", + *settings.bedrock_enabled.value(), + self.bedrock_enabled_toggle.clone(), + AISettingsPageAction::ToggleBedrockEnabled, + &self.bedrock_edit_button, + &self.bedrock_remove_button, + appearance, )); + if cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() { + column.add_child(self.render_builtin_provider_card( + settings.acp_connection_name.value().as_str(), + "Use a local session-oriented agent that owns its model and authentication.", + *settings.acp_enabled.value(), + self.acp_enabled_toggle.clone(), + AISettingsPageAction::ToggleAcpEnabled, + &self.acp_edit_button, + &self.acp_remove_button, + appearance, + )); + } - column.add_child(render_separator(appearance)); - - let mut configured_models = ai_settings - .openai_providers - .value() - .iter() - .flat_map(|provider| provider.models.iter()) - .collect::>(); - configured_models.extend(ai_settings.openai_models.value().iter()); - if !configured_models.is_empty() { - let description = format!( - "{} model{} configured across all OpenAI-compatible providers.", - configured_models.len(), - if configured_models.len() == 1 { - "" - } else { - "s" - } - ); - column.add_child(render_ai_setting_description(description, is_enabled, app)); - - // Show first few model names - let preview: String = configured_models - .iter() - .take(5) - .map(|model| model.display_name.as_str()) - .collect::>() - .join(", "); - let suffix = if configured_models.len() > 5 { - format!(" (+{} more)", configured_models.len() - 5) - } else { - String::new() - }; + if providers.is_empty() { column.add_child(render_ai_setting_description( - format!("Models: {preview}{suffix}"), + "No providers configured. Select Add provider to get started.", is_enabled, app, )); } else { - column.add_child(render_ai_setting_description( - "No models configured. Add a provider and discover its models, or configure [[ai.providers.models]] in ~/.galaxy/settings.toml.", - is_enabled, - app, - )); + for (index, provider) in providers.iter().enumerate() { + column.add_child(Self::render_provider_card( + self, index, provider, appearance, + )); + } } column.finish() diff --git a/app/src/settings_view/mod.rs b/app/src/settings_view/mod.rs index 5e8dc963..b841405a 100644 --- a/app/src/settings_view/mod.rs +++ b/app/src/settings_view/mod.rs @@ -86,6 +86,7 @@ mod platform; mod platform_page; mod privacy; mod privacy_page; +mod provider_setup_modal; mod scripting_page; mod set_default_model_modal; mod settings_file_footer; diff --git a/app/src/settings_view/provider_setup_modal.rs b/app/src/settings_view/provider_setup_modal.rs new file mode 100644 index 00000000..6a9d18d1 --- /dev/null +++ b/app/src/settings_view/provider_setup_modal.rs @@ -0,0 +1,1535 @@ +use galaxyui::clipboard::ClipboardContent; +use galaxyui::elements::{ + Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, + CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, MainAxisAlignment, MainAxisSize, + MouseStateHandle, Padding, ParentElement, Radius, ScrollbarWidth, Text, +}; +use galaxyui::fonts::{Properties, Weight}; +use galaxyui::ui_components::button::ButtonVariant; +use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; +use galaxyui::ui_components::switch::SwitchStateHandle; +use galaxyui::{ + AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, +}; + +#[cfg(not(target_family = "wasm"))] +use crate::ai::chatgpt_auth::{ChatGPTAuthModel, ChatGPTAuthModelEvent, ChatGPTAuthState}; +use crate::ai::llms::{merge_discovered_provider_models, LLMPreferences}; +use crate::appearance::Appearance; +use crate::editor::{ + EditorView, Event as EditorEvent, SingleLineEditorOptions, TextColors, TextOptions, +}; +use crate::modal::{Modal, ModalViewState}; +use crate::settings::ai::{ + BedrockAuthMethod, BedrockModelConfig, OpenAIModelConfig, OpenAIProviderConfig, + OpenAIProviderKind, +}; +use crate::ui_components::icons::Icon; +use crate::view_components::action_button::{ + ActionButton, NakedTheme, PrimaryTheme, SecondaryTheme, +}; + +const MODAL_WIDTH: f32 = 640.; +const MODAL_HEIGHT: f32 = 600.; +const BODY_HEIGHT: f32 = 530.; +const INPUT_FONT_SIZE: f32 = 12.; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ProviderSetupStep { + ProviderType, + Configure, + Discover, + Models, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProviderSetupProviderType { + ChatGPTSubscription, + OpenAICompatible, + Bedrock, + Acp, +} + +#[derive(Clone, Debug)] +pub struct BedrockProviderDraft { + pub name: String, + pub auth_method: BedrockAuthMethod, + pub profile: String, + pub region: String, + pub cross_region_inference: bool, + pub auto_login: bool, + pub auth_refresh_command: String, + pub access_key_id: String, + pub secret_access_key: String, + pub models: Vec, +} + +#[derive(Clone, Debug)] +pub struct AcpProviderDraft { + pub name: String, + pub agent_id: String, + pub command: String, + pub args: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum DiscoveryState { + Idle, + Loading, + Failed(String), +} + +pub enum ProviderSetupModalBodyEvent { + Close, + RequestAcpDiscovery(AcpProviderDraft), + SaveOpenAI { + editing_index: Option, + provider: OpenAIProviderConfig, + }, + SaveBedrock(BedrockProviderDraft), + SaveAcp(AcpProviderDraft), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ProviderSetupModalBodyAction { + SelectProvider(ProviderSetupProviderType), + Next, + Back, + Cancel, + ToggleModel(usize), + ConnectChatGPT, + OpenChatGPTDevicePage, + CopyChatGPTDeviceCode, + SelectBedrockAuth(BedrockAuthMethod), + ToggleBedrockCrossRegion, + ToggleBedrockAutoLogin, +} + +pub type ProviderSetupModalState = ModalViewState>; + +pub struct ProviderSetupModalBody { + step: ProviderSetupStep, + editing_index: Option, + provider_type: ProviderSetupProviderType, + draft_name: String, + draft_base_url: String, + draft_api_key: Option, + draft_models: Vec, + draft_bedrock: BedrockProviderDraft, + draft_acp: AcpProviderDraft, + discovery_state: DiscoveryState, + provider_type_buttons: Vec>, + name_editor: ViewHandle, + base_url_editor: ViewHandle, + api_key_editor: ViewHandle, + bedrock_profile_editor: ViewHandle, + bedrock_region_editor: ViewHandle, + bedrock_refresh_command_editor: ViewHandle, + bedrock_access_key_editor: ViewHandle, + bedrock_secret_key_editor: ViewHandle, + acp_agent_id_editor: ViewHandle, + acp_command_editor: ViewHandle, + acp_args_editor: ViewHandle, + bedrock_auth_buttons: Vec>, + bedrock_cross_region_toggle: SwitchStateHandle, + bedrock_auto_login_toggle: SwitchStateHandle, + model_switches: Vec, + models_scroll_state: ClippedScrollStateHandle, + back_button: ViewHandle, + cancel_button: ViewHandle, + next_button: ViewHandle, +} + +impl ProviderSetupModalBody { + pub fn new(ctx: &mut ViewContext) -> Self { + let provider_type_buttons = [ + ( + ProviderSetupProviderType::ChatGPTSubscription, + "ChatGPT subscription", + ), + ( + ProviderSetupProviderType::OpenAICompatible, + "OpenAI-compatible API", + ), + (ProviderSetupProviderType::Bedrock, "AWS Bedrock"), + (ProviderSetupProviderType::Acp, "ACP agent runtime"), + ] + .into_iter() + .map(|(kind, label)| { + ctx.add_typed_action_view(move |_| { + ActionButton::new(label, NakedTheme) + .with_full_width(true) + .on_click(move |ctx| { + ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectProvider( + kind, + )); + }) + }) + }) + .collect(); + + let name_editor = Self::create_editor("Connection name", false, ctx); + let base_url_editor = Self::create_editor("https://api.example.com/v1", false, ctx); + let api_key_editor = Self::create_editor("sk-... (optional)", true, 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_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); + let acp_agent_id_editor = Self::create_editor("codex", false, ctx); + let acp_command_editor = Self::create_editor("Optional executable", false, ctx); + let acp_args_editor = Self::create_editor(r#"["arg1", "arg2"]"#, false, ctx); + + let bedrock_auth_buttons = [ + BedrockAuthMethod::Profile, + BedrockAuthMethod::Sso, + BedrockAuthMethod::StaticKeys, + ] + .into_iter() + .map(|method| { + ctx.add_typed_action_view(move |_| { + ActionButton::new(method.display_name(), NakedTheme).on_click(move |ctx| { + ctx.dispatch_typed_action(ProviderSetupModalBodyAction::SelectBedrockAuth( + method, + )); + }) + }) + }) + .collect(); + + ctx.subscribe_to_view(&name_editor, |me, editor, event, ctx| { + if matches!(event, EditorEvent::Edited(_)) { + me.draft_name = editor.as_ref(ctx).buffer_text(ctx); + me.update_next_button(ctx); + ctx.notify(); + } + }); + ctx.subscribe_to_view(&base_url_editor, |me, editor, event, ctx| { + if matches!(event, EditorEvent::Edited(_)) { + me.draft_base_url = editor.as_ref(ctx).buffer_text(ctx); + me.update_next_button(ctx); + ctx.notify(); + } + }); + ctx.subscribe_to_view(&api_key_editor, |me, editor, event, ctx| { + if matches!(event, EditorEvent::Edited(_)) { + let value = editor.as_ref(ctx).buffer_text(ctx); + me.draft_api_key = (!value.trim().is_empty()).then_some(value); + ctx.notify(); + } + }); + 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), + (bedrock_secret_key_editor.clone(), 4), + (acp_agent_id_editor.clone(), 5), + (acp_command_editor.clone(), 6), + (acp_args_editor.clone(), 7), + ] { + ctx.subscribe_to_view(&editor, move |me, editor, event, ctx| { + 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, + 4 => me.draft_bedrock.secret_access_key = value, + 5 => me.draft_acp.agent_id = value, + 6 => me.draft_acp.command = value, + 7 => { + if let Ok(args) = serde_json::from_str::>(&value) { + me.draft_acp.args = args; + } + } + _ => unreachable!(), + } + me.update_next_button(ctx); + ctx.notify(); + } + }); + } + + #[cfg(not(target_family = "wasm"))] + ctx.subscribe_to_model(&ChatGPTAuthModel::handle(ctx), |me, _, event, ctx| { + if matches!(event, ChatGPTAuthModelEvent::StateChanged) { + if me.step == ProviderSetupStep::Discover { + me.try_discover_chatgpt(ctx); + } + me.update_next_button(ctx); + ctx.notify(); + } + }); + + let back_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Back", NakedTheme).on_click(|ctx| { + ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Back); + }) + }); + let cancel_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Cancel", NakedTheme).on_click(|ctx| { + ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Cancel); + }) + }); + let next_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Next", PrimaryTheme).on_click(|ctx| { + ctx.dispatch_typed_action(ProviderSetupModalBodyAction::Next); + }) + }); + + Self { + step: ProviderSetupStep::ProviderType, + editing_index: None, + provider_type: ProviderSetupProviderType::OpenAICompatible, + draft_name: String::new(), + draft_base_url: String::new(), + draft_api_key: None, + draft_models: Vec::new(), + draft_bedrock: BedrockProviderDraft { + name: String::new(), + auth_method: BedrockAuthMethod::default(), + profile: "default".to_string(), + region: String::new(), + cross_region_inference: true, + auto_login: true, + auth_refresh_command: "aws sso login".to_string(), + access_key_id: String::new(), + secret_access_key: String::new(), + models: Vec::new(), + }, + draft_acp: AcpProviderDraft { + name: String::new(), + agent_id: "codex".to_string(), + command: String::new(), + args: Vec::new(), + }, + discovery_state: DiscoveryState::Idle, + provider_type_buttons, + name_editor, + base_url_editor, + api_key_editor, + bedrock_profile_editor, + bedrock_region_editor, + bedrock_refresh_command_editor, + bedrock_access_key_editor, + bedrock_secret_key_editor, + acp_agent_id_editor, + acp_command_editor, + acp_args_editor, + bedrock_auth_buttons, + bedrock_cross_region_toggle: SwitchStateHandle::default(), + bedrock_auto_login_toggle: SwitchStateHandle::default(), + model_switches: Vec::new(), + models_scroll_state: ClippedScrollStateHandle::default(), + back_button, + cancel_button, + next_button, + } + } + + fn create_editor( + placeholder: &'static str, + is_password: bool, + ctx: &mut ViewContext, + ) -> ViewHandle { + ctx.add_typed_action_view(move |ctx| { + let appearance = Appearance::as_ref(ctx); + let options = SingleLineEditorOptions { + is_password, + text: TextOptions { + font_size_override: Some(appearance.ui_font_size()), + font_family_override: Some(appearance.monospace_font_family()), + text_colors_override: Some(TextColors { + default_color: appearance.theme().active_ui_text_color(), + disabled_color: appearance.theme().disabled_ui_text_color(), + hint_color: appearance.theme().disabled_ui_text_color(), + }), + ..Default::default() + }, + ..Default::default() + }; + let mut editor = EditorView::single_line(options, ctx); + editor.set_placeholder_text(placeholder, ctx); + editor + }) + } + + pub fn begin_create(&mut self, ctx: &mut ViewContext) { + self.step = ProviderSetupStep::ProviderType; + self.editing_index = None; + self.provider_type = ProviderSetupProviderType::OpenAICompatible; + self.draft_name.clear(); + self.draft_base_url.clear(); + self.draft_api_key = None; + self.draft_models.clear(); + self.draft_bedrock = BedrockProviderDraft { + name: String::new(), + auth_method: BedrockAuthMethod::default(), + profile: "default".to_string(), + region: String::new(), + cross_region_inference: true, + auto_login: true, + auth_refresh_command: "aws sso login".to_string(), + access_key_id: String::new(), + secret_access_key: String::new(), + models: Vec::new(), + }; + self.draft_acp = AcpProviderDraft { + name: String::new(), + agent_id: "codex".to_string(), + command: String::new(), + args: Vec::new(), + }; + self.discovery_state = DiscoveryState::Idle; + self.sync_editors(ctx); + self.sync_provider_type_buttons(ctx); + self.sync_bedrock_auth_buttons(ctx); + self.sync_model_switches(ctx); + self.update_next_button(ctx); + ctx.focus_self(); + ctx.notify(); + } + + pub fn begin_edit( + &mut self, + editing_index: usize, + provider: OpenAIProviderConfig, + ctx: &mut ViewContext, + ) { + self.step = ProviderSetupStep::Configure; + self.editing_index = Some(editing_index); + self.provider_type = match provider.kind { + OpenAIProviderKind::ChatGPTSubscription => { + ProviderSetupProviderType::ChatGPTSubscription + } + OpenAIProviderKind::OpenAICompatible => ProviderSetupProviderType::OpenAICompatible, + }; + self.draft_name = provider.name; + self.draft_base_url = provider.base_url; + self.draft_api_key = provider.api_key; + self.draft_models = provider.models; + self.discovery_state = DiscoveryState::Idle; + self.sync_editors(ctx); + self.sync_provider_type_buttons(ctx); + self.sync_bedrock_auth_buttons(ctx); + self.sync_model_switches(ctx); + self.update_next_button(ctx); + ctx.focus_self(); + ctx.notify(); + } + + pub fn begin_edit_bedrock(&mut self, draft: BedrockProviderDraft, ctx: &mut ViewContext) { + self.step = ProviderSetupStep::Configure; + self.editing_index = None; + self.provider_type = ProviderSetupProviderType::Bedrock; + self.draft_name = draft.name.clone(); + self.draft_bedrock = draft; + self.draft_models.clear(); + self.discovery_state = DiscoveryState::Idle; + self.sync_editors(ctx); + self.sync_provider_type_buttons(ctx); + self.sync_bedrock_auth_buttons(ctx); + self.sync_model_switches(ctx); + self.update_next_button(ctx); + ctx.focus_self(); + ctx.notify(); + } + + pub fn begin_edit_acp(&mut self, draft: AcpProviderDraft, ctx: &mut ViewContext) { + self.step = ProviderSetupStep::Configure; + self.editing_index = None; + self.provider_type = ProviderSetupProviderType::Acp; + self.draft_name = draft.name.clone(); + self.draft_acp = draft; + self.draft_models.clear(); + self.discovery_state = DiscoveryState::Idle; + self.sync_editors(ctx); + self.sync_provider_type_buttons(ctx); + self.sync_bedrock_auth_buttons(ctx); + self.sync_model_switches(ctx); + self.update_next_button(ctx); + ctx.focus_self(); + ctx.notify(); + } + + pub fn finish_acp_discovery( + &mut self, + result: Result<(), String>, + ctx: &mut ViewContext, + ) { + match result { + Ok(()) => { + self.discovery_state = DiscoveryState::Idle; + self.step = ProviderSetupStep::Models; + ctx.focus(&self.name_editor); + } + Err(error) => { + self.discovery_state = DiscoveryState::Failed(error); + } + } + self.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); + }); + self.base_url_editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(&self.draft_base_url, ctx); + }); + self.api_key_editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(self.draft_api_key.as_deref().unwrap_or_default(), ctx); + }); + self.bedrock_profile_editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(&self.draft_bedrock.profile, ctx); + }); + self.bedrock_region_editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(&self.draft_bedrock.region, ctx); + }); + self.bedrock_refresh_command_editor + .update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(&self.draft_bedrock.auth_refresh_command, ctx); + }); + self.bedrock_access_key_editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(&self.draft_bedrock.access_key_id, ctx); + }); + self.bedrock_secret_key_editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(&self.draft_bedrock.secret_access_key, ctx); + }); + self.acp_agent_id_editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(&self.draft_acp.agent_id, ctx); + }); + self.acp_command_editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(&self.draft_acp.command, ctx); + }); + self.acp_args_editor.update(ctx, |editor, ctx| { + let args = + serde_json::to_string(&self.draft_acp.args).unwrap_or_else(|_| "[]".to_string()); + editor.system_reset_buffer_text(&args, ctx); + }); + } + + fn sync_provider_type_buttons(&self, ctx: &mut ViewContext) { + for (index, button) in self.provider_type_buttons.iter().enumerate() { + let button_kind = match index { + 0 => ProviderSetupProviderType::ChatGPTSubscription, + 1 => ProviderSetupProviderType::OpenAICompatible, + 2 => ProviderSetupProviderType::Bedrock, + _ => ProviderSetupProviderType::Acp, + }; + button.update(ctx, |button, ctx| { + button.set_active(button_kind == self.provider_type, ctx); + }); + } + } + + fn sync_bedrock_auth_buttons(&self, ctx: &mut ViewContext) { + for (index, button) in self.bedrock_auth_buttons.iter().enumerate() { + let method = match index { + 0 => BedrockAuthMethod::Profile, + 1 => BedrockAuthMethod::Sso, + 2 => BedrockAuthMethod::StaticKeys, + _ => continue, + }; + button.update(ctx, |button, ctx| { + button.set_active(method == self.draft_bedrock.auth_method, ctx); + }); + } + } + + fn sync_model_switches(&mut self, ctx: &mut ViewContext) { + while self.model_switches.len() < self.draft_models.len() { + self.model_switches.push(SwitchStateHandle::default()); + } + self.model_switches.truncate(self.draft_models.len()); + } + + fn update_next_button(&self, ctx: &mut ViewContext) { + let (label, disabled) = match self.step { + ProviderSetupStep::ProviderType => ("Next", false), + ProviderSetupStep::Configure => { + let disabled = match self.provider_type { + ProviderSetupProviderType::OpenAICompatible => { + self.draft_base_url.trim().is_empty() + } + ProviderSetupProviderType::Acp => self.draft_acp.agent_id.trim().is_empty(), + ProviderSetupProviderType::ChatGPTSubscription + | ProviderSetupProviderType::Bedrock => false, + }; + ("Next", disabled) + } + ProviderSetupStep::Discover => ( + if matches!(self.discovery_state, DiscoveryState::Failed(_)) { + "Retry" + } else { + "Testing..." + }, + !matches!(self.discovery_state, DiscoveryState::Failed(_)), + ), + ProviderSetupStep::Models => match self.provider_type { + ProviderSetupProviderType::OpenAICompatible + | ProviderSetupProviderType::ChatGPTSubscription => ( + "Save", + self.draft_name.trim().is_empty() + || !self.draft_models.iter().any(|model| model.enabled), + ), + ProviderSetupProviderType::Bedrock => ( + "Save", + self.draft_name.trim().is_empty() || self.draft_bedrock.models.is_empty(), + ), + ProviderSetupProviderType::Acp => ("Save", self.draft_name.trim().is_empty()), + }, + }; + self.next_button.update(ctx, |button, ctx| { + button.set_label(label, ctx); + button.set_disabled(disabled, ctx); + }); + self.back_button.update(ctx, |button, ctx| { + button.set_disabled(self.step == ProviderSetupStep::ProviderType, ctx); + }); + } + + fn draft_provider(&self) -> OpenAIProviderConfig { + OpenAIProviderConfig { + kind: match self.provider_type { + ProviderSetupProviderType::ChatGPTSubscription => { + OpenAIProviderKind::ChatGPTSubscription + } + ProviderSetupProviderType::OpenAICompatible + | ProviderSetupProviderType::Bedrock + | ProviderSetupProviderType::Acp => OpenAIProviderKind::OpenAICompatible, + }, + enabled: true, + name: self.draft_name.trim().to_string(), + base_url: if self.provider_type == ProviderSetupProviderType::ChatGPTSubscription { + String::new() + } else { + self.draft_base_url.trim().trim_end_matches('/').to_string() + }, + api_key: self + .draft_api_key + .as_deref() + .filter(|key| !key.trim().is_empty()) + .map(str::to_string), + models: self.draft_models.clone(), + } + } + + fn begin_discovery(&mut self, ctx: &mut ViewContext) { + self.step = ProviderSetupStep::Discover; + self.discovery_state = DiscoveryState::Loading; + self.update_next_button(ctx); + ctx.notify(); + + match self.provider_type { + ProviderSetupProviderType::ChatGPTSubscription => { + self.try_discover_chatgpt(ctx); + return; + } + ProviderSetupProviderType::Bedrock => { + let config = crate::ai::bedrock::client::BedrockClientConfig { + auth_method: self.draft_bedrock.auth_method, + profile: self.draft_bedrock.profile.clone(), + region: self.draft_bedrock.region.clone(), + access_key_id: self.draft_bedrock.access_key_id.clone(), + secret_access_key: self.draft_bedrock.secret_access_key.clone(), + session_token: None, + cross_region_inference: self.draft_bedrock.cross_region_inference, + use_rig: false, + }; + ctx.spawn( + async move { + crate::ai::bedrock::discovery::discover_available_models(config).await + }, + move |me, result, ctx| match result { + Ok(models) => { + me.draft_bedrock.models = models; + me.discovery_state = DiscoveryState::Idle; + me.step = ProviderSetupStep::Models; + me.update_next_button(ctx); + ctx.focus(&me.name_editor); + ctx.notify(); + } + Err(error) => { + me.discovery_state = DiscoveryState::Failed(error); + me.update_next_button(ctx); + ctx.notify(); + } + }, + ); + return; + } + ProviderSetupProviderType::Acp => { + ctx.emit(ProviderSetupModalBodyEvent::RequestAcpDiscovery( + self.draft_acp.clone(), + )); + return; + } + ProviderSetupProviderType::OpenAICompatible => {} + } + + let provider = self.draft_provider(); + let existing_models = self.draft_models.clone(); + ctx.spawn( + async move { LLMPreferences::discover_openai_provider_models(provider).await }, + move |me, result, ctx| match result { + Ok(models) => { + me.draft_models = merge_discovered_provider_models(&existing_models, models); + me.discovery_state = DiscoveryState::Idle; + me.step = ProviderSetupStep::Models; + me.sync_model_switches(ctx); + me.update_next_button(ctx); + ctx.focus(&me.name_editor); + ctx.notify(); + } + Err(error) => { + me.discovery_state = DiscoveryState::Failed(error); + me.update_next_button(ctx); + ctx.notify(); + } + }, + ); + } + + #[cfg(not(target_family = "wasm"))] + fn try_discover_chatgpt(&mut self, ctx: &mut ViewContext) { + if !matches!( + ChatGPTAuthModel::as_ref(ctx).state(), + ChatGPTAuthState::Connected + ) { + self.discovery_state = DiscoveryState::Failed( + "Connect your ChatGPT subscription before continuing.".to_string(), + ); + self.update_next_button(ctx); + ctx.notify(); + return; + } + + if self.draft_models.is_empty() { + self.draft_models = crate::settings::ai::default_chatgpt_provider().models; + } + self.discovery_state = DiscoveryState::Idle; + self.step = ProviderSetupStep::Models; + self.sync_model_switches(ctx); + self.update_next_button(ctx); + ctx.focus(&self.name_editor); + ctx.notify(); + } + + #[cfg(target_family = "wasm")] + fn try_discover_chatgpt(&mut self, ctx: &mut ViewContext) { + self.discovery_state = DiscoveryState::Failed( + "ChatGPT subscription providers are available in the desktop app.".to_string(), + ); + self.update_next_button(ctx); + ctx.notify(); + } + + fn render_label(appearance: &Appearance, label: &str) -> Box { + Text::new_inline( + label.to_string(), + appearance.ui_font_family(), + INPUT_FONT_SIZE, + ) + .with_color(appearance.theme().active_ui_text_color().into()) + .with_style(Properties::default().weight(Weight::Bold)) + .finish() + } + + fn render_input( + &self, + appearance: &Appearance, + label: &str, + editor: &ViewHandle, + ) -> Box { + let input = appearance + .ui_builder() + .text_input(editor.clone()) + .with_style(UiComponentStyles { + padding: Some(Coords { + top: 10., + bottom: 10., + left: 12., + right: 12., + }), + background: Some(appearance.theme().surface_1().into()), + ..Default::default() + }) + .build() + .finish(); + Flex::column() + .with_spacing(6.) + .with_child(Self::render_label(appearance, label)) + .with_child(input) + .finish() + } + + fn render_provider_type(&self, appearance: &Appearance) -> Box { + let description = Text::new( + "Choose how Galaxy should connect to this provider.", + appearance.ui_font_family(), + INPUT_FONT_SIZE, + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .finish(); + + let cards = [ + ( + "ChatGPT subscription", + "Use your ChatGPT Plus or Pro subscription with native OAuth.", + ), + ( + "OpenAI-compatible API", + "Connect LiteLLM, Ollama, vLLM, or another compatible endpoint.", + ), + ( + "AWS Bedrock", + "Use the AWS Bedrock credentials and model configuration already managed by Galaxy.", + ), + ( + "ACP agent runtime", + "Use a session-oriented ACP agent that owns its model and authentication.", + ), + ] + .into_iter() + .enumerate() + .map(|(index, (label, description))| { + let button = ChildView::new(&self.provider_type_buttons[index]).finish(); + Container::new( + Flex::column() + .with_spacing(8.) + .with_child(button) + .with_child( + Text::new(description, appearance.ui_font_family(), INPUT_FONT_SIZE) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ) + .finish(), + ) + .with_padding(Padding::uniform(12.)) + .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) + .finish() + }) + .collect::>(); + + Flex::column() + .with_spacing(16.) + .with_child(description) + .with_children(cards) + .finish() + } + + #[cfg(not(target_family = "wasm"))] + fn render_chatgpt_auth( + &self, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + let state = ChatGPTAuthModel::as_ref(app).state(); + let mut children = vec![Self::render_label(appearance, "ChatGPT authorization")]; + let description = match &state { + ChatGPTAuthState::NotConnected => "Connect your ChatGPT subscription to continue.", + ChatGPTAuthState::Connecting => "Waiting for ChatGPT authorization to start...", + ChatGPTAuthState::AwaitingDeviceCode { .. } => { + "Enter the device code in the ChatGPT sign-in page." + } + ChatGPTAuthState::Connected => "ChatGPT subscription connected.", + ChatGPTAuthState::Failed(_) => "ChatGPT connection failed.", + }; + children.push( + Text::new(description, appearance.ui_font_family(), INPUT_FONT_SIZE) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ); + + if let ChatGPTAuthState::AwaitingDeviceCode { + verification_uri, + user_code, + } = &state + { + children.push( + Container::new( + FormattedTextElement::from_str( + user_code.clone(), + appearance.monospace_font_family(), + 24., + ) + .with_weight(Weight::Bold) + .with_color(appearance.theme().active_ui_text_color().into()) + .finish(), + ) + .with_padding(Padding::uniform(12.)) + .with_background(appearance.theme().surface_1()) + .with_border(Border::all(1.).with_border_fill(appearance.theme().accent())) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) + .finish(), + ); + let buttons = Flex::row() + .with_spacing(8.) + .with_child( + appearance + .ui_builder() + .button(ButtonVariant::Secondary, MouseStateHandle::default()) + .with_text_label("Open sign-in page".to_owned()) + .build() + .on_click(|ctx, _, _| { + ctx.dispatch_typed_action( + ProviderSetupModalBodyAction::OpenChatGPTDevicePage, + ); + }) + .finish(), + ) + .with_child( + appearance + .ui_builder() + .button(ButtonVariant::Secondary, MouseStateHandle::default()) + .with_text_label("Copy code".to_owned()) + .build() + .on_click(|ctx, _, _| { + ctx.dispatch_typed_action( + ProviderSetupModalBodyAction::CopyChatGPTDeviceCode, + ); + }) + .finish(), + ) + .finish(); + children.push(buttons); + children.push( + Text::new( + verification_uri.clone(), + appearance.ui_font_family(), + INPUT_FONT_SIZE, + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ); + } + + if !matches!( + state, + ChatGPTAuthState::Connected | ChatGPTAuthState::Connecting + ) { + children.push( + appearance + .ui_builder() + .button(ButtonVariant::Secondary, MouseStateHandle::default()) + .with_text_label("Connect ChatGPT".to_owned()) + .build() + .on_click(|ctx, _, _| { + ctx.dispatch_typed_action(ProviderSetupModalBodyAction::ConnectChatGPT); + }) + .finish(), + ); + } + + Flex::column() + .with_spacing(10.) + .with_children(children) + .finish() + } + + #[cfg(target_family = "wasm")] + fn render_chatgpt_auth( + &self, + appearance: &Appearance, + _app: &AppContext, + ) -> Box { + Flex::column() + .with_spacing(8.) + .with_child(Self::render_label(appearance, "ChatGPT authorization")) + .with_child( + Text::new( + "ChatGPT subscription providers are available in the desktop app.", + appearance.ui_font_family(), + INPUT_FONT_SIZE, + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ) + .finish() + } + + fn render_configure( + &self, + appearance: &Appearance, + app: &AppContext, + ) -> Box { + let mut children = vec![Text::new( + format!("Configure {}", provider_type_label(self.provider_type)), + appearance.ui_font_family(), + appearance.header_font_size(), + ) + .with_color(appearance.theme().active_ui_text_color().into()) + .with_style(Properties::default().weight(Weight::Bold)) + .finish()]; + + match self.provider_type { + ProviderSetupProviderType::ChatGPTSubscription => { + children.push(self.render_chatgpt_auth(appearance, app)); + } + ProviderSetupProviderType::OpenAICompatible => { + children.push(self.render_input(appearance, "Base URL", &self.base_url_editor)); + children.push(self.render_input(appearance, "API key", &self.api_key_editor)); + children.push( + Text::new( + "The API key is stored locally and is never synced to the cloud.", + appearance.ui_font_family(), + INPUT_FONT_SIZE, + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ); + } + ProviderSetupProviderType::Bedrock => { + children.push(Self::render_label(appearance, "Authentication method")); + children.push( + Flex::row() + .with_spacing(8.) + .with_children( + self.bedrock_auth_buttons + .iter() + .map(|button| ChildView::new(button).finish()), + ) + .finish(), + ); + match self.draft_bedrock.auth_method { + BedrockAuthMethod::Profile | BedrockAuthMethod::Sso => { + children.push(self.render_input( + appearance, + "AWS profile", + &self.bedrock_profile_editor, + )); + if self.draft_bedrock.auth_method == BedrockAuthMethod::Sso { + children.push(self.render_input( + appearance, + "Login command", + &self.bedrock_refresh_command_editor, + )); + children.push( + appearance + .ui_builder() + .switch(self.bedrock_auto_login_toggle.clone()) + .check(self.draft_bedrock.auto_login) + .build() + .on_click(|ctx, _, _| { + ctx.dispatch_typed_action( + ProviderSetupModalBodyAction::ToggleBedrockAutoLogin, + ); + }) + .finish(), + ); + } + } + BedrockAuthMethod::StaticKeys => { + children.push(self.render_input( + appearance, + "Access key ID", + &self.bedrock_access_key_editor, + )); + children.push(self.render_input( + appearance, + "Secret access key", + &self.bedrock_secret_key_editor, + )); + } + } + children.push(self.render_input( + appearance, + "AWS region", + &self.bedrock_region_editor, + )); + children.push( + appearance + .ui_builder() + .switch(self.bedrock_cross_region_toggle.clone()) + .check(self.draft_bedrock.cross_region_inference) + .build() + .on_click(|ctx, _, _| { + ctx.dispatch_typed_action( + ProviderSetupModalBodyAction::ToggleBedrockCrossRegion, + ); + }) + .finish(), + ); + } + ProviderSetupProviderType::Acp => { + children.push(self.render_input( + appearance, + "Agent preset", + &self.acp_agent_id_editor, + )); + children.push(self.render_input( + appearance, + "Custom executable (optional)", + &self.acp_command_editor, + )); + children.push(self.render_input( + appearance, + "Arguments (JSON array)", + &self.acp_args_editor, + )); + children.push( + Text::new( + "ACP agents own their model and authentication. Galaxy will discover the configured runtime before saving.", + appearance.ui_font_family(), + INPUT_FONT_SIZE, + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ); + } + } + + Flex::column() + .with_spacing(16.) + .with_children(children) + .finish() + } + + fn render_discovery(&self, appearance: &Appearance) -> Box { + let (message, color) = match &self.discovery_state { + DiscoveryState::Idle | DiscoveryState::Loading => ( + "Testing the connection and discovering available models...".to_string(), + appearance.theme().active_ui_text_color().into_solid(), + ), + DiscoveryState::Failed(error) => (error.clone(), appearance.theme().ui_error_color()), + }; + Flex::column() + .with_spacing(12.) + .with_child( + Text::new( + "Test connection", + appearance.ui_font_family(), + appearance.header_font_size(), + ) + .with_color(appearance.theme().active_ui_text_color().into()) + .with_style(Properties::default().weight(Weight::Bold)) + .finish(), + ) + .with_child(if matches!(self.discovery_state, DiscoveryState::Loading) { + Flex::row() + .with_spacing(8.) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + ConstrainedBox::new( + Icon::Loading + .to_galaxyui_icon(appearance.theme().active_ui_text_color()) + .finish(), + ) + .with_width(16.) + .with_height(16.) + .finish(), + ) + .with_child( + Text::new(message, appearance.ui_font_family(), INPUT_FONT_SIZE) + .with_color(color) + .soft_wrap(true) + .finish(), + ) + .finish() + } else { + Text::new(message, appearance.ui_font_family(), INPUT_FONT_SIZE) + .with_color(color) + .soft_wrap(true) + .finish() + }) + .finish() + } + + fn render_model_table( + &self, + appearance: &Appearance, + rows: Vec>, + spacing: f32, + ) -> Box { + let list = Flex::column() + .with_spacing(spacing) + .with_children(rows) + .finish(); + let scrollable = ClippedScrollable::vertical( + self.models_scroll_state.clone(), + list, + ScrollbarWidth::Auto, + appearance.theme().nonactive_ui_detail().into(), + appearance.theme().active_ui_detail().into(), + appearance.theme().surface_1().into(), + ) + .with_overlayed_scrollbar() + .finish(); + + ConstrainedBox::new( + Container::new(scrollable) + .with_padding(Padding::uniform(12.)) + .with_background(appearance.theme().surface_1()) + .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) + .finish(), + ) + .with_width(MODAL_WIDTH - 56.) + .with_max_height(320.) + .finish() + } + + fn render_models(&self, appearance: &Appearance) -> Box { + if self.provider_type == ProviderSetupProviderType::Bedrock { + let rows = self + .draft_bedrock + .models + .iter() + .map(|model| { + 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() + }) + .collect::>(); + return Flex::column() + .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(); + } + if self.provider_type == ProviderSetupProviderType::Acp { + return Flex::column() + .with_spacing(12.) + .with_child(self.render_input(appearance, "Connection name", &self.name_editor)) + .with_child( + Text::new( + "ACP owns model selection. The configured agent runtime was checked before this step.", + appearance.ui_font_family(), + INPUT_FONT_SIZE, + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ) + .finish(); + } + let mut rows = Vec::with_capacity(self.draft_models.len()); + for (index, model) in self.draft_models.iter().enumerate() { + let modes = if model.reasoning_efforts.is_empty() { + "Standard".to_string() + } else { + model.reasoning_efforts.join(", ") + }; + let info = 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( + format!("{} · modes: {modes}", model.model_id), + appearance.monospace_font_family(), + 10., + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ) + .finish(); + rows.push( + Flex::row() + .with_spacing(10.) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + appearance + .ui_builder() + .switch(self.model_switches[index].clone()) + .check(model.enabled) + .build() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action( + ProviderSetupModalBodyAction::ToggleModel(index), + ); + }) + .finish(), + ) + .with_child(info) + .finish(), + ); + } + + let table = self.render_model_table(appearance, rows, 12.); + + Flex::column() + .with_spacing(12.) + .with_child(self.render_input(appearance, "Connection name", &self.name_editor)) + .with_child( + Text::new( + "Choose which models Galaxy should make available. Reasoning modes remain selectable from the model picker.", + appearance.ui_font_family(), + INPUT_FONT_SIZE, + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish(), + ) + .with_child(table) + .finish() + } + + fn render_footer(&self) -> Box { + let mut footer = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::End) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(8.); + if self.step != ProviderSetupStep::ProviderType { + footer = footer.with_child(ChildView::new(&self.back_button).finish()); + } + footer = footer.with_child(ChildView::new(&self.cancel_button).finish()); + if !matches!(self.step, ProviderSetupStep::Discover) + || matches!(self.discovery_state, DiscoveryState::Failed(_)) + { + footer = footer.with_child(ChildView::new(&self.next_button).finish()); + } + footer.finish() + } + + fn render_step_indicator(&self, appearance: &Appearance) -> Box { + let steps = [ + (ProviderSetupStep::ProviderType, "Provider"), + (ProviderSetupStep::Configure, "Configure"), + (ProviderSetupStep::Discover, "Test"), + (ProviderSetupStep::Models, "Models"), + ]; + Flex::row() + .with_spacing(10.) + .with_children(steps.into_iter().map(|(step, label)| { + let active = self.step == step; + Text::new(label, appearance.ui_font_family(), INPUT_FONT_SIZE) + .with_color( + if active { + appearance.theme().accent() + } else { + appearance.theme().nonactive_ui_text_color() + } + .into(), + ) + .with_style(Properties::default().weight(if active { + Weight::Bold + } else { + Weight::Normal + })) + .finish() + })) + .finish() + } +} + +impl Entity for ProviderSetupModalBody { + type Event = ProviderSetupModalBodyEvent; +} + +impl View for ProviderSetupModalBody { + fn ui_name() -> &'static str { + "ProviderSetupModalBody" + } + + fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let content = match self.step { + ProviderSetupStep::ProviderType => self.render_provider_type(appearance), + ProviderSetupStep::Configure => self.render_configure(appearance, app), + ProviderSetupStep::Discover => self.render_discovery(appearance), + ProviderSetupStep::Models => self.render_models(appearance), + }; + Flex::column() + .with_spacing(20.) + .with_child(self.render_step_indicator(appearance)) + .with_child(content) + .with_child(self.render_footer()) + .finish() + } +} + +impl TypedActionView for ProviderSetupModalBody { + type Action = ProviderSetupModalBodyAction; + + fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { + match action { + ProviderSetupModalBodyAction::SelectProvider(kind) => { + if self.provider_type != *kind { + self.draft_models.clear(); + self.discovery_state = DiscoveryState::Idle; + } + self.provider_type = *kind; + if *kind == ProviderSetupProviderType::ChatGPTSubscription { + self.draft_base_url.clear(); + self.draft_api_key = None; + } + self.sync_provider_type_buttons(ctx); + self.sync_bedrock_auth_buttons(ctx); + self.update_next_button(ctx); + ctx.notify(); + } + ProviderSetupModalBodyAction::Next => match self.step { + ProviderSetupStep::ProviderType => { + self.step = ProviderSetupStep::Configure; + self.update_next_button(ctx); + ctx.notify(); + } + ProviderSetupStep::Configure => self.begin_discovery(ctx), + ProviderSetupStep::Discover => { + if matches!(self.discovery_state, DiscoveryState::Failed(_)) { + self.begin_discovery(ctx); + } + } + ProviderSetupStep::Models => match self.provider_type { + ProviderSetupProviderType::OpenAICompatible + | ProviderSetupProviderType::ChatGPTSubscription => { + if self.draft_name.trim().is_empty() + || !self.draft_models.iter().any(|model| model.enabled) + { + return; + } + ctx.emit(ProviderSetupModalBodyEvent::SaveOpenAI { + editing_index: self.editing_index, + provider: self.draft_provider(), + }); + } + ProviderSetupProviderType::Bedrock => { + if self.draft_name.trim().is_empty() || self.draft_bedrock.models.is_empty() + { + return; + } + let mut draft = self.draft_bedrock.clone(); + draft.name = self.draft_name.trim().to_string(); + ctx.emit(ProviderSetupModalBodyEvent::SaveBedrock(draft)); + } + ProviderSetupProviderType::Acp => { + if self.draft_name.trim().is_empty() { + return; + } + let mut draft = self.draft_acp.clone(); + draft.name = self.draft_name.trim().to_string(); + ctx.emit(ProviderSetupModalBodyEvent::SaveAcp(draft)); + } + }, + }, + ProviderSetupModalBodyAction::Back => match self.step { + ProviderSetupStep::ProviderType => {} + ProviderSetupStep::Configure => { + self.step = ProviderSetupStep::ProviderType; + self.update_next_button(ctx); + ctx.notify(); + } + ProviderSetupStep::Discover => { + self.step = ProviderSetupStep::Configure; + self.discovery_state = DiscoveryState::Idle; + self.update_next_button(ctx); + ctx.notify(); + } + ProviderSetupStep::Models => { + self.step = ProviderSetupStep::Configure; + self.update_next_button(ctx); + ctx.notify(); + } + }, + ProviderSetupModalBodyAction::Cancel => { + ctx.emit(ProviderSetupModalBodyEvent::Close); + } + ProviderSetupModalBodyAction::ToggleModel(index) => { + if let Some(model) = self.draft_models.get_mut(*index) { + model.enabled = !model.enabled; + self.update_next_button(ctx); + ctx.notify(); + } + } + ProviderSetupModalBodyAction::ConnectChatGPT => { + #[cfg(not(target_family = "wasm"))] + ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx)); + } + ProviderSetupModalBodyAction::OpenChatGPTDevicePage => { + #[cfg(not(target_family = "wasm"))] + let auth_state = ChatGPTAuthModel::as_ref(ctx).state().clone(); + let verification_uri = match auth_state { + ChatGPTAuthState::AwaitingDeviceCode { + verification_uri, .. + } => Some(verification_uri), + _ => None, + }; + if let Some(verification_uri) = verification_uri { + ctx.open_url(&verification_uri); + } + } + ProviderSetupModalBodyAction::CopyChatGPTDeviceCode => { + #[cfg(not(target_family = "wasm"))] + let auth_state = ChatGPTAuthModel::as_ref(ctx).state().clone(); + if let ChatGPTAuthState::AwaitingDeviceCode { user_code, .. } = auth_state { + ctx.clipboard() + .write(ClipboardContent::plain_text(user_code)); + } + } + ProviderSetupModalBodyAction::SelectBedrockAuth(method) => { + self.draft_bedrock.auth_method = *method; + self.sync_bedrock_auth_buttons(ctx); + self.update_next_button(ctx); + ctx.notify(); + } + ProviderSetupModalBodyAction::ToggleBedrockCrossRegion => { + self.draft_bedrock.cross_region_inference = + !self.draft_bedrock.cross_region_inference; + ctx.notify(); + } + ProviderSetupModalBodyAction::ToggleBedrockAutoLogin => { + self.draft_bedrock.auto_login = !self.draft_bedrock.auto_login; + ctx.notify(); + } + } + } +} + +fn provider_type_label(kind: ProviderSetupProviderType) -> &'static str { + match kind { + ProviderSetupProviderType::OpenAICompatible => "OpenAI-compatible API", + ProviderSetupProviderType::ChatGPTSubscription => "ChatGPT subscription", + ProviderSetupProviderType::Bedrock => "AWS Bedrock", + ProviderSetupProviderType::Acp => "ACP agent runtime", + } +} diff --git a/app/src/themes/default_themes.rs b/app/src/themes/default_themes.rs index a29496f3..fc2fdfbb 100644 --- a/app/src/themes/default_themes.rs +++ b/app/src/themes/default_themes.rs @@ -113,45 +113,45 @@ const DRACULA_BRIGHT_COLORS: AnsiColors = AnsiColors::new( AnsiColor::from_u32(0xFFFFFFFF), ); const GALAXY_DARK_NORMAL_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0x3A4050FF), - AnsiColor::from_u32(0xF07178FF), - AnsiColor::from_u32(0x65B88AFF), - AnsiColor::from_u32(0xDAB965FF), - AnsiColor::from_u32(0x6F8EFFFF), - AnsiColor::from_u32(0xB38CF3FF), - AnsiColor::from_u32(0x62B8C8FF), - AnsiColor::from_u32(0xD9DCE8FF), + AnsiColor::from_u32(0x444B61FF), + AnsiColor::from_u32(0xF27A86FF), + AnsiColor::from_u32(0x68C093FF), + AnsiColor::from_u32(0xDFB968FF), + AnsiColor::from_u32(0x7396FFFF), + AnsiColor::from_u32(0xC184F4FF), + AnsiColor::from_u32(0x62C2D1FF), + AnsiColor::from_u32(0xDDE1EDFF), ); const GALAXY_DARK_BRIGHT_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0x60687AFF), - AnsiColor::from_u32(0xFF8B91FF), - AnsiColor::from_u32(0x7DCB9FFF), - AnsiColor::from_u32(0xE8CC7EFF), - AnsiColor::from_u32(0x91A6FFFF), - AnsiColor::from_u32(0xC9A9FFFF), - AnsiColor::from_u32(0x7CCDDDFF), - AnsiColor::from_u32(0xFAFAFDFF), + AnsiColor::from_u32(0x707A93FF), + AnsiColor::from_u32(0xFF99A3FF), + AnsiColor::from_u32(0x86D6ADFF), + AnsiColor::from_u32(0xF0D184FF), + AnsiColor::from_u32(0x9EB7FFFF), + AnsiColor::from_u32(0xD8A8FFFF), + AnsiColor::from_u32(0x83D8E4FF), + AnsiColor::from_u32(0xFCFBFFFF), ); const GALAXY_DAY_NORMAL_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0x4D5363FF), - AnsiColor::from_u32(0xB64B59FF), - AnsiColor::from_u32(0x317C5AFF), - AnsiColor::from_u32(0x886B2EFF), - AnsiColor::from_u32(0x435FC7FF), - AnsiColor::from_u32(0x7653A8FF), - AnsiColor::from_u32(0x347789FF), - AnsiColor::from_u32(0xD3D6DFFF), + AnsiColor::from_u32(0x50566BFF), + AnsiColor::from_u32(0xB94E64FF), + AnsiColor::from_u32(0x2F8060FF), + AnsiColor::from_u32(0x89682AFF), + AnsiColor::from_u32(0x435FC8FF), + AnsiColor::from_u32(0x8154A8FF), + AnsiColor::from_u32(0x2F7D8BFF), + AnsiColor::from_u32(0xD0D5E2FF), ); const GALAXY_DAY_BRIGHT_COLORS: AnsiColors = AnsiColors::new( - AnsiColor::from_u32(0x697080FF), - AnsiColor::from_u32(0xCB606BFF), - AnsiColor::from_u32(0x3E9169FF), - AnsiColor::from_u32(0xA07D37FF), - AnsiColor::from_u32(0x5D76DBFF), - AnsiColor::from_u32(0x8C6CBCFF), - AnsiColor::from_u32(0x468C9EFF), - AnsiColor::from_u32(0xF6F7FAFF), + AnsiColor::from_u32(0x6F778EFF), + AnsiColor::from_u32(0xD06374FF), + AnsiColor::from_u32(0x43966FFF), + AnsiColor::from_u32(0xA17C35FF), + AnsiColor::from_u32(0x607ADEFF), + AnsiColor::from_u32(0x9B6EBFFF), + AnsiColor::from_u32(0x4693A1FF), + AnsiColor::from_u32(0xF7F7FBFF), ); const PHENOMENON_NORMAL_COLORS: AnsiColors = AnsiColors::new( @@ -665,9 +665,15 @@ pub(super) fn adeberry() -> GalaxyTheme { pub(super) fn galaxy_dark() -> GalaxyTheme { GalaxyTheme::new( - Fill::Solid(ColorU::from_u32(0x1B1E2BFF)), - ColorU::from_u32(0xF2F3FAFF), - Fill::Solid(ColorU::from_u32(0x7C83FFFF)), + Fill::VerticalGradient(VerticalGradient::new( + ColorU::from_u32(0x252A46FF), + ColorU::from_u32(0x171925FF), + )), + ColorU::from_u32(0xF4F3FBFF), + Fill::HorizontalGradient(HorizontalGradient::new( + ColorU::from_u32(0x6F8BFFFF), + ColorU::from_u32(0xAE74E6FF), + )), None, Some(Details::Darker), galaxy_dark_colors(), @@ -678,9 +684,15 @@ pub(super) fn galaxy_dark() -> GalaxyTheme { pub(super) fn galaxy_day() -> GalaxyTheme { GalaxyTheme::new( - Fill::Solid(ColorU::from_u32(0xE1E4EBFF)), - ColorU::from_u32(0x242735FF), - Fill::Solid(ColorU::from_u32(0x5765D8FF)), + Fill::VerticalGradient(VerticalGradient::new( + ColorU::from_u32(0xECECF4FF), + ColorU::from_u32(0xD6DCE8FF), + )), + ColorU::from_u32(0x29283AFF), + Fill::HorizontalGradient(HorizontalGradient::new( + ColorU::from_u32(0x5168D6FF), + ColorU::from_u32(0x8C64B8FF), + )), None, Some(Details::Lighter), galaxy_day_colors(), diff --git a/app/src/workspaces/user_workspaces.rs b/app/src/workspaces/user_workspaces.rs index b47ce71c..957ffd6f 100644 --- a/app/src/workspaces/user_workspaces.rs +++ b/app/src/workspaces/user_workspaces.rs @@ -19,6 +19,7 @@ use crate::auth::{AuthStateProvider, UserUid}; use crate::channel::{Channel, ChannelState}; use crate::cloud_object::model::persistence::CloudModel; use crate::cloud_object::{CloudObjectEventEntrypoint, ObjectType, Owner, Space}; +use crate::local_object_repository::local_owner; use crate::pricing::PricingInfoModel; use crate::report_error; use crate::server::experiments::{ServerExperiment, ServerExperiments, ServerExperimentsEvent}; @@ -673,6 +674,12 @@ impl UserWorkspaces { // Returns a Vec of the user's active spaces, based on their // team membership. Includes the "Personal Space" by default. pub fn all_user_spaces(&self, ctx: &AppContext) -> Vec { + // Galaxy's OSS channel is local-first. It has no authenticated cloud + // identity or shared drive, so never expose the legacy shared space. + if ChannelState::channel().is_local_first() { + return vec![Space::Personal]; + } + if AuthStateProvider::as_ref(ctx) .get() .is_user_web_anonymous_user() @@ -695,8 +702,12 @@ impl UserWorkspaces { } // Returns the [`Owner`] for the user's personal drive. If the user is not authenticated, this - // returns `None`. + // returns the stable local owner in local-first channels. pub fn personal_drive(&self, ctx: &AppContext) -> Option { + if ChannelState::channel().is_local_first() { + return Some(local_owner()); + } + // Return the authenticated user's ID if available, otherwise provide a // synthetic local owner so cloud objects (rules, etc.) can be created and // stored locally without requiring Warp authentication. @@ -724,8 +735,24 @@ impl UserWorkspaces { // Maps an [`Owner`] into a [`Space`], based on the user's team memberships. // This is always possible, as unknown owners imply the shared space. pub fn owner_to_space(&self, owner: Owner, ctx: &AppContext) -> Space { + if ChannelState::channel().is_local_first() { + return if owner == local_owner() { + Space::Personal + } else { + Space::Shared + }; + } + match owner { Owner::User { user_uid } => { + if matches!( + local_owner(), + Owner::User { + user_uid: local_uid + } if local_uid == user_uid + ) { + return Space::Personal; + } if !FeatureFlag::SharedWithMe.is_enabled() { return Space::Personal; } diff --git a/app/src/workspaces/user_workspaces_tests.rs b/app/src/workspaces/user_workspaces_tests.rs index a9b4e9b3..99930cba 100644 --- a/app/src/workspaces/user_workspaces_tests.rs +++ b/app/src/workspaces/user_workspaces_tests.rs @@ -10,6 +10,7 @@ use crate::ai::llms::LLMModelHost; use crate::auth::AuthManager; use crate::cloud_object::model::persistence::CloudModel; use crate::features::FeatureFlag; +use crate::local_object_repository::local_owner; use crate::network::NetworkStatus; use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::ids::ClientId; @@ -96,6 +97,39 @@ fn initialize_app_with_auth( }); } +#[test] +fn oss_exposes_only_local_personal_space() { + App::test((), |mut app| async move { + app.add_singleton_model(|ctx| { + UserWorkspaces::mock( + Arc::new(MockTeamClient::new()), + Arc::new(MockWorkspaceClient::new()), + vec![], + ctx, + ) + }); + + app.read(|ctx| { + let user_workspaces = UserWorkspaces::as_ref(ctx); + assert_eq!(user_workspaces.all_user_spaces(ctx), vec![Space::Personal]); + assert_eq!(user_workspaces.personal_drive(ctx), Some(local_owner())); + assert_eq!( + user_workspaces.space_to_owner(Space::Personal, ctx), + Some(local_owner()) + ); + assert_eq!( + user_workspaces.owner_to_space( + Owner::User { + user_uid: UserUid::new("legacy-cloud-user"), + }, + ctx, + ), + Space::Shared + ); + }); + }) +} + #[test] fn test_loading_all_spaces_after_switching_from_offline() { let _flag = FeatureFlag::KnowledgeSidebar.override_enabled(true); diff --git a/crates/ai/src/agent/action_result/mod.rs b/crates/ai/src/agent/action_result/mod.rs index fe78dc38..735cd526 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -150,12 +150,14 @@ impl AIAgentActionResultType { .. } => command_result_content(Some(command), output, exit_code.value()), RequestCommandOutputResult::LongRunningCommandSnapshot { + block_id, command, grid_contents, cursor, is_alt_screen_active, .. } => shell_snapshot_content( + block_id, Some(command), grid_contents, cursor, @@ -167,12 +169,14 @@ impl AIAgentActionResultType { }, Self::WriteToLongRunningShellCommand(result) => match result { WriteToLongRunningShellCommandResult::Snapshot { + block_id, grid_contents, cursor, is_alt_screen_active, is_preempted, .. } => shell_snapshot_content( + block_id, None, grid_contents, cursor, @@ -229,6 +233,7 @@ impl AIAgentActionResultType { .. } => command_result_content(Some(command), output, exit_code.value()), ReadShellCommandOutputResult::LongRunningCommandSnapshot { + block_id, command, grid_contents, cursor, @@ -236,6 +241,7 @@ impl AIAgentActionResultType { is_preempted, .. } => shell_snapshot_content( + block_id, Some(command), grid_contents, cursor, @@ -247,6 +253,7 @@ impl AIAgentActionResultType { }, Self::TransferShellCommandControlToUser(result) => match result { TransferShellCommandControlToUserResult::Snapshot { + block_id, grid_contents, cursor, is_alt_screen_active, @@ -255,6 +262,7 @@ impl AIAgentActionResultType { } => format!( "{}\nControl has been transferred to the user. Do not write to the command until control is returned.", shell_snapshot_content( + block_id, None, grid_contents, cursor, @@ -305,6 +313,7 @@ fn command_result_content(command: Option<&str>, output: &str, exit_code: i32) - } fn shell_snapshot_content( + block_id: &BlockId, command: Option<&str>, grid_contents: &str, cursor: &str, @@ -318,7 +327,7 @@ fn shell_snapshot_content( .map(|is_preempted| format!("\nPreempted: {is_preempted}")) .unwrap_or_default(); format!( - "{command}Command is still running.\nCurrent output:\n{grid_contents}\nCursor: {cursor}\nAlt screen active: {is_alt_screen_active}{preempted}" + "{command}Command is still running.\nCommand ID: {block_id}\nCurrent output:\n{grid_contents}\nCursor: {cursor}\nAlt screen active: {is_alt_screen_active}{preempted}\nContinue monitoring with `read_shell_command_output` using command_id `{block_id}` and a short wait. Do not report completion while the command is still running." ) } diff --git a/crates/galaxy_agent_rig/src/chatgpt.rs b/crates/galaxy_agent_rig/src/chatgpt.rs new file mode 100644 index 00000000..50cae334 --- /dev/null +++ b/crates/galaxy_agent_rig/src/chatgpt.rs @@ -0,0 +1,219 @@ +use std::path::PathBuf; + +use async_trait::async_trait; +use futures::StreamExt; +use galaxy_agent_core::{ + AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, ConversationMessage, + MessageContent, MessageRole, RuntimeCapabilities, RuntimeDescriptor, RuntimeKind, TurnControl, + TurnRequest, turn_control, +}; +use rig_core::client::CompletionClient; +use rig_core::providers::chatgpt; + +use crate::request::build_completion_request; +use crate::stream::start_model_turn; + +/// The information a user needs to complete ChatGPT's device authorization flow. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChatGPTDeviceCode { + pub verification_uri: String, + pub user_code: String, +} + +/// Small application-facing wrapper around Rig's native ChatGPT OAuth client. +/// +/// Keeping the Rig auth type behind this wrapper lets Galaxy present device-code +/// instructions without depending on Rig's private auth module. +pub struct ChatGPTSubscriptionClient { + client: chatgpt::Client, +} + +impl ChatGPTSubscriptionClient { + pub fn with_device_code_handler(handler: F) -> Result + where + F: Fn(ChatGPTDeviceCode) + Send + Sync + 'static, + { + let client = chatgpt::Client::builder() + .oauth() + .on_device_code(move |prompt| { + handler(ChatGPTDeviceCode { + verification_uri: prompt.verification_uri, + user_code: prompt.user_code, + }); + }) + .build() + .map_err(|error| error.to_string())?; + Ok(Self { client }) + } + + pub async fn authorize(&self) -> Result<(), String> { + self.client + .authorize() + .await + .map_err(|error| error.to_string()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChatGPTSubscriptionRuntimeConfig { + pub model: String, + pub reasoning_effort: Option, + pub max_output_tokens: Option, + pub auth_file: Option, +} + +#[derive(Clone, Debug)] +pub struct ChatGPTSubscriptionRuntime { + config: ChatGPTSubscriptionRuntimeConfig, + descriptor: RuntimeDescriptor, +} + +fn reasoning_additional_params(reasoning_effort: Option<&str>) -> Option { + reasoning_effort.map(|effort| { + // Codex exposes `ultra` as a client-side delegation mode, while the + // Responses wire format carries its maximum reasoning as `max`. + let wire_effort = if effort == "ultra" { "max" } else { effort }; + serde_json::json!({"reasoning": {"effort": wire_effort}}) + }) +} + +impl ChatGPTSubscriptionRuntime { + pub fn new(config: ChatGPTSubscriptionRuntimeConfig) -> Self { + let descriptor = RuntimeDescriptor { + id: format!("rig-chatgpt-subscription:{}", config.model), + display_name: format!("ChatGPT Subscription / {}", config.model), + kind: RuntimeKind::Provider, + capabilities: RuntimeCapabilities::provider(), + }; + Self { config, descriptor } + } + + /// Completes a text-only request for callers such as crosscheck reviewers. + pub async fn complete_text( + &self, + system_prompt: String, + user_prompt: String, + ) -> Result { + let mut request = TurnRequest::new( + self.config.model.clone(), + vec![ConversationMessage { + role: MessageRole::User, + content: MessageContent::Text(user_prompt), + }], + ); + request.system_prompt = Some(system_prompt); + request.max_output_tokens = self.config.max_output_tokens; + + let (_sender, control) = turn_control(); + let mut stream = self + .start_turn(request, control) + .await + .map_err(|error| error.to_string())?; + let mut output = String::new(); + while let Some(event) = stream.next().await { + match event.map_err(|error| error.to_string())? { + AgentEvent::TextDelta { text } => output.push_str(&text), + AgentEvent::ReasoningDelta { .. } + | AgentEvent::ReasoningCompleted { .. } + | AgentEvent::TurnStarted { .. } + | AgentEvent::UsageUpdated { .. } + | AgentEvent::RuntimeActivityUpdated { .. } + | AgentEvent::ContextUsageUpdated { .. } + | AgentEvent::UserInputAccepted { .. } + | AgentEvent::RuntimeNotice { .. } + | AgentEvent::TurnStopped { .. } + | AgentEvent::Tool { .. } => {} + } + } + + if output.is_empty() { + return Err("ChatGPT returned an empty response.".to_string()); + } + Ok(output) + } +} + +#[async_trait] +impl AgentRuntime for ChatGPTSubscriptionRuntime { + fn descriptor(&self) -> &RuntimeDescriptor { + &self.descriptor + } + + async fn start_turn( + &self, + request: TurnRequest, + control: TurnControl, + ) -> Result { + let mut builder = chatgpt::Client::builder().oauth(); + if let Some(auth_file) = &self.config.auth_file { + builder = builder.auth_file(auth_file); + } + let client = builder + .build() + .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; + let model = client.completion_model(&self.config.model); + let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens); + let additional_params = + reasoning_additional_params(self.config.reasoning_effort.as_deref()); + let completion_request = build_completion_request( + request, + self.config.max_output_tokens, + true, + false, + additional_params, + )?; + + start_model_turn(model, completion_request, control, max_output_tokens).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_descriptor_identifies_chatgpt_subscription() { + let runtime = ChatGPTSubscriptionRuntime::new(ChatGPTSubscriptionRuntimeConfig { + model: "gpt-5.3-codex".to_string(), + reasoning_effort: Some("high".to_string()), + max_output_tokens: None, + auth_file: None, + }); + + assert_eq!( + runtime.descriptor().id, + "rig-chatgpt-subscription:gpt-5.3-codex" + ); + } + + #[test] + fn reasoning_effort_is_encoded_for_the_responses_request() { + let request = TurnRequest::new( + "gpt-5.4".to_string(), + vec![ConversationMessage { + role: MessageRole::User, + content: MessageContent::Text("hello".to_string()), + }], + ); + let request = build_completion_request( + request, + None, + true, + false, + reasoning_additional_params(Some("xhigh")), + ) + .expect("request should convert"); + + assert_eq!( + request.additional_params, + reasoning_additional_params(Some("xhigh")) + ); + + assert_eq!( + reasoning_additional_params(Some("ultra")), + Some(serde_json::json!({ + "reasoning": { "effort": "max" } + })) + ); + } +} diff --git a/crates/galaxy_agent_rig/src/lib.rs b/crates/galaxy_agent_rig/src/lib.rs index 9fb4a485..2ce918d7 100644 --- a/crates/galaxy_agent_rig/src/lib.rs +++ b/crates/galaxy_agent_rig/src/lib.rs @@ -1,9 +1,11 @@ //! Rig-backed implementations of Galaxy's provider-neutral agent runtime. mod bedrock; +mod chatgpt; mod openai_compatible; mod request; mod stream; pub use bedrock::*; +pub use chatgpt::*; pub use openai_compatible::*; diff --git a/crates/galaxy_agent_rig/src/openai_compatible_tests.rs b/crates/galaxy_agent_rig/src/openai_compatible_tests.rs index f94fde67..b1dfece0 100644 --- a/crates/galaxy_agent_rig/src/openai_compatible_tests.rs +++ b/crates/galaxy_agent_rig/src/openai_compatible_tests.rs @@ -276,6 +276,7 @@ fn request_conversion_preserves_tool_call_and_denied_result_for_the_next_turn() panic!("expected assistant tool call content"); }; assert_eq!(call.id, "call-1"); + assert_eq!(call.call_id.as_deref(), Some("call-1")); assert_eq!(call.function.name, "run_shell_command"); let Message::User { content } = messages[1] else { @@ -285,6 +286,7 @@ fn request_conversion_preserves_tool_call_and_denied_result_for_the_next_turn() panic!("expected user tool result content"); }; assert_eq!(result.id, "call-1"); + assert_eq!(result.call_id.as_deref(), Some("call-1")); let Some(ToolResultContent::Text(text)) = result.content.iter().next() else { panic!("expected text tool result"); }; diff --git a/crates/galaxy_agent_rig/src/request.rs b/crates/galaxy_agent_rig/src/request.rs index 5b85c005..8c086b1a 100644 --- a/crates/galaxy_agent_rig/src/request.rs +++ b/crates/galaxy_agent_rig/src/request.rs @@ -59,6 +59,7 @@ pub(crate) fn build_completion_request( tool_choice: None, additional_params, output_schema: None, + record_telemetry_content: false, }) } @@ -87,7 +88,8 @@ fn user_content( tool_use_id, content, is_error, - } => vec![UserContent::tool_result( + } => vec![UserContent::tool_result_with_call_id( + tool_use_id.clone(), tool_use_id, OneOrMany::one(ToolResultContent::text(tool_result_text(content, is_error))), )], @@ -112,7 +114,12 @@ fn assistant_content( tool_use_id, name, input, - } => vec![AssistantContent::tool_call(tool_use_id, name, input)], + } => vec![AssistantContent::tool_call_with_call_id( + tool_use_id.clone(), + tool_use_id, + name, + input, + )], MessageContent::MultiPart(parts) => parts .into_iter() .map(|part| convert_assistant_part(part, encode_images_as_base64)) @@ -147,7 +154,8 @@ fn convert_user_part( tool_use_id, content, is_error, - } => Ok(UserContent::tool_result( + } => Ok(UserContent::tool_result_with_call_id( + tool_use_id.clone(), tool_use_id, OneOrMany::one(ToolResultContent::text(tool_result_text(content, is_error))), )), @@ -178,7 +186,12 @@ fn convert_assistant_part( tool_use_id, name, input, - } => Ok(AssistantContent::tool_call(tool_use_id, name, input)), + } => Ok(AssistantContent::tool_call_with_call_id( + tool_use_id.clone(), + tool_use_id, + name, + input, + )), ContentPart::ToolResult { .. } => Err(invalid_role("tool result", "assistant")), } } diff --git a/crates/galaxy_agent_rig/src/stream.rs b/crates/galaxy_agent_rig/src/stream.rs index 3c6eb6b7..0f6f0b9b 100644 --- a/crates/galaxy_agent_rig/src/stream.rs +++ b/crates/galaxy_agent_rig/src/stream.rs @@ -103,11 +103,7 @@ where Ok(StreamedAssistantContent::ToolCall { tool_call, .. }) => { yield Ok(AgentEvent::Tool { event: galaxy_agent_core::ToolEvent::Proposed { - call: ToolCall { - id: tool_call.id, - name: tool_call.function.name, - arguments: tool_call.function.arguments, - }, + call: domain_tool_call(tool_call), }, }); } @@ -146,6 +142,18 @@ where Ok(Box::pin(events)) } +fn domain_tool_call(tool_call: rig_core::message::ToolCall) -> ToolCall { + ToolCall { + // OpenAI Responses uses a separate `call_id` for function-call output + // correlation. The domain model has one ID, so preserve that value when + // it is available and fall back to the standard tool-call ID for other + // OpenAI-compatible providers. + id: tool_call.call_id.unwrap_or(tool_call.id), + name: tool_call.function.name, + arguments: tool_call.function.arguments, + } +} + fn stopped_before_stream(runtime_request_id: String) -> AgentEventStream { Box::pin(futures::stream::iter([ Ok(AgentEvent::TurnStarted { runtime_request_id }), @@ -206,3 +214,25 @@ fn map_completion_error(error: CompletionError) -> AgentError { ); mapped } + +#[cfg(test)] +mod tests { + use super::domain_tool_call; + + #[test] + fn domain_tool_call_prefers_responses_call_id() { + let tool_call = rig_core::message::ToolCall::new( + "fc_item_123".to_string(), + rig_core::message::ToolFunction { + name: "read_files".to_string(), + arguments: serde_json::json!({"files": ["Cargo.toml"]}), + }, + ) + .with_call_id("call_123".to_string()); + + let call = domain_tool_call(tool_call); + + assert_eq!(call.id, "call_123"); + assert_eq!(call.name, "read_files"); + } +} diff --git a/crates/galaxy_core/src/channel/channel_tests.rs b/crates/galaxy_core/src/channel/channel_tests.rs index b5089e69..43a0d32b 100644 --- a/crates/galaxy_core/src/channel/channel_tests.rs +++ b/crates/galaxy_core/src/channel/channel_tests.rs @@ -22,3 +22,13 @@ fn only_oss_disables_warp_service_egress() { assert!(Channel::Integration.allows_warp_service_egress()); assert!(!Channel::Oss.allows_warp_service_egress()); } + +#[test] +fn only_oss_is_local_first() { + assert!(!Channel::Stable.is_local_first()); + assert!(!Channel::Preview.is_local_first()); + assert!(!Channel::Dev.is_local_first()); + assert!(!Channel::Local.is_local_first()); + assert!(!Channel::Integration.is_local_first()); + assert!(Channel::Oss.is_local_first()); +} diff --git a/crates/galaxy_core/src/channel/mod.rs b/crates/galaxy_core/src/channel/mod.rs index c0735d42..1f747b14 100644 --- a/crates/galaxy_core/src/channel/mod.rs +++ b/crates/galaxy_core/src/channel/mod.rs @@ -63,6 +63,12 @@ impl Channel { } } + /// Whether the channel is local-first and must not expose Warp-owned + /// identity, sharing, or workspace state to the product surface. + pub fn is_local_first(&self) -> bool { + !self.allows_warp_service_egress() + } + /// Returns the CLI command name corresponding to this channel. pub fn cli_command_name(&self) -> &'static str { match self { diff --git a/plans/galaxy-local-first-rig.md b/plans/galaxy-local-first-rig.md index 396c0af0..ed9c3992 100644 --- a/plans/galaxy-local-first-rig.md +++ b/plans/galaxy-local-first-rig.md @@ -386,13 +386,31 @@ metadata. restoration to the local service without account or online-state requirements. - [x] Move environment-variable collection create, edit, duplicate, trash, restore, delete, and local pane loading to the local service. -- [ ] Move MCP configs to the local service. -- [ ] Replace remaining account/workspace ownership with local scopes. +- [x] Move templatable MCP config create, edit, delete, local ownership checks, and SQLite-backed + persistence to the local service while keeping process lifecycle and credentials separate. +- [x] Make the OSS channel expose only the local Personal scope and resolve it to the stable local + owner; remote-capable channels retain their existing workspace/shared-space behavior. +- [ ] Replace remaining account/workspace ownership in kept content flows with local scopes. - [ ] Remove auth, teams, billing, referral, cloud sync, GraphQL, RTC, sharing, and remote-control UI. Exit condition: none of the kept content flows require `AuthState`, `CloudModel`, `UpdateManager`, `SyncQueue`, or a server ID. +## Noticed bugs and TLC backlog + +This list tracks bugs and rough edges noticed while completing the migration phases. Items should be +assigned to the phase that owns the affected flow before the related work is considered finished. + +- [ ] Long-Running command monitor: give the monitor state machine and UI a focused pass. Audit + command start/stop/completion transitions, stale monitor state after cancellation or restart, + output refresh and scrolling, failure/timeout handling, and restore behavior. Add deterministic + unit coverage and a hermetic integration flow for a command that remains active while the agent + continues running. +- [ ] Open-source project presentation: structure the About page around Galaxy’s local-first + identity, audit the repository’s license and third-party notices, and make the root metadata, + contribution guidance, and license files agree on the final permissive license (currently the + repository contains both MIT and AGPL materials and needs an explicit policy). + ### Phase 7 — UI untangling - Split coordinator files along the flow boundaries in the UI ledger. @@ -446,7 +464,8 @@ contract is what the UI and persistence observe. ## Immediate next vertical slice -Continue Phase 6 by moving MCP configs behind `LocalObjectRepository`, preserving existing SQLite -rows and pane/session restore behavior while removing account ownership and online-state gates from -their kept flows. Live Phase 4 Bedrock semantic comparisons remain an explicit opt-in validation task -because they require configured AWS access. +Continue Phase 6 by replacing remaining account/workspace ownership with local scopes and removing +cloud identity UI from kept flows. The next bounded implementation is to move the remaining Galaxy +Drive folder mutations (create, rename, trash, and open-state persistence) behind +`LocalObjectRepository` for OSS. Live Phase 4 Bedrock semantic comparisons remain an explicit +opt-in validation task because they require configured AWS access.