Rebasing, going about this another way
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(_)
|
||||
));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
@@ -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,
|
||||
¤t_tool_use_id,
|
||||
¤t_tool_name,
|
||||
¤t_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],
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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, ®ion)
|
||||
} 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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user