Skip to main content

codetether_agent/provider/bedrock/
response.rs

1//! Bedrock Converse API response types and helpers.
2//!
3//! These types mirror the JSON shape returned from the `/converse` endpoint
4//! and provide a small helper to translate it into the crate's generic
5//! [`CompletionResponse`].
6//!
7//! # Examples
8//!
9//! ```rust
10//! use codetether_agent::provider::bedrock::parse_converse_response;
11//!
12//! let json = serde_json::json!({
13//!     "output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}},
14//!     "stopReason": "end_turn",
15//!     "usage": {"inputTokens": 3, "outputTokens": 1, "totalTokens": 4}
16//! });
17//! let resp = parse_converse_response(&json.to_string()).unwrap();
18//! assert_eq!(resp.usage.total_tokens, 4);
19//! ```
20
21use crate::provider::{CompletionResponse, ContentPart, FinishReason, Message, Role, Usage};
22use anyhow::{Context, Result};
23use serde::Deserialize;
24use serde_json::Value;
25
26#[derive(Debug, Deserialize)]
27#[serde(rename_all = "camelCase")]
28struct ConverseResponse {
29    output: ConverseOutput,
30    #[serde(default)]
31    stop_reason: Option<String>,
32    #[serde(default)]
33    usage: Option<ConverseUsage>,
34}
35
36#[derive(Debug, Deserialize)]
37struct ConverseOutput {
38    message: ConverseMessage,
39}
40
41#[derive(Debug, Deserialize)]
42struct ConverseMessage {
43    #[allow(dead_code)]
44    role: String,
45    content: Vec<ConverseContent>,
46}
47
48#[derive(Debug, Deserialize)]
49#[serde(untagged)]
50enum ConverseContent {
51    ReasoningContent {
52        #[serde(rename = "reasoningContent")]
53        reasoning_content: ReasoningContentBlock,
54    },
55    Text {
56        text: String,
57    },
58    ToolUse {
59        #[serde(rename = "toolUse")]
60        tool_use: ConverseToolUse,
61    },
62}
63
64#[derive(Debug, Deserialize)]
65#[serde(rename_all = "camelCase")]
66struct ReasoningContentBlock {
67    reasoning_text: ReasoningText,
68}
69
70#[derive(Debug, Deserialize)]
71struct ReasoningText {
72    text: String,
73}
74
75#[derive(Debug, Deserialize)]
76#[serde(rename_all = "camelCase")]
77struct ConverseToolUse {
78    tool_use_id: String,
79    name: String,
80    input: Value,
81}
82
83#[derive(Debug, Deserialize)]
84#[serde(rename_all = "camelCase")]
85struct ConverseUsage {
86    #[serde(default)]
87    input_tokens: usize,
88    #[serde(default)]
89    output_tokens: usize,
90    #[serde(default)]
91    total_tokens: usize,
92}
93
94/// Error body returned by Bedrock when a request is rejected.
95#[derive(Debug, Deserialize)]
96pub struct BedrockError {
97    /// Human-readable error message.
98    pub message: String,
99}
100
101/// Parse a Bedrock Converse API response JSON string into a
102/// [`CompletionResponse`].
103///
104/// # Errors
105///
106/// Returns [`anyhow::Error`] if the string is not valid JSON in the expected
107/// Converse response shape.
108///
109/// # Examples
110///
111/// ```rust
112/// use codetether_agent::provider::bedrock::parse_converse_response;
113/// use codetether_agent::provider::{ContentPart, Role};
114///
115/// let body = r#"{"output":{"message":{"role":"assistant","content":[{"text":"hi"}]}},"stopReason":"end_turn"}"#;
116/// let resp = parse_converse_response(body).unwrap();
117/// assert!(matches!(resp.message.role, Role::Assistant));
118/// assert!(matches!(&resp.message.content[0], ContentPart::Text { text } if text == "hi"));
119/// ```
120pub fn parse_converse_response(text: &str) -> Result<CompletionResponse> {
121    let response: ConverseResponse = serde_json::from_str(text).context(format!(
122        "Failed to parse Bedrock response: {}",
123        crate::util::truncate_bytes_safe(text, 300)
124    ))?;
125
126    let mut content = Vec::new();
127    let mut has_tool_calls = false;
128
129    for part in &response.output.message.content {
130        match part {
131            ConverseContent::ReasoningContent { reasoning_content } => {
132                if !reasoning_content.reasoning_text.text.is_empty() {
133                    content.push(ContentPart::Thinking {
134                        text: reasoning_content.reasoning_text.text.clone(),
135                    });
136                }
137            }
138            ConverseContent::Text { text } => {
139                if !text.is_empty() {
140                    content.push(ContentPart::Text { text: text.clone() });
141                }
142            }
143            ConverseContent::ToolUse { tool_use } => {
144                has_tool_calls = true;
145                content.push(ContentPart::ToolCall {
146                    id: tool_use.tool_use_id.clone(),
147                    name: tool_use.name.clone(),
148                    arguments: serde_json::to_string(&tool_use.input).unwrap_or_default(),
149                    thought_signature: None,
150                });
151            }
152        }
153    }
154
155    let finish_reason = if has_tool_calls {
156        FinishReason::ToolCalls
157    } else {
158        match response.stop_reason.as_deref() {
159            Some("end_turn") | Some("stop") | Some("stop_sequence") => FinishReason::Stop,
160            Some("max_tokens") => FinishReason::Length,
161            Some("tool_use") => FinishReason::ToolCalls,
162            Some("content_filtered") => FinishReason::ContentFilter,
163            _ => FinishReason::Stop,
164        }
165    };
166
167    let usage = response.usage.as_ref();
168
169    Ok(CompletionResponse {
170        message: Message {
171            role: Role::Assistant,
172            content,
173        },
174        usage: Usage {
175            prompt_tokens: usage.map(|u| u.input_tokens).unwrap_or(0),
176            completion_tokens: usage.map(|u| u.output_tokens).unwrap_or(0),
177            total_tokens: usage.map(|u| u.total_tokens).unwrap_or(0),
178            cache_read_tokens: None,
179            cache_write_tokens: None,
180        },
181        finish_reason,
182    })
183}