Add unified models UI and Rig Bedrock runtime

This commit is contained in:
2026-08-04 17:25:19 -05:00
parent a3c68e9c30
commit b0ad07f6f2
41 changed files with 2122 additions and 564 deletions
+1 -1
View File
@@ -328,7 +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-bedrockruntime = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-sdk-bedrockruntime.workspace = true
aws-sdk-sts = { version = "1", default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-smithy-types = "1"
aws-types = "1"
+26 -2
View File
@@ -28,8 +28,8 @@ pub async fn generate_multi_agent_output(
redaction::redact_inputs(&mut params.input);
}
if let ProviderConfig::OpenAI(config) = &provider_config {
if config.use_rig {
match &provider_config {
ProviderConfig::OpenAI(config) if config.use_rig => {
return Ok(crate::ai::runtime::rig_openai_response_stream(
config.clone(),
params,
@@ -38,6 +38,30 @@ pub async fn generate_multi_agent_output(
cancellation_rx,
));
}
ProviderConfig::Bedrock(config) if config.use_rig => {
return match crate::ai::runtime::rig_bedrock_response_stream(
config.clone(),
params,
supported_tools,
supported_cli_agent_tools,
cancellation_rx,
)
.await
{
Ok(stream) => Ok(stream),
Err(error) => {
log::error!("[rig/bedrock] Runtime error: {error}");
let error = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "rig_bedrock",
source: error,
});
let (sender, receiver) = async_channel::unbounded();
let _ = sender.send(Err(error)).await;
Ok(Box::pin(receiver))
}
};
}
ProviderConfig::OpenAI(_) | ProviderConfig::Bedrock(_) | ProviderConfig::None => {}
}
let mut logging_metadata = HashMap::new();
+27 -2
View File
@@ -5,6 +5,8 @@ use aws_config::BehaviorVersion;
use aws_credential_types::provider::ProvideCredentials;
use aws_sdk_bedrockruntime::config::Region;
use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
use galaxy_agent_core::AgentError;
use galaxy_agent_rig::{BedrockRigConfig, BedrockRuntime};
use super::convert::{build_converse_request, CachingConfig, ConversationMessage, ToolDefinition};
use super::diagnostic::BedrockDiagnosticLogger;
@@ -38,6 +40,7 @@ pub struct BedrockClientConfig {
pub secret_access_key: String,
pub session_token: Option<String>,
pub cross_region_inference: bool,
pub use_rig: bool,
}
impl BedrockClientConfig {
@@ -141,8 +144,8 @@ impl BedrockClient {
match provider.provide_credentials().await {
Ok(creds) => {
log::info!(
"[bedrock] Resolved AWS credentials successfully: access_key_id={:?}, has_session_token={}, expiry={:?}",
creds.access_key_id(),
"[bedrock] Resolved AWS credentials successfully: has_access_key_id={}, has_session_token={}, expiry={:?}",
!creds.access_key_id().is_empty(),
creds.session_token().is_some(),
creds.expiry(),
);
@@ -168,6 +171,28 @@ impl BedrockClient {
})
}
/// Builds the Phase 4 Rig runtime from the AWS SDK client whose region and
/// credentials Galaxy already resolved. This does not change production
/// routing; callers opt in only after the Bedrock parity suite passes.
pub fn rig_runtime(
&self,
model: String,
cross_region_inference: bool,
prompt_caching: bool,
max_output_tokens: Option<u64>,
) -> Result<BedrockRuntime, AgentError> {
BedrockRuntime::from_aws_client(
self.runtime_client.clone(),
BedrockRigConfig {
model,
region: self.region.clone(),
cross_region_inference,
prompt_caching,
max_output_tokens,
},
)
}
#[allow(clippy::too_many_arguments)]
pub async fn converse_stream(
&self,
+12 -2
View File
@@ -3,8 +3,9 @@ use std::collections::HashMap;
use aws_sdk_bedrockruntime::types::{
CachePointBlock, CachePointType, CacheTtl, ContentBlock, ConversationRole, ImageBlock,
ImageFormat, ImageSource, InferenceConfiguration, Message as BedrockMessage,
SystemContentBlock, Tool, ToolConfiguration, ToolInputSchema, ToolResultBlock,
ToolResultContentBlock, ToolResultStatus, ToolSpecification, ToolUseBlock,
ReasoningContentBlock, ReasoningTextBlock, SystemContentBlock, Tool, ToolConfiguration,
ToolInputSchema, ToolResultBlock, ToolResultContentBlock, ToolResultStatus, ToolSpecification,
ToolUseBlock,
};
use aws_smithy_types::{Blob, Document};
use serde_json::Value as JsonValue;
@@ -148,6 +149,15 @@ fn convert_messages(
.into_iter()
.map(|part| match part {
ContentPart::Text(text) => ContentBlock::Text(text),
ContentPart::Reasoning { text, signature } => {
ContentBlock::ReasoningContent(ReasoningContentBlock::ReasoningText(
ReasoningTextBlock::builder()
.text(text)
.set_signature(signature)
.build()
.expect("valid reasoning text block"),
))
}
ContentPart::Image { data, mime_type } => image_content_block(data, &mime_type),
ContentPart::ToolUse {
tool_use_id,
+8
View File
@@ -492,6 +492,14 @@ fn serialize_messages(messages: &[ConversationMessage]) -> JsonValue {
super::convert::ContentPart::Text(t) => {
serde_json::json!({"type": "text", "text": t})
}
super::convert::ContentPart::Reasoning { text, signature } => {
serde_json::json!({
"type": "reasoning",
"char_length": text.len(),
"has_signature": signature.is_some(),
"text": "REDACTED",
})
}
super::convert::ContentPart::Image { data, mime_type } => {
serde_json::json!({
"type": "image",
+1
View File
@@ -269,6 +269,7 @@ fn get_test_config() -> Option<BedrockClientConfig> {
secret_access_key: String::new(),
session_token: None,
cross_region_inference: false,
use_rig: false,
})
}
+1
View File
@@ -144,6 +144,7 @@ fn parse_claude_code_model_map(
model_id: arn,
display_name,
vision_supported: true,
use_rig: false,
}
})
.collect()
+1
View File
@@ -24,6 +24,7 @@ fn get_test_config() -> Option<BedrockClientConfig> {
secret_access_key: String::new(),
session_token: None,
cross_region_inference: false,
use_rig: false,
})
}
+29
View File
@@ -129,6 +129,7 @@ pub fn get_effective_models(user_models: &[BedrockModelConfig]) -> Vec<BedrockMo
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 {
@@ -145,10 +146,38 @@ pub fn get_effective_models(user_models: &[BedrockModelConfig]) -> Vec<BedrockMo
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],
region: &str,
cross_region_inference: bool,
) -> bool {
let selected_model_id = strip_context_marker(selected_model_id);
configured_models.iter().any(|model| {
if !model.use_rig {
return false;
}
let configured_model_id = strip_context_marker(&model.model_id);
if configured_model_id == selected_model_id {
return true;
}
galaxy_agent_rig::resolve_bedrock_model_id(&model.model_id, region, cross_region_inference)
.is_ok_and(|resolved| strip_context_marker(&resolved) == selected_model_id)
})
}
fn strip_context_marker(model_id: &str) -> &str {
model_id
.strip_suffix("[1m]")
.or_else(|| model_id.strip_suffix("[1M]"))
.unwrap_or(model_id)
}
pub fn apply_cross_region_prefix(model_id: &str, region: &str) -> String {
if model_id.starts_with("arn:") {
return model_id.to_string();
+32 -2
View File
@@ -82,8 +82,8 @@ fn test_cross_region_prefix_unknown_region() {
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, "anthropic.claude-opus-4-6[1m]");
assert_eq!(models[0].display_name, "Claude Opus 4.6");
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]
@@ -92,6 +92,7 @@ fn test_get_effective_models_custom_overrides() {
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);
@@ -103,3 +104,32 @@ fn test_cross_region_prefix_skips_arn() {
let arn = "arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/fma5bsw4oxhy";
assert_eq!(apply_cross_region_prefix(arn, "us-east-1"), arn);
}
#[test]
fn rig_opt_in_matches_context_markers_and_resolved_inference_profiles() {
let configured = vec![BedrockModelConfig {
model_id: "anthropic.claude-test[1m]".to_string(),
display_name: "Claude Test".to_string(),
vision_supported: false,
use_rig: true,
}];
assert!(configured_model_uses_rig(
"us.anthropic.claude-test",
&configured,
"us-east-1",
true,
));
assert!(configured_model_uses_rig(
"anthropic.claude-test[1M]",
&configured,
"us-east-1",
false,
));
assert!(!configured_model_uses_rig(
"anthropic.other-model",
&configured,
"us-east-1",
false,
));
}
+15 -2
View File
@@ -750,6 +750,7 @@ fn persist_input_images_on_latest_user_message(
Some(api::input_context::Image { data, mime_type })
}
Some(ContentPart::Text(_))
| Some(ContentPart::Reasoning { .. })
| Some(ContentPart::ToolUse { .. })
| Some(ContentPart::ToolResult { .. })
| None => None,
@@ -873,11 +874,17 @@ fn is_pure_tool_result(content: &MessageContent) -> bool {
fn strip_tool_result_parts(content: &mut MessageContent) {
if let MessageContent::MultiPart(parts) = content {
parts.retain(|p| !matches!(p, ContentPart::ToolResult { .. }));
if parts.len() == 1 && !matches!(parts.first(), Some(ContentPart::Image { .. })) {
if parts.len() == 1
&& !matches!(
parts.first(),
Some(ContentPart::Image { .. } | ContentPart::Reasoning { .. })
)
{
let part = parts.remove(0);
*content = match part {
ContentPart::Text(t) => MessageContent::Text(t),
ContentPart::Image { .. } => unreachable!(),
ContentPart::Reasoning { .. } => unreachable!(),
ContentPart::ToolUse {
tool_use_id,
name,
@@ -916,11 +923,17 @@ fn strip_orphaned_tool_result_parts(
ContentPart::ToolResult { tool_use_id, .. } => valid_ids.contains(tool_use_id),
_ => true,
});
if parts.len() == 1 && !matches!(parts.first(), Some(ContentPart::Image { .. })) {
if parts.len() == 1
&& !matches!(
parts.first(),
Some(ContentPart::Image { .. } | ContentPart::Reasoning { .. })
)
{
let part = parts.remove(0);
*content = match part {
ContentPart::Text(t) => MessageContent::Text(t),
ContentPart::Image { .. } => unreachable!(),
ContentPart::Reasoning { .. } => unreachable!(),
ContentPart::ToolUse {
tool_use_id,
name,
+7
View File
@@ -180,6 +180,13 @@ fn describe_message_content(content: &crate::ai::bedrock::convert::MessageConten
.iter()
.map(|p| match p {
ContentPart::Text(t) => format!("Text({})", t.len()),
ContentPart::Reasoning { text, signature } => {
format!(
"Reasoning({}chars,signed={})",
text.len(),
signature.is_some()
)
}
ContentPart::Image { data, mime_type } => {
format!("Image({mime_type},{}bytes)", data.len())
}
+7
View File
@@ -4346,6 +4346,7 @@ impl BlocklistAIController {
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,
};
if let ::ai::api_keys::AwsCredentialsState::Loaded { credentials, .. } =
@@ -4431,6 +4432,9 @@ impl BlocklistAIController {
.iter()
.map(|p| match p {
ContentPart::Text(t) => t.clone(),
ContentPart::Reasoning { text, .. } => {
format!("[Reasoning] {text}")
}
ContentPart::Image { .. } => "[Image attachment]".to_string(),
ContentPart::ToolUse { name, input, .. } => {
format!("[Tool: {}] {}", name, input)
@@ -4540,6 +4544,9 @@ impl BlocklistAIController {
.iter()
.map(|p| match p {
ContentPart::Text(t) => (t.len() / 4) as u32,
ContentPart::Reasoning { text, .. } => {
(text.len() / 4) as u32
}
ContentPart::Image { .. } => 1_600,
ContentPart::ToolUse { input, .. } => {
(input.to_string().len() / 4) as u32
@@ -243,15 +243,33 @@ impl ResponseStream {
// Fall back to Bedrock
if *settings.bedrock_enabled.value() {
let auth_method = *settings.bedrock_auth_method.value();
let region = settings.bedrock_region.value().clone();
let cross_region_inference = *settings.bedrock_cross_region_inference.value();
let mut use_rig = crate::ai::bedrock::models::configured_model_uses_rig(
model_id,
settings.bedrock_models.value(),
&region,
cross_region_inference,
);
if use_rig
&& crate::ai::bedrock::external_config::ExternalBedrockConfig::load()
.enable_prompt_caching_1h
{
log::warn!(
"[rig/bedrock] Using the compatibility runtime because Rig does not yet expose Bedrock's one-hour cache TTL"
);
use_rig = false;
}
let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx);
let mut config = BedrockClientConfig {
auth_method,
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
region,
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(),
cross_region_inference,
use_rig,
};
if let ::ai::api_keys::AwsCredentialsState::Loaded { credentials, .. } =
@@ -45,6 +45,7 @@ impl View for ContextWindowView {
.iter()
.map(|p| match p {
ContentPart::Text(t) => t.len(),
ContentPart::Reasoning { text, .. } => text.len(),
ContentPart::Image { .. } => 6_400,
ContentPart::ToolUse { input, .. } => input.to_string().len(),
ContentPart::ToolResult { content, .. } => content.len(),
@@ -112,6 +113,14 @@ impl View for ContextWindowView {
ContentPart::Text(t) => {
out.push_str(&format!("[Part {} Text] {}\n", pi, t));
}
ContentPart::Reasoning { text, signature } => {
out.push_str(&format!(
"[Part {} Reasoning] signed={}\n{}\n",
pi,
signature.is_some(),
text
));
}
ContentPart::Image { data, mime_type } => {
out.push_str(&format!(
"[Part {} Image] mime_type={}, bytes={}\n",
+1
View File
@@ -186,6 +186,7 @@ impl CrosscheckReviewer {
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,
};
if let ::ai::api_keys::AwsCredentialsState::Loaded { credentials, .. } =
+131 -2
View File
@@ -721,6 +721,7 @@ impl LLMPreferences {
model_id: default.model_id.to_string(),
display_name: default.display_name.to_string(),
vision_supported: default.vision_supported,
use_rig: false,
});
added = true;
}
@@ -1234,6 +1235,80 @@ impl LLMPreferences {
);
}
/// Explicitly refreshes the models for one entry in the OpenAI-compatible
/// provider registry. Unlike the legacy endpoint refresh, this is only
/// called from a user action so configured remote endpoints are never
/// contacted merely because Galaxy started.
#[cfg(not(target_family = "wasm"))]
pub fn fetch_openai_provider_models(
&mut self,
provider_index: usize,
ctx: &mut ModelContext<Self>,
) {
let settings = AISettings::as_ref(ctx);
if !*settings.openai_enabled.value() {
return;
}
let Some(provider) = settings
.openai_providers
.value()
.get(provider_index)
.cloned()
else {
return;
};
if provider.base_url.trim().is_empty() {
return;
}
let requested_base_url = provider.base_url;
let api_key = provider.api_key.filter(|key| !key.is_empty());
let request_base_url = requested_base_url.clone();
let _ = ctx.spawn(
async move {
let base = request_base_url.trim_end_matches('/');
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_default();
if let Some(models) =
fetch_from_litellm_model_info(base, api_key.as_deref(), &client).await
{
return models;
}
fetch_from_openai_models(base, api_key.as_deref(), &client).await
},
move |_, discovered_models, ctx| {
if discovered_models.is_empty() {
return;
}
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let mut providers = settings.openai_providers.value().clone();
let Some(provider) = providers.get_mut(provider_index) else {
return;
};
// Do not apply a response to an entry that was edited or
// reordered while its discovery request was in flight.
if provider.base_url != requested_base_url {
return;
}
provider.models =
merge_discovered_provider_models(&provider.models, discovered_models);
if let Err(err) = settings.openai_providers.set_value(providers, ctx) {
report_error!(err.context("Failed to persist discovered provider 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,
@@ -1994,6 +2069,52 @@ fn openai_model_context_size(model: &OpenAIModelConfig) -> u32 {
model.max_input_tokens.unwrap_or(model.context_size)
}
/// 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(
existing_models: &[OpenAIModelConfig],
discovered_models: Vec<OpenAIModelConfig>,
) -> Vec<OpenAIModelConfig> {
let mut merged = Vec::with_capacity(discovered_models.len() + existing_models.len());
let mut discovered_ids = HashSet::new();
for mut discovered in discovered_models {
if !discovered_ids.insert(discovered.model_id.clone()) {
continue;
}
if let Some(existing) = existing_models
.iter()
.find(|model| model.model_id == discovered.model_id)
{
discovered.display_name = existing.display_name.clone();
discovered.use_rig = existing.use_rig;
if existing.supports_system_messages.is_some() {
discovered.supports_system_messages = existing.supports_system_messages;
}
if discovered.provider.is_none() {
discovered.provider = existing.provider.clone();
}
} else {
discovered.use_rig = true;
}
if discovered.model_id.starts_with("codex-gpt-") {
discovered.supports_system_messages = Some(false);
}
merged.push(discovered);
}
merged.extend(
existing_models
.iter()
.filter(|model| !discovered_ids.contains(&model.model_id))
.cloned(),
);
merged
}
#[cfg(not(target_family = "wasm"))]
fn openai_model_context_window(model: &OpenAIModelConfig) -> LLMContextWindow {
let context_size = openai_model_context_size(model);
@@ -2118,7 +2239,11 @@ async fn fetch_from_litellm_model_info(
max_output_tokens,
provider,
use_rig: false,
supports_system_messages: model_info["supports_system_messages"].as_bool(),
supports_system_messages: if model_name.starts_with("codex-gpt-") {
Some(false)
} else {
model_info["supports_system_messages"].as_bool()
},
})
})
.collect();
@@ -2241,7 +2366,11 @@ async fn fetch_from_openai_models(
max_output_tokens,
provider,
use_rig: false,
supports_system_messages: m["supports_system_messages"].as_bool(),
supports_system_messages: if id.starts_with("codex-gpt-") {
Some(false)
} else {
m["supports_system_messages"].as_bool()
},
})
})
.collect();
+65 -1
View File
@@ -10,7 +10,7 @@ use crate::network::NetworkStatus;
use crate::server::cloud_objects::update_manager::UpdateManager;
use crate::server::server_api::ServerApiProvider;
use crate::server::sync_queue::SyncQueue;
use crate::settings::{OpenAIModelConfig, OpenAIProviderConfig};
use crate::settings::OpenAIModelConfig;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::workspaces::team_tester::TeamTesterStatus;
use crate::workspaces::user_workspaces::UserWorkspaces;
@@ -138,3 +138,67 @@ fn llm_info_round_trip_serializes_and_deserializes() {
assert_eq!(info, round_tripped);
}
fn openai_model(model_id: &str) -> OpenAIModelConfig {
OpenAIModelConfig {
model_id: model_id.to_string(),
display_name: model_id.to_string(),
vision_supported: false,
context_size: 200_000,
max_input_tokens: None,
max_output_tokens: None,
provider: None,
use_rig: false,
supports_system_messages: None,
}
}
#[test]
fn provider_discovery_preserves_local_model_overrides() {
let mut existing = openai_model("codex-gpt-5.6-sol-xhigh");
existing.display_name = "My Codex".to_string();
existing.context_size = 100_000;
existing.provider = Some("openai".to_string());
existing.use_rig = true;
// Even stale or incorrect endpoint metadata must not opt ChatGPT-backed
// Codex models back into the system role.
existing.supports_system_messages = Some(true);
let mut discovered = openai_model("codex-gpt-5.6-sol-xhigh");
discovered.display_name = "Codex from endpoint".to_string();
discovered.context_size = 400_000;
discovered.max_output_tokens = Some(32_000);
discovered.supports_system_messages = Some(true);
let merged = merge_discovered_provider_models(&[existing], vec![discovered]);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].display_name, "My Codex");
assert_eq!(merged[0].context_size, 400_000);
assert_eq!(merged[0].max_output_tokens, Some(32_000));
assert!(merged[0].use_rig);
assert_eq!(merged[0].supports_system_messages, Some(false));
assert_eq!(merged[0].provider.as_deref(), Some("openai"));
}
#[test]
fn codex_models_reject_system_messages_even_with_stale_true_metadata() {
let mut model = openai_model("codex-gpt-5.6-sol-xhigh");
model.supports_system_messages = Some(true);
assert!(!model.supports_system_messages());
}
#[test]
fn provider_discovery_enables_rig_for_new_models_and_keeps_manual_models() {
let manual = openai_model("manual-model");
let discovered = openai_model("codex-gpt-new");
let merged = merge_discovered_provider_models(&[manual], vec![discovered]);
assert_eq!(merged.len(), 2);
assert_eq!(merged[0].model_id, "codex-gpt-new");
assert!(merged[0].use_rig);
assert_eq!(merged[0].supports_system_messages, Some(false));
assert_eq!(merged[1].model_id, "manual-model");
}
+9
View File
@@ -121,6 +121,9 @@ fn convert_user_message(content: MessageContent) -> ConvertedMessages {
ContentPart::Text(text) => {
user_content_parts.push(UserContentPart::Text(text));
}
ContentPart::Reasoning { text, .. } => {
user_content_parts.push(UserContentPart::Text(text));
}
ContentPart::Image { data, mime_type } => {
user_content_parts.push(UserContentPart::Image { data, mime_type });
}
@@ -201,6 +204,12 @@ fn convert_assistant_message(content: MessageContent) -> ConvertedMessages {
}
text_content.push_str(&text);
}
ContentPart::Reasoning { text, .. } => {
if !text_content.is_empty() {
text_content.push('\n');
}
text_content.push_str(&text);
}
ContentPart::ToolUse {
tool_use_id,
name,
+1 -1
View File
@@ -4,4 +4,4 @@ mod rig_request;
mod rig_tool;
pub(crate) use provider::ProviderRuntime;
pub(crate) use rig::rig_openai_response_stream;
pub(crate) use rig::{rig_bedrock_response_stream, rig_openai_response_stream};
+111 -19
View File
@@ -11,10 +11,12 @@ use uuid::Uuid;
use warp_multi_agent_api::response_event::stream_finished;
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent, ToolType};
use super::rig_request::{prepare_rig_turn, PreparedRigTurn};
use super::rig_request::{prepare_bedrock_rig_turn, prepare_rig_turn, PreparedRigTurn};
use super::rig_tool::action_from_tool_call;
use crate::ai::agent::api::{Event, RequestParams, ResponseStream, StreamEvent};
use crate::ai::agent::AIAgentAction;
use crate::ai::bedrock::client::{BedrockClient, BedrockClientConfig};
use crate::ai::bedrock::external_config::ExternalBedrockConfig;
use crate::ai::bedrock::response_translator::{
build_add_agent_output_message, build_append_text, build_create_task, build_stream_init,
build_user_query_message,
@@ -32,6 +34,75 @@ pub(crate) fn rig_openai_response_stream(
cancellation_rx: oneshot::Receiver<()>,
) -> ResponseStream {
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,
)
}
pub(crate) async fn rig_bedrock_response_stream(
config: BedrockClientConfig,
params: RequestParams,
supported_tools: Vec<ToolType>,
supported_cli_agent_tools: Vec<ToolType>,
cancellation_rx: oneshot::Receiver<()>,
) -> anyhow::Result<ResponseStream> {
let skill_path_origin = params.session_context.skill_path_origin();
let max_context_tokens = params.context_window_limit;
let model = params.model.as_str().to_string();
let max_output_tokens = Some(64_000);
let cross_region_inference = config.cross_region_inference;
let external_config = ExternalBedrockConfig::load();
let prompt_caching = !external_config.disable_prompt_caching;
let client = BedrockClient::from_config(config).await?;
let runtime = client.rig_runtime(
model.clone(),
cross_region_inference,
prompt_caching,
max_output_tokens,
)?;
let prepared = prepare_bedrock_rig_turn(
model,
max_output_tokens,
params,
supported_tools,
supported_cli_agent_tools,
);
Ok(rig_response_stream(
runtime,
prepared,
skill_path_origin,
max_context_tokens,
"rig_bedrock",
cancellation_rx,
))
}
fn rig_response_stream<R>(
runtime: R,
prepared: PreparedRigTurn,
skill_path_origin: ai::skills::SkillPathOrigin,
max_context_tokens: Option<u32>,
stream_type: &'static str,
cancellation_rx: oneshot::Receiver<()>,
) -> ResponseStream
where
R: AgentRuntime + Send + Sync + 'static,
{
let PreparedRigTurn {
task_id,
needs_create_task,
@@ -40,21 +111,12 @@ pub(crate) fn rig_openai_response_stream(
persistent_messages,
tool_result_archive,
messages_sent,
} = prepare_rig_turn(&config, params, supported_tools, supported_cli_agent_tools);
} = prepared;
store_messages_sent(&messages_sent, &persistent_messages);
let conversation_id = turn_request.conversation_id.clone();
let model_id = turn_request.model.as_str().to_string();
let tool_policy = ToolPolicy::new(&turn_request.tools);
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,
});
let max_context_tokens = config.max_input_tokens;
let stream = async_stream::stream! {
let (control_sender, control) = turn_control();
let start_future = runtime.start_turn(turn_request, control).fuse();
@@ -67,7 +129,7 @@ pub(crate) fn rig_openai_response_stream(
match start_future.await {
Ok(stream) => stream,
Err(error) => {
yield Err(agent_error(error));
yield Err(agent_error(error, stream_type));
return;
}
}
@@ -75,7 +137,7 @@ pub(crate) fn rig_openai_response_stream(
result = start_future => match result {
Ok(stream) => stream,
Err(error) => {
yield Err(agent_error(error));
yield Err(agent_error(error, stream_type));
return;
}
},
@@ -87,6 +149,8 @@ pub(crate) fn rig_openai_response_stream(
let mut current_text_message_id: Option<String> = None;
let mut current_reasoning_message_id: Option<String> = None;
let mut full_text = String::new();
let mut full_reasoning = String::new();
let mut reasoning_signature = None;
let mut proposed_tools = Vec::new();
let mut assistant_history_index = None;
let mut usage = Usage::default();
@@ -106,7 +170,7 @@ pub(crate) fn rig_openai_response_stream(
let event = match event {
Ok(event) => event,
Err(error) => {
yield Err(agent_error(error));
yield Err(agent_error(error, stream_type));
return;
}
};
@@ -133,6 +197,7 @@ pub(crate) fn rig_openai_response_stream(
}
}
AgentEvent::ReasoningDelta { text } => {
full_reasoning.push_str(&text);
if let Some(message_id) = &current_reasoning_message_id {
yield Ok(StreamEvent::Response(build_append_reasoning(&task_id, message_id, &text)));
} else {
@@ -141,6 +206,17 @@ pub(crate) fn rig_openai_response_stream(
current_reasoning_message_id = Some(message_id);
}
}
AgentEvent::ReasoningCompleted { text, signature } => {
if current_reasoning_message_id.is_none() && !text.is_empty() {
let message_id = Uuid::new_v4().to_string();
yield Ok(StreamEvent::Response(build_add_reasoning(&task_id, &message_id, &text)));
current_reasoning_message_id = Some(message_id);
}
if !text.is_empty() {
full_reasoning = text;
}
reasoning_signature = signature;
}
AgentEvent::UsageUpdated { usage: updated } => usage = updated,
AgentEvent::Tool {
event: ToolEvent::Proposed { call },
@@ -148,6 +224,8 @@ pub(crate) fn rig_openai_response_stream(
proposed_tools.push(call.clone());
sync_assistant_turn(
&messages_sent,
&full_reasoning,
reasoning_signature.as_deref(),
&full_text,
&proposed_tools,
&mut assistant_history_index,
@@ -164,7 +242,7 @@ pub(crate) fn rig_openai_response_stream(
yield Err(agent_error(AgentError::new(
galaxy_agent_core::AgentErrorKind::Protocol,
message,
)));
), stream_type));
return;
}
}
@@ -198,6 +276,8 @@ pub(crate) fn rig_openai_response_stream(
}
sync_assistant_turn(
&messages_sent,
&full_reasoning,
reasoning_signature.as_deref(),
&full_text,
&proposed_tools,
&mut assistant_history_index,
@@ -222,7 +302,7 @@ pub(crate) fn rig_openai_response_stream(
yield Err(agent_error(AgentError::new(
galaxy_agent_core::AgentErrorKind::Protocol,
"the provider runtime attempted to execute a tool outside Galaxy's permission boundary",
)));
), stream_type));
return;
}
}
@@ -264,11 +344,22 @@ fn append_tool_result(
fn sync_assistant_turn(
messages_sent: &std::sync::Arc<std::sync::Mutex<Vec<ConversationMessage>>>,
reasoning_text: &str,
reasoning_signature: Option<&str>,
text: &str,
tool_calls: &[ToolCall],
history_index: &mut Option<usize>,
) {
let mut parts = Vec::with_capacity(usize::from(!text.is_empty()) + tool_calls.len());
let has_reasoning = !reasoning_text.is_empty() || reasoning_signature.is_some();
let mut parts = Vec::with_capacity(
usize::from(has_reasoning) + usize::from(!text.is_empty()) + tool_calls.len(),
);
if has_reasoning {
parts.push(ContentPart::Reasoning {
text: reasoning_text.to_string(),
signature: reasoning_signature.map(str::to_string),
});
}
if !text.is_empty() {
parts.push(ContentPart::Text(text.to_string()));
}
@@ -293,6 +384,7 @@ fn sync_assistant_turn(
name,
input,
},
reasoning @ ContentPart::Reasoning { .. } => MessageContent::MultiPart(vec![reasoning]),
ContentPart::Image { .. } | ContentPart::ToolResult { .. } => unreachable!(),
}
} else {
@@ -395,10 +487,10 @@ fn saturating_i32(value: u64) -> i32 {
i32::try_from(value).unwrap_or(i32::MAX)
}
fn agent_error(error: AgentError) -> Arc<AIApiError> {
fn agent_error(error: AgentError, stream_type: &'static str) -> Arc<AIApiError> {
Arc::new(
AIApiError::Stream {
stream_type: "rig_openai_compatible",
stream_type,
source: anyhow::anyhow!(error),
}
.into_quota_limit_if_provider_budget_exhausted(),
+52 -6
View File
@@ -13,7 +13,9 @@ use warp_multi_agent_api::ToolType;
use crate::ai::agent::api::RequestParams;
use crate::ai::agent::{AIAgentContext, AIAgentInput, MCPContext, UserQueryMode};
use crate::ai::bedrock::request_translator::{default_tool_definitions, tool_name_is_supported};
use crate::ai::bedrock::request_translator::{
default_tool_definitions, sanitize_messages_for_bedrock, tool_name_is_supported,
};
use crate::ai::openai::client::OpenAIClientConfig;
use crate::ai::openai::request_translator::sanitize_messages_for_openai;
@@ -32,6 +34,47 @@ pub(crate) fn prepare_rig_turn(
params: RequestParams,
supported_tools: Vec<ToolType>,
supported_cli_agent_tools: Vec<ToolType>,
) -> PreparedRigTurn {
prepare_rig_turn_for_provider(
config.model.clone(),
config.max_output_tokens.map(u64::from),
RigRequestSanitizer::OpenAICompatible,
params,
supported_tools,
supported_cli_agent_tools,
)
}
pub(crate) fn prepare_bedrock_rig_turn(
model: String,
max_output_tokens: Option<u64>,
params: RequestParams,
supported_tools: Vec<ToolType>,
supported_cli_agent_tools: Vec<ToolType>,
) -> PreparedRigTurn {
prepare_rig_turn_for_provider(
Some(model),
max_output_tokens,
RigRequestSanitizer::Bedrock,
params,
supported_tools,
supported_cli_agent_tools,
)
}
#[derive(Clone, Copy)]
enum RigRequestSanitizer {
OpenAICompatible,
Bedrock,
}
fn prepare_rig_turn_for_provider(
model_override: Option<String>,
max_output_tokens: Option<u64>,
sanitizer: RigRequestSanitizer,
params: RequestParams,
supported_tools: Vec<ToolType>,
supported_cli_agent_tools: Vec<ToolType>,
) -> PreparedRigTurn {
let RequestParams {
input,
@@ -70,7 +113,12 @@ pub(crate) fn prepare_rig_turn(
for message in &mut persistent_messages {
message.truncate_tool_results_for_provider_request();
}
sanitize_messages_for_openai(&mut persistent_messages);
match sanitizer {
RigRequestSanitizer::OpenAICompatible => {
sanitize_messages_for_openai(&mut persistent_messages)
}
RigRequestSanitizer::Bedrock => sanitize_messages_for_bedrock(&mut persistent_messages),
}
let mut turn_messages = Vec::new();
if let Some(summary) = progressive_summary {
@@ -91,16 +139,14 @@ pub(crate) fn prepare_rig_turn(
}
turn_messages.extend(persistent_messages.clone());
let model_id = config
.model
.clone()
let model_id = model_override
.filter(|model| !model.is_empty() && model != "auto")
.unwrap_or_else(|| model.as_str().to_string());
let mut request = TurnRequest::new(model_id, turn_messages);
request.conversation_id = conversation_token.map(|token| token.as_str().to_string());
request.system_prompt = Some(system_prompt);
request.tools = tools;
request.max_output_tokens = config.max_output_tokens.map(u64::from);
request.max_output_tokens = max_output_tokens;
PreparedRigTurn {
task_id,
+35 -1
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use galaxy_agent_core::{ContentPart, MessageContent, MessageRole, ToolResult, ToolResultStatus};
use warp_multi_agent_api::ToolType;
use super::{input_messages, prepare_rig_turn, tool_definitions};
use super::{input_messages, prepare_bedrock_rig_turn, prepare_rig_turn, tool_definitions};
use crate::ai::agent::api::RequestParams;
use crate::ai::agent::{
AIAgentContext, AIAgentInput, AnyFileContent, FileContext, MCPContext, MCPServer, UserQueryMode,
@@ -114,6 +114,40 @@ fn builds_a_rig_turn_directly_from_galaxy_request_state() {
));
}
#[test]
fn bedrock_rig_turn_uses_bedrock_history_invariants_without_a_proto_round_trip() {
let mut params = RequestParams::new_for_test();
params.message_history = vec![galaxy_agent_core::ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text("Prior assistant message".to_string()),
}];
params.input = vec![user_query("Continue safely")];
let prepared = prepare_bedrock_rig_turn(
"anthropic.claude-test".to_string(),
Some(64_000),
params,
Vec::new(),
Vec::new(),
);
assert_eq!(prepared.request.model.as_str(), "anthropic.claude-test");
assert_eq!(prepared.request.max_output_tokens, Some(64_000));
assert_eq!(
prepared
.request
.messages
.first()
.map(|message| message.role),
Some(MessageRole::User)
);
assert_eq!(
prepared.request.messages.last().map(|message| message.role),
Some(MessageRole::User)
);
assert_eq!(prepared.request.messages, prepared.persistent_messages);
}
#[test]
#[allow(deprecated)]
fn grouped_mcp_tool_names_use_the_installation_id_not_the_display_name() {
+44 -1
View File
@@ -2,7 +2,7 @@ use std::sync::{Arc, Mutex};
use ai::skills::SkillPathOrigin;
use galaxy_agent_core::{
MessageContent, MessageRole, StopReason, ToolCall, ToolResult, ToolResultStatus,
ContentPart, MessageContent, MessageRole, StopReason, ToolCall, ToolResult, ToolResultStatus,
};
use warp_multi_agent_api::response_event::stream_finished;
@@ -134,12 +134,16 @@ fn assistant_history_is_updated_before_fast_tool_execution_can_continue() {
sync_assistant_turn(
&messages,
"",
None,
"I'll inspect both.",
std::slice::from_ref(&first_call),
&mut history_index,
);
sync_assistant_turn(
&messages,
"",
None,
"I'll inspect both.",
&[first_call, second_call],
&mut history_index,
@@ -162,6 +166,43 @@ fn assistant_history_is_updated_before_fast_tool_execution_can_continue() {
);
}
#[test]
fn signed_reasoning_is_persisted_before_the_tool_call() {
let messages = Arc::new(Mutex::new(Vec::new()));
let mut history_index = None;
let call = ToolCall {
id: "call-1".to_string(),
name: "read_files".to_string(),
arguments: serde_json::json!({"files": ["Cargo.toml"]}),
};
sync_assistant_turn(
&messages,
"I should inspect the manifest.",
Some("signed-reasoning"),
"",
std::slice::from_ref(&call),
&mut history_index,
);
let messages = messages.lock().unwrap();
let MessageContent::MultiPart(parts) = &messages[0].content else {
panic!("expected reasoning and tool call parts");
};
assert!(matches!(
parts.as_slice(),
[
ContentPart::Reasoning {
text,
signature: Some(signature),
},
ContentPart::ToolUse { tool_use_id, .. },
] if text == "I should inspect the manifest."
&& signature == "signed-reasoning"
&& tool_use_id == "call-1"
));
}
#[test]
fn rejected_tool_result_is_paired_with_the_assistant_call_in_history() {
let messages = Arc::new(Mutex::new(Vec::new()));
@@ -174,6 +215,8 @@ fn rejected_tool_result_is_paired_with_the_assistant_call_in_history() {
sync_assistant_turn(
&messages,
"",
None,
"",
std::slice::from_ref(&call),
&mut history_index,
);
+9 -2
View File
@@ -828,6 +828,11 @@ pub struct BedrockModelConfig {
#[serde(default)]
#[schemars(description = "Whether the model supports image/vision input.")]
pub vision_supported: bool,
#[serde(default)]
#[schemars(
description = "Route this model through Galaxy's Rig Bedrock runtime. Disabled by default while compatibility validation is in progress."
)]
pub use_rig: bool,
}
impl settings_value::SettingsValue for BedrockModelConfig {}
@@ -890,8 +895,10 @@ impl settings_value::SettingsValue for OpenAIModelConfig {}
impl OpenAIModelConfig {
pub fn supports_system_messages(&self) -> bool {
self.supports_system_messages
.unwrap_or_else(|| !self.model_id.starts_with("codex-gpt-"))
if self.model_id.starts_with("codex-gpt-") {
return false;
}
self.supports_system_messages.unwrap_or(true)
}
}
+405 -143
View File
@@ -88,8 +88,8 @@ use crate::settings::{
GitOperationsAutogenEnabled, IncludeAgentCommandsInHistory, InputSettings,
IntelligentAutosuggestionsEnabled, LongRunningCommandSubmissionMode, MemoryEnabled,
NLDInTerminalEnabled, NaturalLanguageAutosuggestionsEnabled, OpenAIEnabled,
OrchestrationMessageDisplayMode, PromptSubmissionMode, RuleSuggestionsEnabled,
SharedBlockTitleGenerationEnabled, ShouldRenderCLIAgentToolbar,
OpenAIProviderConfig, OrchestrationMessageDisplayMode, PromptSubmissionMode,
RuleSuggestionsEnabled, SharedBlockTitleGenerationEnabled, ShouldRenderCLIAgentToolbar,
ShouldRenderUseAgentToolbarForUserCommands, ShowAgentTips, ShowConversationHistory,
ShowHintText, ThinkingDisplayMode, VoiceInputEnabled, WarpDriveContextEnabled,
};
@@ -117,10 +117,8 @@ pub enum AISubpage {
Knowledge,
/// Third-party CLI agent settings.
ThirdPartyCLIAgents,
/// AWS Bedrock direct provider configuration.
Bedrock,
/// OpenAI-compatible (LiteLLM) provider configuration.
OpenAI,
/// Unified model and provider configuration.
Models,
/// Experimental features.
Experiments,
}
@@ -132,8 +130,7 @@ impl AISubpage {
SettingsSection::AgentProfiles => Some(Self::Profiles),
SettingsSection::Knowledge => Some(Self::Knowledge),
SettingsSection::ThirdPartyCLIAgents => Some(Self::ThirdPartyCLIAgents),
SettingsSection::Bedrock => Some(Self::Bedrock),
SettingsSection::OpenAI => Some(Self::OpenAI),
SettingsSection::Models => Some(Self::Models),
SettingsSection::Experiments => Some(Self::Experiments),
// AgentMCPServers renders the standalone MCPServers page, not an AI subpage.
_ => None,
@@ -1908,15 +1905,18 @@ impl AISettingsPageView {
}
}
/// Fetches models from the LiteLLM endpoint and stores them in memory via LLMPreferences.
fn fetch_litellm_models(&mut self, ctx: &mut ViewContext<Self>) {
use crate::ai::llms::LLMPreferences;
fn fetch_openai_provider_models(&mut self, provider_index: usize, ctx: &mut ViewContext<Self>) {
LLMPreferences::handle(ctx).update(ctx, |llm_prefs, ctx| {
llm_prefs.fetch_openai_models_from_endpoint(ctx);
llm_prefs.fetch_openai_provider_models(provider_index, ctx);
});
}
fn rebuild_active_subpage(&mut self, ctx: &mut ViewContext<Self>) {
let (page, _) = Self::build_page(self.active_subpage, ctx);
self.page = page;
ctx.notify();
}
fn build_page(
subpage: Option<AISubpage>,
ctx: &mut ViewContext<Self>,
@@ -2034,15 +2034,10 @@ impl AISettingsPageView {
Some(AISubpage::ThirdPartyCLIAgents) => {
widgets.push(Box::new(CLIAgentWidget::default()));
}
Some(AISubpage::Bedrock) => {
let widget = BedrockSettingsWidget::new(ctx);
widgets.push(Box::new(widget));
let title: Option<&str> = None;
return (PageType::new_uncategorized(widgets, title), None);
}
Some(AISubpage::OpenAI) => {
let widget = OpenAISettingsWidget::new(ctx);
widgets.push(Box::new(widget));
Some(AISubpage::Models) => {
widgets.push(Box::new(ModelsOverviewWidget));
widgets.push(Box::new(OpenAISettingsWidget::new(ctx)));
widgets.push(Box::new(BedrockSettingsWidget::new(ctx)));
let title: Option<&str> = None;
return (PageType::new_uncategorized(widgets, title), None);
}
@@ -2807,10 +2802,13 @@ pub enum AISettingsPageAction {
SetBedrockAuthMethod(BedrockAuthMethod),
SetBedrockProfile(String),
ToggleBedrockCrossRegionInference,
ToggleBedrockModelRig(usize),
ToggleOpenAIEnabled,
ToggleAcpEnabled,
RefreshAcpDiscovery,
FetchOpenAIModels,
FetchOpenAIProviderModels(usize),
AddOpenAIProvider,
RemoveOpenAIProvider(usize),
ToggleFileBasedMcp,
ToggleIncludeAgentCommandsInHistory,
ToggleAgentAttribution,
@@ -3562,6 +3560,17 @@ impl TypedActionView for AISettingsPageView {
});
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));
@@ -3580,9 +3589,32 @@ impl TypedActionView for AISettingsPageView {
#[cfg(not(target_family = "wasm"))]
self.refresh_acp_discovery(ctx);
}
AISettingsPageAction::FetchOpenAIModels => {
// Trigger a fetch of models from the LiteLLM endpoint
self.fetch_litellm_models(ctx);
AISettingsPageAction::FetchOpenAIProviderModels(provider_index) => {
self.fetch_openai_provider_models(*provider_index, ctx);
}
AISettingsPageAction::AddOpenAIProvider => {
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(),
});
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| {
let mut providers = settings.openai_providers.value().clone();
if *provider_index < providers.len() {
providers.remove(*provider_index);
report_if_error!(settings.openai_providers.set_value(providers, ctx));
}
});
self.rebuild_active_subpage(ctx);
}
AISettingsPageAction::ToggleFileBasedMcp => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
@@ -7230,6 +7262,50 @@ impl SettingsWidget for CloudHandoffWidget {
}
}
struct ModelsOverviewWidget;
impl SettingsWidget for ModelsOverviewWidget {
type View = AISettingsPageView;
fn search_terms(&self) -> &str {
"models providers rig litellm openai compatible ollama lm studio bedrock"
}
fn render(
&self,
_view: &Self::View,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
let settings = AISettings::as_ref(app);
let endpoint_count = settings.openai_providers.value().len();
let endpoint_model_count = settings
.openai_providers
.value()
.iter()
.map(|provider| provider.models.len())
.sum::<usize>();
let bedrock_model_count = settings.bedrock_models.value().len();
Flex::column()
.with_spacing(8.)
.with_child(build_sub_header(appearance, "Models", None).finish())
.with_child(render_ai_setting_description(
"Configure the model providers available to Galaxy. OpenAI-compatible endpoints and opted-in Bedrock models share the same Rig conversation, tool, and UI runtime; Bedrock's compatibility path remains available during validation.",
true,
app,
))
.with_child(render_ai_setting_description(
format!(
"{endpoint_count} OpenAI-compatible provider(s) with {endpoint_model_count} model(s); {bedrock_model_count} Bedrock model(s)."
),
true,
app,
))
.finish()
}
}
struct BedrockSettingsWidget {
enabled_toggle: SwitchStateHandle,
auto_login_toggle: SwitchStateHandle,
@@ -7239,6 +7315,7 @@ struct BedrockSettingsWidget {
auth_refresh_command_editor: ViewHandle<EditorView>,
access_key_editor: ViewHandle<EditorView>,
secret_key_editor: ViewHandle<EditorView>,
model_rig_toggles: RefCell<Vec<SwitchStateHandle>>,
}
impl BedrockSettingsWidget {
@@ -7250,6 +7327,7 @@ impl BedrockSettingsWidget {
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);
@@ -7475,6 +7553,11 @@ impl BedrockSettingsWidget {
auth_refresh_command_editor,
access_key_editor,
secret_key_editor,
model_rig_toggles: RefCell::new(
(0..bedrock_model_count)
.map(|_| SwitchStateHandle::default())
.collect(),
),
}
}
@@ -7540,6 +7623,8 @@ impl SettingsWidget for BedrockSettingsWidget {
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 {
@@ -7673,6 +7758,47 @@ impl SettingsWidget for BedrockSettingsWidget {
}
);
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::<AISettingsPageAction>(
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].",
@@ -8003,104 +8129,149 @@ impl SettingsWidget for ACPSettingsWidget {
}
}
struct OpenAISettingsWidget {
enabled_toggle: SwitchStateHandle,
struct OpenAIProviderEditor {
name_editor: ViewHandle<EditorView>,
base_url_editor: ViewHandle<EditorView>,
api_key_editor: ViewHandle<EditorView>,
fetch_button: MouseStateHandle,
remove_button: MouseStateHandle,
}
struct OpenAISettingsWidget {
enabled_toggle: SwitchStateHandle,
provider_editors: Vec<OpenAIProviderEditor>,
add_provider_button: MouseStateHandle,
}
impl OpenAISettingsWidget {
fn create_editor(
value: String,
placeholder: &'static str,
is_password: bool,
ctx: &mut ViewContext<<Self as SettingsWidget>::View>,
) -> ViewHandle<EditorView> {
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<<Self as SettingsWidget>::View>) -> Self {
let ai_settings = AISettings::as_ref(ctx);
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());
let base_url_val = ai_settings.openai_base_url.value().clone();
let api_key_val = ai_settings.openai_api_key.value().clone();
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 = 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("http://localhost:4000/v1", ctx);
editor.set_buffer_text(&base_url_val, ctx);
editor
});
ctx.subscribe_to_view(&base_url_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.openai_base_url.set_value(value, 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,
);
}
});
let api_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("sk-... (optional)", ctx);
editor.set_buffer_text(&api_key_val, ctx);
editor
});
ctx.subscribe_to_view(&api_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.openai_api_key.set_value(value, ctx);
});
}
});
provider_editors.push(OpenAIProviderEditor {
name_editor,
base_url_editor,
api_key_editor,
fetch_button: MouseStateHandle::default(),
remove_button: MouseStateHandle::default(),
});
}
let base_url_editor_clone = base_url_editor.clone();
let api_key_editor_clone = api_key_editor.clone();
let editor_handles = provider_editors
.iter()
.flat_map(|provider| {
[
provider.name_editor.clone(),
provider.base_url_editor.clone(),
provider.api_key_editor.clone(),
]
})
.collect::<Vec<_>>();
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();
AISettingsPageView::update_editor_interaction_state(
base_url_editor_clone.clone(),
is_enabled,
ctx,
);
AISettingsPageView::update_editor_interaction_state(
api_key_editor_clone.clone(),
is_enabled,
ctx,
);
for editor in &editor_handles {
AISettingsPageView::update_editor_interaction_state(
editor.clone(),
is_enabled,
ctx,
);
}
ctx.notify();
}
});
Self {
enabled_toggle: SwitchStateHandle::default(),
base_url_editor,
api_key_editor,
fetch_button: MouseStateHandle::default(),
provider_editors,
add_provider_button: MouseStateHandle::default(),
}
}
@@ -8164,8 +8335,11 @@ impl SettingsWidget for OpenAISettingsWidget {
let mut column = Flex::column().with_spacing(16.);
column
.add_child(build_sub_header(appearance, "OpenAI-compatible providers", None).finish());
column.add_child(render_ai_setting_toggle::<OpenAIEnabled>(
"Enable OpenAI-Compatible Provider",
"Enable model providers",
AISettingsPageAction::ToggleOpenAIEnabled,
is_enabled,
true,
@@ -8174,65 +8348,153 @@ impl SettingsWidget for OpenAISettingsWidget {
app,
));
column.add_child(render_ai_setting_description(
"Route AI requests through an OpenAI-compatible endpoint (e.g. LiteLLM proxy).",
"Route configured LiteLLM, Ollama, LM Studio, vLLM, and other OpenAI-compatible models through Galaxy's provider registry.",
true,
app,
));
column.add_child(render_separator(appearance));
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,
));
}
column.add_child(Self::render_input(
appearance,
"Base URL",
self.base_url_editor.clone(),
is_enabled,
app,
));
column.add_child(render_ai_setting_description(
"The OpenAI-compatible API base URL (e.g. http://localhost:4000/v1).",
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(Self::render_input(
appearance,
"API Key",
self.api_key_editor.clone(),
is_enabled,
app,
));
column.add_child(render_ai_setting_description(
"Optional. Leave empty if the proxy handles authentication.",
is_enabled,
app,
));
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));
// Fetch models button
let fetch_button = appearance
let add_provider_button = appearance
.ui_builder()
.button(ButtonVariant::Secondary, self.fetch_button.clone())
.with_text_label("Fetch Models from Endpoint".to_owned())
.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::FetchOpenAIModels);
ctx.dispatch_typed_action(AISettingsPageAction::AddOpenAIProvider);
})
.finish();
column.add_child(fetch_button);
column.add_child(add_provider_button);
column.add_child(render_ai_setting_description(
"Queries the /models endpoint and populates the model list with available models and their context window sizes.",
"Model discovery only contacts an endpoint when you click Discover Models.",
is_enabled,
app,
));
column.add_child(render_separator(appearance));
// Show configured models count
let configured_models: Vec<_> = ai_settings.openai_models.value().clone();
let mut configured_models = ai_settings
.openai_providers
.value()
.iter()
.flat_map(|provider| provider.models.iter())
.collect::<Vec<_>>();
configured_models.extend(ai_settings.openai_models.value().iter());
if !configured_models.is_empty() {
let description = format!(
"{} model{} configured via settings.toml.",
"{} model{} configured across all OpenAI-compatible providers.",
configured_models.len(),
if configured_models.len() == 1 {
""
@@ -8246,7 +8508,7 @@ impl SettingsWidget for OpenAISettingsWidget {
let preview: String = configured_models
.iter()
.take(5)
.map(|m| m.display_name.as_str())
.map(|model| model.display_name.as_str())
.collect::<Vec<_>>()
.join(", ");
let suffix = if configured_models.len() > 5 {
@@ -8261,7 +8523,7 @@ impl SettingsWidget for OpenAISettingsWidget {
));
} else {
column.add_child(render_ai_setting_description(
"No models configured. Use 'Fetch Models' or add them to ~/.galaxy/settings.toml under [ai.openai].",
"No models configured. Add a provider and discover its models, or configure [[ai.providers.models]] in ~/.galaxy/settings.toml.",
is_enabled,
app,
));
+7 -10
View File
@@ -245,8 +245,7 @@ pub enum SettingsSection {
AgentMCPServers,
Knowledge,
ThirdPartyCLIAgents,
Bedrock,
OpenAI,
Models,
Experiments,
/// Internal backing-page identifier for CodeSettingsPageView. Multiple subpages
/// (CodeIndexing, EditorAndCodeReview) share this single backing page,
@@ -274,8 +273,7 @@ impl Display for SettingsSection {
SettingsSection::AgentMCPServers => write!(f, "MCP servers"),
SettingsSection::Knowledge => write!(f, "Knowledge"),
SettingsSection::ThirdPartyCLIAgents => write!(f, "Third party CLI agents"),
SettingsSection::Bedrock => write!(f, "AWS Bedrock"),
SettingsSection::OpenAI => write!(f, "OpenAI / LiteLLM"),
SettingsSection::Models => write!(f, "Models"),
SettingsSection::Experiments => write!(f, "Experiments"),
SettingsSection::Warpify => write!(f, "Wormhole"),
SettingsSection::CodeIndexing => write!(f, "Indexing and projects"),
@@ -300,8 +298,7 @@ impl SettingsSection {
| Self::AgentMCPServers
| Self::Knowledge
| Self::ThirdPartyCLIAgents
| Self::Bedrock
| Self::OpenAI
| Self::Models
| Self::Experiments
)
}
@@ -329,12 +326,11 @@ impl SettingsSection {
pub fn ai_subpages() -> &'static [Self] {
&[
Self::WarpAgent,
Self::Models,
Self::AgentProfiles,
Self::AgentMCPServers,
Self::Knowledge,
Self::ThirdPartyCLIAgents,
Self::Bedrock,
Self::OpenAI,
Self::Experiments,
]
}
@@ -367,8 +363,9 @@ impl FromStr for SettingsSection {
"MCP servers" | "AgentMCPServers" => Ok(Self::AgentMCPServers),
"Knowledge" => Ok(Self::Knowledge),
"Third party CLI agents" | "ThirdPartyCLIAgents" => Ok(Self::ThirdPartyCLIAgents),
"AWS Bedrock" | "Bedrock" => Ok(Self::Bedrock),
"OpenAI / LiteLLM" | "OpenAI" => Ok(Self::OpenAI),
"Models" | "AWS Bedrock" | "Bedrock" | "OpenAI / LiteLLM" | "OpenAI" => {
Ok(Self::Models)
}
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
"Experiments" => Ok(Self::Experiments),
+7 -6
View File
@@ -86,8 +86,7 @@ fn current_settings_display_names_round_trip() {
SettingsSection::ThirdPartyCLIAgents,
"Third party CLI agents",
),
(SettingsSection::Bedrock, "AWS Bedrock"),
(SettingsSection::OpenAI, "OpenAI / LiteLLM"),
(SettingsSection::Models, "Models"),
(SettingsSection::Experiments, "Experiments"),
(SettingsSection::CodeIndexing, "Indexing and projects"),
(
@@ -111,8 +110,10 @@ fn legacy_settings_names_remain_parseable() {
("AgentProfiles", SettingsSection::AgentProfiles),
("AgentMCPServers", SettingsSection::AgentMCPServers),
("ThirdPartyCLIAgents", SettingsSection::ThirdPartyCLIAgents),
("Bedrock", SettingsSection::Bedrock),
("OpenAI", SettingsSection::OpenAI),
("AWS Bedrock", SettingsSection::Models),
("Bedrock", SettingsSection::Models),
("OpenAI / LiteLLM", SettingsSection::Models),
("OpenAI", SettingsSection::Models),
("CodeIndexing", SettingsSection::CodeIndexing),
("EditorAndCodeReview", SettingsSection::EditorAndCodeReview),
] {
@@ -215,7 +216,7 @@ fn collapsed_umbrella_uses_first_and_last_visible_subpages() {
let stops = build_nav_stops(&nav_items, |section| {
!matches!(
section,
SettingsSection::WarpAgent | SettingsSection::OpenAI | SettingsSection::Experiments
SettingsSection::WarpAgent | SettingsSection::Models | SettingsSection::Experiments
)
});
@@ -224,7 +225,7 @@ fn collapsed_umbrella_uses_first_and_last_visible_subpages() {
NavStop::CollapsedUmbrella {
nav_index: 0,
first_subpage: SettingsSection::AgentProfiles,
last_subpage: SettingsSection::Bedrock,
last_subpage: SettingsSection::ThirdPartyCLIAgents,
}
);
}