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 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
24// Import Digest trait and Sha256 type for SHA256 hashing
25use sha2::{Digest, Sha256};
26
27/// HMAC-SHA256 type for SigV4 signing
28type HmacSha256 = Hmac<Sha256>;
29
30/// Amazon Bedrock provider
31#[derive(Clone)]
32pub struct BedrockProvider {
33    client: &'static Client,
34    default_region: String,
35}
36
37impl BedrockProvider {
38    /// Create a new Bedrock provider with default region (us-east-1)
39    ///
40    /// Region is resolved from:
41    /// 1. `~/.aws/config` (via AWS CLI profile)
42    /// 2. Environment variable `AWS_REGION` (CI/CD fallback)
43    /// 3. Default "us-east-1"
44    pub fn new() -> Self {
45        let region = Self::resolve_region();
46        Self {
47            client: shared_client(),
48            default_region: region,
49        }
50    }
51
52    /// Resolve AWS region from file config, then env, then default.
53    fn resolve_region() -> String {
54        // 1. Try ~/.aws/config
55        if let Some(region) = Self::region_from_aws_config() {
56            return region;
57        }
58        // 2. Fallback to env (CI/CD)
59        if let Ok(region) = std::env::var("AWS_REGION") {
60            return region;
61        }
62        // 3. Default
63        "us-east-1".to_string()
64    }
65
66    /// Read region from `~/.aws/config` under the `[default]` section.
67    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    /// Get AWS credentials from auth.json, ~/.aws/credentials, or env.
84    ///
85    /// Priority:
86    /// 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
87    /// 2. ~/.aws/credentials file
88    fn get_credentials(&self) -> Result<(String, String, String), ProviderError> {
89        // 1. Try environment variables (CI/CD)
90        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        // 2. Try ~/.aws/credentials
100        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    /// Read credentials from `~/.aws/credentials` under the `[default]` section.
108    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    /// Get optional session token (for temporary credentials)
142    fn get_session_token(&self) -> Option<String> {
143        std::env::var("AWS_SESSION_TOKEN").ok()
144    }
145
146    /// Get the endpoint URL for a model
147    fn get_endpoint(&self, model: &Model, region: &str) -> String {
148        // Use model's base_url if available, otherwise construct from region
149        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    /// Sign a request using AWS SigV4
165    #[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        // Parse the URL to get host and path
178        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        // Get current time for signing
186        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        // Content hash
193        let content_hash = hex_encode(hash_sha256(body));
194
195        // Set required headers
196        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        // Build canonical request
222        let canonical_request =
223            build_canonical_request(method, path, query, headers, &content_hash);
224
225        // Build string to sign
226        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        // Calculate signature
234        let signature =
235            self.calculate_signature(secret_key, region, service, timestamp, &string_to_sign);
236
237        // Build authorization header
238        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    /// Calculate AWS SigV4 signature
257    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        // AWS4 secret key
268        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        // Final signature
275        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
285/// AWS timestamp format: YYYYMMDDTHHMMSSZ using chrono
286fn format_timestamp(timestamp: u64) -> String {
287    use chrono::TimeZone;
288    // SAFETY: `timestamp` is derived from `SystemTime::now().duration_since(UNIX_EPOCH)`
289    // (always >= 0 in practice). A value outside chrono's representable range would
290    // be a clock/formatting bug in this crate, not a caller error — refusing to
291    // sign would hide the real failure. Infallible by construction.
292    #[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
300/// Build canonical request for SigV4
301fn build_canonical_request(
302    method: &str,
303    path: &str,
304    query: &str,
305    headers: &reqwest::header::HeaderMap,
306    content_hash: &str,
307) -> String {
308    // Canonical query string
309    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    // Canonical headers (sorted)
330    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
356/// HMAC-SHA256 sign
357fn hmac_sign(msg: &str, key: &[u8]) -> Vec<u8> {
358    // SAFETY: HMAC-SHA256 accepts keys of any length (RFC 2104); `new_from_slice`
359    // only fails if the digest rejects the key size, which is not the case for
360    // any fixed HMAC-SHA256 instance. Infallible by construction.
361    #[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
367/// HMAC-SHA256 sign with pre-computed key
368fn hmac_sign_n(msg: &[u8], key: &[u8]) -> Vec<u8> {
369    // SAFETY: same as `hmac_sign` — any-size keys are always accepted by
370    // HMAC-SHA256. Infallible by construction.
371    #[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
377/// SHA256 hash using Digest trait
378fn hash_sha256(data: &[u8]) -> Vec<u8> {
379    let mut hasher = Sha256::new();
380    hasher.update(data);
381    hasher.finalize().to_vec()
382}
383
384/// Hex encode bytes
385fn hex_encode(data: Vec<u8>) -> String {
386    data.iter().map(|b| format!("{:02x}", b)).collect()
387}
388
389/// URL encoding for SigV4 (RFC 3986)
390fn 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            // Get credentials
415            let (access_key, secret_key, region) = self.get_credentials()?;
416            let session_token = self.get_session_token();
417
418            // Get endpoint
419            let url = self.get_endpoint(model, &region);
420
421            // Build messages
422            let messages = build_bedrock_messages(context)?;
423
424            // Build request body
425            let mut body = serde_json::json!({
426                "messages": messages,
427            });
428
429            // Add system prompt
430            if let Some(ref prompt) = context.system_prompt {
431                body["system"] = serde_json::json!([{
432                    "text": prompt,
433                }]);
434            }
435
436            // Add inference config
437            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            // Add tool config if tools are present
447            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            // Build headers
454            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            // Add session token if present (for temporary credentials)
463            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            // Sign the request
473            self.sign_request(
474                "POST",
475                &url,
476                &mut headers,
477                &body_bytes,
478                &access_key,
479                &secret_key,
480                &region,
481                "bedrock",
482            )?;
483
484            // Make request
485            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            // Create event stream
503            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
529/// Build messages in Bedrock Converse format
530fn 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
577/// Convert content blocks to Bedrock format
578fn 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                // Bedrock doesn't have native thinking, but Claude models support it
599                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                // Skip unknown blocks
617            }
618        }
619    }
620
621    Ok(items)
622}
623
624/// Build tool config in Bedrock format
625fn 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
646/// Parse Bedrock ConverseStream SSE events
647fn parse_bedrock_events(text: &str, provider: &str, model_id: &str) -> Vec<ProviderEvent> {
648    // F-6 (audit 2026-06-21): length-based estimate replaces 2-pass scan.
649    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                                // pi-mono: accumulate into partial_message so the TUI can
719                                // diff against its snapshot tracker.
720                                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                                // Emit tool call name if present
743                                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                                // Emit arguments
751                                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                                // pi-mono: accumulate into partial_message
763                                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                // Content block ended
790            }
791            Some("messageStop") => {
792                // Check for stop reason in metadata
793                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    // Emit done event if we saw a start
816    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
828/// Create error assistant message
829fn 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// Bedrock ConverseStream event structure
837#[derive(Debug, Deserialize)]
838// serde deserialization structs
839struct 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)]
851// serde deserialization structs
852struct ContentBlockRef {
853    #[serde(rename = "type")]
854    block_type: Option<String>,
855    #[serde(rename = "index")]
856    _index: Option<usize>,
857    /// Tool call ID present for toolUse blocks
858    #[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)]
869// serde deserialization structs
870struct 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)]
882// serde deserialization structs
883struct 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)]
891// serde deserialization structs
892struct 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)]
902// serde deserialization structs
903struct 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        // Test timestamp for known date: 2024-01-15 13:50:45 UTC
924        let timestamp = 1705326645u64;
925        let formatted = format_timestamp(timestamp);
926        // UTC timestamp 1705326645 = 2024-01-15T13:50:45Z
927        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); // SHA256 output length
937    }
938
939    #[test]
940    fn test_hash_sha256() {
941        let data = b"hello world";
942        let result = hash_sha256(data);
943        // Known SHA256 hash of "hello world"
944        assert_eq!(
945            hex_encode(result),
946            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
947        );
948    }
949
950    #[test]
951    fn test_urlencoding() {
952        // RFC 3986 percent-encoding - = is a reserved character and should be encoded
953        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]; // "Hello"
1034        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}