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