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;