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