Files
galaxy/app/src/ai/provider/convert.rs
T

386 lines
13 KiB
Rust

use std::collections::HashMap;
use aws_sdk_bedrockruntime::types::{
CachePointBlock, CachePointType, CacheTtl, ContentBlock, ConversationRole, ImageBlock,
ImageFormat, ImageSource, InferenceConfiguration, Message as BedrockMessage,
ReasoningContentBlock, ReasoningTextBlock, SystemContentBlock, Tool, ToolConfiguration,
ToolInputSchema, ToolResultBlock, ToolResultContentBlock, ToolResultStatus, ToolSpecification,
ToolUseBlock,
};
use aws_smithy_types::{Blob, Document};
use serde_json::Value as JsonValue;
use super::external_config::ExternalBedrockConfig;
// Re-export shared provider types so existing imports from bedrock::convert continue to work.
pub use crate::ai::provider::types::{
ContentPart, ConversationMessage, MessageContent, MessageRole, ToolDefinition,
};
#[derive(Clone, Debug)]
pub struct CachingConfig {
pub enabled: bool,
pub extended_ttl_requested: bool,
}
impl Default for CachingConfig {
fn default() -> Self {
Self {
enabled: true,
extended_ttl_requested: false,
}
}
}
impl CachingConfig {
pub fn from_external_config(external: &ExternalBedrockConfig) -> Self {
Self {
enabled: !external.disable_prompt_caching,
extended_ttl_requested: external.enable_prompt_caching_1h,
}
}
}
pub struct ConvertedRequest {
pub messages: Vec<BedrockMessage>,
pub system: Vec<SystemContentBlock>,
pub inference_config: InferenceConfiguration,
pub tool_config: Option<ToolConfiguration>,
}
#[allow(clippy::too_many_arguments)]
pub fn build_converse_request(
messages: Vec<ConversationMessage>,
system_prompt: Option<String>,
compact_summary: Option<String>,
tools: Vec<ToolDefinition>,
max_tokens: i32,
temperature: Option<f32>,
top_p: Option<f32>,
stop_sequences: Option<Vec<String>>,
caching_config: CachingConfig,
) -> ConvertedRequest {
let bedrock_messages = convert_messages(messages, &caching_config);
let system = convert_system_prompt(system_prompt, compact_summary, &caching_config);
let inference_config = build_inference_config(max_tokens, temperature, top_p, stop_sequences);
let tool_config = build_tool_config(tools, &caching_config);
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>,
caching_config: &CachingConfig,
) -> 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::Reasoning { text, signature } => {
ContentBlock::ReasoningContent(ReasoningContentBlock::ReasoningText(
ReasoningTextBlock::builder()
.text(text)
.set_signature(signature)
.build()
.expect("valid reasoning text block"),
))
}
ContentPart::Image { data, mime_type } => image_content_block(data, &mime_type),
ContentPart::ToolUse {
tool_use_id,
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);
}
let mut messages = coalesce_consecutive_roles(result);
// Add a cache point to the second-to-last message (the conversation prefix
// that is stable between requests). This allows Bedrock to cache all prior
// context and only process the latest message as new input tokens.
if caching_config.enabled && messages.len() >= 2 {
let cache_idx = messages.len() - 2;
let msg = messages.remove(cache_idx);
let mut content = msg.content().to_vec();
let mut builder = CachePointBlock::builder().r#type(CachePointType::Default);
if caching_config.extended_ttl_requested {
builder = builder.ttl(CacheTtl::OneHour);
log::info!("[bedrock] Using 1-hour cache TTL (ENABLE_PROMPT_CACHING_1H=1)");
}
content.push(ContentBlock::CachePoint(
builder.build().expect("valid cache point"),
));
let cached_msg = BedrockMessage::builder()
.role(msg.role().clone())
.set_content(Some(content))
.build()
.expect("valid message with cache point");
messages.insert(cache_idx, cached_msg);
}
messages
}
fn image_content_block(data: Vec<u8>, mime_type: &str) -> ContentBlock {
let format = match mime_type.to_ascii_lowercase().as_str() {
"image/gif" | "gif" => ImageFormat::Gif,
"image/jpeg" | "image/jpg" | "jpeg" | "jpg" => ImageFormat::Jpeg,
"image/png" | "png" => ImageFormat::Png,
"image/webp" | "webp" => ImageFormat::Webp,
_ => {
log::warn!(
"[bedrock] Omitting image attachment with unsupported MIME type: {mime_type}"
);
return ContentBlock::Text(
"[Image attachment omitted because its format is unsupported.]".to_string(),
);
}
};
ContentBlock::Image(
ImageBlock::builder()
.format(format)
.source(ImageSource::Bytes(Blob::new(data)))
.build()
.expect("valid image block"),
)
}
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>,
_compact_summary: Option<String>,
caching_config: &CachingConfig,
) -> Vec<SystemContentBlock> {
let mut blocks = Vec::new();
if let Some(prompt) = system_prompt {
if !prompt.is_empty() {
blocks.push(SystemContentBlock::Text(prompt));
}
}
// Progressive summary is now prepended to the messages array instead of
// being injected here. The compact_summary parameter is kept for API compat
// but ignored.
if !blocks.is_empty() && caching_config.enabled {
let mut builder = CachePointBlock::builder().r#type(CachePointType::Default);
if caching_config.extended_ttl_requested {
builder = builder.ttl(CacheTtl::OneHour);
}
blocks.push(SystemContentBlock::CachePoint(
builder.build().expect("valid cache point"),
));
}
blocks
}
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>,
caching_config: &CachingConfig,
) -> Option<ToolConfiguration> {
if tools.is_empty() {
return None;
}
let mut 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();
if caching_config.enabled {
let mut builder = CachePointBlock::builder().r#type(CachePointType::Default);
if caching_config.extended_ttl_requested {
builder = builder.ttl(CacheTtl::OneHour);
}
tool_specs.push(Tool::CachePoint(
builder.build().expect("valid cache point"),
));
}
Some(
ToolConfiguration::builder()
.set_tools(Some(tool_specs))
.build()
.expect("valid tool config"),
)
}