1use bytes::Bytes;
7use futures::{Stream, StreamExt};
8use hmac::{Hmac, Mac};
9use reqwest::Client;
10use serde::Deserialize;
11use serde_json::Value as JsonValue;
12use serde_json::json;
13use std::future::Future;
14use std::pin::Pin;
15use std::sync::Arc;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18use crate::{
19 Api, AssistantMessage, ContentBlock, Context, Model, Provider, ProviderEvent, StopReason,
20 StreamOptions, StreamResult, Usage, error::ProviderError,
21};
22
23use super::shared_client;
24
25use sha2::{Digest, Sha256};
27
28type HmacSha256 = Hmac<Sha256>;
30
31#[derive(Clone)]
33pub struct BedrockProvider {
34 client: &'static Client,
35 default_region: String,
36}
37
38impl BedrockProvider {
39 pub fn new() -> Self {
46 let region = Self::resolve_region();
47 Self {
48 client: shared_client(),
49 default_region: region,
50 }
51 }
52
53 fn resolve_region() -> String {
55 if let Some(region) = Self::region_from_aws_config() {
57 return region;
58 }
59 if let Ok(region) = std::env::var("AWS_REGION") {
61 return region;
62 }
63 "us-east-1".to_string()
65 }
66
67 fn region_from_aws_config() -> Option<String> {
69 let home = dirs::home_dir()?;
70 let config_path = home.join(".aws").join("config");
71 let content = std::fs::read_to_string(&config_path).ok()?;
72 for line in content.lines() {
73 let trimmed = line.trim();
74 if let Some(value) = trimmed.strip_prefix("region") {
75 let value = value.trim_start_matches([' ', '=']).trim();
76 if !value.is_empty() {
77 return Some(value.to_string());
78 }
79 }
80 }
81 None
82 }
83
84 fn get_credentials(&self) -> Result<(String, String, String), ProviderError> {
90 if let (Ok(access_key), Ok(secret_key)) = (
92 std::env::var("AWS_ACCESS_KEY_ID"),
93 std::env::var("AWS_SECRET_ACCESS_KEY"),
94 ) {
95 let region =
96 std::env::var("AWS_REGION").unwrap_or_else(|_| self.default_region.clone());
97 return Ok((access_key, secret_key, region));
98 }
99
100 if let Some((access_key, secret_key)) = Self::creds_from_aws_file() {
102 return Ok((access_key, secret_key, self.default_region.clone()));
103 }
104
105 Err(ProviderError::MissingApiKey)
106 }
107
108 fn creds_from_aws_file() -> Option<(String, String)> {
110 let home = dirs::home_dir()?;
111 let creds_path = home.join(".aws").join("credentials");
112 let content = std::fs::read_to_string(&creds_path).ok()?;
113
114 let mut access_key = None;
115 let mut secret_key = None;
116 let mut in_default = false;
117
118 for line in content.lines() {
119 let trimmed = line.trim();
120 if trimmed.starts_with('[') {
121 in_default = trimmed == "[default]";
122 continue;
123 }
124 if !in_default {
125 continue;
126 }
127 if let Some(value) = trimmed.strip_prefix("aws_access_key_id") {
128 let value = value.trim_start_matches([' ', '=']).trim();
129 access_key = Some(value.to_string());
130 } else if let Some(value) = trimmed.strip_prefix("aws_secret_access_key") {
131 let value = value.trim_start_matches([' ', '=']).trim();
132 secret_key = Some(value.to_string());
133 }
134 }
135
136 match (access_key, secret_key) {
137 (Some(a), Some(s)) => Some((a, s)),
138 _ => None,
139 }
140 }
141
142 fn get_session_token(&self) -> Option<String> {
144 std::env::var("AWS_SESSION_TOKEN").ok()
145 }
146
147 fn get_endpoint(&self, model: &Model, region: &str) -> String {
149 if !model.base_url.is_empty() {
151 format!("{}/converse-stream", model.base_url)
152 } else {
153 let region = if region.is_empty() {
154 &self.default_region
155 } else {
156 region
157 };
158 format!(
159 "https://bedrock-runtime.{}.amazonaws.com/model/{}/converse-stream",
160 region, model.id
161 )
162 }
163 }
164
165 #[allow(clippy::too_many_arguments)]
167 fn sign_request(
168 &self,
169 method: &str,
170 url: &str,
171 headers: &mut reqwest::header::HeaderMap,
172 body: &[u8],
173 access_key: &str,
174 secret_key: &str,
175 region: &str,
176 service: &str,
177 ) -> Result<(), ProviderError> {
178 let parsed_url =
180 url::Url::parse(url).map_err(|e| ProviderError::InvalidResponse(e.to_string()))?;
181
182 let host = parsed_url.host_str().unwrap_or("");
183 let path = parsed_url.path();
184 let query = parsed_url.query().unwrap_or("");
185
186 let now = SystemTime::now()
188 .duration_since(UNIX_EPOCH)
189 .map_err(|_| ProviderError::InvalidResponse("Invalid system time".into()))?;
190 let timestamp = now.as_secs();
191 let datetime = format_timestamp(timestamp);
192
193 let content_hash = hex_encode(hash_sha256(body));
195
196 headers.insert(
198 "content-type",
199 "application/json".parse().map_err(|e| {
200 ProviderError::InvalidResponse(format!("invalid header value: {e}"))
201 })?,
202 );
203 headers.insert(
204 "host",
205 host.parse().map_err(|e| {
206 ProviderError::InvalidResponse(format!("invalid header value: {e}"))
207 })?,
208 );
209 headers.insert(
210 "x-amz-date",
211 datetime.parse().map_err(|e| {
212 ProviderError::InvalidResponse(format!("invalid header value: {e}"))
213 })?,
214 );
215 headers.insert(
216 "x-amz-content-sha256",
217 content_hash.parse().map_err(|e| {
218 ProviderError::InvalidResponse(format!("invalid header value: {e}"))
219 })?,
220 );
221
222 let canonical_request =
224 build_canonical_request(method, path, query, headers, &content_hash);
225
226 let credential_scope = format!("{}/{}/*", datetime, service);
228 let hashed_canonical = hex_encode(hash_sha256(canonical_request.as_bytes()));
229 let string_to_sign = format!(
230 "AWS4-HMAC-SHA256\n{}\n{}\n{}",
231 datetime, credential_scope, hashed_canonical
232 );
233
234 let signature =
236 self.calculate_signature(secret_key, region, service, timestamp, &string_to_sign);
237
238 let authorization = format!(
240 "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
241 access_key,
242 credential_scope,
243 "content-type;host;x-amz-content-sha256;x-amz-date",
244 signature
245 );
246
247 headers.insert(
248 "authorization",
249 authorization.parse().map_err(|e| {
250 ProviderError::InvalidResponse(format!("invalid header value: {e}"))
251 })?,
252 );
253
254 Ok(())
255 }
256
257 fn calculate_signature(
259 &self,
260 secret_key: &str,
261 region: &str,
262 service: &str,
263 timestamp: u64,
264 string_to_sign: &str,
265 ) -> String {
266 let datetime = format_timestamp(timestamp);
267
268 let k_secret = format!("AWS4{}", secret_key);
270 let k_date = hmac_sign(&datetime[..8], k_secret.as_bytes());
271 let k_region = hmac_sign(region, &k_date);
272 let k_service = hmac_sign(service, &k_region);
273 let k_signing = hmac_sign("aws4_request", &k_service);
274
275 hex_encode(hmac_sign_n(string_to_sign.as_bytes(), &k_signing))
277 }
278}
279
280impl Default for BedrockProvider {
281 fn default() -> Self {
282 Self::new()
283 }
284}
285
286fn format_timestamp(timestamp: u64) -> String {
288 use chrono::TimeZone;
289 #[allow(clippy::expect_used)]
294 let datetime = chrono::Utc
295 .timestamp_opt(timestamp as i64, 0)
296 .single()
297 .expect("invalid timestamp");
298 datetime.format("%Y%m%dT%H%M%SZ").to_string()
299}
300
301fn build_canonical_request(
303 method: &str,
304 path: &str,
305 query: &str,
306 headers: &reqwest::header::HeaderMap,
307 content_hash: &str,
308) -> String {
309 let canonical_query = if query.is_empty() {
311 String::new()
312 } else {
313 let mut parts: Vec<(String, String)> = query
314 .split('&')
315 .map(|part| {
316 let mut split = part.split('=');
317 let key = split.next().unwrap_or("");
318 let val = split.next().unwrap_or("");
319 (key.to_string(), val.to_string())
320 })
321 .collect();
322 parts.sort_by(|a, b| a.0.cmp(&b.0));
323 parts
324 .iter()
325 .map(|(k, v)| format!("{}={}", urlencoding_encode(k), urlencoding_encode(v)))
326 .collect::<Vec<_>>()
327 .join("&")
328 };
329
330 let mut header_vec: Vec<(String, String)> = headers
332 .iter()
333 .map(|(k, v)| {
334 (
335 k.as_str().to_lowercase(),
336 String::from_utf8_lossy(v.as_bytes()).trim().to_string(),
337 )
338 })
339 .collect();
340 header_vec.sort_by(|a, b| a.0.cmp(&b.0));
341
342 let canonical_headers: Vec<String> = header_vec
343 .iter()
344 .map(|(k, v)| format!("{}:{}", k, v))
345 .collect();
346 let canonical_headers_str = canonical_headers.join("\n");
347
348 let signed_headers: Vec<&str> = header_vec.iter().map(|(k, _)| k.as_str()).collect();
349 let signed_headers_str = signed_headers.join(";");
350
351 format!(
352 "{}\n{}\n{}\n{}\n\n{}\n{}",
353 method, path, canonical_query, canonical_headers_str, signed_headers_str, content_hash
354 )
355}
356
357fn hmac_sign(msg: &str, key: &[u8]) -> Vec<u8> {
359 #[allow(clippy::expect_used)]
363 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take key of any size");
364 mac.update(msg.as_bytes());
365 mac.finalize().into_bytes().to_vec()
366}
367
368fn hmac_sign_n(msg: &[u8], key: &[u8]) -> Vec<u8> {
370 #[allow(clippy::expect_used)]
373 let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take key of any size");
374 mac.update(msg);
375 mac.finalize().into_bytes().to_vec()
376}
377
378fn hash_sha256(data: &[u8]) -> Vec<u8> {
380 let mut hasher = Sha256::new();
381 hasher.update(data);
382 hasher.finalize().to_vec()
383}
384
385fn hex_encode(data: Vec<u8>) -> String {
387 data.iter().map(|b| format!("{:02x}", b)).collect()
388}
389
390fn urlencoding_encode(s: &str) -> String {
392 let mut result = String::new();
393 for c in s.chars() {
394 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '~' {
395 result.push(c);
396 } else {
397 for b in c.to_string().as_bytes() {
398 result.push_str(&format!("%{:02X}", b));
399 }
400 }
401 }
402 result
403}
404
405impl Provider for BedrockProvider {
406 fn stream<'a>(
407 &'a self,
408 model: &'a Model,
409 context: &'a Context,
410 options: Option<StreamOptions>,
411 ) -> Pin<Box<dyn Future<Output = StreamResult> + Send + 'a>> {
412 Box::pin(async move {
413 let options = options.unwrap_or_default();
414
415 let (access_key, secret_key, region) = self.get_credentials()?;
417 let session_token = self.get_session_token();
418
419 let url = self.get_endpoint(model, ®ion);
421
422 let messages = build_bedrock_messages(context)?;
424
425 let mut body = serde_json::json!({
427 "messages": messages,
428 });
429
430 if let Some(ref prompt) = context.system_prompt {
432 body["system"] = serde_json::json!([{
433 "text": prompt,
434 }]);
435 }
436
437 let mut inference_config = serde_json::json!({});
439 if let Some(temp) = options.temperature {
440 inference_config["temperature"] = serde_json::json!(temp);
441 }
442 if let Some(max) = options.max_tokens {
443 inference_config["maxTokens"] = serde_json::json!(max);
444 }
445 body["inferenceConfig"] = inference_config;
446
447 if !context.tools.is_empty() {
449 body["toolConfig"] = build_bedrock_tool_config(&context.tools)?;
450 }
451
452 if let Some(choice) = build_bedrock_tool_choice(options.tool_choice.as_ref()) {
454 body["toolChoice"] = choice;
455 }
456
457 let body_bytes = serde_json::to_vec(&body)?;
458
459 let mut headers = reqwest::header::HeaderMap::new();
461 headers.insert(
462 reqwest::header::CONTENT_TYPE,
463 "application/json".parse().map_err(|e| {
464 ProviderError::InvalidResponse(format!("invalid header value: {e}"))
465 })?,
466 );
467
468 if let Some(token) = session_token {
470 headers.insert(
471 "x-amz-security-token",
472 token.parse().map_err(|e| {
473 ProviderError::InvalidResponse(format!("invalid header value: {e}"))
474 })?,
475 );
476 }
477
478 self.sign_request(
480 "POST",
481 &url,
482 &mut headers,
483 &body_bytes,
484 &access_key,
485 &secret_key,
486 ®ion,
487 "bedrock",
488 )?;
489
490 let response = self
492 .client
493 .post(&url)
494 .headers(headers)
495 .body(body_bytes)
496 .send()
497 .await
498 .map_err(ProviderError::RequestFailed)?;
499
500 if !response.status().is_success() {
501 let status = response.status();
502 let body: String = response.text().await.unwrap_or_default();
503 return Err(ProviderError::HttpError(
504 crate::error::HttpErrorDetail::new(status.as_u16(), body),
505 ));
506 }
507
508 let provider_name = "bedrock".to_string();
510 let model_id = model.id.clone();
511
512 let stream =
513 response
514 .bytes_stream()
515 .flat_map(move |chunk: Result<Bytes, reqwest::Error>| match chunk {
516 Ok(bytes) => {
517 let text = String::from_utf8_lossy(&bytes).to_string();
518 futures::stream::iter(parse_bedrock_events(
519 &text,
520 &provider_name,
521 &model_id,
522 ))
523 }
524 Err(e) => futures::stream::iter(vec![ProviderEvent::Error {
525 reason: StopReason::Error,
526 error: create_error_message(&e.to_string(), &provider_name, &model_id),
527 }]),
528 });
529
530 Ok(Box::pin(stream) as Pin<Box<dyn Stream<Item = ProviderEvent> + Send>>)
531 })
532 }
533}
534
535fn build_bedrock_messages(context: &Context) -> Result<Vec<JsonValue>, ProviderError> {
537 let mut messages = Vec::new();
538
539 for msg in &context.messages {
540 match msg {
541 crate::Message::User(u) => {
542 let content = match &u.content {
543 crate::MessageContent::Text(s) => {
544 vec![serde_json::json!({
545 "text": s,
546 })]
547 }
548 crate::MessageContent::Blocks(blocks) => blocks_to_bedrock_content(blocks)?,
549 };
550 messages.push(serde_json::json!({
551 "role": "user",
552 "content": content,
553 }));
554 }
555 crate::Message::Assistant(a) => {
556 let content = blocks_to_bedrock_content(&a.content)?;
557 messages.push(serde_json::json!({
558 "role": "assistant",
559 "content": content,
560 }));
561 }
562 crate::Message::ToolResult(t) => {
563 let content = blocks_to_bedrock_content(&t.content)?;
564 messages.push(serde_json::json!({
565 "role": "user",
566 "content": [{
567 "toolResult": {
568 "toolUseId": t.tool_call_id,
569 "toolName": t.tool_name,
570 "content": [{
571 "json": content,
572 }],
573 }
574 }],
575 }));
576 }
577 }
578 }
579
580 Ok(messages)
581}
582
583fn blocks_to_bedrock_content(blocks: &[ContentBlock]) -> Result<Vec<JsonValue>, ProviderError> {
585 let mut items = Vec::new();
586
587 for block in blocks {
588 match block {
589 ContentBlock::Text(t) => {
590 items.push(serde_json::json!({
591 "text": t.text,
592 }));
593 }
594 ContentBlock::ToolCall(tc) => {
595 items.push(serde_json::json!({
596 "toolUse": {
597 "toolUseId": tc.id,
598 "name": tc.name,
599 "input": tc.arguments,
600 },
601 }));
602 }
603 ContentBlock::Thinking(th) => {
604 items.push(serde_json::json!({
606 "thinking": {
607 "thinking": th.thinking,
608 },
609 }));
610 }
611 ContentBlock::Image(img) => {
612 items.push(serde_json::json!({
613 "image": {
614 "format": img.mime_type.split('/').next_back().unwrap_or("jpeg"),
615 "source": {
616 "bytes": img.data,
617 },
618 },
619 }));
620 }
621 ContentBlock::Unknown(_) => {
622 }
624 }
625 }
626
627 Ok(items)
628}
629
630fn build_bedrock_tool_choice(tool_choice: Option<&crate::tools::ToolChoice>) -> Option<JsonValue> {
632 match tool_choice {
633 None | Some(crate::tools::ToolChoice::Auto) => None,
634 Some(crate::tools::ToolChoice::Named(name)) => Some(json!({"tool": {"name": name}})),
635 }
636}
637
638fn build_bedrock_tool_config(tools: &[crate::Tool]) -> Result<JsonValue, ProviderError> {
640 let items: Vec<_> = tools
641 .iter()
642 .map(|tool| {
643 serde_json::json!({
644 "toolSpec": {
645 "name": tool.name,
646 "description": tool.description,
647 "inputSchema": {
648 "json": tool.parameters,
649 },
650 },
651 })
652 })
653 .collect();
654
655 Ok(serde_json::json!({
656 "tools": items,
657 }))
658}
659
660fn parse_bedrock_events(text: &str, provider: &str, model_id: &str) -> Vec<ProviderEvent> {
662 let mut events = Vec::with_capacity(text.len() / 80);
664 let mut partial_message = AssistantMessage::new(Api::BedrockConverseStream, provider, model_id);
665
666 let mut accumulated_usage = Usage::default();
667 let mut stop_reason: Option<StopReason> = None;
668 let mut seen_start = false;
669
670 for line in text.split('\n') {
671 let line = line.trim_end_matches('\r');
672 if line.is_empty() {
673 continue;
674 }
675
676 if !line.starts_with("data: ") {
677 continue;
678 }
679
680 let data = &line[6..];
681
682 if data.is_empty() {
683 continue;
684 }
685
686 let event = match serde_json::from_str::<BedrockEvent>(data) {
687 Ok(e) => e,
688 Err(_) => continue,
689 };
690
691 match event.type_.as_deref() {
692 Some("messageStart") => {
693 seen_start = true;
694 events.push(ProviderEvent::Start {
695 partial: Arc::new(partial_message.clone()),
696 });
697 }
698 Some("contentBlockStart") => {
699 if let Some(block) = &event.content_block {
700 let block_type = block.get_type();
701
702 match block_type {
703 Some("text") => {
704 events.push(ProviderEvent::TextStart {
705 content_index: event.index.unwrap_or(0),
706 partial: Arc::new(partial_message.clone()),
707 });
708 }
709 Some("toolUse") => {
710 events.push(ProviderEvent::ToolCallStart {
711 content_index: event.index.unwrap_or(0),
712 tool_call_id: block.id.clone(),
713 tool_name: None,
714 partial: Arc::new(partial_message.clone()),
715 });
716 }
717 Some("thinking") => {
718 events.push(ProviderEvent::ThinkingStart {
719 content_index: event.index.unwrap_or(0),
720 partial: Arc::new(partial_message.clone()),
721 });
722 }
723 _ => {}
724 }
725 }
726 }
727 Some("contentBlockDelta") => {
728 if let Some(delta) = &event.delta {
729 match delta.type_.as_deref() {
730 Some("textDelta") => {
731 if let Some(text) = &delta.text {
732 let last_text_idx = partial_message
735 .content
736 .iter()
737 .rposition(|b| matches!(b, ContentBlock::Text(_)));
738 if let Some(idx) = last_text_idx
739 && let ContentBlock::Text(t) = &mut partial_message.content[idx]
740 {
741 t.text.push_str(text);
742 } else {
743 partial_message.content.push(ContentBlock::Text(
744 crate::TextContent::new(text.clone()),
745 ));
746 }
747 events.push(ProviderEvent::TextDelta {
748 content_index: event.index.unwrap_or(0),
749 delta: text.clone(),
750 partial: Arc::new(partial_message.clone()),
751 });
752 }
753 }
754 Some("toolUseDelta") => {
755 if let Some(tool_use) = &delta.tool_use {
756 if let Some(name) = &tool_use.name {
758 events.push(ProviderEvent::ToolCallDelta {
759 content_index: event.index.unwrap_or(0),
760 delta: format!("name:{}:DELIMITER", name),
761 partial: Arc::new(partial_message.clone()),
762 });
763 }
764 if let Some(input) = &tool_use.input {
766 events.push(ProviderEvent::ToolCallDelta {
767 content_index: event.index.unwrap_or(0),
768 delta: input.clone(),
769 partial: Arc::new(partial_message.clone()),
770 });
771 }
772 }
773 }
774 Some("thinkingDelta") => {
775 if let Some(thinking) = &delta.thinking {
776 let last_think_idx = partial_message
778 .content
779 .iter()
780 .rposition(|b| matches!(b, ContentBlock::Thinking(_)));
781 if let Some(idx) = last_think_idx
782 && let ContentBlock::Thinking(t) =
783 &mut partial_message.content[idx]
784 {
785 t.thinking.push_str(thinking);
786 } else {
787 partial_message.content.push(ContentBlock::Thinking(
788 crate::ThinkingContent::new(thinking.clone()),
789 ));
790 }
791 events.push(ProviderEvent::ThinkingDelta {
792 content_index: event.index.unwrap_or(0),
793 delta: thinking.clone(),
794 partial: Arc::new(partial_message.clone()),
795 });
796 }
797 }
798 _ => {}
799 }
800 }
801 }
802 Some("contentBlockStop") => {
803 }
805 Some("messageStop") => {
806 if let Some(metadata) = &event.metadata {
808 if let Some(reason) = &metadata.stop_reason {
809 stop_reason = Some(match reason.as_str() {
810 "end_turn" => StopReason::Stop,
811 "max_tokens" => StopReason::Length,
812 "tool_use" => StopReason::ToolUse,
813 "content_filtered" => StopReason::Error,
814 _ => StopReason::Stop,
815 });
816 }
817 if let Some(usage) = &metadata.usage {
818 accumulated_usage.input = usage.input_tokens.unwrap_or(0);
819 accumulated_usage.output = usage.output_tokens.unwrap_or(0);
820 accumulated_usage.total_tokens =
821 usage.input_tokens.unwrap_or(0) + usage.output_tokens.unwrap_or(0);
822 }
823 }
824 }
825 _ => {}
826 }
827 }
828
829 if seen_start {
831 let mut done_msg = partial_message.clone();
832 done_msg.usage = accumulated_usage.clone();
833 events.push(ProviderEvent::Done {
834 reason: stop_reason.unwrap_or(StopReason::Stop),
835 message: done_msg,
836 });
837 }
838
839 events
840}
841
842fn create_error_message(msg: &str, provider: &str, model_id: &str) -> AssistantMessage {
844 let mut message = AssistantMessage::new(Api::BedrockConverseStream, provider, model_id);
845 message.stop_reason = StopReason::Error;
846 message.error_message = Some(msg.to_string());
847 message
848}
849
850#[derive(Debug, Deserialize)]
852struct BedrockEvent {
854 #[serde(rename = "type")]
855 type_: Option<String>,
856 #[serde(rename = "index")]
857 index: Option<usize>,
858 #[serde(rename = "contentBlock")]
859 content_block: Option<ContentBlockRef>,
860 delta: Option<BedrockDelta>,
861 metadata: Option<BedrockMetadata>,
862}
863
864#[derive(Debug, Deserialize)]
865struct ContentBlockRef {
867 #[serde(rename = "type")]
868 block_type: Option<String>,
869 #[serde(rename = "index")]
870 _index: Option<usize>,
871 #[serde(default)]
873 id: Option<String>,
874}
875
876impl ContentBlockRef {
877 fn get_type(&self) -> Option<&str> {
878 self.block_type.as_deref()
879 }
880}
881
882#[derive(Debug, Deserialize)]
883struct BedrockDelta {
885 #[serde(rename = "type")]
886 type_: Option<String>,
887 text: Option<String>,
888 #[serde(rename = "toolUse")]
889 tool_use: Option<ToolUseDelta>,
890 thinking: Option<String>,
891 #[serde(rename = "partialJson")]
892 _partial_json: Option<String>,
893}
894
895#[derive(Debug, Deserialize)]
896struct ToolUseDelta {
898 #[serde(rename = "toolUseId")]
899 _tool_use_id: Option<String>,
900 name: Option<String>,
901 input: Option<String>,
902}
903
904#[derive(Debug, Deserialize)]
905struct BedrockMetadata {
907 #[serde(rename = "stopReason")]
908 stop_reason: Option<String>,
909 #[serde(rename = "usage")]
910 usage: Option<BedrockUsage>,
911 #[serde(rename = "trace")]
912 _trace: Option<serde_json::Value>,
913}
914
915#[derive(Debug, Deserialize)]
916struct BedrockUsage {
918 #[serde(rename = "inputTokens")]
919 input_tokens: Option<usize>,
920 #[serde(rename = "outputTokens")]
921 output_tokens: Option<usize>,
922 #[serde(rename = "totalTokens")]
923 _total_tokens: Option<usize>,
924 #[serde(rename = "cacheReadInputTokens")]
925 _cache_read_input_tokens: Option<usize>,
926 #[serde(rename = "cacheCreationInputTokens")]
927 _cache_creation_input_tokens: Option<usize>,
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933
934 #[test]
935 fn build_bedrock_tool_choice_maps_named_to_tool_shape() {
936 assert!(build_bedrock_tool_choice(None).is_none());
937 assert!(build_bedrock_tool_choice(Some(&crate::tools::ToolChoice::Auto)).is_none());
938 assert_eq!(
939 build_bedrock_tool_choice(Some(&crate::tools::ToolChoice::Named("todo".into()))),
940 Some(serde_json::json!({"tool": {"name": "todo"}}))
941 );
942 }
943 use crate::Message;
944
945 #[test]
946 fn test_timestamp_format() {
947 let timestamp = 1705326645u64;
949 let formatted = format_timestamp(timestamp);
950 assert!(formatted.starts_with("20240115T1350"));
952 assert!(formatted.ends_with("Z"));
953 }
954
955 #[test]
956 fn test_hmac_sign() {
957 let key = b"secret";
958 let msg = "test message";
959 let result = hmac_sign(msg, key);
960 assert_eq!(result.len(), 32); }
962
963 #[test]
964 fn test_hash_sha256() {
965 let data = b"hello world";
966 let result = hash_sha256(data);
967 assert_eq!(
969 hex_encode(result),
970 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
971 );
972 }
973
974 #[test]
975 fn test_urlencoding() {
976 assert_eq!(urlencoding_encode("hello world"), "hello%20world");
978 assert_eq!(urlencoding_encode("test-file.png"), "test-file.png");
979 assert_eq!(
980 urlencoding_encode("key=value&other=1"),
981 "key%3Dvalue%26other%3D1"
982 );
983 }
984
985 #[test]
986 fn test_build_bedrock_messages() {
987 let mut context = Context::default();
988 context.add_message(Message::user("Hello, world!"));
989
990 let messages = build_bedrock_messages(&context).unwrap();
991 assert_eq!(messages.len(), 1);
992 assert_eq!(messages[0]["role"], "user");
993 }
994
995 #[test]
996 fn test_parse_bedrock_events_usage() {
997 let json = r#"{"type":"messageStart","message":{}}"#;
998 let json2 = r#"{"type":"messageStop","metadata":{"stopReason":"end_turn","usage":{"inputTokens":100,"outputTokens":50}}}"#;
999
1000 let events = parse_bedrock_events(
1001 &format!("data: {}\ndata: {}", json, json2),
1002 "bedrock",
1003 "anthropic.claude-3-sonnet",
1004 );
1005
1006 let done_event = events
1007 .iter()
1008 .find(|e| matches!(e, ProviderEvent::Done { .. }));
1009 assert!(done_event.is_some());
1010 if let ProviderEvent::Done { message, .. } = done_event.unwrap() {
1011 assert_eq!(message.usage.input, 100);
1012 assert_eq!(message.usage.output, 50);
1013 }
1014 }
1015
1016 #[test]
1017 fn test_blocks_to_bedrock_content_text() {
1018 let blocks = vec![ContentBlock::Text(crate::TextContent::new("Hello"))];
1019 let result = blocks_to_bedrock_content(&blocks).unwrap();
1020 assert_eq!(result.len(), 1);
1021 assert_eq!(result[0]["text"], "Hello");
1022 }
1023
1024 #[test]
1025 fn test_blocks_to_bedrock_content_tool_call() {
1026 let blocks = vec![ContentBlock::ToolCall(crate::ToolCall::new(
1027 "call-123",
1028 "get_weather",
1029 serde_json::json!({"city": "NYC"}),
1030 ))];
1031 let result = blocks_to_bedrock_content(&blocks).unwrap();
1032 assert_eq!(result.len(), 1);
1033 assert_eq!(result[0]["toolUse"]["toolUseId"], "call-123");
1034 assert_eq!(result[0]["toolUse"]["name"], "get_weather");
1035 }
1036
1037 #[test]
1038 fn test_build_bedrock_tool_config() {
1039 let tools = vec![crate::Tool {
1040 name: "get_weather".to_string(),
1041 description: "Get weather for a city".to_string(),
1042 parameters: serde_json::json!({
1043 "type": "object",
1044 "properties": {
1045 "city": {"type": "string"}
1046 }
1047 }),
1048 }];
1049
1050 let config = build_bedrock_tool_config(&tools).unwrap();
1051 assert_eq!(config["tools"].as_array().unwrap().len(), 1);
1052 assert_eq!(config["tools"][0]["toolSpec"]["name"], "get_weather");
1053 }
1054
1055 #[test]
1056 fn test_hex_encode() {
1057 let data = vec![0x48, 0x65, 0x6c, 0x6c, 0x6f]; assert_eq!(hex_encode(data), "48656c6c6f");
1059 }
1060
1061 #[test]
1062 fn test_parse_bedrock_events_message_start() {
1063 let json = r#"{"type":"messageStart"}"#;
1064 let events = parse_bedrock_events(
1065 &format!("data: {}", json),
1066 "bedrock",
1067 "anthropic.claude-3-sonnet",
1068 );
1069 assert!(!events.is_empty());
1070 assert!(matches!(events[0], ProviderEvent::Start { .. }));
1071 }
1072
1073 #[test]
1074 fn test_parse_bedrock_events_content_blocks() {
1075 let j1 = r#"{"type":"messageStart"}"#;
1076 let j2 = r#"{"type":"contentBlockStart","contentBlock":{"type":"text","index":0}}"#;
1077 let j3 =
1078 r#"{"type":"contentBlockDelta","index":0,"delta":{"type":"textDelta","text":"Hello"}}"#;
1079 let j4 = r#"{"type":"contentBlockStop","index":0}"#;
1080 let j5 = r#"{"type":"messageStop","metadata":{"stopReason":"end_turn"}}"#;
1081 let text = format!(
1082 "data: {}\ndata: {}\ndata: {}\ndata: {}\ndata: {}",
1083 j1, j2, j3, j4, j5
1084 );
1085 let events = parse_bedrock_events(&text, "bedrock", "model");
1086 assert!(
1087 events
1088 .iter()
1089 .any(|e| matches!(e, ProviderEvent::Start { .. }))
1090 );
1091 assert!(
1092 events
1093 .iter()
1094 .any(|e| matches!(e, ProviderEvent::TextStart { .. }))
1095 );
1096 assert!(
1097 events
1098 .iter()
1099 .any(|e| matches!(e, ProviderEvent::TextDelta { .. }))
1100 );
1101 assert!(
1102 events
1103 .iter()
1104 .any(|e| matches!(e, ProviderEvent::Done { .. }))
1105 );
1106 }
1107 #[test]
1108 fn test_parse_bedrock_events_thinking() {
1109 let j1 = r#"{"type":"messageStart"}"#;
1110 let j2 = r#"{"type":"contentBlockStart","contentBlock":{"type":"thinking","index":0}}"#;
1111 let j3 = r#"{"type":"contentBlockDelta","index":0,"delta":{"type":"thinkingDelta","thinking":"test"}}"#;
1112 let j4 = r#"{"type":"contentBlockStop","index":0}"#;
1113 let j5 = r#"{"type":"messageStop","metadata":{"stopReason":"end_turn"}}"#;
1114 let text = format!(
1115 "data: {}\ndata: {}\ndata: {}\ndata: {}\ndata: {}",
1116 j1, j2, j3, j4, j5
1117 );
1118 let events = parse_bedrock_events(&text, "bedrock", "model");
1119 assert!(
1120 events
1121 .iter()
1122 .any(|e| matches!(e, ProviderEvent::ThinkingStart { .. }))
1123 );
1124 assert!(
1125 events
1126 .iter()
1127 .any(|e| matches!(e, ProviderEvent::ThinkingDelta { .. }))
1128 );
1129 }
1130 #[test]
1131 fn test_parse_bedrock_events_tool_call() {
1132 let j1 = r#"{"type":"messageStart"}"#;
1133 let j2 = r#"{"type":"contentBlockStart","contentBlock":{"type":"toolUse","index":0}}"#;
1134 let j3 = r#"{"type":"contentBlockDelta","index":0,"delta":{"type":"toolUseDelta","toolUse":{"name":"test"}}}"#;
1135 let j4 = r#"{"type":"contentBlockStop","index":0}"#;
1136 let j5 = r#"{"type":"messageStop","metadata":{"stopReason":"tool_use"}}"#;
1137 let text = format!(
1138 "data: {}\ndata: {}\ndata: {}\ndata: {}\ndata: {}",
1139 j1, j2, j3, j4, j5
1140 );
1141 let events = parse_bedrock_events(&text, "bedrock", "model");
1142 assert!(
1143 events
1144 .iter()
1145 .any(|e| matches!(e, ProviderEvent::ToolCallStart { .. }))
1146 );
1147 assert!(events.iter().any(|e| matches!(
1148 e,
1149 ProviderEvent::Done {
1150 reason: StopReason::ToolUse,
1151 ..
1152 }
1153 )));
1154 }
1155}