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}