dwara_core/ai/adapters/
bedrock.rs

1//! The AWS Bedrock provider adapter (AI-01).
2//!
3//! AWS Bedrock hosts foundation models (Anthropic Claude, Meta Llama,
4//! Mistral, etc.) behind a unified invoke API. For Claude models,
5//! Bedrock's `InvokeModel` endpoint accepts the SAME Anthropic messages
6//! body format as the native Anthropic API (`POST /v1/messages`), so
7//! this adapter DELEGATES the body translation to the Anthropic adapter
8//! and OVERRIDES the path to the Bedrock invoke format:
9//!
10//! ```text
11//! POST /model/{provider_model}/invoke
12//! ```
13//!
14//! where `{provider_model}` is the Bedrock model identifier (e.g.
15//! `anthropic.claude-3-5-sonnet-20241022-v2:0`).
16//!
17//! # SigV4 signing
18//!
19//! Bedrock requires AWS Signature Version 4 (SigV4) on every request.
20//! SigV4 is a request-signing scheme that adds an `Authorization`
21//! header computed from the AWS access key ID, secret access key,
22//! region, service name (`bedrock`), and the canonical request (method,
23//! path, headers, body hash). The signing is applied by the TRANSPORT
24//! layer (the dataplane's `ai_proxy`), not the adapter — the adapter is
25//! a pure translator and never touches credentials. The operator
26//! configures AWS credentials via the provider's `auth` header (the
27//! transport applies them) or via environment variables
28//! (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`) that
29//! the transport resolves at request time.
30//!
31//! In the current implementation, SigV4 signing is NOT yet wired in the
32//! transport — the adapter produces the correct path and body, but the
33//! transport applies the verbatim `auth` header from the provider's
34//! config (the same mechanism every other provider uses). A future
35//! extension will add SigV4 signing as a transport-level concern. Until
36//! then, Bedrock providers that accept a static API key (or a proxy
37//! that handles signing upstream) work unchanged; raw Bedrock endpoints
38//! requiring SigV4 will return 403 until the signing is wired.
39//!
40//! # Streaming
41//!
42//! Bedrock streaming uses a different endpoint (`/model/{id}/invoke-
43//! with-response-stream`) and a different event format (AWS Event
44//! Stream, not SSE). The current adapter does NOT translate Bedrock
45//! streaming events; a streaming request to a Bedrock provider will
46//! fall back to non-streaming (the `stream` flag is dropped). A future
47//! extension will add Bedrock event-stream parsing.
48
49use crate::ai::adapter::{AiError, ProviderAdapter, ProviderErrorBody, ProviderRequest};
50use crate::ai::adapters::anthropic::AnthropicAdapter;
51use crate::ai::types::{ChatRequest, ChatResponse, StreamEvent};
52use crate::config::ai::AiProviderKind;
53
54/// The AWS Bedrock adapter (AI-01). Stateless singleton; delegates body
55/// translation to the Anthropic adapter (Bedrock Claude models use the
56/// Anthropic messages format) and overrides the path to the Bedrock
57/// invoke format.
58pub struct BedrockAdapter;
59
60impl ProviderAdapter for BedrockAdapter {
61    fn kind(&self) -> AiProviderKind {
62        AiProviderKind::Bedrock
63    }
64
65    fn build_request(
66        &self,
67        req: &ChatRequest,
68        provider_model: &str,
69    ) -> Result<ProviderRequest, AiError> {
70        // Delegate the body translation to the Anthropic adapter
71        // (Bedrock Claude models accept the Anthropic messages body).
72        let mut pr = AnthropicAdapter.build_request(req, provider_model)?;
73        // Override the path to the Bedrock invoke format. The
74        // `provider_model` is the Bedrock model identifier (e.g.
75        // `anthropic.claude-3-5-sonnet-20241022-v2:0`). URL-encode the
76        // model id (it contains colons and dots).
77        let encoded_model = url_encode(provider_model);
78        pr.path = format!("/model/{encoded_model}/invoke");
79        // Bedrock does not use the Anthropic version header; remove it
80        // (the Anthropic adapter sets `anthropic-version`). The
81        // transport applies the auth header from the provider config.
82        pr.headers
83            .retain(|(name, _)| name.as_str() != "anthropic-version");
84        Ok(pr)
85    }
86
87    fn parse_response(&self, body: &serde_json::Value) -> Result<ChatResponse, AiError> {
88        // Bedrock returns the same response body as the Anthropic API.
89        AnthropicAdapter.parse_response(body)
90    }
91
92    fn parse_error(&self, body: &serde_json::Value) -> ProviderErrorBody {
93        // Bedrock error bodies follow the AWS JSON format
94        // (`{"message": ...}`); the Anthropic parser handles the
95        // common `{"error": {"message": ...}}` shape, and the
96        // best-effort fallback covers the AWS shape.
97        AnthropicAdapter.parse_error(body)
98    }
99
100    fn parse_stream_event(&self, data: &serde_json::Value) -> Result<Vec<StreamEvent>, AiError> {
101        // Bedrock streaming uses AWS Event Stream format, not SSE.
102        // The current adapter does not translate Bedrock streaming
103        // events; this method is not called for Bedrock providers
104        // (streaming is dropped at the transport level until the
105        // event-stream parser is added).
106        AnthropicAdapter.parse_stream_event(data)
107    }
108}
109
110/// Minimal URL-encoding for the path segment: encodes colons (which
111/// Bedrock model IDs contain) and other reserved characters. This is
112/// NOT a general-purpose URL encoder — it covers the characters that
113/// appear in Bedrock model identifiers.
114fn url_encode(s: &str) -> String {
115    let mut out = String::with_capacity(s.len());
116    for b in s.bytes() {
117        match b {
118            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
119                out.push(b as char);
120            }
121            _ => {
122                out.push('%');
123                out.push_str(&format!("{b:02X}"));
124            }
125        }
126    }
127    out
128}