Skip to main content

oxicode_ai/providers/
bedrock.rs

1//! Amazon Bedrock provider implementation
2//!
3//! This provider uses AWS SigV4 authentication with the Bedrock ConverseStream API.
4//! Supports Claude, Mistral, and other Bedrock models.
5
6use 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
25// Import Digest trait and Sha256 type for SHA256 hashing
26use sha2::{Digest, Sha256};
27
28/// HMAC-SHA256 type for SigV4 signing
29type HmacSha256 = Hmac<Sha256>;
30
31/// Amazon Bedrock provider
32#[derive(Clone)]
33pub struct BedrockProvider {
34    client: &'static Client,
35    default_region: String,
36}
37
38impl BedrockProvider {
39    /// Create a new Bedrock provider with default region (us-east-1)
40    ///
41    /// Region is resolved from:
42    /// 1. `~/.aws/config` (via AWS CLI profile)
43    /// 2. Environment variable `AWS_REGION` (CI/CD fallback)
44    /// 3. Default "us-east-1"
45    pub fn new() -> Self {
46        let region = Self::resolve_region();
47        Self {
48            client: shared_client(),
49            default_region: region,
50        }
51    }
52
53    /// Resolve AWS region from file config, then env, then default.
54    fn resolve_region() -> String {
55        // 1. Try ~/.aws/config
56        if let Some(region) = Self::region_from_aws_config() {
57            return region;
58        }
59        // 2. Fallback to env (CI/CD)
60        if let Ok(region) = std::env::var("AWS_REGION") {
61            return region;
62        }
63        // 3. Default
64        "us-east-1".to_string()
65    }
66
67    /// Read region from `~/.aws/config` under the `[default]` section.
68    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    /// Get AWS credentials from auth.json, ~/.aws/credentials, or env.
85    ///
86    /// Priority:
87    /// 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
88    /// 2. ~/.aws/credentials file
89    fn get_credentials(&self) -> Result<(String, String, String), ProviderError> {
90        // 1. Try environment variables (CI/CD)
91        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        // 2. Try ~/.aws/credentials
101        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    /// Read credentials from `~/.aws/credentials` under the `[default]` section.
109    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    /// Get optional session token (for temporary credentials)
143    fn get_session_token(&self) -> Option<String> {
144        std::env::var("AWS_SESSION_TOKEN").ok()
145    }
146
147    /// Get the endpoint URL for a model
148    fn get_endpoint(&self, model: &Model, region: &str) -> String {
149        // Use model's base_url if available, otherwise construct from region
150        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    /// Sign a request using AWS SigV4
166    #[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        // Parse the URL to get host and path
179        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        // Get current time for signing
187        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        // Content hash
194        let content_hash = hex_encode(hash_sha256(body));
195
196        // Set required headers
197        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        // Build canonical request
223        let canonical_request =
224            build_canonical_request(method, path, query, headers, &content_hash);
225
226        // Build string to sign
227        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        // Calculate signature
235        let signature =
236            self.calculate_signature(secret_key, region, service, timestamp, &string_to_sign);
237
238        // Build authorization header
239        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    /// Calculate AWS SigV4 signature
258    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        // AWS4 secret key
269        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        // Final signature
276        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
286/// AWS timestamp format: YYYYMMDDTHHMMSSZ using chrono
287fn format_timestamp(timestamp: u64) -> String {
288    use chrono::TimeZone;
289    // SAFETY: `timestamp` is derived from `SystemTime::now().duration_since(UNIX_EPOCH)`
290    // (always >= 0 in practice). A value outside chrono's representable range would
291    // be a clock/formatting bug in this crate, not a caller error — refusing to
292    // sign would hide the real failure. Infallible by construction.
293    #[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
301/// Build canonical request for SigV4
302fn build_canonical_request(
303    method: &str,
304    path: &str,
305    query: &str,
306    headers: &reqwest::header::HeaderMap,
307    content_hash: &str,
308) -> String {
309    // Canonical query string
310    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    // Canonical headers (sorted)
331    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
357/// HMAC-SHA256 sign
358fn hmac_sign(msg: &str, key: &[u8]) -> Vec<u8> {
359    // SAFETY: HMAC-SHA256 accepts keys of any length (RFC 2104); `new_from_slice`
360    // only fails if the digest rejects the key size, which is not the case for
361    // any fixed HMAC-SHA256 instance. Infallible by construction.
362    #[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
368/// HMAC-SHA256 sign with pre-computed key
369fn hmac_sign_n(msg: &[u8], key: &[u8]) -> Vec<u8> {
370    // SAFETY: same as `hmac_sign` — any-size keys are always accepted by
371    // HMAC-SHA256. Infallible by construction.
372    #[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
378/// SHA256 hash using Digest trait
379fn hash_sha256(data: &[u8]) -> Vec<u8> {
380    let mut hasher = Sha256::new();
381    hasher.update(data);
382    hasher.finalize().to_vec()
383}
384
385/// Hex encode bytes
386fn hex_encode(data: Vec<u8>) -> String {
387    data.iter().map(|b| format!("{:02x}", b)).collect()
388}
389
390/// URL encoding for SigV4 (RFC 3986)
391fn 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            // Get credentials
416            let (access_key, secret_key, region) = self.get_credentials()?;
417            let session_token = self.get_session_token();
418
419            // Get endpoint
420            let url = self.get_endpoint(model, &region);
421
422            // Build messages
423            let messages = build_bedrock_messages(context)?;
424
425            // Build request body
426            let mut body = serde_json::json!({
427                "messages": messages,
428            });
429
430            // Add system prompt
431            if let Some(ref prompt) = context.system_prompt {
432                body["system"] = serde_json::json!([{
433                    "text": prompt,
434                }]);
435            }
436
437            // Add inference config
438            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            // Add tool config if tools are present
448            if !context.tools.is_empty() {
449                body["toolConfig"] = build_bedrock_tool_config(&context.tools)?;
450            }
451
452            // Force the tool choice when a Named choice is set (and tools exist).
453            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            // Build headers
460            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            // Add session token if present (for temporary credentials)
469            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            // Sign the request
479            self.sign_request(
480                "POST",
481                &url,
482                &mut headers,
483                &body_bytes,
484                &access_key,
485                &secret_key,
486                &region,
487                "bedrock",
488            )?;
489
490            // Make request
491            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            // Create event stream
509            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
535/// Build messages in Bedrock Converse format
536fn 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
583/// Convert content blocks to Bedrock format
584fn 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                // Bedrock doesn't have native thinking, but Claude models support it
605                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                // Skip unknown blocks
623            }
624        }
625    }
626
627    Ok(items)
628}
629
630/// Map a `ToolChoice` to Bedrock Converse's forced-tool-choice shape.
631fn 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
638/// Build tool config in Bedrock format
639fn 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
660/// Parse Bedrock ConverseStream SSE events
661fn parse_bedrock_events(text: &str, provider: &str, model_id: &str) -> Vec<ProviderEvent> {
662    // F-6 (audit 2026-06-21): length-based estimate replaces 2-pass scan.
663    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                                // pi-mono: accumulate into partial_message so the TUI can
733                                // diff against its snapshot tracker.
734                                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                                // Emit tool call name if present
757                                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                                // Emit arguments
765                                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                                // pi-mono: accumulate into partial_message
777                                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                // Content block ended
804            }
805            Some("messageStop") => {
806                // Check for stop reason in metadata
807                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    // Emit done event if we saw a start
830    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
842/// Create error assistant message
843fn 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// Bedrock ConverseStream event structure
851#[derive(Debug, Deserialize)]
852// serde deserialization structs
853struct 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)]
865// serde deserialization structs
866struct ContentBlockRef {
867    #[serde(rename = "type")]
868    block_type: Option<String>,
869    #[serde(rename = "index")]
870    _index: Option<usize>,
871    /// Tool call ID present for toolUse blocks
872    #[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)]
883// serde deserialization structs
884struct 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)]
896// serde deserialization structs
897struct 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)]
905// serde deserialization structs
906struct 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)]
916// serde deserialization structs
917struct 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        // Test timestamp for known date: 2024-01-15 13:50:45 UTC
948        let timestamp = 1705326645u64;
949        let formatted = format_timestamp(timestamp);
950        // UTC timestamp 1705326645 = 2024-01-15T13:50:45Z
951        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); // SHA256 output length
961    }
962
963    #[test]
964    fn test_hash_sha256() {
965        let data = b"hello world";
966        let result = hash_sha256(data);
967        // Known SHA256 hash of "hello world"
968        assert_eq!(
969            hex_encode(result),
970            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
971        );
972    }
973
974    #[test]
975    fn test_urlencoding() {
976        // RFC 3986 percent-encoding - = is a reserved character and should be encoded
977        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]; // "Hello"
1058        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}