use crate::chat::{
Binary, BinarySource, ChatOptionsSet, ChatRequest, ChatResponse, ChatRole, ContentPart, MessageContent,
ReasoningEffort, StopReason, Tool, ToolCall, ToolName, Usage,
};
use crate::webc::WebResponse;
use crate::{Error, ModelIden, Result};
use serde_json::{Map, Value, json};
use tracing::warn;
use value_ext::JsonValueExt;
#[derive(Debug, Clone, Copy)]
pub(super) enum BedrockPublisher {
Anthropic,
AmazonNova,
Other,
}
impl BedrockPublisher {
pub(super) fn from_model_id(model_id: &str) -> Self {
let tail = model_id.split_once('.').map(|(_, rest)| rest).unwrap_or(model_id);
let publisher_segment = tail.split_once('.').map(|(p, _)| p).unwrap_or(tail);
let publisher = if publisher_segment.is_empty() {
model_id.split_once('.').map(|(p, _)| p).unwrap_or(model_id)
} else {
publisher_segment
};
match publisher {
"anthropic" => Self::Anthropic,
"amazon" => Self::AmazonNova, _ => Self::Other,
}
}
}
pub(super) fn build_converse_payload(
model_iden: &ModelIden,
chat_req: ChatRequest,
options_set: ChatOptionsSet<'_, '_>,
) -> Result<Value> {
let (_, model_name) = model_iden.model_name.namespace_and_name();
let publisher = BedrockPublisher::from_model_id(model_name);
let ConverseRequestParts {
system,
messages,
tools,
} = into_converse_request_parts(chat_req)?;
let mut payload = json!({ "messages": messages });
if let Some(system) = system {
payload.x_insert("system", system)?;
}
if let Some(tools) = tools {
payload.x_insert("toolConfig", json!({ "tools": tools }))?;
}
let mut inference: Map<String, Value> = Map::new();
let max_tokens = resolve_max_tokens(model_name, &options_set);
inference.insert("maxTokens".to_string(), json!(max_tokens));
if let Some(temperature) = options_set.temperature() {
inference.insert("temperature".to_string(), json!(temperature));
}
if let Some(top_p) = options_set.top_p() {
inference.insert("topP".to_string(), json!(top_p));
}
if !options_set.stop_sequences().is_empty() {
inference.insert("stopSequences".to_string(), json!(options_set.stop_sequences()));
}
payload.x_insert("inferenceConfig", Value::Object(inference))?;
if let Some(effort) = options_set.reasoning_effort()
&& let Some(additional) = publisher_additional_fields(publisher, effort)
{
payload.x_insert("additionalModelRequestFields", additional)?;
}
Ok(payload)
}
pub(super) fn parse_converse_response(model_iden: ModelIden, web_response: WebResponse) -> Result<ChatResponse> {
let WebResponse { mut body, .. } = web_response;
let stop_reason = body
.x_take::<Option<String>>("stopReason")
.ok()
.flatten()
.map(|s| StopReason::from(normalize_stop_reason(s.as_str()).to_string()));
let usage_value = body.x_take::<Value>("usage").ok();
let usage = usage_value.map(parse_usage).unwrap_or_default();
let content_items: Vec<Value> = body.x_take("/output/message/content").unwrap_or_default();
let mut content: MessageContent = MessageContent::default();
let mut reasoning_content: Vec<String> = Vec::new();
for mut item in content_items {
if let Ok(text) = item.x_take::<String>("text") {
content.push(ContentPart::from_text(text));
} else if let Ok(mut tool_use) = item.x_take::<Value>("toolUse") {
let call_id = tool_use.x_take::<String>("toolUseId")?;
let fn_name = tool_use.x_take::<String>("name")?;
let fn_arguments = tool_use.x_take::<Value>("input").unwrap_or_default();
content.push(ContentPart::ToolCall(ToolCall {
call_id,
fn_name,
fn_arguments,
thought_signatures: None,
}));
} else if let Ok(mut reasoning) = item.x_take::<Value>("reasoningContent") {
if let Ok(text) = reasoning.x_take::<String>("/reasoningText/text") {
reasoning_content.push(text);
}
} else {
content.push(ContentPart::from_custom(item, Some(model_iden.clone())));
}
}
let reasoning_content = if reasoning_content.is_empty() {
None
} else {
Some(reasoning_content.join("\n"))
};
let provider_model_iden = model_iden.clone();
Ok(ChatResponse {
content,
reasoning_content,
model_iden,
provider_model_iden,
stop_reason,
usage,
captured_raw_body: None,
response_id: None,
})
}
pub(super) fn normalize_stop_reason(converse_reason: &str) -> &str {
converse_reason
}
fn parse_usage(mut usage_value: Value) -> Usage {
let input_tokens: i32 = usage_value.x_take("inputTokens").ok().unwrap_or(0);
let output_tokens: i32 = usage_value.x_take("outputTokens").ok().unwrap_or(0);
let total_tokens: i32 = usage_value.x_take("totalTokens").ok().unwrap_or(input_tokens + output_tokens);
let cache_read: Option<i32> = usage_value.x_take("cacheReadInputTokens").ok();
let cache_write: Option<i32> = usage_value.x_take("cacheWriteInputTokens").ok();
let prompt_tokens_details = if cache_read.is_some() || cache_write.is_some() {
Some(crate::chat::PromptTokensDetails {
cache_creation_tokens: cache_write,
cache_creation_details: None,
cached_tokens: cache_read,
audio_tokens: None,
})
} else {
None
};
Usage {
prompt_tokens: Some(input_tokens),
prompt_tokens_details,
completion_tokens: Some(output_tokens),
completion_tokens_details: None,
total_tokens: Some(total_tokens),
}
}
fn resolve_max_tokens(model_name: &str, options_set: &ChatOptionsSet) -> u32 {
options_set.max_tokens().unwrap_or_else(|| {
match BedrockPublisher::from_model_id(model_name) {
BedrockPublisher::Anthropic => {
if model_name.contains("claude-sonnet")
|| model_name.contains("claude-haiku")
|| model_name.contains("claude-opus-4-5")
{
crate::adapter::adapters::anthropic::MAX_TOKENS_64K.max(64000)
} else if model_name.contains("claude-opus-4") {
32000
} else if model_name.contains("claude-3-5") {
8192
} else {
4096
}
}
BedrockPublisher::AmazonNova => 5000,
BedrockPublisher::Other => 4096,
}
})
}
fn publisher_additional_fields(publisher: BedrockPublisher, effort: &ReasoningEffort) -> Option<Value> {
match publisher {
BedrockPublisher::Anthropic => {
let budget = match effort {
ReasoningEffort::None => return None,
ReasoningEffort::Budget(n) => *n,
ReasoningEffort::Minimal | ReasoningEffort::Low => 1024,
ReasoningEffort::Medium => 8000,
ReasoningEffort::High | ReasoningEffort::XHigh | ReasoningEffort::Max => 24000,
};
Some(json!({
"thinking": {
"type": "enabled",
"budget_tokens": budget,
}
}))
}
BedrockPublisher::AmazonNova => {
match effort {
ReasoningEffort::None => None,
_ => Some(json!({
"inferenceConfig": { "reasoningConfig": { "type": "enabled" } }
})),
}
}
BedrockPublisher::Other => None,
}
}
struct ConverseRequestParts {
system: Option<Value>,
messages: Vec<Value>,
tools: Option<Vec<Value>>,
}
fn into_converse_request_parts(chat_req: ChatRequest) -> Result<ConverseRequestParts> {
let mut messages: Vec<Value> = Vec::new();
let mut systems: Vec<String> = Vec::new();
if let Some(system) = chat_req.system {
systems.push(system);
}
for msg in chat_req.messages {
match msg.role {
ChatRole::System => {
if let Some(text) = msg.content.joined_texts() {
systems.push(text);
}
}
ChatRole::User => {
let blocks = user_content_to_converse_blocks(msg.content);
if !blocks.is_empty() {
messages.push(json!({ "role": "user", "content": blocks }));
}
}
ChatRole::Assistant => {
let blocks = assistant_content_to_converse_blocks(msg.content);
if !blocks.is_empty() {
messages.push(json!({ "role": "assistant", "content": blocks }));
}
}
ChatRole::Tool => {
let blocks = tool_content_to_converse_blocks(msg.content);
if !blocks.is_empty() {
messages.push(json!({ "role": "user", "content": blocks }));
}
}
}
}
let system = if systems.is_empty() {
None
} else {
let parts: Vec<Value> = systems.into_iter().map(|s| json!({ "text": s })).collect();
Some(Value::Array(parts))
};
let tools: Option<Vec<Value>> = chat_req
.tools
.map(|tools| tools.into_iter().map(tool_to_converse_tool).collect::<Result<Vec<Value>>>())
.transpose()?;
Ok(ConverseRequestParts {
system,
messages,
tools,
})
}
fn user_content_to_converse_blocks(content: MessageContent) -> Vec<Value> {
let mut blocks = Vec::new();
for part in content {
match part {
ContentPart::Text(text) => blocks.push(json!({ "text": text })),
ContentPart::Binary(binary) => {
if let Some(block) = binary_to_converse_block(binary) {
blocks.push(block);
}
}
ContentPart::ToolResponse(tool_response) => {
blocks.push(json!({
"toolResult": {
"toolUseId": tool_response.call_id,
"content": [{ "text": tool_response.content }],
}
}));
}
ContentPart::ToolCall(_) => {}
ContentPart::ThoughtSignature(_) => {}
ContentPart::ReasoningContent(_) => {}
ContentPart::Custom(_) => {}
}
}
blocks
}
fn assistant_content_to_converse_blocks(content: MessageContent) -> Vec<Value> {
let mut blocks = Vec::new();
for part in content {
match part {
ContentPart::Text(text) => blocks.push(json!({ "text": text })),
ContentPart::ToolCall(tool_call) => {
let input = if tool_call.fn_arguments.is_null() {
Value::Object(Map::new())
} else {
tool_call.fn_arguments
};
blocks.push(json!({
"toolUse": {
"toolUseId": tool_call.call_id,
"name": tool_call.fn_name,
"input": input,
}
}));
}
ContentPart::Binary(_) => {}
ContentPart::ToolResponse(_) => {}
ContentPart::ThoughtSignature(_) => {}
ContentPart::ReasoningContent(_) => {}
ContentPart::Custom(_) => {}
}
}
blocks
}
fn tool_content_to_converse_blocks(content: MessageContent) -> Vec<Value> {
let mut blocks = Vec::new();
for part in content {
if let ContentPart::ToolResponse(tool_response) = part {
blocks.push(json!({
"toolResult": {
"toolUseId": tool_response.call_id,
"content": [{ "text": tool_response.content }],
}
}));
}
}
blocks
}
fn binary_to_converse_block(binary: Binary) -> Option<Value> {
let is_image = binary.is_image();
let Binary {
content_type, source, ..
} = binary;
let data = match source {
BinarySource::Base64(data) => data,
BinarySource::Url(_) => {
warn!("Bedrock Converse: URL-based binary sources are not yet supported, skipping");
return None;
}
};
let format = converse_format_from_content_type(&content_type, is_image)?;
if is_image {
Some(json!({
"image": {
"format": format,
"source": { "bytes": data },
}
}))
} else {
Some(json!({
"document": {
"format": format,
"name": "document",
"source": { "bytes": data },
}
}))
}
}
fn converse_format_from_content_type(content_type: &str, is_image: bool) -> Option<&'static str> {
if is_image {
match content_type {
"image/jpeg" | "image/jpg" => Some("jpeg"),
"image/png" => Some("png"),
"image/gif" => Some("gif"),
"image/webp" => Some("webp"),
_ => {
warn!("Bedrock Converse: unsupported image content-type: {content_type}");
None
}
}
} else {
match content_type {
"application/pdf" => Some("pdf"),
"text/csv" => Some("csv"),
"application/msword" => Some("doc"),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => Some("docx"),
"application/vnd.ms-excel" => Some("xls"),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => Some("xlsx"),
"text/html" => Some("html"),
"text/plain" => Some("txt"),
"text/markdown" => Some("md"),
_ => {
warn!("Bedrock Converse: unsupported document content-type: {content_type}");
None
}
}
}
}
fn tool_to_converse_tool(tool: Tool) -> Result<Value> {
let Tool {
name,
description,
schema,
config: _,
..
} = tool;
let name = match name {
ToolName::Custom(name) => name,
ToolName::WebSearch => {
return Err(Error::AdapterNotSupported {
adapter_kind: crate::adapter::AdapterKind::BedrockApi,
feature: "web_search builtin tool".to_string(),
});
}
};
let mut tool_spec = json!({
"name": name,
"inputSchema": { "json": schema },
});
if let Some(description) = description {
tool_spec.x_insert("description", description)?;
}
Ok(json!({ "toolSpec": tool_spec }))
}