1use crate::wire::intel::{Message, Request, Response, StopReason, ToolCall, Usage};
24use serde_json::{Map, Value, json};
25
26pub const DEFAULT_PATH: &str = "/";
30
31pub fn converse_path(model: &str) -> String {
39 format!("/model/{}/converse", encode_segment(model))
40}
41
42fn encode_segment(s: &str) -> String {
45 let mut out = String::with_capacity(s.len());
46 for &b in s.as_bytes() {
47 match b {
48 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
49 out.push(b as char)
50 }
51 _ => out.push_str(&format!("%{b:02X}")),
52 }
53 }
54 out
55}
56
57pub fn build_request(req: &Request, _token: Option<&str>) -> (Vec<u8>, Vec<(String, String)>) {
60 let system: Vec<Value> = req
62 .messages
63 .iter()
64 .filter_map(|m| match m {
65 Message::System(s) if !s.is_empty() => Some(json!({"text": s})),
66 _ => None,
67 })
68 .collect();
69
70 let mut inference = Map::new();
71 inference.insert("maxTokens".into(), json!(req.max_tokens));
72 if let Some(t) = req.temperature {
73 inference.insert("temperature".into(), json!(t));
74 }
75
76 let mut body = Map::new();
77 body.insert("messages".into(), json!(messages_to_bedrock(&req.messages)));
78 if !system.is_empty() {
79 body.insert("system".into(), json!(system));
80 }
81 body.insert("inferenceConfig".into(), Value::Object(inference));
82 if !req.tools.is_empty() {
83 let tools: Vec<Value> = req
84 .tools
85 .iter()
86 .map(|t| {
87 json!({"toolSpec": {
88 "name": t.name,
89 "description": t.description,
90 "inputSchema": {"json": t.input_schema},
91 }})
92 })
93 .collect();
94 body.insert("toolConfig".into(), json!({"tools": tools}));
95 }
96
97 let bytes = serde_json::to_vec(&Value::Object(body)).unwrap_or_default();
98 let headers = vec![
101 ("content-type".to_string(), "application/json".to_string()),
102 ("accept".to_string(), "application/json".to_string()),
103 ];
104 (bytes, headers)
105}
106
107fn messages_to_bedrock(messages: &[Message]) -> Vec<Value> {
113 let mut turns: Vec<(&'static str, Vec<Value>)> = Vec::new();
114 let mut push = |role: &'static str, block: Value| match turns.last_mut() {
115 Some((r, blocks)) if *r == role => blocks.push(block),
116 _ => turns.push((role, vec![block])),
117 };
118 for m in messages {
119 match m {
120 Message::System(_) => {} Message::User(s) => push("user", json!({"text": s})),
122 Message::Assistant { text, tool_calls } => {
123 if let Some(t) = text.as_deref().filter(|t| !t.is_empty()) {
124 push("assistant", json!({"text": t}));
125 }
126 for tc in tool_calls {
127 push(
128 "assistant",
129 json!({"toolUse": {
130 "toolUseId": tc.id,
131 "name": tc.name,
132 "input": tc.arguments,
133 }}),
134 );
135 }
136 }
137 Message::ToolResult {
138 id,
139 content,
140 is_error,
141 } => push(
142 "user",
143 json!({"toolResult": {
144 "toolUseId": id,
145 "content": [{"text": content}],
146 "status": if *is_error { "error" } else { "success" },
147 }}),
148 ),
149 }
150 }
151 turns
152 .into_iter()
153 .map(|(role, content)| json!({"role": role, "content": content}))
154 .collect()
155}
156
157pub fn parse_response(body: &[u8]) -> Result<Response, String> {
160 let v: Value =
161 serde_json::from_slice(body).map_err(|e| format!("intel: bad JSON response: {e}"))?;
162
163 let message = v.pointer("/output/message");
167 if message.is_none()
168 && let Some(msg) = v.get("message").and_then(Value::as_str)
169 {
170 return Err(format!("intel: provider error: {msg}"));
171 }
172
173 let mut text_parts: Vec<String> = Vec::new();
174 let mut tool_calls = Vec::new();
175 if let Some(blocks) = message
176 .and_then(|m| m.get("content"))
177 .and_then(Value::as_array)
178 {
179 for b in blocks {
180 if let Some(t) = b.get("text").and_then(Value::as_str) {
181 text_parts.push(t.to_string());
182 } else if let Some(tu) = b.get("toolUse") {
183 tool_calls.push(ToolCall {
184 id: tu
185 .get("toolUseId")
186 .and_then(Value::as_str)
187 .unwrap_or("")
188 .to_string(),
189 name: tu
190 .get("name")
191 .and_then(Value::as_str)
192 .unwrap_or("")
193 .to_string(),
194 arguments: tu.get("input").cloned().unwrap_or(Value::Null),
195 });
196 }
197 }
198 }
199
200 let stop_reason = match v.get("stopReason").and_then(Value::as_str) {
201 Some("end_turn") | Some("stop_sequence") => StopReason::EndTurn,
202 Some("tool_use") => StopReason::ToolUse,
203 Some("max_tokens") => StopReason::MaxTokens,
204 _ => StopReason::Other,
205 };
206
207 let usage = v.get("usage").map(|u| Usage {
208 input_tokens: u.get("inputTokens").and_then(Value::as_u64).unwrap_or(0),
209 output_tokens: u.get("outputTokens").and_then(Value::as_u64).unwrap_or(0),
210 });
211
212 let text = if text_parts.is_empty() {
213 None
214 } else {
215 Some(text_parts.join(""))
216 };
217 Ok(Response {
218 text,
219 tool_calls,
220 stop_reason,
221 usage: usage.unwrap_or_default(),
222 })
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::wire::intel::ToolDef;
229
230 #[test]
231 fn converse_path_encodes_the_model_id() {
232 assert_eq!(
234 converse_path("anthropic.claude-3-5-sonnet-20241022-v2:0"),
235 "/model/anthropic.claude-3-5-sonnet-20241022-v2%3A0/converse"
236 );
237 assert_eq!(
239 converse_path("arn:aws:bedrock:us-east-1::foundation-model/x"),
240 "/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A%3Afoundation-model%2Fx/converse"
241 );
242 assert_eq!(
244 converse_path("amazon.titan-text-express-v1"),
245 "/model/amazon.titan-text-express-v1/converse"
246 );
247 }
248
249 fn req() -> Request {
250 Request {
251 model: "anthropic.claude-3-5-sonnet-20241022-v2:0".into(),
252 messages: vec![Message::system("be terse"), Message::user("hi")],
253 tools: vec![ToolDef {
254 name: "read_file".into(),
255 description: "read a file".into(),
256 input_schema: json!({"type": "object"}),
257 }],
258 max_tokens: 256,
259 temperature: Some(0.0),
260 }
261 }
262
263 #[test]
264 fn build_hoists_system_and_wraps_inference_and_tools() {
265 let (body, headers) = build_request(&req(), Some("ignored"));
266 let v: Value = serde_json::from_slice(&body).unwrap();
267 assert!(
269 v.get("model").is_none(),
270 "model must not be in the body: {v}"
271 );
272 assert_eq!(v["system"][0]["text"], "be terse");
273 assert_eq!(v["messages"][0]["role"], "user");
274 assert_eq!(v["messages"][0]["content"][0]["text"], "hi");
275 assert_eq!(v["inferenceConfig"]["maxTokens"], 256);
276 assert_eq!(v["inferenceConfig"]["temperature"], 0.0);
277 assert_eq!(v["toolConfig"]["tools"][0]["toolSpec"]["name"], "read_file");
278 assert_eq!(
279 v["toolConfig"]["tools"][0]["toolSpec"]["inputSchema"]["json"]["type"],
280 "object"
281 );
282 assert!(
284 !headers
285 .iter()
286 .any(|(k, _)| k == "authorization" || k == "x-api-key"),
287 "no bearer header for Bedrock: {headers:?}"
288 );
289 }
290
291 #[test]
292 fn build_omits_toolconfig_when_no_tools() {
293 let mut r = req();
294 r.tools.clear();
295 let v: Value = serde_json::from_slice(&build_request(&r, None).0).unwrap();
296 assert!(
297 v.get("toolConfig").is_none(),
298 "empty tools ⇒ no toolConfig: {v}"
299 );
300 }
301
302 #[test]
303 fn consecutive_tool_results_merge_into_one_user_turn() {
304 let r = Request {
308 model: "m".into(),
309 messages: vec![
310 Message::user("go"),
311 Message::Assistant {
312 text: Some("working".into()),
313 tool_calls: vec![
314 ToolCall {
315 id: "t1".into(),
316 name: "a".into(),
317 arguments: json!({}),
318 },
319 ToolCall {
320 id: "t2".into(),
321 name: "b".into(),
322 arguments: json!({}),
323 },
324 ],
325 },
326 Message::tool_result("t1", "r1", false),
327 Message::tool_result("t2", "r2", true),
328 ],
329 tools: vec![],
330 max_tokens: 8,
331 temperature: None,
332 };
333 let v: Value = serde_json::from_slice(&build_request(&r, None).0).unwrap();
334 let msgs = v["messages"].as_array().unwrap();
335 assert_eq!(msgs.len(), 3, "roles must alternate: {v}");
337 assert_eq!(msgs[1]["role"], "assistant");
338 assert_eq!(msgs[1]["content"][0]["text"], "working");
339 assert_eq!(msgs[1]["content"][1]["toolUse"]["toolUseId"], "t1");
340 assert_eq!(msgs[1]["content"][2]["toolUse"]["name"], "b");
341 assert_eq!(msgs[2]["role"], "user");
342 assert_eq!(msgs[2]["content"][0]["toolResult"]["toolUseId"], "t1");
343 assert_eq!(msgs[2]["content"][0]["toolResult"]["status"], "success");
344 assert_eq!(msgs[2]["content"][1]["toolResult"]["status"], "error");
345 }
346
347 #[test]
348 fn parse_text_and_tool_use() {
349 let body = br#"{"output":{"message":{"role":"assistant","content":[
350 {"text":"hi"},
351 {"toolUse":{"toolUseId":"tu_1","name":"read","input":{"p":1}}}
352 ]}},"stopReason":"tool_use","usage":{"inputTokens":5,"outputTokens":7,"totalTokens":12}}"#;
353 let r = parse_response(body).unwrap();
354 assert_eq!(r.text.as_deref(), Some("hi"));
355 assert_eq!(r.tool_calls[0].id, "tu_1");
356 assert_eq!(r.tool_calls[0].name, "read");
357 assert_eq!(r.tool_calls[0].arguments["p"], 1);
358 assert_eq!(r.stop_reason, StopReason::ToolUse);
359 assert_eq!(r.usage.total(), 12);
360 }
361
362 #[test]
363 fn parse_final_text_and_stop_reasons() {
364 let body = br#"{"output":{"message":{"content":[{"text":"done"}]}},"stopReason":"end_turn","usage":{"inputTokens":10,"outputTokens":2}}"#;
365 let r = parse_response(body).unwrap();
366 assert_eq!(r.text.as_deref(), Some("done"));
367 assert_eq!(r.stop_reason, StopReason::EndTurn);
368 assert!(!r.wants_tools());
369 let body =
371 br#"{"output":{"message":{"content":[{"text":"x"}]}},"stopReason":"max_tokens"}"#;
372 assert_eq!(
373 parse_response(body).unwrap().stop_reason,
374 StopReason::MaxTokens
375 );
376 }
377}