Rebasing, going about this another way

This commit is contained in:
Ryan Ward
2026-05-06 07:02:12 -05:00
parent d8d4ac9e5d
commit f4e2475c60
36 changed files with 3040 additions and 466 deletions
+9 -1
View File
@@ -124,6 +124,9 @@ pub struct RequestParams {
pub research_agent_enabled: bool,
pub orchestration_enabled: bool,
pub supported_tools_override: Option<Vec<warp_multi_agent_api::ToolType>>,
/// The root task ID for the conversation — needed for direct Bedrock streaming
/// since optimistic tasks don't appear in the proto task_context.
pub root_task_id: Option<String>,
/// The conversation ID of the parent agent that spawned this child agent, if any.
pub parent_agent_id: Option<String>,
/// The display name for this agent (e.g. "Agent 1"), assigned by the orchestrator.
@@ -235,7 +238,7 @@ impl RequestParams {
let user_workspaces = UserWorkspaces::as_ref(app);
let api_keys = ApiKeyManager::as_ref(app).api_keys_for_request(
user_workspaces.is_byo_api_key_enabled(),
user_workspaces.is_aws_bedrock_credentials_enabled(app),
user_workspaces.is_bedrock_enabled(app),
);
let allow_use_of_warp_credits_with_byok =
*AISettings::as_ref(app).can_use_warp_credits_with_byok;
@@ -307,6 +310,11 @@ impl RequestParams {
research_agent_enabled,
orchestration_enabled,
supported_tools_override: request_input.supported_tools_override.clone(),
root_task_id: request_input
.input_messages
.keys()
.next()
.map(|id| id.to_string()),
parent_agent_id: None,
agent_name: None,
}
+97
View File
@@ -5,12 +5,14 @@ use futures_util::StreamExt;
use warp_core::features::FeatureFlag;
use warp_multi_agent_api as api;
use crate::ai::bedrock::client::{BedrockClient, BedrockClientConfig};
use crate::server::server_api::ServerApi;
use super::{convert_to::convert_input, ConvertToAPITypeError, RequestParams, ResponseStream};
pub async fn generate_multi_agent_output(
server_api: Arc<ServerApi>,
bedrock_config: Option<BedrockClientConfig>,
mut params: RequestParams,
cancellation_rx: futures::channel::oneshot::Receiver<()>,
) -> Result<ResponseStream, ConvertToAPITypeError> {
@@ -129,6 +131,101 @@ pub async fn generate_multi_agent_output(
mcp_context: params.mcp_context.map(Into::into),
};
if let Some(config) = bedrock_config {
let model_id_for_fallback_check = request
.settings
.as_ref()
.and_then(|s| s.model_config.as_ref())
.map(|mc| mc.base.clone())
.unwrap_or_default();
let is_arn = model_id_for_fallback_check.starts_with("arn:");
let fallback_to_warp = config.fallback_to_warp && !is_arn;
if is_arn && config.fallback_to_warp {
log::info!("[bedrock] Fallback disabled for ARN-based model (not available on Warp server)");
}
match BedrockClient::from_config(config).await {
Ok(bedrock) => {
let task_id = params.root_task_id.clone().unwrap_or_else(|| {
request
.task_context
.as_ref()
.and_then(|tc| tc.tasks.first())
.map(|t| t.id.clone())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
});
log::info!("[bedrock] Starting stream with task_id={task_id}");
let model_id = request
.settings
.as_ref()
.and_then(|s| s.model_config.as_ref())
.map(|mc| mc.base.clone())
.unwrap_or_default();
log::info!("[bedrock] Model: {model_id}");
let messages =
crate::ai::bedrock::convert_request::extract_messages_from_request(&request);
let system_prompt =
crate::ai::bedrock::convert_request::extract_system_prompt(&request);
let tools = crate::ai::bedrock::convert_request::extract_tools(&request);
log::info!(
"[bedrock] Sending {} messages, system_prompt={}, tools={}",
messages.len(),
system_prompt.is_some(),
tools.len()
);
match bedrock
.converse_stream(
&model_id,
&task_id,
messages,
system_prompt,
tools,
8192,
None,
true,
)
.await
{
Ok(stream) => {
let output_stream = stream.take_until(cancellation_rx);
return Ok(Box::pin(output_stream));
}
Err(e) => {
if fallback_to_warp {
log::warn!("Bedrock stream failed, falling back to server: {e}");
} else {
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "bedrock_converse",
source: anyhow::anyhow!("{e}"),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
return Ok(Box::pin(rx));
}
}
}
}
Err(e) => {
if fallback_to_warp {
log::warn!("Bedrock client creation failed, falling back to server: {e}");
} else {
let err = Arc::new(crate::server::server_api::AIApiError::Stream {
stream_type: "bedrock_converse",
source: anyhow::anyhow!("{e}"),
});
let (tx, rx) = async_channel::unbounded();
let _ = tx.send(Err(err)).await;
return Ok(Box::pin(rx));
}
}
}
}
let response_stream = server_api.generate_multi_agent_output(&request).await;
match response_stream {
Ok(stream) => {
+1
View File
@@ -37,6 +37,7 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
research_agent_enabled: false,
orchestration_enabled: false,
supported_tools_override: None,
root_task_id: None,
parent_agent_id: None,
agent_name: None,
}
+6 -6
View File
@@ -189,7 +189,7 @@ impl AwsCredentialRefresher for ApiKeyManager {
..
}) = event
{
let auth_command = &AISettings::as_ref(ctx).aws_bedrock_auth_refresh_command;
let auth_command = &AISettings::as_ref(ctx).bedrock_auth_refresh_command;
if command.trim().starts_with(auth_command.trim()) {
log::debug!("Detected AWS auth command completion, refreshing credentials");
drop(refresh_aws_credentials(manager, ctx));
@@ -215,9 +215,9 @@ impl AwsCredentialRefresher for ApiKeyManager {
ctx.subscribe_to_model(&AISettings::handle(ctx), |manager, event, ctx| {
if matches!(
event,
AISettingsChangedEvent::AwsBedrockProfile { .. }
| AISettingsChangedEvent::AwsBedrockAuthRefreshCommand { .. }
| AISettingsChangedEvent::AwsBedrockCredentialsEnabled { .. }
AISettingsChangedEvent::BedrockProfile { .. }
| AISettingsChangedEvent::BedrockAuthRefreshCommand { .. }
| AISettingsChangedEvent::BedrockEnabled { .. }
) {
drop(refresh_aws_credentials(manager, ctx));
}
@@ -248,14 +248,14 @@ fn refresh_aws_credentials_local_chain(
manager: &mut ApiKeyManager,
ctx: &mut ModelContext<ApiKeyManager>,
) -> BoxFuture<'static, Result<(), String>> {
let is_available = UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(ctx);
let is_available = UserWorkspaces::as_ref(ctx).is_bedrock_enabled(ctx);
if !is_available {
manager.set_aws_credentials_state(AwsCredentialsState::Disabled, ctx);
return Box::pin(async { Ok(()) });
}
let profile = (*AISettings::as_ref(ctx).aws_bedrock_profile).clone();
let profile = (*AISettings::as_ref(ctx).bedrock_profile).clone();
manager.set_aws_credentials_state(AwsCredentialsState::Refreshing, ctx);
+174
View File
@@ -0,0 +1,174 @@
use anyhow::Result;
use aws_config::BehaviorVersion;
use aws_sdk_bedrockruntime::config::Region;
use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
use crate::settings::ai::BedrockAuthMethod;
use super::convert::{build_converse_request, ConversationMessage, ToolDefinition};
use super::models::apply_cross_region_prefix;
use super::stream::bedrock_stream_to_response_events;
use crate::ai::agent::api::ResponseStream;
pub struct BedrockClient {
runtime_client: BedrockRuntimeClient,
region: String,
}
#[derive(Debug, Clone)]
pub struct BedrockClientConfig {
pub auth_method: BedrockAuthMethod,
pub profile: String,
pub region: String,
pub access_key_id: String,
pub secret_access_key: String,
pub cross_region_inference: bool,
pub fallback_to_warp: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum BedrockError {
#[error("Bedrock credentials not configured")]
CredentialsNotConfigured,
#[error("Bedrock region not configured and could not be auto-detected")]
RegionNotConfigured,
#[error("AWS credential error: {0}")]
CredentialError(String),
#[error("Bedrock API error: {0}")]
ApiError(String),
#[error("Model not found: {0}")]
ModelNotFound(String),
#[error("Access denied: {0}")]
AccessDenied(String),
#[error("Throttling: {0}")]
Throttling(String),
#[error("Validation error: {0}")]
ValidationError(String),
}
impl BedrockClient {
pub async fn from_config(config: BedrockClientConfig) -> Result<Self, BedrockError> {
let aws_config = match config.auth_method {
BedrockAuthMethod::Profile | BedrockAuthMethod::Sso => {
let mut loader =
aws_config::defaults(BehaviorVersion::latest()).profile_name(&config.profile);
if !config.region.is_empty() {
loader = loader.region(Region::new(config.region.clone()));
}
loader.load().await
}
BedrockAuthMethod::StaticKeys => {
if config.access_key_id.is_empty() || config.secret_access_key.is_empty() {
return Err(BedrockError::CredentialsNotConfigured);
}
let creds = aws_credential_types::Credentials::new(
&config.access_key_id,
&config.secret_access_key,
None,
None,
"warp-bedrock-static",
);
let mut loader =
aws_config::defaults(BehaviorVersion::latest()).credentials_provider(creds);
if !config.region.is_empty() {
loader = loader.region(Region::new(config.region.clone()));
} else {
loader = loader.region(Region::new("us-east-1".to_string()));
}
loader.load().await
}
};
let region = aws_config
.region()
.map(|r| r.to_string())
.ok_or(BedrockError::RegionNotConfigured)?;
let runtime_client = BedrockRuntimeClient::new(&aws_config);
Ok(Self {
runtime_client,
region,
})
}
pub async fn converse_stream(
&self,
model_id: &str,
task_id: &str,
messages: Vec<ConversationMessage>,
system_prompt: Option<String>,
tools: Vec<ToolDefinition>,
max_tokens: i32,
temperature: Option<f32>,
cross_region_inference: bool,
) -> Result<ResponseStream, BedrockError> {
let effective_model_id = if cross_region_inference {
apply_cross_region_prefix(model_id, &self.region)
} else {
model_id.to_string()
};
log::info!(
"[bedrock] converse_stream: model={effective_model_id}, region={}, messages={}, tools={}",
self.region,
messages.len(),
tools.len()
);
let converted =
build_converse_request(messages, system_prompt, tools, max_tokens, temperature, None, None);
let mut request = self
.runtime_client
.converse_stream()
.model_id(&effective_model_id)
.set_system(Some(converted.system))
.set_messages(Some(converted.messages))
.inference_config(converted.inference_config);
if let Some(tool_config) = converted.tool_config {
request = request.tool_config(tool_config);
}
let output = request.send().await.map_err(|e| {
let debug_msg = format!("{:?}", e);
let display_msg = format!("{}", e);
log::error!("[bedrock] API error (display): {display_msg}");
log::error!("[bedrock] API error (debug): {debug_msg}");
let msg = if debug_msg.len() > display_msg.len() {
debug_msg
} else {
display_msg
};
if msg.contains("AccessDenied") || msg.contains("access denied") {
BedrockError::AccessDenied(msg)
} else if msg.contains("ThrottlingException") || msg.contains("throttl") {
BedrockError::Throttling(msg)
} else if msg.contains("ValidationException") || msg.contains("validation") {
BedrockError::ValidationError(msg)
} else if msg.contains("ResourceNotFoundException") {
BedrockError::ModelNotFound(effective_model_id.clone())
} else {
BedrockError::ApiError(msg)
}
})?;
log::info!("[bedrock] Stream connected successfully");
Ok(Box::pin(bedrock_stream_to_response_events(output, task_id.to_string())))
}
pub fn runtime_client(&self) -> &BedrockRuntimeClient {
&self.runtime_client
}
pub fn region(&self) -> &str {
&self.region
}
}
+298
View File
@@ -0,0 +1,298 @@
use std::collections::HashMap;
use aws_sdk_bedrockruntime::types::{
ContentBlock, ConversationRole, InferenceConfiguration, Message as BedrockMessage,
SystemContentBlock, Tool, ToolConfiguration, ToolInputSchema, ToolResultBlock,
ToolResultContentBlock, ToolResultStatus, ToolSpecification, ToolUseBlock,
};
use aws_smithy_types::Document;
use serde_json::Value as JsonValue;
pub struct ConvertedRequest {
pub messages: Vec<BedrockMessage>,
pub system: Vec<SystemContentBlock>,
pub inference_config: InferenceConfiguration,
pub tool_config: Option<ToolConfiguration>,
}
pub struct ConversationMessage {
pub role: MessageRole,
pub content: MessageContent,
}
pub enum MessageRole {
User,
Assistant,
}
pub enum MessageContent {
Text(String),
ToolUse {
tool_use_id: String,
name: String,
input: JsonValue,
},
ToolResult {
tool_use_id: String,
content: String,
is_error: bool,
},
MultiPart(Vec<ContentPart>),
}
pub enum ContentPart {
Text(String),
ToolUse {
tool_use_id: String,
name: String,
input: JsonValue,
},
ToolResult {
tool_use_id: String,
content: String,
is_error: bool,
},
}
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub input_schema: JsonValue,
}
pub fn build_converse_request(
messages: Vec<ConversationMessage>,
system_prompt: Option<String>,
tools: Vec<ToolDefinition>,
max_tokens: i32,
temperature: Option<f32>,
top_p: Option<f32>,
stop_sequences: Option<Vec<String>>,
) -> ConvertedRequest {
let bedrock_messages = convert_messages(messages);
let system = convert_system_prompt(system_prompt);
let inference_config = build_inference_config(max_tokens, temperature, top_p, stop_sequences);
let tool_config = build_tool_config(tools);
ConvertedRequest {
messages: bedrock_messages,
system,
inference_config,
tool_config,
}
}
fn json_to_document(value: JsonValue) -> Document {
match value {
JsonValue::Null => Document::Null,
JsonValue::Bool(b) => Document::Bool(b),
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
Document::Number(aws_smithy_types::Number::PosInt(i as u64))
} else if let Some(f) = n.as_f64() {
Document::Number(aws_smithy_types::Number::Float(f))
} else {
Document::Null
}
}
JsonValue::String(s) => Document::String(s),
JsonValue::Array(arr) => {
Document::Array(arr.into_iter().map(json_to_document).collect())
}
JsonValue::Object(obj) => {
let map: HashMap<String, Document> = obj
.into_iter()
.map(|(k, v)| (k, json_to_document(v)))
.collect();
Document::Object(map)
}
}
}
fn convert_messages(messages: Vec<ConversationMessage>) -> Vec<BedrockMessage> {
let mut result = Vec::new();
for msg in messages {
let role = match msg.role {
MessageRole::User => ConversationRole::User,
MessageRole::Assistant => ConversationRole::Assistant,
};
let content_blocks = match msg.content {
MessageContent::Text(text) => vec![ContentBlock::Text(text)],
MessageContent::ToolUse {
tool_use_id,
name,
input,
} => {
let input_doc = json_to_document(input);
vec![ContentBlock::ToolUse(
ToolUseBlock::builder()
.tool_use_id(tool_use_id)
.name(name)
.input(input_doc)
.build()
.expect("valid tool use block"),
)]
}
MessageContent::ToolResult {
tool_use_id,
content,
is_error,
} => {
let status = if is_error {
ToolResultStatus::Error
} else {
ToolResultStatus::Success
};
vec![ContentBlock::ToolResult(
ToolResultBlock::builder()
.tool_use_id(tool_use_id)
.status(status)
.content(ToolResultContentBlock::Text(content))
.build()
.expect("valid tool result block"),
)]
}
MessageContent::MultiPart(parts) => parts
.into_iter()
.map(|part| match part {
ContentPart::Text(text) => ContentBlock::Text(text),
ContentPart::ToolUse {
tool_use_id,
name,
input,
} => {
let input_doc = json_to_document(input);
ContentBlock::ToolUse(
ToolUseBlock::builder()
.tool_use_id(tool_use_id)
.name(name)
.input(input_doc)
.build()
.expect("valid tool use block"),
)
}
ContentPart::ToolResult {
tool_use_id,
content,
is_error,
} => {
let status = if is_error {
ToolResultStatus::Error
} else {
ToolResultStatus::Success
};
ContentBlock::ToolResult(
ToolResultBlock::builder()
.tool_use_id(tool_use_id)
.status(status)
.content(ToolResultContentBlock::Text(content))
.build()
.expect("valid tool result block"),
)
}
})
.collect(),
};
let message = BedrockMessage::builder()
.role(role)
.set_content(Some(content_blocks))
.build()
.expect("valid message");
result.push(message);
}
coalesce_consecutive_roles(result)
}
fn coalesce_consecutive_roles(messages: Vec<BedrockMessage>) -> Vec<BedrockMessage> {
if messages.is_empty() {
return messages;
}
let mut result: Vec<BedrockMessage> = Vec::new();
for msg in messages {
let should_merge = result
.last()
.map(|last| last.role() == msg.role())
.unwrap_or(false);
if should_merge {
let last = result.pop().unwrap();
let mut combined_content: Vec<ContentBlock> = last.content().to_vec();
combined_content.extend(msg.content().to_vec());
let merged = BedrockMessage::builder()
.role(last.role().clone())
.set_content(Some(combined_content))
.build()
.expect("valid merged message");
result.push(merged);
} else {
result.push(msg);
}
}
result
}
fn convert_system_prompt(system_prompt: Option<String>) -> Vec<SystemContentBlock> {
match system_prompt {
Some(prompt) if !prompt.is_empty() => {
vec![SystemContentBlock::Text(prompt)]
}
_ => vec![],
}
}
fn build_inference_config(
max_tokens: i32,
temperature: Option<f32>,
top_p: Option<f32>,
stop_sequences: Option<Vec<String>>,
) -> InferenceConfiguration {
let mut builder = InferenceConfiguration::builder().max_tokens(max_tokens);
if let Some(temp) = temperature {
builder = builder.temperature(temp);
}
if let Some(p) = top_p {
builder = builder.top_p(p);
}
if let Some(stops) = stop_sequences {
builder = builder.set_stop_sequences(Some(stops));
}
builder.build()
}
fn build_tool_config(tools: Vec<ToolDefinition>) -> Option<ToolConfiguration> {
if tools.is_empty() {
return None;
}
let tool_specs: Vec<Tool> = tools
.into_iter()
.map(|tool| {
let input_schema_doc = json_to_document(tool.input_schema);
Tool::ToolSpec(
ToolSpecification::builder()
.name(tool.name)
.description(tool.description)
.input_schema(ToolInputSchema::Json(input_schema_doc))
.build()
.expect("valid tool spec"),
)
})
.collect();
Some(
ToolConfiguration::builder()
.set_tools(Some(tool_specs))
.build()
.expect("valid tool config"),
)
}
+378
View File
@@ -0,0 +1,378 @@
use warp_multi_agent_api as api;
use super::convert::{ConversationMessage, MessageContent, MessageRole, ToolDefinition};
pub fn extract_messages_from_request(request: &api::Request) -> Vec<ConversationMessage> {
let mut messages = Vec::new();
if let Some(task_context) = &request.task_context {
for task in &task_context.tasks {
for msg in &task.messages {
if let Some(converted) = convert_proto_message(msg) {
messages.push(converted);
}
}
}
}
if let Some(input) = &request.input {
if let Some(input_type) = &input.r#type {
#[allow(deprecated)]
match input_type {
api::request::input::Type::UserInputs(user_inputs) => {
for user_input in &user_inputs.inputs {
if let Some(input_variant) = &user_input.input {
match input_variant {
api::request::input::user_inputs::user_input::Input::UserQuery(
query,
) => {
if !query.query.is_empty() {
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(query.query.clone()),
});
}
}
api::request::input::user_inputs::user_input::Input::ToolCallResult(
result,
) => {
let content = extract_tool_result_content(result);
if !result.tool_call_id.is_empty() {
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id: result.tool_call_id.clone(),
content,
is_error: false,
},
});
}
}
_ => {}
}
}
}
}
api::request::input::Type::UserQuery(query) => {
if !query.query.is_empty() {
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(query.query.clone()),
});
}
}
api::request::input::Type::ToolCallResult(result) => {
let content = extract_tool_result_content(result);
if !result.tool_call_id.is_empty() {
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id: result.tool_call_id.clone(),
content,
is_error: false,
},
});
}
}
_ => {}
}
}
}
messages
}
pub fn extract_system_prompt(_request: &api::Request) -> Option<String> {
Some("You are a helpful AI coding assistant. You help users with software engineering tasks including writing code, debugging, and explaining concepts.".to_string())
}
pub fn extract_tools(request: &api::Request) -> Vec<ToolDefinition> {
let mut tools = Vec::new();
let mut seen_names = std::collections::HashSet::new();
if let Some(task_context) = &request.task_context {
for task in &task_context.tasks {
for msg in &task.messages {
if let Some(api::message::Message::ToolCall(tool_call)) = &msg.message {
let (name, _) = extract_tool_call_info(tool_call);
if name != "unknown_tool" && seen_names.insert(name.clone()) {
tools.push(tool_definition_for_name(&name));
}
}
}
}
}
if let Some(input) = &request.input {
if let Some(input_type) = &input.r#type {
#[allow(deprecated)]
match input_type {
api::request::input::Type::UserInputs(user_inputs) => {
for user_input in &user_inputs.inputs {
if let Some(api::request::input::user_inputs::user_input::Input::ToolCallResult(_)) = &user_input.input {
break;
}
}
}
api::request::input::Type::ToolCallResult(_) => {}
_ => {}
}
}
}
if tools.is_empty() {
let messages = extract_messages_from_request(request);
let has_tool_content = messages.iter().any(|m| {
matches!(
m.content,
MessageContent::ToolUse { .. } | MessageContent::ToolResult { .. }
)
});
if has_tool_content {
tools = default_tool_definitions();
}
}
tools
}
fn tool_definition_for_name(name: &str) -> ToolDefinition {
match name {
"run_shell_command" => ToolDefinition {
name: "run_shell_command".to_string(),
description: "Execute a shell command and return its output.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "The shell command to execute" }
},
"required": ["command"]
}),
},
"read_files" => ToolDefinition {
name: "read_files".to_string(),
description: "Read the contents of one or more files.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"files": { "type": "array", "items": { "type": "string" }, "description": "File paths to read" }
},
"required": ["files"]
}),
},
"apply_file_diffs" => ToolDefinition {
name: "apply_file_diffs".to_string(),
description: "Apply search/replace diffs to files.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"diffs": { "type": "array", "items": { "type": "object", "properties": { "file_path": { "type": "string" }, "search": { "type": "string" }, "replace": { "type": "string" } }, "required": ["file_path", "search", "replace"] } }
},
"required": ["diffs"]
}),
},
"grep" => ToolDefinition {
name: "grep".to_string(),
description: "Search for patterns in files using grep.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"queries": { "type": "array", "items": { "type": "string" }, "description": "Search patterns" },
"path": { "type": "string", "description": "Directory to search in" }
},
"required": ["queries"]
}),
},
"file_glob" => ToolDefinition {
name: "file_glob".to_string(),
description: "Find files matching glob patterns.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"patterns": { "type": "array", "items": { "type": "string" }, "description": "Glob patterns to match" }
},
"required": ["patterns"]
}),
},
_ => ToolDefinition {
name: name.to_string(),
description: format!("Tool: {}", name),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
},
}
}
fn default_tool_definitions() -> Vec<ToolDefinition> {
vec![
tool_definition_for_name("run_shell_command"),
tool_definition_for_name("read_files"),
tool_definition_for_name("apply_file_diffs"),
tool_definition_for_name("grep"),
tool_definition_for_name("file_glob"),
]
}
fn convert_proto_message(msg: &api::Message) -> Option<ConversationMessage> {
let message_content = msg.message.as_ref()?;
match message_content {
api::message::Message::UserQuery(query) => Some(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(query.query.clone()),
}),
api::message::Message::AgentOutput(output) => Some(ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text(output.text.clone()),
}),
api::message::Message::ToolCall(tool_call) => {
let (name, input) = extract_tool_call_info(tool_call);
Some(ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::ToolUse {
tool_use_id: msg.id.clone(),
name,
input,
},
})
}
api::message::Message::ToolCallResult(result) => {
let content = format_tool_call_result(result);
Some(ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id: result.tool_call_id.clone(),
content,
is_error: false,
},
})
}
api::message::Message::AgentReasoning(_) => None,
_ => None,
}
}
fn extract_tool_call_info(tool_call: &api::message::ToolCall) -> (String, serde_json::Value) {
if let Some(tool) = &tool_call.tool {
match tool {
api::message::tool_call::Tool::RunShellCommand(cmd) => (
"run_shell_command".to_string(),
serde_json::json!({ "command": cmd.command }),
),
api::message::tool_call::Tool::ReadFiles(read) => (
"read_files".to_string(),
serde_json::json!({ "files": read.files.iter().map(|f| &f.name).collect::<Vec<_>>() }),
),
api::message::tool_call::Tool::ApplyFileDiffs(diffs) => (
"apply_file_diffs".to_string(),
serde_json::json!({ "diffs": diffs.diffs.iter().map(|d| {
serde_json::json!({
"file_path": d.file_path,
"search": d.search,
"replace": d.replace
})
}).collect::<Vec<_>>() }),
),
api::message::tool_call::Tool::Grep(grep) => (
"grep".to_string(),
serde_json::json!({ "queries": grep.queries, "path": grep.path }),
),
#[allow(deprecated)]
api::message::tool_call::Tool::FileGlob(glob) => (
"file_glob".to_string(),
serde_json::json!({ "patterns": glob.patterns }),
),
_ => ("unknown_tool".to_string(), serde_json::json!({})),
}
} else {
("unknown_tool".to_string(), serde_json::json!({}))
}
}
fn extract_tool_result_content(result: &api::request::input::ToolCallResult) -> String {
if let Some(result_type) = &result.result {
match result_type {
api::request::input::tool_call_result::Result::RunShellCommand(cmd_result) => {
match &cmd_result.result {
Some(
api::run_shell_command_result::Result::CommandFinished(finished),
) => finished.output.clone(),
Some(
api::run_shell_command_result::Result::LongRunningCommandSnapshot(
snapshot,
),
) => snapshot.output.clone(),
_ => "Command completed.".to_string(),
}
}
api::request::input::tool_call_result::Result::ReadFiles(read_result) => {
match &read_result.result {
Some(api::read_files_result::Result::TextFilesSuccess(success)) => success
.files
.iter()
.map(|f| format!("{}:\n{}", f.file_path, f.content))
.collect::<Vec<_>>()
.join("\n\n"),
Some(api::read_files_result::Result::AnyFilesSuccess(success)) => success
.files
.iter()
.filter_map(|f| match &f.content {
Some(api::any_file_content::Content::TextContent(t)) => {
Some(format!("{}:\n{}", t.file_path, t.content))
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n"),
_ => "Failed to read files.".to_string(),
}
}
_ => "Tool completed successfully.".to_string(),
}
} else {
"Tool completed.".to_string()
}
}
fn format_tool_call_result(result: &api::message::ToolCallResult) -> String {
if let Some(result_type) = &result.result {
match result_type {
api::message::tool_call_result::Result::RunShellCommand(cmd_result) => {
match &cmd_result.result {
Some(
api::run_shell_command_result::Result::CommandFinished(finished),
) => {
format!(
"Exit code: {}\nOutput: {}",
finished.exit_code, finished.output
)
}
Some(
api::run_shell_command_result::Result::LongRunningCommandSnapshot(
snapshot,
),
) => {
format!("Output (running): {}", snapshot.output)
}
_ => "Command completed.".to_string(),
}
}
api::message::tool_call_result::Result::ReadFiles(read_result) => {
match &read_result.result {
Some(api::read_files_result::Result::TextFilesSuccess(success)) => success
.files
.iter()
.map(|f| format!("{}:\n{}", f.file_path, f.content))
.collect::<Vec<_>>()
.join("\n\n"),
_ => "Read files completed.".to_string(),
}
}
_ => "Tool completed successfully.".to_string(),
}
} else {
"Tool completed.".to_string()
}
}
+280
View File
@@ -0,0 +1,280 @@
use aws_sdk_bedrockruntime::types::{ContentBlock, ConversationRole};
use serde_json::json;
use super::convert::*;
#[test]
fn test_text_message_converts_to_single_block() {
let messages = vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Hello".to_string()),
}];
let result = build_converse_request(messages, None, vec![], 4096, None, None, None);
assert_eq!(result.messages.len(), 1);
assert_eq!(result.messages[0].role(), &ConversationRole::User);
assert_eq!(result.messages[0].content().len(), 1);
assert!(matches!(&result.messages[0].content()[0], ContentBlock::Text(t) if t == "Hello"));
}
#[test]
fn test_tool_use_produces_valid_json_input() {
let messages = vec![ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::ToolUse {
tool_use_id: "tool_123".to_string(),
name: "read_file".to_string(),
input: json!({"path": "/tmp/test.txt"}),
},
}];
let result = build_converse_request(messages, None, vec![], 4096, None, None, None);
assert_eq!(result.messages.len(), 1);
assert_eq!(result.messages[0].role(), &ConversationRole::Assistant);
match &result.messages[0].content()[0] {
ContentBlock::ToolUse(block) => {
assert_eq!(block.tool_use_id(), "tool_123");
assert_eq!(block.name(), "read_file");
}
other => panic!("Expected ToolUse, got {:?}", other),
}
}
#[test]
fn test_tool_result_with_matching_id() {
let messages = vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id: "tool_123".to_string(),
content: "file contents here".to_string(),
is_error: false,
},
}];
let result = build_converse_request(messages, None, vec![], 4096, None, None, None);
assert_eq!(result.messages.len(), 1);
match &result.messages[0].content()[0] {
ContentBlock::ToolResult(block) => {
assert_eq!(block.tool_use_id(), "tool_123");
}
other => panic!("Expected ToolResult, got {:?}", other),
}
}
#[test]
fn test_tool_result_error_status() {
let messages = vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id: "tool_456".to_string(),
content: "permission denied".to_string(),
is_error: true,
},
}];
let result = build_converse_request(messages, None, vec![], 4096, None, None, None);
match &result.messages[0].content()[0] {
ContentBlock::ToolResult(block) => {
assert_eq!(
block.status(),
Some(&aws_sdk_bedrockruntime::types::ToolResultStatus::Error)
);
}
other => panic!("Expected ToolResult, got {:?}", other),
}
}
#[test]
fn test_consecutive_same_role_messages_coalesced() {
let messages = vec![
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("first".to_string()),
},
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("second".to_string()),
},
];
let result = build_converse_request(messages, None, vec![], 4096, None, None, None);
assert_eq!(result.messages.len(), 1);
assert_eq!(result.messages[0].content().len(), 2);
assert!(matches!(&result.messages[0].content()[0], ContentBlock::Text(t) if t == "first"));
assert!(matches!(&result.messages[0].content()[1], ContentBlock::Text(t) if t == "second"));
}
#[test]
fn test_alternating_roles_not_coalesced() {
let messages = vec![
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("question".to_string()),
},
ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text("answer".to_string()),
},
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("followup".to_string()),
},
];
let result = build_converse_request(messages, None, vec![], 4096, None, None, None);
assert_eq!(result.messages.len(), 3);
assert_eq!(result.messages[0].role(), &ConversationRole::User);
assert_eq!(result.messages[1].role(), &ConversationRole::Assistant);
assert_eq!(result.messages[2].role(), &ConversationRole::User);
}
#[test]
fn test_system_prompt_separated_from_messages() {
let messages = vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("hi".to_string()),
}];
let result = build_converse_request(
messages,
Some("You are a helpful assistant.".to_string()),
vec![],
4096,
None,
None,
None,
);
assert_eq!(result.system.len(), 1);
assert_eq!(result.messages.len(), 1);
assert_eq!(result.messages[0].role(), &ConversationRole::User);
}
#[test]
fn test_empty_system_prompt_produces_empty_vec() {
let result = build_converse_request(vec![], Some("".to_string()), vec![], 4096, None, None, None);
assert!(result.system.is_empty());
let result2 = build_converse_request(vec![], None, vec![], 4096, None, None, None);
assert!(result2.system.is_empty());
}
#[test]
fn test_empty_tools_produce_none_config() {
let result = build_converse_request(vec![], None, vec![], 4096, None, None, None);
assert!(result.tool_config.is_none());
}
#[test]
fn test_tool_definitions_produce_tool_config() {
let tools = vec![ToolDefinition {
name: "read_file".to_string(),
description: "Read a file from disk".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "File path"}
},
"required": ["path"]
}),
}];
let result = build_converse_request(vec![], None, tools, 4096, None, None, None);
assert!(result.tool_config.is_some());
let config = result.tool_config.unwrap();
assert_eq!(config.tools().len(), 1);
}
#[test]
fn test_inference_config_max_tokens_only() {
let result = build_converse_request(vec![], None, vec![], 8192, None, None, None);
assert_eq!(result.inference_config.max_tokens(), Some(8192));
assert_eq!(result.inference_config.temperature(), None);
assert_eq!(result.inference_config.top_p(), None);
assert!(result.inference_config.stop_sequences().is_empty());
}
#[test]
fn test_inference_config_all_params() {
let result = build_converse_request(
vec![],
None,
vec![],
4096,
Some(0.7),
Some(0.9),
Some(vec!["STOP".to_string()]),
);
assert_eq!(result.inference_config.max_tokens(), Some(4096));
assert_eq!(result.inference_config.temperature(), Some(0.7));
assert_eq!(result.inference_config.top_p(), Some(0.9));
assert_eq!(result.inference_config.stop_sequences(), &["STOP"]);
}
#[test]
fn test_multipart_content_produces_multiple_blocks() {
let messages = vec![ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::MultiPart(vec![
ContentPart::Text("Let me help.".to_string()),
ContentPart::ToolUse {
tool_use_id: "tu_1".to_string(),
name: "run_command".to_string(),
input: json!({"command": "ls"}),
},
]),
}];
let result = build_converse_request(messages, None, vec![], 4096, None, None, None);
assert_eq!(result.messages[0].content().len(), 2);
assert!(matches!(&result.messages[0].content()[0], ContentBlock::Text(_)));
assert!(matches!(
&result.messages[0].content()[1],
ContentBlock::ToolUse(_)
));
}
#[test]
fn test_tool_result_after_tool_use_coalesced_into_user_message() {
let messages = vec![
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Do something".to_string()),
},
ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::ToolUse {
tool_use_id: "tu_1".to_string(),
name: "cmd".to_string(),
input: json!({}),
},
},
ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id: "tu_1".to_string(),
content: "done".to_string(),
is_error: false,
},
},
];
let result = build_converse_request(messages, None, vec![], 4096, None, None, None);
assert_eq!(result.messages.len(), 3);
assert_eq!(result.messages[0].role(), &ConversationRole::User);
assert_eq!(result.messages[1].role(), &ConversationRole::Assistant);
assert_eq!(result.messages[2].role(), &ConversationRole::User);
assert!(matches!(
&result.messages[2].content()[0],
ContentBlock::ToolResult(_)
));
}
+176
View File
@@ -0,0 +1,176 @@
use std::collections::BTreeSet;
use std::path::PathBuf;
use anyhow::Result;
use aws_config::BehaviorVersion;
use aws_sdk_bedrock::types::InferenceProfileType;
use aws_sdk_bedrock::Client as BedrockControlClient;
use aws_sdk_bedrockruntime::config::Region;
use crate::settings::ai::{BedrockAuthMethod, BedrockModelConfig};
use super::client::BedrockClientConfig;
pub fn list_aws_profiles() -> Vec<String> {
let mut profiles = BTreeSet::new();
if let Some(home) = dirs::home_dir() {
parse_config_file(home.join(".aws").join("config"), &mut profiles);
parse_credentials_file(home.join(".aws").join("credentials"), &mut profiles);
}
profiles.into_iter().collect()
}
fn parse_config_file(path: PathBuf, profiles: &mut BTreeSet<String>) {
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return,
};
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let section = &trimmed[1..trimmed.len() - 1];
if section == "default" {
profiles.insert("default".to_string());
} else if let Some(name) = section.strip_prefix("profile ") {
profiles.insert(name.trim().to_string());
}
}
}
}
fn parse_credentials_file(path: PathBuf, profiles: &mut BTreeSet<String>) {
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return,
};
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let name = &trimmed[1..trimmed.len() - 1];
profiles.insert(name.trim().to_string());
}
}
}
pub async fn discover_inference_profiles(
config: &BedrockClientConfig,
) -> Result<Vec<BedrockModelConfig>> {
let aws_config = build_aws_config(config).await;
let client = BedrockControlClient::new(&aws_config);
let mut models = Vec::new();
fetch_profiles_by_type(&client, InferenceProfileType::SystemDefined, &mut models).await?;
fetch_profiles_by_type(&client, InferenceProfileType::Application, &mut models).await?;
models.sort_by(|a, b| a.display_name.cmp(&b.display_name));
models.dedup_by(|a, b| a.model_id == b.model_id);
Ok(models)
}
async fn fetch_profiles_by_type(
client: &BedrockControlClient,
profile_type: InferenceProfileType,
models: &mut Vec<BedrockModelConfig>,
) -> Result<()> {
let mut next_token: Option<String> = None;
loop {
let mut req = client
.list_inference_profiles()
.type_equals(profile_type.clone())
.max_results(100);
if let Some(token) = next_token.take() {
req = req.next_token(token);
}
let resp = req.send().await?;
for summary in resp.inference_profile_summaries() {
let profile_id = summary.inference_profile_id();
let profile_name = summary.inference_profile_name();
let profile_arn = summary.inference_profile_arn();
let model_id = if profile_arn.contains(":application-inference-profile/") {
profile_arn.to_string()
} else {
profile_id.to_string()
};
if should_skip_model(profile_name) {
continue;
}
models.push(BedrockModelConfig {
model_id,
display_name: profile_name.to_string(),
vision_supported: true,
});
}
next_token = resp.next_token().map(|s| s.to_string());
if next_token.is_none() {
break;
}
}
Ok(())
}
fn should_skip_model(name: &str) -> bool {
let lower = name.to_lowercase();
lower.contains("embed")
|| lower.contains("stable image")
|| lower.contains("stable-image")
|| lower.contains("upscale")
|| lower.contains("outpaint")
|| lower.contains("inpaint")
|| lower.contains("recolor")
|| lower.contains("erase")
|| lower.contains("style transfer")
|| lower.contains("style guide")
|| lower.contains("remove background")
|| lower.contains("search and replace")
|| lower.contains("control sketch")
|| lower.contains("control structure")
}
async fn build_aws_config(config: &BedrockClientConfig) -> aws_config::SdkConfig {
match config.auth_method {
BedrockAuthMethod::Profile | BedrockAuthMethod::Sso => {
let mut loader =
aws_config::defaults(BehaviorVersion::latest()).profile_name(&config.profile);
if !config.region.is_empty() {
loader = loader.region(Region::new(config.region.clone()));
}
loader.load().await
}
BedrockAuthMethod::StaticKeys => {
let creds = aws_credential_types::Credentials::new(
&config.access_key_id,
&config.secret_access_key,
None,
None,
"warp-bedrock-discovery",
);
let mut loader =
aws_config::defaults(BehaviorVersion::latest()).credentials_provider(creds);
if !config.region.is_empty() {
loader = loader.region(Region::new(config.region.clone()));
} else {
loader = loader.region(Region::new("us-east-1".to_string()));
}
loader.load().await
}
}
}
+13
View File
@@ -0,0 +1,13 @@
pub mod client;
pub mod convert;
pub mod convert_request;
pub mod discovery;
pub mod models;
pub mod stream;
#[cfg(test)]
mod convert_tests;
#[cfg(test)]
mod models_tests;
#[cfg(test)]
mod stream_tests;
+107
View File
@@ -0,0 +1,107 @@
use crate::settings::ai::BedrockModelConfig;
pub struct DefaultModel {
pub model_id: &'static str,
pub display_name: &'static str,
pub vision_supported: bool,
}
pub const DEFAULT_BEDROCK_MODELS: &[DefaultModel] = &[
DefaultModel {
model_id: "anthropic.claude-opus-4-7",
display_name: "Claude Opus 4.7",
vision_supported: true,
},
DefaultModel {
model_id: "anthropic.claude-sonnet-4-6",
display_name: "Claude Sonnet 4.6",
vision_supported: true,
},
DefaultModel {
model_id: "anthropic.claude-sonnet-4-20250514-v1:0",
display_name: "Claude Sonnet 4",
vision_supported: true,
},
DefaultModel {
model_id: "anthropic.claude-haiku-4-5-20251001-v1:0",
display_name: "Claude Haiku 4.5",
vision_supported: true,
},
DefaultModel {
model_id: "amazon.nova-pro-v1:0",
display_name: "Amazon Nova Pro",
vision_supported: true,
},
DefaultModel {
model_id: "amazon.nova-lite-v1:0",
display_name: "Amazon Nova Lite",
vision_supported: true,
},
DefaultModel {
model_id: "amazon.nova-micro-v1:0",
display_name: "Amazon Nova Micro",
vision_supported: false,
},
DefaultModel {
model_id: "deepseek.r1-v1:0",
display_name: "DeepSeek R1",
vision_supported: false,
},
];
pub fn get_effective_models(user_models: &[BedrockModelConfig]) -> Vec<BedrockModelConfig> {
if user_models.is_empty() {
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,
})
.collect()
} else {
user_models.to_vec()
}
}
pub fn apply_cross_region_prefix(model_id: &str, region: &str) -> String {
if model_id.starts_with("arn:") {
return model_id.to_string();
}
if model_id.contains('.') && model_id.split('.').next().unwrap_or("").len() <= 6 {
return model_id.to_string();
}
let prefix = match region {
r if r.starts_with("us-") || r.starts_with("ca-") => "us",
r if r.starts_with("eu-") || r == "il-central-1" => "eu",
r if r == "ap-northeast-1" || r == "ap-northeast-3" => "jp",
r if r == "ap-southeast-2" || r == "ap-southeast-4" || r == "ap-southeast-6" => "au",
r if r.starts_with("ap-") => "apac",
_ => return model_id.to_string(),
};
format!("{}.{}", prefix, model_id)
}
pub fn is_bedrock_model(model_id: &str, configured_models: &[BedrockModelConfig]) -> bool {
if model_id.starts_with("arn:aws:bedrock:") {
return true;
}
let effective = get_effective_models(configured_models);
effective.iter().any(|m| m.model_id == model_id)
|| model_id.starts_with("anthropic.")
|| model_id.starts_with("amazon.")
|| model_id.starts_with("meta.")
|| model_id.starts_with("mistral.")
|| model_id.starts_with("cohere.")
|| model_id.starts_with("ai21.")
|| model_id.starts_with("deepseek.")
|| has_cross_region_prefix(model_id)
}
fn has_cross_region_prefix(model_id: &str) -> bool {
let prefixes = ["us.", "eu.", "jp.", "apac.", "au.", "global."];
prefixes.iter().any(|p| model_id.starts_with(p))
}
+158
View File
@@ -0,0 +1,158 @@
use super::models::*;
use crate::settings::ai::BedrockModelConfig;
#[test]
fn test_cross_region_prefix_us_east() {
assert_eq!(
apply_cross_region_prefix("anthropic.claude-3-5-sonnet-20241022-v1:0", "us-east-1"),
"us.anthropic.claude-3-5-sonnet-20241022-v1:0"
);
}
#[test]
fn test_cross_region_prefix_eu_west() {
assert_eq!(
apply_cross_region_prefix("anthropic.claude-3-5-sonnet-20241022-v1:0", "eu-west-1"),
"eu.anthropic.claude-3-5-sonnet-20241022-v1:0"
);
}
#[test]
fn test_cross_region_prefix_ap_northeast_1() {
assert_eq!(
apply_cross_region_prefix("anthropic.claude-3-5-sonnet-20241022-v1:0", "ap-northeast-1"),
"jp.anthropic.claude-3-5-sonnet-20241022-v1:0"
);
}
#[test]
fn test_cross_region_prefix_ap_southeast_2() {
assert_eq!(
apply_cross_region_prefix("anthropic.claude-3-5-sonnet-20241022-v1:0", "ap-southeast-2"),
"au.anthropic.claude-3-5-sonnet-20241022-v1:0"
);
}
#[test]
fn test_cross_region_prefix_ap_southeast_1() {
assert_eq!(
apply_cross_region_prefix("anthropic.claude-3-5-sonnet-20241022-v1:0", "ap-southeast-1"),
"apac.anthropic.claude-3-5-sonnet-20241022-v1:0"
);
}
#[test]
fn test_cross_region_prefix_already_prefixed() {
assert_eq!(
apply_cross_region_prefix("us.anthropic.claude-sonnet-4-6", "us-east-1"),
"us.anthropic.claude-sonnet-4-6"
);
assert_eq!(
apply_cross_region_prefix("global.anthropic.claude-sonnet-4-6", "us-east-1"),
"global.anthropic.claude-sonnet-4-6"
);
}
#[test]
fn test_cross_region_prefix_canada_maps_to_us() {
assert_eq!(
apply_cross_region_prefix("anthropic.claude-sonnet-4-6", "ca-central-1"),
"us.anthropic.claude-sonnet-4-6"
);
}
#[test]
fn test_cross_region_prefix_unknown_region() {
assert_eq!(
apply_cross_region_prefix("anthropic.claude-3-5-sonnet-20241022-v1:0", "me-south-1"),
"anthropic.claude-3-5-sonnet-20241022-v1:0"
);
}
#[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, "anthropic.claude-opus-4-7");
assert_eq!(models[0].display_name, "Claude Opus 4.7");
}
#[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,
}];
let models = get_effective_models(&custom);
assert_eq!(models.len(), 1);
assert_eq!(models[0].model_id, "custom.model-v1:0");
}
#[test]
fn test_is_bedrock_model_known_prefix() {
assert!(is_bedrock_model("anthropic.claude-sonnet-4-6", &[]));
assert!(is_bedrock_model("amazon.nova-pro-v1:0", &[]));
assert!(is_bedrock_model("meta.llama3-70b-instruct-v1:0", &[]));
assert!(is_bedrock_model("mistral.mistral-large-v1:0", &[]));
assert!(is_bedrock_model("deepseek.r1-v1:0", &[]));
}
#[test]
fn test_is_bedrock_model_cross_region_prefix() {
assert!(is_bedrock_model("us.anthropic.claude-sonnet-4-6", &[]));
assert!(is_bedrock_model("eu.anthropic.claude-sonnet-4-6", &[]));
assert!(is_bedrock_model("global.anthropic.claude-opus-4-7", &[]));
}
#[test]
fn test_is_bedrock_model_unknown() {
assert!(!is_bedrock_model("gpt-4o", &[]));
assert!(!is_bedrock_model("gemini-pro", &[]));
}
#[test]
fn test_is_bedrock_model_custom_config() {
let custom = vec![BedrockModelConfig {
model_id: "custom.my-model-v1:0".to_string(),
display_name: "Custom".to_string(),
vision_supported: false,
}];
assert!(is_bedrock_model("custom.my-model-v1:0", &custom));
}
#[test]
fn test_is_bedrock_model_arn() {
assert!(is_bedrock_model(
"arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/fma5bsw4oxhy",
&[],
));
}
#[test]
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 test_is_bedrock_model_coding_agent_arn() {
assert!(is_bedrock_model(
"arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/coding-agent-anthropic-claude-opus-4-6-lt2v72",
&[],
));
}
#[test]
fn test_is_bedrock_model_custom_config_with_arn() {
let custom = vec![BedrockModelConfig {
model_id: "arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/coding-assistant-inference-profile".to_string(),
display_name: "Coding Assistant".to_string(),
vision_supported: true,
}];
assert!(is_bedrock_model(
"arn:aws:bedrock:us-east-1:156729649053:application-inference-profile/coding-assistant-inference-profile",
&custom,
));
}
+343
View File
@@ -0,0 +1,343 @@
use std::sync::Arc;
use aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamOutput;
use aws_sdk_bedrockruntime::types::{
ContentBlockDelta, ContentBlockStart, ConverseStreamOutput as StreamEvent,
ReasoningContentBlockDelta, StopReason,
};
use futures::stream::BoxStream;
use uuid::Uuid;
use warp_multi_agent_api::response_event::stream_finished;
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
use crate::ai::agent::api::Event;
use crate::server::server_api::AIApiError;
pub fn bedrock_stream_to_response_events(
mut output: ConverseStreamOutput,
task_id: String,
) -> BoxStream<'static, Event> {
let request_id = Uuid::new_v4().to_string();
let conversation_id = Uuid::new_v4().to_string();
let stream = async_stream::stream! {
log::info!("[bedrock] Stream started: task_id={task_id}, request_id={request_id}");
let init_event = build_stream_init(&request_id, &conversation_id);
yield Ok(init_event);
let mut current_text_message_id: Option<String> = None;
let mut current_tool_use_id = String::new();
let mut current_tool_name = String::new();
let mut current_tool_input_json = String::new();
let mut input_tokens: i32 = 0;
let mut output_tokens: i32 = 0;
let mut stop_reason = stream_finished::Reason::Done(stream_finished::Done {});
loop {
match output.stream.recv().await {
Ok(Some(event)) => match event {
StreamEvent::MessageStart(_) => {}
StreamEvent::ContentBlockStart(block_start) => {
if let Some(start) = block_start.start() {
match start {
ContentBlockStart::ToolUse(tool_start) => {
current_tool_use_id = tool_start.tool_use_id().to_string();
current_tool_name = tool_start.name().to_string();
current_tool_input_json.clear();
}
_ => {}
}
}
}
StreamEvent::ContentBlockDelta(delta) => {
if let Some(d) = delta.delta() {
match d {
ContentBlockDelta::Text(text) => {
log::trace!("[bedrock] Text delta ({} chars): {:?}", text.len(), &text[..text.len().min(100)]);
if current_text_message_id.is_none() {
let msg_id = Uuid::new_v4().to_string();
current_text_message_id = Some(msg_id.clone());
log::debug!("[bedrock] First text chunk, creating message msg_id={msg_id}");
let add_msg = build_add_agent_output_message(
&task_id,
&msg_id,
text,
);
yield Ok(add_msg);
} else {
let msg_id = current_text_message_id.as_ref().unwrap();
let append = build_append_text(
&task_id,
msg_id,
text,
);
yield Ok(append);
}
}
ContentBlockDelta::ReasoningContent(reasoning) => {
if let ReasoningContentBlockDelta::Text(text) = reasoning {
log::trace!("[bedrock] Reasoning delta ({} chars)", text.len());
if current_text_message_id.is_none() {
let msg_id = Uuid::new_v4().to_string();
current_text_message_id = Some(msg_id.clone());
log::debug!("[bedrock] First reasoning chunk, creating message msg_id={msg_id}");
let add_msg = build_add_agent_output_message(
&task_id,
&msg_id,
text,
);
yield Ok(add_msg);
} else {
let msg_id = current_text_message_id.as_ref().unwrap();
let append = build_append_text(
&task_id,
msg_id,
text,
);
yield Ok(append);
}
}
}
ContentBlockDelta::ToolUse(tool_delta) => {
current_tool_input_json.push_str(tool_delta.input());
}
_ => {}
}
}
}
StreamEvent::ContentBlockStop(_) => {
if !current_tool_use_id.is_empty() {
log::debug!("[bedrock] Tool call complete: {} ({})", current_tool_name, current_tool_use_id);
let tool_msg = build_tool_call_message(
&task_id,
&current_tool_use_id,
&current_tool_name,
&current_tool_input_json,
);
yield Ok(tool_msg);
current_tool_use_id.clear();
current_tool_name.clear();
current_tool_input_json.clear();
}
}
StreamEvent::MessageStop(stop) => {
stop_reason = match stop.stop_reason() {
StopReason::EndTurn => {
stream_finished::Reason::Done(stream_finished::Done {})
}
StopReason::MaxTokens => {
stream_finished::Reason::MaxTokenLimit(
stream_finished::ReachedMaxTokenLimit {},
)
}
StopReason::ToolUse => {
stream_finished::Reason::Done(stream_finished::Done {})
}
_ => stream_finished::Reason::Other(stream_finished::Other {}),
};
}
StreamEvent::Metadata(metadata) => {
if let Some(usage) = metadata.usage() {
input_tokens = usage.input_tokens();
output_tokens = usage.output_tokens();
}
}
_ => {}
},
Ok(None) => {
log::info!("[bedrock] Stream ended normally");
break;
}
Err(e) => {
log::error!("[bedrock] Stream error: {e}");
yield Err(Arc::new(AIApiError::Stream {
stream_type: "bedrock_converse",
source: anyhow::anyhow!("Bedrock stream error: {}", e),
}));
return;
}
}
}
log::info!("[bedrock] Stream finished: input_tokens={input_tokens}, output_tokens={output_tokens}");
let finished_event = build_stream_finished(stop_reason, input_tokens, output_tokens);
yield Ok(finished_event);
};
Box::pin(stream)
}
pub(super) fn build_stream_init(request_id: &str, conversation_id: &str) -> ResponseEvent {
ResponseEvent {
r#type: Some(api::response_event::Type::Init(
api::response_event::StreamInit {
conversation_id: conversation_id.to_string(),
request_id: request_id.to_string(),
run_id: String::new(),
},
)),
}
}
pub(super) fn build_stream_finished(
reason: stream_finished::Reason,
input_tokens: i32,
output_tokens: i32,
) -> ResponseEvent {
let total_tokens = (input_tokens + output_tokens) as u32;
let mut byok_token_usage = std::collections::HashMap::new();
if total_tokens > 0 {
#[allow(deprecated)]
byok_token_usage.insert(
"bedrock".to_string(),
stream_finished::ModelTokenUsage {
model_id: String::new(),
total_tokens,
token_usage_by_category: std::collections::HashMap::new(),
},
);
}
#[allow(deprecated)]
let conversation_usage_metadata = Some(stream_finished::ConversationUsageMetadata {
context_window_usage: 0.0,
summarized: false,
credits_spent: 0.0,
token_usage: vec![],
tool_usage_metadata: None,
warp_token_usage: std::collections::HashMap::new(),
byok_token_usage,
});
ResponseEvent {
r#type: Some(api::response_event::Type::Finished(
api::response_event::StreamFinished {
reason: Some(reason),
token_usage: vec![],
should_refresh_model_config: false,
request_cost: None,
conversation_usage_metadata,
},
)),
}
}
fn build_add_agent_output_message(
task_id: &str,
message_id: &str,
initial_text: &str,
) -> ResponseEvent {
let message = api::Message {
id: message_id.to_string(),
task_id: task_id.to_string(),
request_id: String::new(),
timestamp: None,
server_message_data: String::new(),
citations: vec![],
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: initial_text.to_string(),
},
)),
};
let action = ClientAction {
action: Some(api::client_action::Action::AddMessagesToTask(
api::client_action::AddMessagesToTask {
task_id: task_id.to_string(),
messages: vec![message],
},
)),
};
ResponseEvent {
r#type: Some(api::response_event::Type::ClientActions(
api::response_event::ClientActions {
actions: vec![action],
},
)),
}
}
fn build_append_text(task_id: &str, message_id: &str, text_delta: &str) -> ResponseEvent {
let message = api::Message {
id: message_id.to_string(),
task_id: task_id.to_string(),
request_id: String::new(),
timestamp: None,
server_message_data: String::new(),
citations: vec![],
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: text_delta.to_string(),
},
)),
};
let mask = prost_types::FieldMask {
paths: vec!["message.agent_output.text".to_string()],
};
let action = ClientAction {
action: Some(api::client_action::Action::AppendToMessageContent(
api::client_action::AppendToMessageContent {
task_id: task_id.to_string(),
message: Some(message),
mask: Some(mask),
},
)),
};
ResponseEvent {
r#type: Some(api::response_event::Type::ClientActions(
api::response_event::ClientActions {
actions: vec![action],
},
)),
}
}
fn build_tool_call_message(
task_id: &str,
tool_use_id: &str,
tool_name: &str,
tool_input_json: &str,
) -> ResponseEvent {
let _tool_use_id = tool_use_id.to_string();
let _tool_name = tool_name.to_string();
let _tool_input_json = tool_input_json.to_string();
let message = api::Message {
id: Uuid::new_v4().to_string(),
task_id: task_id.to_string(),
request_id: String::new(),
timestamp: None,
server_message_data: String::new(),
citations: vec![],
message: Some(api::message::Message::AgentOutput(
api::message::AgentOutput {
text: format!("[Tool call: {} ({})]", _tool_name, _tool_use_id),
},
)),
};
let action = ClientAction {
action: Some(api::client_action::Action::AddMessagesToTask(
api::client_action::AddMessagesToTask {
task_id: task_id.to_string(),
messages: vec![message],
},
)),
};
ResponseEvent {
r#type: Some(api::response_event::Type::ClientActions(
api::response_event::ClientActions {
actions: vec![action],
},
)),
}
}
+69
View File
@@ -0,0 +1,69 @@
use warp_multi_agent_api::{self as api, response_event::stream_finished};
use super::stream::*;
#[test]
fn test_build_stream_init_has_valid_ids() {
let event = build_stream_init("req-123", "conv-456");
match event.r#type {
Some(api::response_event::Type::Init(init)) => {
assert_eq!(init.request_id, "req-123");
assert_eq!(init.conversation_id, "conv-456");
assert_eq!(init.run_id, "");
}
other => panic!("Expected Init event, got {:?}", other),
}
}
#[test]
fn test_build_stream_finished_done_reason() {
let reason = stream_finished::Reason::Done(stream_finished::Done {});
let event = build_stream_finished(reason, 100, 50);
match event.r#type {
Some(api::response_event::Type::Finished(finished)) => {
assert!(matches!(
finished.reason,
Some(stream_finished::Reason::Done(_))
));
assert!(!finished.should_refresh_model_config);
let metadata = finished.conversation_usage_metadata.unwrap();
assert_eq!(metadata.byok_token_usage.get("bedrock").unwrap().total_tokens, 150);
}
other => panic!("Expected Finished event, got {:?}", other),
}
}
#[test]
fn test_build_stream_finished_max_token_limit() {
let reason =
stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {});
let event = build_stream_finished(reason, 200, 100);
match event.r#type {
Some(api::response_event::Type::Finished(finished)) => {
assert!(matches!(
finished.reason,
Some(stream_finished::Reason::MaxTokenLimit(_))
));
}
other => panic!("Expected Finished event, got {:?}", other),
}
}
#[test]
fn test_build_stream_finished_other_reason() {
let reason = stream_finished::Reason::Other(stream_finished::Other {});
let event = build_stream_finished(reason, 0, 0);
match event.r#type {
Some(api::response_event::Type::Finished(finished)) => {
assert!(matches!(
finished.reason,
Some(stream_finished::Reason::Other(_))
));
}
other => panic!("Expected Finished event, got {:?}", other),
}
}
+6 -6
View File
@@ -3607,8 +3607,8 @@ impl AIBlock {
}
let ai_settings = AISettings::as_ref(ctx);
let login_command = ai_settings.aws_bedrock_auth_refresh_command.value().clone();
let auto_login_enabled = *ai_settings.aws_bedrock_auto_login.value();
let login_command = ai_settings.bedrock_auth_refresh_command.value().clone();
let auto_login_enabled = *ai_settings.bedrock_auto_login.value();
// If auto-login is enabled, run the login command automatically
if auto_login_enabled {
@@ -5646,7 +5646,7 @@ pub enum AIBlockAction {
ToggleReferencesSection,
ToggleAutoexecuteReadonlyCommandsSpeedbumpCheckbox,
ToggleAutoreadFilesSpeedbumpCheckbox,
ToggleAwsBedrockAutoLogin,
ToggleBedrockAutoLogin,
ToggleCodebaseSearchSpeedbump(Option<usize>),
StartNewConversationButtonClicked {
action_id: AIAgentActionId,
@@ -6246,11 +6246,11 @@ impl TypedActionView for AIBlock {
AIBlockAction::RunAwsLoginCommand => {
ctx.emit(AIBlockEvent::RunAwsLoginCommand);
}
AIBlockAction::ToggleAwsBedrockAutoLogin => {
AIBlockAction::ToggleBedrockAutoLogin => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let current = *settings.aws_bedrock_auto_login.value();
let current = *settings.bedrock_auto_login.value();
let new_value = !current;
report_if_error!(settings.aws_bedrock_auto_login.set_value(new_value, ctx));
report_if_error!(settings.bedrock_auto_login.set_value(new_value, ctx));
});
}
AIBlockAction::ConfigureAwsLoginCommand => {
@@ -8,15 +8,20 @@ use warp_multi_agent_api::response_event;
use warpui::{Entity, ModelContext, SingletonEntity};
use crate::{
ai::agent::{
api::{self, generate_multi_agent_output, ConvertToAPITypeError},
conversation::AIConversationId,
AIIdentifiers, CancellationReason,
ai::{
agent::{
api::{self, generate_multi_agent_output, ConvertToAPITypeError},
conversation::AIConversationId,
AIIdentifiers, CancellationReason,
},
bedrock::{client::BedrockClientConfig, models::is_bedrock_model},
},
network::NetworkStatus,
report_error, send_telemetry_from_ctx,
server::server_api::ServerApiProvider,
settings::ai::AISettings,
};
use settings::Setting;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ResponseStreamId(String);
@@ -79,6 +84,30 @@ pub struct ResponseStream {
}
impl ResponseStream {
fn bedrock_config_if_applicable(
model_id: &str,
ctx: &ModelContext<Self>,
) -> Option<BedrockClientConfig> {
let settings = AISettings::as_ref(ctx);
if !*settings.bedrock_enabled.value() {
return None;
}
let configured_models = settings.bedrock_models.value().clone();
if !is_bedrock_model(model_id, &configured_models) {
return None;
}
let auth_method = *settings.bedrock_auth_method.value();
Some(BedrockClientConfig {
auth_method,
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(),
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
fallback_to_warp: *settings.bedrock_fallback_to_warp.value(),
})
}
pub fn new(
params: api::RequestParams,
ai_identifiers: AIIdentifiers,
@@ -90,11 +119,12 @@ impl ResponseStream {
let start_time = Local::now();
let request_id = Uuid::new_v4();
let bedrock_config = Self::bedrock_config_if_applicable(params.model.as_str(), ctx);
let params_clone = params.clone();
let _ =
ctx.spawn(
async move {
generate_multi_agent_output(server_api, params_clone, cancellation_rx).await
generate_multi_agent_output(server_api, bedrock_config, params_clone, cancellation_rx).await
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
@@ -155,9 +185,10 @@ impl ResponseStream {
let request_id = Uuid::new_v4();
self.current_request_id = Some(request_id);
let params = self.params.clone();
let bedrock_config = Self::bedrock_config_if_applicable(params.model.as_str(), ctx);
let server_api = ServerApiProvider::as_ref(ctx).get();
let _ = ctx.spawn(
async move { generate_multi_agent_output(server_api, params, cancellation_rx).await },
async move { generate_multi_agent_output(server_api, bedrock_config, params, cancellation_rx).await },
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
},
@@ -78,7 +78,7 @@ impl AwsBedrockCredentialsErrorView {
// Subscribe to AISettings changes to update checkbox state
ctx.subscribe_to_model(&AISettings::handle(ctx), |_me, _, event, ctx| {
if matches!(event, AISettingsChangedEvent::AwsBedrockAutoLogin { .. }) {
if matches!(event, AISettingsChangedEvent::BedrockAutoLogin { .. }) {
ctx.notify();
}
});
@@ -125,7 +125,7 @@ impl View for AwsBedrockCredentialsErrorView {
.finish();
}
let auto_login_enabled = *AISettings::as_ref(app).aws_bedrock_auto_login.value();
let auto_login_enabled = *AISettings::as_ref(app).bedrock_auto_login.value();
// Helper closures to create elements (since Box<dyn Element> can't be cloned)
let make_alert_icon = || {
@@ -280,8 +280,8 @@ impl TypedActionView for AwsBedrockCredentialsErrorView {
}
AwsBedrockCredentialsErrorAction::ToggleAutoLogin => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let current = *settings.aws_bedrock_auto_login.value();
report_if_error!(settings.aws_bedrock_auto_login.set_value(!current, ctx));
let current = *settings.bedrock_auto_login.value();
report_if_error!(settings.bedrock_auto_login.set_value(!current, ctx));
});
ctx.notify();
}
@@ -174,7 +174,7 @@ impl PassiveSuggestionsModel {
let stream_handle = ctx.spawn(
async move {
let stream_result =
generate_multi_agent_output(server_api, request_params, cancellation_rx).await;
generate_multi_agent_output(server_api, None, request_params, cancellation_rx).await;
extract_suggestion_from_stream(stream_result).await
},
move |me, result, ctx| {
@@ -1,4 +1,4 @@
use crate::ai::llms::{is_using_api_key_for_provider, DisableReason, LLMId, LLMInfo};
use crate::ai::llms::{is_using_api_key_for_provider, DisableReason, LLMId, LLMInfo, LLMProvider};
use crate::menu::{MenuItem, MenuItemFields, MenuTooltipPosition};
use itertools::Itertools;
use std::sync::Arc;
@@ -80,6 +80,7 @@ fn make_item_fields<A: Action + Clone>(
llm.menu_display_name()
};
let is_using_api_key = is_using_api_key_for_provider(&llm.provider, app);
let is_bedrock = llm.provider == LLMProvider::Bedrock;
let mut item = if let Some(position_id_fn) = position_id_fn {
let position_id = position_id_fn(&llm.id);
@@ -89,7 +90,11 @@ fn make_item_fields<A: Action + Clone>(
Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let icon_container = Container::new(
ConstrainedBox::new(if is_using_api_key {
ConstrainedBox::new(if is_bedrock {
Icon::BedrockLogo
.to_warpui_icon(appearance.theme().foreground())
.finish()
} else if is_using_api_key {
Icon::Key
.to_warpui_icon(appearance.theme().foreground())
.finish()
+131 -5
View File
@@ -16,11 +16,17 @@ use crate::{
network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind},
report_error,
server::server_api::ServerApiProvider,
settings::ai::{AISettings, AISettingsChangedEvent, BedrockModelConfig},
workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent},
};
use settings::Setting;
use super::execution_profiles::profiles::AIExecutionProfilesModel;
#[cfg(not(target_family = "wasm"))]
use super::bedrock::models::get_effective_models;
pub use ai::LLMId;
/// Checks if a user's' API key is being used for the given provider.
@@ -36,6 +42,7 @@ pub fn is_using_api_key_for_provider(provider: &LLMProvider, app: &AppContext) -
LLMProvider::OpenAI => api_keys.is_some_and(|keys| keys.openai.is_some()),
LLMProvider::Anthropic => api_keys.is_some_and(|keys| keys.anthropic.is_some()),
LLMProvider::Google => api_keys.is_some_and(|keys| keys.google.is_some()),
LLMProvider::Bedrock => true,
_ => false,
}
}
@@ -89,6 +96,7 @@ pub enum LLMProvider {
Anthropic,
Google,
Xai,
Bedrock,
Unknown,
}
@@ -99,6 +107,7 @@ impl LLMProvider {
LLMProvider::OpenAI => Some(Icon::OpenAILogo),
LLMProvider::Anthropic => Some(Icon::ClaudeLogo),
LLMProvider::Google => Some(Icon::GeminiLogo),
LLMProvider::Bedrock => Some(Icon::BedrockLogo),
LLMProvider::Xai => None,
LLMProvider::Unknown => None,
}
@@ -498,11 +507,9 @@ struct AvailableLLMsUpdate {
pub struct LLMPreferences {
models_by_feature: ModelsByFeature,
last_update: Option<AvailableLLMsUpdate>,
// Stores temporary model overrides for a given terminal view.
// NOTE: We only store an override if the model selected by the user is different
// from the base LLM for the active profile. This means that if the user selects the
// profile's default model and changes their profile, the model will update to that profile's default.
base_llm_for_terminal_view: HashMap<EntityId, LLMId>,
#[cfg(not(target_family = "wasm"))]
bedrock_models_fetched: bool,
}
impl LLMPreferences {
@@ -534,12 +541,34 @@ impl LLMPreferences {
}
});
#[cfg(not(target_family = "wasm"))]
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, event, ctx| {
if matches!(
event,
AISettingsChangedEvent::BedrockEnabled { .. }
| AISettingsChangedEvent::BedrockModels { .. }
| AISettingsChangedEvent::BedrockCrossRegionInference { .. }
| AISettingsChangedEvent::BedrockRegion { .. }
) {
if matches!(event, AISettingsChangedEvent::BedrockEnabled { .. }) {
let enabled = *AISettings::as_ref(ctx).bedrock_enabled.value();
if enabled && !me.bedrock_models_fetched {
me.trigger_bedrock_discovery(ctx);
}
}
me.inject_bedrock_models(ctx);
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
}
});
let base_llm_for_terminal_view = HashMap::new();
let me = Self {
let mut me = Self {
models_by_feature,
last_update: None,
base_llm_for_terminal_view,
#[cfg(not(target_family = "wasm"))]
bedrock_models_fetched: false,
};
// In agent mode eval builds, eagerly kick off a fetch of the model list from the server
@@ -549,9 +578,103 @@ impl LLMPreferences {
#[cfg(feature = "agent_mode_evals")]
me.refresh_available_models(ctx);
#[cfg(not(target_family = "wasm"))]
{
me.inject_bedrock_models(ctx);
if *AISettings::as_ref(ctx).bedrock_enabled.value() {
me.trigger_bedrock_discovery(ctx);
}
}
me
}
#[cfg(not(target_family = "wasm"))]
fn trigger_bedrock_discovery(&mut self, ctx: &mut ModelContext<Self>) {
use crate::ai::bedrock::client::BedrockClientConfig;
use crate::ai::bedrock::discovery::discover_inference_profiles;
self.bedrock_models_fetched = true;
let settings = AISettings::as_ref(ctx);
let config = BedrockClientConfig {
auth_method: settings.bedrock_auth_method.value().clone(),
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(),
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
fallback_to_warp: *settings.bedrock_fallback_to_warp.value(),
};
ctx.spawn(
async move { discover_inference_profiles(&config).await },
|me, result, ctx| match result {
Ok(models) => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings.bedrock_models.set_value(models, ctx);
});
me.inject_bedrock_models(ctx);
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
}
Err(e) => {
log::error!("Failed to discover Bedrock inference profiles: {e}");
}
},
);
}
#[cfg(not(target_family = "wasm"))]
fn inject_bedrock_models(&mut self, ctx: &AppContext) {
self.models_by_feature
.agent_mode
.choices
.retain(|m| m.provider != LLMProvider::Bedrock);
let settings = AISettings::as_ref(ctx);
if !*settings.bedrock_enabled.value() {
return;
}
let user_models: Vec<BedrockModelConfig> = settings.bedrock_models.value().clone();
let region = settings.bedrock_region.value().clone();
let cross_region = *settings.bedrock_cross_region_inference.value();
let effective = get_effective_models(&user_models);
for model in effective {
let model_id = if cross_region && !region.is_empty() {
super::bedrock::models::apply_cross_region_prefix(&model.model_id, &region)
} else {
model.model_id.clone()
};
let llm_info = LLMInfo {
id: LLMId::from(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("AWS Bedrock".to_string()),
disable_reason: None,
vision_supported: model.vision_supported,
spec: None,
provider: LLMProvider::Bedrock,
host_configs: HashMap::from([(
LLMModelHost::AwsBedrock,
RoutingHostConfig {
enabled: true,
model_routing_host: LLMModelHost::AwsBedrock,
},
)]),
discount_percentage: None,
};
self.models_by_feature.agent_mode.choices.push(llm_info);
}
}
/// Returns the `LLMInfo` for the base LLM to be used for an Agent Mode request.
pub fn get_active_base_model<'a>(
&'a self,
@@ -924,6 +1047,9 @@ impl LLMPreferences {
}
}
#[cfg(not(target_family = "wasm"))]
self.inject_bedrock_models(ctx);
// Clear any model selections where the model is no longer supported.
let profiles_model = AIExecutionProfilesModel::handle(ctx);
profiles_model.update(ctx, |profiles, ctx| {
+3
View File
@@ -15,6 +15,9 @@ pub mod artifacts;
pub(crate) mod attachment_utils;
#[cfg(not(target_family = "wasm"))]
pub mod aws_credentials;
#[cfg(not(target_family = "wasm"))]
#[allow(dead_code)]
pub mod bedrock;
pub(crate) mod block_context;
pub(crate) mod blocklist;
pub mod control_code_parser;
+146 -34
View File
@@ -394,6 +394,65 @@ impl ThinkingDisplayMode {
}
}
/// Authentication method for AWS Bedrock.
#[derive(
Default,
Debug,
serde::Serialize,
serde::Deserialize,
PartialEq,
Copy,
Clone,
EnumIter,
schemars::JsonSchema,
settings_value::SettingsValue,
)]
#[schemars(
description = "Authentication method for AWS Bedrock.",
rename_all = "snake_case"
)]
pub enum BedrockAuthMethod {
#[default]
Profile,
StaticKeys,
Sso,
}
settings::macros::implement_setting_for_enum!(
BedrockAuthMethod,
AISettings,
SupportedPlatforms::DESKTOP,
SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "ai.bedrock.auth_method",
description: "Authentication method for AWS Bedrock.",
);
impl BedrockAuthMethod {
pub fn display_name(&self) -> &'static str {
match self {
BedrockAuthMethod::Profile => "AWS Profile",
BedrockAuthMethod::StaticKeys => "Static Keys",
BedrockAuthMethod::Sso => "SSO",
}
}
}
/// Configuration for a single Bedrock model.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
#[schemars(description = "Configuration for a single AWS Bedrock model.")]
pub struct BedrockModelConfig {
#[schemars(description = "The Bedrock model ID (e.g. anthropic.claude-sonnet-4-20250514-v1:0).")]
pub model_id: String,
#[schemars(description = "Display name shown in the model picker.")]
pub display_name: String,
#[serde(default)]
#[schemars(description = "Whether the model supports image/vision input.")]
pub vision_supported: bool,
}
impl settings_value::SettingsValue for BedrockModelConfig {}
/// Tracks the state of the quota reset banner
#[derive(
Debug,
@@ -1007,53 +1066,106 @@ define_settings_group!(AISettings, settings: [
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: true,
}
// Whether to use locally loaded AWS credentials for Bedrock-enabled requests.
aws_bedrock_credentials_enabled: AwsBedrockCredentialsEnabled {
// Whether direct Bedrock integration is enabled (client calls Bedrock API directly).
bedrock_enabled: BedrockEnabled {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "cloud_platform.third_party_api_keys.aws_bedrock_credentials_enabled",
description: "Whether Warp should use your local AWS credentials for Bedrock-enabled requests.",
toml_path: "ai.bedrock.enabled",
description: "Whether to use AWS Bedrock directly for AI requests.",
}
// Whether to automatically run the AWS login command when Bedrock credentials are expired.
//
// When true, the configured login command will be run automatically without asking.
// When false (default), a prompt will be shown asking for permission.
aws_bedrock_auto_login: AwsBedrockAutoLogin {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "cloud_platform.third_party_api_keys.aws_bedrock_auto_login",
description: "Whether to automatically run the AWS login command when Bedrock credentials expire.",
}
// Command to run to refresh AWS credentials when using Bedrock auto-login.
aws_bedrock_auth_refresh_command: AwsBedrockAuthRefreshCommand {
type: String,
default: "aws login".to_string(),
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "cloud_platform.third_party_api_keys.aws_bedrock_auth_refresh_command",
description: "The command to run to refresh AWS credentials for Bedrock.",
}
// AWS profile name to use when loading credentials from the local AWS credential/config chain.
aws_bedrock_profile: AwsBedrockProfile {
// Authentication method for Bedrock: "profile", "static_keys", or "sso".
bedrock_auth_method: BedrockAuthMethod,
// AWS profile name to use when auth_method is Profile or SSO.
bedrock_profile: BedrockProfile {
type: String,
default: "default".to_string(),
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "cloud_platform.third_party_api_keys.aws_bedrock_profile",
toml_path: "ai.bedrock.profile",
description: "The AWS profile name to use for Bedrock credentials.",
}
// Whether the AWS Bedrock login banner has been permanently dismissed.
//
// Not a user-visible setting - we model it as a setting so we can track state.
aws_bedrock_login_banner_dismissed: AwsBedrockLoginBannerDismissed {
// AWS region for Bedrock API calls. Empty string means auto-detect from profile/config.
bedrock_region: BedrockRegion {
type: String,
default: String::new(),
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "ai.bedrock.region",
description: "AWS region for Bedrock API calls. Leave empty to auto-detect from profile.",
}
// Whether to automatically add cross-region inference prefixes to model IDs.
bedrock_cross_region_inference: BedrockCrossRegionInference {
type: bool,
default: true,
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "ai.bedrock.cross_region_inference",
description: "Whether to automatically add cross-region inference prefixes to model IDs.",
}
// Whether to fall back to routing through the Warp server when Bedrock credentials fail.
bedrock_fallback_to_warp: BedrockFallbackToWarp {
type: bool,
default: true,
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "ai.bedrock.fallback_to_warp",
description: "Whether to fall back to the Warp server when Bedrock credentials are invalid.",
}
// Custom Bedrock model configurations.
bedrock_models: BedrockModels {
type: Vec<BedrockModelConfig>,
default: Vec::new(),
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "ai.bedrock.models",
description: "Custom AWS Bedrock model configurations.",
}
// Whether to automatically run the login command when Bedrock credentials expire.
bedrock_auto_login: BedrockAutoLogin {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "ai.bedrock.auto_login",
description: "Whether to automatically run the login command when Bedrock credentials expire.",
}
// Command to run to refresh AWS credentials (e.g. "aws sso login").
bedrock_auth_refresh_command: BedrockAuthRefreshCommand {
type: String,
default: "aws sso login".to_string(),
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "ai.bedrock.auth_refresh_command",
description: "The command to run to refresh AWS credentials for Bedrock.",
}
// AWS access key ID for static key authentication (stored in OS keychain).
bedrock_access_key_id: BedrockAccessKeyId {
type: String,
default: String::new(),
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Never,
private: true,
}
// AWS secret access key for static key authentication (stored in OS keychain).
bedrock_secret_access_key: BedrockSecretAccessKey {
type: String,
default: String::new(),
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Never,
private: true,
}
// Whether the Bedrock login banner has been permanently dismissed.
bedrock_login_banner_dismissed: BedrockLoginBannerDismissed {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::DESKTOP,
+459 -341
View File
@@ -1,5 +1,3 @@
#[cfg(not(target_family = "wasm"))]
use crate::ai::aws_credentials::refresh_aws_credentials;
use crate::ai::blocklist::agent_view::agent_input_footer::editor::{
AgentToolbarEditorMode, AgentToolbarInlineEditor,
};
@@ -25,8 +23,9 @@ use crate::settings::InputSettings;
use crate::settings::{
AIAutoDetectionEnabled, AICommandDenylist, AISettingsChangedEvent,
AgentModeCodingPermissionsType, AgentModeCommandExecutionDenylist,
AgentModeCommandExecutionPredicate, AgentModeQuerySuggestionsEnabled, AwsBedrockAutoLogin,
AwsBedrockCredentialsEnabled, CanUseWarpCreditsWithByok, CodeSettings, CodebaseContextEnabled,
AgentModeCommandExecutionPredicate, AgentModeQuerySuggestionsEnabled, BedrockAutoLogin,
BedrockAuthMethod, BedrockCrossRegionInference, BedrockEnabled, BedrockFallbackToWarp,
CanUseWarpCreditsWithByok, CodeSettings, CodebaseContextEnabled,
FileBasedMcpEnabled, GitOperationsAutogenEnabled, IncludeAgentCommandsInHistory,
IntelligentAutosuggestionsEnabled, MemoryEnabled, NLDInTerminalEnabled,
NaturalLanguageAutosuggestionsEnabled, OrchestrationEnabled, RuleSuggestionsEnabled,
@@ -53,7 +52,7 @@ use warp_core::context_flag::ContextFlag;
use warp_core::features::FeatureFlag;
use warp_core::ui::theme::color::internal_colors;
use warpui::elements::{
Border, ChildView, ConstrainedBox, CornerRadius, CrossAxisAlignment, Expanded, Fill,
ChildView, ConstrainedBox, CornerRadius, CrossAxisAlignment, Fill,
HyperlinkLens, MainAxisAlignment, MainAxisSize, MouseStateHandle, Radius, Shrinkable, Text,
};
use warpui::fonts::{Properties, Weight};
@@ -99,6 +98,8 @@ pub enum AISubpage {
Knowledge,
/// Third-party CLI agent settings.
ThirdPartyCLIAgents,
/// AWS Bedrock direct provider configuration.
Bedrock,
}
impl AISubpage {
@@ -108,6 +109,7 @@ impl AISubpage {
SettingsSection::AgentProfiles => Some(Self::Profiles),
SettingsSection::Knowledge => Some(Self::Knowledge),
SettingsSection::ThirdPartyCLIAgents => Some(Self::ThirdPartyCLIAgents),
SettingsSection::Bedrock => Some(Self::Bedrock),
// AgentMCPServers renders the standalone MCPServers page, not an AI subpage.
_ => None,
}
@@ -1468,7 +1470,6 @@ impl AISettingsPageView {
}
widgets.push(Box::new(CLIAgentWidget::default()));
widgets.push(Box::new(ApiKeysWidget::new(ctx)));
widgets.push(Box::new(AwsBedrockWidget::new(ctx)));
widgets.push(Box::new(AgentAttributionWidget::default()));
widgets.push(Box::new(OtherAIWidget::default()));
if FeatureFlag::AgentModeComputerUse.is_enabled() {
@@ -1508,7 +1509,6 @@ impl AISettingsPageView {
widgets.push(Box::new(VoiceWidget::default()));
}
widgets.push(Box::new(ApiKeysWidget::new(ctx)));
widgets.push(Box::new(AwsBedrockWidget::new(ctx)));
widgets.push(Box::new(AgentAttributionWidget::default()));
widgets.push(Box::new(OtherAIWidget::default()));
if FeatureFlag::AgentModeComputerUse.is_enabled() {
@@ -1529,6 +1529,9 @@ impl AISettingsPageView {
Some(AISubpage::ThirdPartyCLIAgents) => {
widgets.push(Box::new(CLIAgentWidget::default()));
}
Some(AISubpage::Bedrock) => {
widgets.push(Box::new(BedrockSettingsWidget::new(ctx)));
}
}
// Subpage widgets render their own subheader-sized titles internally,
@@ -2107,9 +2110,13 @@ pub enum AISettingsPageAction {
RemoveFromMCPDenylist(uuid::Uuid),
CreateProfile,
SignupAnonymousUser,
ToggleAwsBedrockAutoLogin,
ToggleAwsBedrockCredentialsEnabled,
RefreshAwsBedrockCredentials,
ToggleBedrockAutoLogin,
ToggleBedrockEnabled,
RefreshAwsBedrock,
SetBedrockAuthMethod(BedrockAuthMethod),
SetBedrockProfile(String),
ToggleBedrockCrossRegionInference,
ToggleBedrockFallbackToWarp,
ToggleCloudAgentComputerUse,
ToggleFileBasedMcp,
ToggleIncludeAgentCommandsInHistory,
@@ -2753,24 +2760,81 @@ impl TypedActionView for AISettingsPageView {
AISettingsPageAction::SignupAnonymousUser => {
ctx.emit(AISettingsPageEvent::SignupAnonymousUser);
}
AISettingsPageAction::ToggleAwsBedrockAutoLogin => {
AISettingsPageAction::ToggleBedrockAutoLogin => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings.aws_bedrock_auto_login.toggle_and_save_value(ctx));
report_if_error!(settings.bedrock_auto_login.toggle_and_save_value(ctx));
});
ctx.notify();
}
AISettingsPageAction::ToggleAwsBedrockCredentialsEnabled => {
AISettingsPageAction::ToggleBedrockEnabled => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.aws_bedrock_credentials_enabled
.bedrock_enabled
.toggle_and_save_value(ctx));
});
ctx.notify();
}
AISettingsPageAction::RefreshAwsBedrockCredentials => {
AISettingsPageAction::RefreshAwsBedrock => {
#[cfg(not(target_family = "wasm"))]
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
drop(refresh_aws_credentials(manager, ctx));
{
use crate::ai::bedrock::client::BedrockClientConfig;
use crate::ai::bedrock::discovery::discover_inference_profiles;
use settings::Setting;
let ai_settings = AISettings::as_ref(ctx);
let config = BedrockClientConfig {
auth_method: ai_settings.bedrock_auth_method.value().clone(),
profile: ai_settings.bedrock_profile.value().clone(),
region: ai_settings.bedrock_region.value().clone(),
access_key_id: ai_settings.bedrock_access_key_id.value().clone(),
secret_access_key: ai_settings.bedrock_secret_access_key.value().clone(),
cross_region_inference: *ai_settings.bedrock_cross_region_inference.value(),
fallback_to_warp: *ai_settings.bedrock_fallback_to_warp.value(),
};
ctx.spawn(
async move { discover_inference_profiles(&config).await },
|_me, result, ctx| match result {
Ok(models) => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings.bedrock_models.set_value(models, ctx);
});
}
Err(e) => {
log::error!(
"Failed to discover Bedrock inference profiles: {e}"
);
}
},
);
}
ctx.notify();
}
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::ToggleBedrockFallbackToWarp => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.bedrock_fallback_to_warp
.toggle_and_save_value(ctx));
});
ctx.notify();
}
@@ -6301,25 +6365,85 @@ impl SettingsWidget for ApiKeysWidget {
}
}
struct AwsBedrockWidget {
aws_auth_refresh_command_editor: ViewHandle<EditorView>,
aws_auth_refresh_profile_editor: ViewHandle<EditorView>,
credentials_enabled_toggle: SwitchStateHandle,
struct BedrockSettingsWidget {
enabled_toggle: SwitchStateHandle,
cross_region_toggle: SwitchStateHandle,
fallback_toggle: SwitchStateHandle,
auto_login_toggle: SwitchStateHandle,
refresh_credentials_button: ViewHandle<ActionButton>,
auth_method_dropdown: ViewHandle<Dropdown<AISettingsPageAction>>,
profile_dropdown: ViewHandle<Dropdown<AISettingsPageAction>>,
region_editor: ViewHandle<EditorView>,
auth_refresh_command_editor: ViewHandle<EditorView>,
access_key_editor: ViewHandle<EditorView>,
secret_key_editor: ViewHandle<EditorView>,
refresh_button: ViewHandle<ActionButton>,
}
impl AwsBedrockWidget {
impl BedrockSettingsWidget {
fn new(ctx: &mut ViewContext<<Self as SettingsWidget>::View>) -> Self {
let ai_settings = AISettings::as_ref(ctx);
let is_any_ai_enabled = ai_settings.is_any_ai_enabled(ctx);
let is_enabled = *ai_settings.bedrock_enabled.value();
let aws_auth_refresh_command = ai_settings.aws_bedrock_auth_refresh_command.value().clone();
let aws_auth_refresh_profile = ai_settings.aws_bedrock_profile.value().clone();
let is_usage_enabled = is_any_ai_enabled
&& UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(ctx);
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 aws_auth_refresh_command_editor = ctx.add_typed_action_view(move |ctx| {
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().clone();
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),
)
})
.collect(),
ctx,
);
dropdown.set_selected_by_index(selected_index, ctx);
dropdown
});
let profile_dropdown = ctx.add_typed_action_view(|ctx| {
use crate::ai::bedrock::discovery::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()),
)
})
.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,
@@ -6336,38 +6460,20 @@ impl AwsBedrockWidget {
..Default::default()
};
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text("aws login", ctx);
editor.set_buffer_text(&aws_auth_refresh_command, ctx);
editor.set_placeholder_text("auto-detect from profile", ctx);
editor.set_buffer_text(&region_val, ctx);
editor
});
AISettingsPageView::update_editor_interaction_state(
aws_auth_refresh_command_editor.clone(),
is_usage_enabled,
ctx,
);
ctx.subscribe_to_view(&aws_auth_refresh_command_editor, |_, editor, event, ctx| {
ctx.subscribe_to_view(&region_editor, |_, editor, event, ctx| {
if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) {
let buffer_text = editor.as_ref(ctx).buffer_text(ctx);
let should_reset = buffer_text.trim().is_empty();
let value = if should_reset {
"aws login".to_string()
} else {
buffer_text
};
let value = editor.as_ref(ctx).buffer_text(ctx);
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings
.aws_bedrock_auth_refresh_command
.set_value(value, ctx);
let _ = settings.bedrock_region.set_value(value, ctx);
});
if should_reset {
editor.update(ctx, |editor, ctx| {
editor.set_buffer_text("aws login", ctx);
});
}
}
});
let aws_auth_refresh_profile_editor = ctx.add_typed_action_view(move |ctx| {
let auth_refresh_command_editor = ctx.add_typed_action_view(move |ctx| {
let appearance = Appearance::as_ref(ctx);
let options = SingleLineEditorOptions {
is_password: false,
@@ -6384,330 +6490,201 @@ impl AwsBedrockWidget {
..Default::default()
};
let mut editor = EditorView::single_line(options, ctx);
editor.set_placeholder_text("default", ctx);
editor.set_buffer_text(&aws_auth_refresh_profile, ctx);
editor.set_placeholder_text("aws sso login", ctx);
editor.set_buffer_text(&auth_cmd_val, ctx);
editor
});
AISettingsPageView::update_editor_interaction_state(
aws_auth_refresh_profile_editor.clone(),
is_usage_enabled,
ctx,
);
ctx.subscribe_to_view(&aws_auth_refresh_profile_editor, |_, editor, event, ctx| {
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 should_reset = buffer_text.trim().is_empty();
let value = if should_reset {
"default".to_string()
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.aws_bedrock_profile.set_value(value, ctx);
let _ = settings.bedrock_auth_refresh_command.set_value(value, ctx);
});
if should_reset {
editor.update(ctx, |editor, ctx| {
editor.set_buffer_text("default", ctx);
});
}
}
});
let refresh_credentials_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Refresh", SecondaryTheme)
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 refresh_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Refresh AWS Bedrock", SecondaryTheme)
.with_icon(Icon::RefreshCw04)
.with_size(ButtonSize::Small)
.on_click(|ctx| {
ctx.dispatch_typed_action(AISettingsPageAction::RefreshAwsBedrockCredentials);
ctx.dispatch_typed_action(AISettingsPageAction::RefreshAwsBedrock);
})
});
refresh_credentials_button.update(ctx, |button, ctx| {
button.set_disabled(!is_usage_enabled, ctx);
refresh_button.update(ctx, |button, ctx| {
button.set_disabled(!is_enabled, ctx);
});
// Keep enablement in sync with the Global AI toggle.
let aws_auth_refresh_command_editor_clone = aws_auth_refresh_command_editor.clone();
let aws_auth_refresh_profile_editor_clone = aws_auth_refresh_profile_editor.clone();
let refresh_credentials_button_clone = refresh_credentials_button.clone();
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();
let refresh_button_clone = refresh_button.clone();
ctx.subscribe_to_model(&AISettings::handle(ctx), move |_, _, event, ctx| {
if matches!(
event,
AISettingsChangedEvent::IsAnyAIEnabled { .. }
| AISettingsChangedEvent::AwsBedrockCredentialsEnabled { .. }
) {
let is_any_ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx);
let is_usage_enabled = is_any_ai_enabled
&& UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(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(
aws_auth_refresh_command_editor_clone.clone(),
is_usage_enabled,
region_editor_clone.clone(),
is_enabled,
ctx,
);
AISettingsPageView::update_editor_interaction_state(
aws_auth_refresh_profile_editor_clone.clone(),
is_usage_enabled,
auth_refresh_command_editor_clone.clone(),
is_enabled,
ctx,
);
refresh_credentials_button_clone.update(ctx, |button, ctx| {
button.set_disabled(!is_usage_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,
);
refresh_button_clone.update(ctx, |button, ctx| {
button.set_disabled(!is_enabled, ctx);
});
ctx.notify();
}
});
let aws_auth_refresh_command_editor_clone = aws_auth_refresh_command_editor.clone();
let aws_auth_refresh_profile_editor_clone = aws_auth_refresh_profile_editor.clone();
let refresh_credentials_button_clone = refresh_credentials_button.clone();
ctx.subscribe_to_model(
&UserWorkspaces::handle(ctx),
move |_, workspace, event, ctx| {
if let UserWorkspacesEvent::TeamsChanged = event {
let is_any_ai_enabled = AISettings::as_ref(ctx).is_any_ai_enabled(ctx);
let is_usage_enabled = is_any_ai_enabled
&& workspace
.as_ref(ctx)
.is_aws_bedrock_credentials_enabled(ctx);
AISettingsPageView::update_editor_interaction_state(
aws_auth_refresh_command_editor_clone.clone(),
is_usage_enabled,
ctx,
);
AISettingsPageView::update_editor_interaction_state(
aws_auth_refresh_profile_editor_clone.clone(),
is_usage_enabled,
ctx,
);
refresh_credentials_button_clone.update(ctx, |button, ctx| {
button.set_disabled(!is_usage_enabled, ctx);
});
ctx.notify();
}
},
);
Self {
aws_auth_refresh_command_editor,
aws_auth_refresh_profile_editor,
credentials_enabled_toggle: SwitchStateHandle::default(),
enabled_toggle: SwitchStateHandle::default(),
cross_region_toggle: SwitchStateHandle::default(),
fallback_toggle: SwitchStateHandle::default(),
auto_login_toggle: SwitchStateHandle::default(),
refresh_credentials_button,
auth_method_dropdown,
profile_dropdown,
region_editor,
auth_refresh_command_editor,
access_key_editor,
secret_key_editor,
refresh_button,
}
}
fn render_aws_bedrock_section(
&self,
fn render_input(
appearance: &Appearance,
label: &'static str,
editor: ViewHandle<EditorView>,
is_enabled: bool,
app: &AppContext,
is_bedrock_available: bool,
) -> Box<dyn Element> {
let ai_settings = AISettings::as_ref(app);
let user_workspaces = UserWorkspaces::as_ref(app);
let is_any_ai_enabled = ai_settings.is_any_ai_enabled(app);
let is_section_enabled = is_any_ai_enabled && is_bedrock_available;
let is_admin_enforced = matches!(
user_workspaces.aws_bedrock_host_enablement_setting(),
crate::workspaces::workspace::HostEnablementSetting::Enforce
);
let is_toggleable =
is_section_enabled && user_workspaces.is_aws_bedrock_credentials_toggleable();
let are_credentials_enabled = user_workspaces.is_aws_bedrock_credentials_enabled(app);
let is_usage_enabled = is_section_enabled && are_credentials_enabled;
let toggle_description = if is_admin_enforced {
"Warp loads and sends local AWS CLI credentials for Bedrock-supported models. This setting is managed by your organization.".to_string()
} else {
"Warp loads and sends local AWS CLI credentials for Bedrock-supported models."
.to_string()
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 mut column = Flex::column().with_spacing(16.).with_child(
Flex::column()
.with_child(render_ai_setting_toggle::<AwsBedrockCredentialsEnabled>(
"Use AWS Bedrock credentials",
AISettingsPageAction::ToggleAwsBedrockCredentialsEnabled,
are_credentials_enabled,
is_toggleable,
self.credentials_enabled_toggle.clone(),
&RefCell::new(HashMap::new()),
app,
))
.with_child(render_ai_setting_description(
toggle_description,
is_section_enabled,
app,
))
.finish(),
);
/// Helper function to render the UI for an input field.
fn render_input(
appearance: &Appearance,
label: &'static str,
editor: ViewHandle<EditorView>,
is_enabled: bool,
app: &AppContext,
) -> Box<dyn Element> {
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()
}
fn render_credential_status_card(
refresh_button: &ViewHandle<ActionButton>,
appearance: &Appearance,
are_credentials_enabled: bool,
app: &AppContext,
) -> Box<dyn Element> {
let (title_color, detail_color) = (
styles::header_font_color(are_credentials_enabled, app),
styles::description_font_color(are_credentials_enabled, app),
);
let (title_text, detail_text, icon) = ApiKeyManager::as_ref(app)
.aws_credentials_state()
.user_facing_components();
let icon = Container::new(
ConstrainedBox::new(icon.to_warpui_icon(title_color).finish())
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_horizontal_padding(4.)
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 text_column = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_spacing(4.)
.with_child(
Text::new_inline(title_text, appearance.ui_font_family(), CONTENT_FONT_SIZE)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(title_color.into())
.finish(),
)
.with_child(
Text::new(detail_text, appearance.ui_font_family(), CONTENT_FONT_SIZE)
.with_color(detail_color.into())
.soft_wrap(true)
.finish(),
);
let input = appearance
.ui_builder()
.text_input(editor)
.with_style(editor_style)
.build()
.finish();
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(12.)
.with_child(
Expanded::new(
1.,
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(12.)
.with_child(icon)
.with_child(Expanded::new(1., text_column.finish()).finish())
.finish(),
)
.finish(),
)
.with_child(ChildView::new(refresh_button).finish())
.finish(),
)
.with_uniform_padding(12.)
.with_background(appearance.theme().surface_2())
.with_border(Border::all(1.).with_border_fill(appearance.theme().outline()))
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.)))
Flex::column()
.with_spacing(8.)
.with_child(label)
.with_child(input)
.finish()
}
column.add_child(
Container::new(render_credential_status_card(
&self.refresh_credentials_button,
appearance,
are_credentials_enabled,
app,
))
.with_margin_top(-styles::DESCRIPTION_MARGIN_BOTTOM)
.finish(),
);
column.add_child(render_input(
appearance,
"Login Command",
self.aws_auth_refresh_command_editor.clone(),
is_usage_enabled,
app,
));
column.add_child(render_input(
appearance,
"AWS Profile",
self.aws_auth_refresh_profile_editor.clone(),
is_usage_enabled,
app,
));
let auto_login_enabled = *AISettings::as_ref(app).aws_bedrock_auto_login.value();
let toggle = render_ai_setting_toggle::<AwsBedrockAutoLogin>(
"Automatically run login command",
AISettingsPageAction::ToggleAwsBedrockAutoLogin,
auto_login_enabled,
is_usage_enabled,
self.auto_login_toggle.clone(),
&RefCell::new(HashMap::new()),
app,
);
let description = render_ai_setting_description(
"When enabled, the login command will run automatically when AWS Bedrock credentials expire.",
is_usage_enabled,
app,
);
column.add_child(
Flex::column()
.with_child(toggle)
.with_child(description)
.finish(),
);
column.finish()
}
}
impl SettingsWidget for AwsBedrockWidget {
impl SettingsWidget for BedrockSettingsWidget {
type View = AISettingsPageView;
fn search_terms(&self) -> &str {
"aws bedrock amazon credentials login profile"
"aws bedrock amazon credentials login profile region sso static keys"
}
fn should_render(&self, app: &AppContext) -> bool {
// Only show if admin has enabled AWS Bedrock for the workspace
UserWorkspaces::as_ref(app).is_aws_bedrock_available_from_workspace()
fn should_render(&self, _app: &AppContext) -> bool {
true
}
fn render(
@@ -6717,26 +6694,167 @@ impl SettingsWidget for AwsBedrockWidget {
app: &AppContext,
) -> Box<dyn Element> {
let ai_settings = AISettings::as_ref(app);
let is_any_ai_enabled = ai_settings.is_any_ai_enabled(app);
let is_bedrock_available =
UserWorkspaces::as_ref(app).is_aws_bedrock_available_from_workspace();
let is_enabled = *ai_settings.bedrock_enabled.value();
let auth_method = ai_settings.bedrock_auth_method.value().clone();
let cross_region = *ai_settings.bedrock_cross_region_inference.value();
let fallback = *ai_settings.bedrock_fallback_to_warp.value();
let auto_login = *ai_settings.bedrock_auto_login.value();
let column = Flex::column()
.with_child(render_separator(appearance))
.with_child(
build_sub_header(
appearance,
"AWS Bedrock",
Some(styles::header_font_color(is_any_ai_enabled, app)),
)
.with_padding_bottom(HEADER_PADDING)
let mut column = Flex::column().with_spacing(16.);
column.add_child(render_ai_setting_toggle::<BedrockEnabled>(
"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(),
)
.with_child(self.render_aws_bedrock_section(appearance, app, is_bedrock_available));
);
Container::new(column.finish())
.with_margin_bottom(HEADER_PADDING)
.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::<BedrockAutoLogin>(
"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(
Flex::column()
.with_child(render_ai_setting_toggle::<BedrockCrossRegionInference>(
"Cross-region inference",
AISettingsPageAction::ToggleBedrockCrossRegionInference,
cross_region,
is_enabled,
self.cross_region_toggle.clone(),
&RefCell::new(HashMap::new()),
app,
))
.with_child(render_ai_setting_description(
"Automatically add geographic prefixes to model IDs for higher availability.",
is_enabled,
app,
))
.finish(),
);
column.add_child(
Flex::column()
.with_child(render_ai_setting_toggle::<BedrockFallbackToWarp>(
"Fallback to Warp server",
AISettingsPageAction::ToggleBedrockFallbackToWarp,
fallback,
is_enabled,
self.fallback_toggle.clone(),
&RefCell::new(HashMap::new()),
app,
))
.with_child(render_ai_setting_description(
"When enabled, requests will route through the Warp server if Bedrock credentials are invalid.",
is_enabled,
app,
))
.finish(),
);
column.add_child(render_separator(appearance));
column.add_child(ChildView::new(&self.refresh_button).finish());
column.finish()
}
}
+5
View File
@@ -211,6 +211,7 @@ pub enum SettingsSection {
AgentMCPServers,
Knowledge,
ThirdPartyCLIAgents,
Bedrock,
/// Internal backing-page identifier for CodeSettingsPageView. Multiple subpages
/// (CodeIndexing, EditorAndCodeReview) share this single backing page,
/// so this variant is needed as the key in `settings_pages`.
@@ -240,6 +241,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::CodeIndexing => write!(f, "Indexing and projects"),
SettingsSection::EditorAndCodeReview => write!(f, "Editor and Code Review"),
SettingsSection::CloudEnvironments => write!(f, "Environments"),
@@ -264,6 +266,7 @@ impl SettingsSection {
| Self::AgentMCPServers
| Self::Knowledge
| Self::ThirdPartyCLIAgents
| Self::Bedrock
)
}
@@ -301,6 +304,7 @@ impl SettingsSection {
Self::AgentMCPServers,
Self::Knowledge,
Self::ThirdPartyCLIAgents,
Self::Bedrock,
]
}
@@ -341,6 +345,7 @@ 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),
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
"CloudEnvironments" => Ok(Self::CloudEnvironments),
+39 -39
View File
@@ -318,16 +318,16 @@ impl Input {
// Handle the slash command action based on its kind
match command.name {
add_mcp if command.name == commands::ADD_MCP.name => {
_add_mcp if command.name == commands::ADD_MCP.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenAddMCPPane);
}
add_prompt if command.name == commands::ADD_PROMPT.name => {
_add_prompt if command.name == commands::ADD_PROMPT.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenAddPromptPane);
}
add_rule if command.name == commands::ADD_RULE.name => {
_add_rule if command.name == commands::ADD_RULE.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenAddRulePane);
}
agent_or_new
_agent_or_new
if command.name == commands::NEW.name || command.name == commands::AGENT.name =>
{
if !self
@@ -382,7 +382,7 @@ impl Input {
origin: AgentViewEntryOrigin::SlashCommand { trigger },
});
}
cloud_agent if command.name == commands::CLOUD_AGENT.name => {
_cloud_agent if command.name == commands::CLOUD_AGENT.name => {
let prompt = argument.and_then(|argument| {
let trimmed = argument.trim();
if trimmed.is_empty() {
@@ -396,17 +396,17 @@ impl Input {
initial_prompt: prompt,
});
}
create_docker_sandbox if command.name == commands::CREATE_DOCKER_SANDBOX.name => {
_create_docker_sandbox if command.name == commands::CREATE_DOCKER_SANDBOX.name => {
ctx.emit(Event::CreateDockerSandbox);
}
conversations if command.name == commands::CONVERSATIONS.name => {
_conversations if command.name == commands::CONVERSATIONS.name => {
if FeatureFlag::AgentView.is_enabled() {
self.open_conversation_menu(ctx);
} else {
ctx.dispatch_typed_action(&TerminalAction::OpenConversationsPalette);
}
}
rename_tab if command.name == commands::RENAME_TAB.name => {
_rename_tab if command.name == commands::RENAME_TAB.name => {
let Some(name) = argument
.map(|name| name.trim())
.filter(|name| !name.is_empty())
@@ -420,7 +420,7 @@ impl Input {
ctx.dispatch_typed_action(&WorkspaceAction::SetActiveTabName(name.to_owned()));
}
create_env if command.name == commands::CREATE_ENVIRONMENT.name => {
_create_env if command.name == commands::CREATE_ENVIRONMENT.name => {
// If the user included args after the slash command, treat them as repo paths/URLs.
let repos = argument
.map(|arg| {
@@ -433,7 +433,7 @@ impl Input {
ctx.emit(Event::TriggerEnvironmentSetup { repos });
}
create_project if command.name == commands::CREATE_NEW_PROJECT.name => {
_create_project if command.name == commands::CREATE_NEW_PROJECT.name => {
if argument.is_none_or(|args| args.is_empty()) {
show_error_toast(
"Please describe the project you want to create after /create-new-project"
@@ -446,7 +446,7 @@ impl Input {
let args = argument.expect("args are Some()");
self.initiate_create_new_project(args.to_owned(), ctx);
}
edit if command.name == commands::EDIT.name => {
_edit if command.name == commands::EDIT.name => {
#[cfg(feature = "local_fs")]
match argument {
Some(args) if !args.is_empty() => {
@@ -539,7 +539,7 @@ impl Input {
return true;
}
}
export_to_clipboard if command.name == commands::EXPORT_TO_CLIPBOARD.name => {
_export_to_clipboard if command.name == commands::EXPORT_TO_CLIPBOARD.name => {
let history = BlocklistAIHistoryModel::handle(ctx);
let Some(conversation) = history
.as_ref(ctx)
@@ -564,7 +564,7 @@ impl Input {
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
});
}
export_to_file if command.name == commands::EXPORT_TO_FILE.name => {
_export_to_file if command.name == commands::EXPORT_TO_FILE.name => {
#[cfg(not(target_family = "wasm"))]
{
self.export_conversation_to_file(
@@ -581,76 +581,76 @@ impl Input {
return true;
}
}
index if command.name == commands::INDEX.name => {
_index if command.name == commands::INDEX.name => {
ctx.dispatch_typed_action(&TerminalAction::IndexProjectSpeedbump);
}
init if command.name == commands::INIT.name => {
_init if command.name == commands::INIT.name => {
ctx.dispatch_typed_action(&TerminalAction::InitProject);
}
changelog if command.name == commands::CHANGELOG.name => {
_changelog if command.name == commands::CHANGELOG.name => {
if !FeatureFlag::Changelog.is_enabled() {
return false;
}
ctx.dispatch_typed_action(&WorkspaceAction::ViewLatestChangelog);
}
feedback if command.name == commands::FEEDBACK.name => {
_feedback if command.name == commands::FEEDBACK.name => {
ctx.dispatch_typed_action(&WorkspaceAction::SendFeedback);
}
open_code_review if command.name == commands::OPEN_CODE_REVIEW.name => {
_open_code_review if command.name == commands::OPEN_CODE_REVIEW.name => {
ctx.dispatch_typed_action(&TerminalAction::ToggleCodeReviewPane {
entrypoint: CodeReviewPaneEntrypoint::SlashCommand,
});
}
open_mcp_servers if command.name == commands::OPEN_MCP_SERVERS.name => {
_open_mcp_servers if command.name == commands::OPEN_MCP_SERVERS.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenViewMCPPane);
}
open_settings_file if command.name == commands::OPEN_SETTINGS_FILE.name => {
_open_settings_file if command.name == commands::OPEN_SETTINGS_FILE.name => {
if !FeatureFlag::SettingsFile.is_enabled() || !cfg!(feature = "local_fs") {
return false;
}
ctx.dispatch_typed_action(&WorkspaceAction::OpenSettingsFile);
}
open_project_rules if command.name == commands::OPEN_PROJECT_RULES.name => {
_open_project_rules if command.name == commands::OPEN_PROJECT_RULES.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenProjectRulesPane);
}
open_rules if command.name == commands::OPEN_RULES.name => {
_open_rules if command.name == commands::OPEN_RULES.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenRulesPane);
}
edit_skill if command.name == commands::EDIT_SKILL.name => {
_edit_skill if command.name == commands::EDIT_SKILL.name => {
if !FeatureFlag::ListSkills.is_enabled() {
return false;
}
// Open the skill selector menu - user will select a skill from the inline menu
self.open_skill_selector(ctx);
}
invoke_skill if command.name == commands::INVOKE_SKILL.name => {
_invoke_skill if command.name == commands::INVOKE_SKILL.name => {
if !FeatureFlag::ListSkills.is_enabled() {
return false;
}
// Open the skill selector menu for invocation - skill command will be inserted into buffer
self.open_invoke_skill_selector(ctx);
}
models if command.name == commands::MODEL.name => {
_models if command.name == commands::MODEL.name => {
self.open_model_selector(ctx);
}
profiles if command.name == commands::PROFILE.name => {
_profiles if command.name == commands::PROFILE.name => {
if !FeatureFlag::InlineProfileSelector.is_enabled() {
return false;
}
self.open_profile_selector(ctx);
}
prompts if command.name == commands::PROMPTS.name => {
_prompts if command.name == commands::PROMPTS.name => {
if FeatureFlag::AgentView.is_enabled() {
self.open_prompts_menu(ctx);
} else {
return false;
}
}
rewind if command.name == commands::REWIND.name => {
_rewind if command.name == commands::REWIND.name => {
self.open_rewind_menu(ctx);
}
pr_comments if command.name == commands::PR_COMMENTS.name => {
_pr_comments if command.name == commands::PR_COMMENTS.name => {
if !FeatureFlag::PRCommentsSlashCommand.is_enabled() {
return false;
}
@@ -671,10 +671,10 @@ impl Input {
)
});
}
usage if command.name == commands::USAGE.name => {
_usage if command.name == commands::USAGE.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenBillingAndUsagePane);
}
remote_control if command.name == commands::REMOTE_CONTROL.name => {
_remote_control if command.name == commands::REMOTE_CONTROL.name => {
if !FeatureFlag::CreatingSharedSessions.is_enabled()
|| !FeatureFlag::HOARemoteControl.is_enabled()
{
@@ -691,7 +691,7 @@ impl Input {
}
ctx.emit(Event::StartRemoteControl);
}
cost if command.name == commands::COST.name => {
_cost if command.name == commands::COST.name => {
let history = BlocklistAIHistoryModel::handle(ctx);
let conversation = history
.as_ref(ctx)
@@ -715,7 +715,7 @@ impl Input {
ctx.dispatch_typed_action(&TerminalAction::ToggleUsageFooter);
}
}
fork if command.name == commands::FORK.name => {
_fork if command.name == commands::FORK.name => {
let Some(conversation_id) = self
.ai_context_model
.as_ref(ctx)
@@ -740,11 +740,11 @@ impl Input {
destination,
});
}
fork_from if command.name == commands::FORK_FROM.name => {
_fork_from if command.name == commands::FORK_FROM.name => {
self.open_user_query_menu(UserQueryMenuAction::ForkFrom, ctx);
return true;
}
fork_and_compact if command.name == commands::FORK_AND_COMPACT.name => {
_fork_and_compact if command.name == commands::FORK_AND_COMPACT.name => {
let Some(conversation_id) = self
.ai_context_model
.as_ref(ctx)
@@ -772,7 +772,7 @@ impl Input {
destination,
});
}
compact_and if command.name == commands::COMPACT_AND.name => {
_compact_and if command.name == commands::COMPACT_AND.name => {
if self
.ai_context_model
.as_ref(ctx)
@@ -791,7 +791,7 @@ impl Input {
initial_prompt: argument.cloned(),
});
}
queue if command.name == commands::QUEUE.name => {
_queue if command.name == commands::QUEUE.name => {
let Some(conversation_id) = self
.ai_context_model
.as_ref(ctx)
@@ -820,13 +820,13 @@ impl Input {
self.submit_queued_prompt(prompt, ctx);
}
}
open_repo if command.name == commands::OPEN_REPO.name => {
_open_repo if command.name == commands::OPEN_REPO.name => {
if !FeatureFlag::InlineRepoMenu.is_enabled() {
return false;
}
self.open_repos_menu(ctx);
}
command_that_just_sends_ai_request_with_prefix
_command_that_just_sends_ai_request_with_prefix
if command.name == commands::COMPACT.name
|| command.name == commands::PLAN.name
|| command.name == commands::ORCHESTRATE.name =>
+5 -5
View File
@@ -3950,8 +3950,8 @@ impl TerminalView {
}
ctx.subscribe_to_model(&AISettings::handle(ctx), |me, _, ai_settings_event, ctx| {
if let AISettingsChangedEvent::AwsBedrockCredentialsEnabled { .. } = ai_settings_event {
if !UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(ctx) {
if let AISettingsChangedEvent::BedrockEnabled { .. } = ai_settings_event {
if !UserWorkspaces::as_ref(ctx).is_bedrock_enabled(ctx) {
me.remove_aws_bedrock_login_banner(ctx);
}
}
@@ -9318,7 +9318,7 @@ impl TerminalView {
AwsBedrockLoginBannerAction::DontShowAgain => {
AISettings::handle(ctx).update(ctx, |ai_settings, ctx| {
report_if_error!(ai_settings
.aws_bedrock_login_banner_dismissed
.bedrock_login_banner_dismissed
.set_value(true, ctx));
});
}
@@ -9338,7 +9338,7 @@ impl TerminalView {
/// user interaction (e.g. "do you want to override X profile? y/n" is common)
fn run_aws_login_command(&mut self, ctx: &mut ViewContext<Self>) {
let login_command = AISettings::as_ref(ctx)
.aws_bedrock_auth_refresh_command
.bedrock_auth_refresh_command
.value()
.clone();
@@ -9377,7 +9377,7 @@ impl TerminalView {
}
// Check if AWS Bedrock is available in the workspace
if !UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(ctx) {
if !UserWorkspaces::as_ref(ctx).is_bedrock_enabled(ctx) {
return;
}
@@ -17,14 +17,14 @@ impl ByoLlmAuthBannerSessionState {
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
// Initialize from the persisted permanent dismissal setting
let dismissed = *AISettings::as_ref(ctx)
.aws_bedrock_login_banner_dismissed
.bedrock_login_banner_dismissed
.value();
// Subscribe to changes in the permanent dismissal setting
ctx.subscribe_to_model(&AISettings::handle(ctx), |state, event, ctx| {
if let AISettingsChangedEvent::AwsBedrockLoginBannerDismissed { .. } = event {
if let AISettingsChangedEvent::BedrockLoginBannerDismissed { .. } = event {
let permanently_dismissed = *AISettings::as_ref(ctx)
.aws_bedrock_login_banner_dismissed
.bedrock_login_banner_dismissed
.value();
if permanently_dismissed && !state.dismissed {
state.dismissed = true;
+2 -2
View File
@@ -541,7 +541,7 @@ pub enum WorkspaceAction {
ResetBuildPlanMigrationModalState,
/// Reset the AWS Bedrock login banner dismissed state (for debugging).
#[cfg(debug_assertions)]
DebugResetAwsBedrockLoginBannerDismissed,
DebugResetBedrockLoginBannerDismissed,
/// Open the Oz Launch Modal (for debugging)
#[cfg(debug_assertions)]
OpenOzLaunchModal,
@@ -941,7 +941,7 @@ impl WorkspaceAction {
#[cfg(debug_assertions)]
OpenBuildPlanMigrationModal
| ResetBuildPlanMigrationModalState
| DebugResetAwsBedrockLoginBannerDismissed
| DebugResetBedrockLoginBannerDismissed
| OpenOzLaunchModal
| ResetOzLaunchModalState
| OpenOpenWarpLaunchModal
+2 -2
View File
@@ -198,9 +198,9 @@ pub fn init(app: &mut AppContext) {
)
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
"workspace:debug_reset_aws_bedrock_login_banner_dismissed",
"workspace:debug_reset_bedrock_login_banner_dismissed",
"[Debug] Un-dismiss AWS login banner",
WorkspaceAction::DebugResetAwsBedrockLoginBannerDismissed,
WorkspaceAction::DebugResetBedrockLoginBannerDismissed,
)
.with_context_predicate(id!("Workspace")),
EditableBinding::new(
+2 -2
View File
@@ -21233,11 +21233,11 @@ impl TypedActionView for Workspace {
log::info!("Build plan migration modal dismissed state has been reset");
}
#[cfg(debug_assertions)]
DebugResetAwsBedrockLoginBannerDismissed => {
DebugResetBedrockLoginBannerDismissed => {
// Reset the AWS Bedrock login banner dismissed state for debugging
AISettings::handle(ctx).update(ctx, |ai_settings, ctx| {
if let Err(e) = ai_settings
.aws_bedrock_login_banner_dismissed
.bedrock_login_banner_dismissed
.set_value(false, ctx)
{
log::warn!(
+2 -2
View File
@@ -511,7 +511,7 @@ impl UserWorkspaces {
)
}
pub fn is_aws_bedrock_credentials_enabled(&self, app: &AppContext) -> bool {
pub fn is_bedrock_enabled(&self, app: &AppContext) -> bool {
// i.e. did the admin go and toggle on aws bedrock in the admin panel?
if !self.is_aws_bedrock_available_from_workspace() {
return false;
@@ -520,7 +520,7 @@ impl UserWorkspaces {
match self.aws_bedrock_host_enablement_setting() {
HostEnablementSetting::Enforce => true,
HostEnablementSetting::RespectUserSetting => *AISettings::as_ref(app)
.aws_bedrock_credentials_enabled
.bedrock_enabled
.value(),
}
}
+5 -5
View File
@@ -254,7 +254,7 @@ fn test_aws_bedrock_credentials_default_off_when_admin_respects_user_setting() {
app.read(|ctx| {
assert!(
!UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(ctx),
!UserWorkspaces::as_ref(ctx).is_bedrock_enabled(ctx),
"respect-user-setting should default the local Bedrock credentials toggle to off"
);
assert!(
@@ -303,13 +303,13 @@ fn test_aws_bedrock_credentials_respect_user_setting() {
AISettings::handle(&app).update(&mut app, |settings, ctx| {
let _ = settings
.aws_bedrock_credentials_enabled
.bedrock_enabled
.set_value(false, ctx);
});
app.read(|ctx| {
assert!(
!UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(ctx),
!UserWorkspaces::as_ref(ctx).is_bedrock_enabled(ctx),
"respect-user-setting should honor the local Bedrock credentials toggle"
);
assert!(
@@ -358,13 +358,13 @@ fn test_aws_bedrock_credentials_enforced_by_admin() {
AISettings::handle(&app).update(&mut app, |settings, ctx| {
let _ = settings
.aws_bedrock_credentials_enabled
.bedrock_enabled
.set_value(false, ctx);
});
app.read(|ctx| {
assert!(
UserWorkspaces::as_ref(ctx).is_aws_bedrock_credentials_enabled(ctx),
UserWorkspaces::as_ref(ctx).is_bedrock_enabled(ctx),
"enforced Bedrock host policy should ignore the local Bedrock credentials toggle"
);
assert!(