dwara_core/ai/adapter.rs
1//! The [`ProviderAdapter`] trait (DW-075): the seam every provider
2//! dialect implements and every higher layer composes on.
3//!
4//! An adapter is a PURE TRANSLATOR. It holds no state, opens no
5//! connections, and knows nothing about routing or failover — it turns
6//! a [`ChatRequest`] into a provider
7//! HTTP request shape, and provider JSON back into the canonical types.
8//! Transport (endpoints, TLS, pooling, timeouts, breakers) belongs to
9//! the named upstream; composing behaviors (DW-076 routing/failover,
10//! DW-078 budgets) wrap the CALL, not the adapter — which is why the
11//! trait has no I/O: composition layers stay free to retry or reroute a
12//! translated request without re-translating.
13//!
14//! Streaming (DW-077 wires the gateway side): adapters translate one
15//! SSE data payload at a time via [`ProviderAdapter::parse_stream_event`];
16//! SSE framing itself is dialect-independent and lives in
17//! [`crate::ai::sse`].
18
19use crate::ai::types::{ChatRequest, ChatResponse, StreamEvent};
20use crate::config::ai::AiProviderKind;
21use http::HeaderName;
22use serde_json::Value;
23
24use crate::ai::adapters::anthropic::AnthropicAdapter;
25use crate::ai::adapters::azure_openai::AzureOpenAiAdapter;
26use crate::ai::adapters::bedrock::BedrockAdapter;
27use crate::ai::adapters::gemini::GeminiAdapter;
28use crate::ai::adapters::openai::OpenAiAdapter;
29// DW-114: the A2A adapter is a stateless singleton like the others.
30use crate::ai::a2a::A2AAdapter;
31
32/// The error type of the AI domain: client-side malformation, adapter
33/// translation failure, or a provider-reported error response.
34#[derive(Debug, Clone, PartialEq)]
35pub enum AiError {
36 /// The CLIENT's request cannot be served (malformed JSON, missing
37 /// model, unsupported streaming until DW-077, ...). Message is safe
38 /// to return to the client.
39 InvalidRequest(String),
40 /// The provider's payload could not be translated (unexpected
41 /// shape). Message names the offending field; safe for logs and
42 /// client responses.
43 Translation(String),
44 /// The provider answered a non-success status. Carries the
45 /// provider's message/type/code for pass-through. Constructed by
46 /// the TRANSPORT layer: the dataplane's `ai_proxy` maps provider
47 /// errors to responses directly via `parse_error` today, and DW-076's
48 /// failover transport is the intended constructor of this variant
49 /// (a retry decision matches on it) — which is why the variant
50 /// exists before anything builds it.
51 Provider {
52 status: u16,
53 message: String,
54 error_type: Option<String>,
55 code: Option<String>,
56 },
57}
58
59impl AiError {
60 /// A short, stable classification for metrics and logs.
61 pub fn kind(&self) -> &'static str {
62 match self {
63 AiError::InvalidRequest(_) => "invalid_request",
64 AiError::Translation(_) => "translation",
65 AiError::Provider { .. } => "provider",
66 }
67 }
68}
69
70impl std::fmt::Display for AiError {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 AiError::InvalidRequest(m) => write!(f, "invalid request: {m}"),
74 AiError::Translation(m) => write!(f, "translation failure: {m}"),
75 AiError::Provider {
76 status,
77 message,
78 error_type,
79 code,
80 } => {
81 write!(f, "provider error {status}")?;
82 if let Some(t) = error_type {
83 write!(f, " type={t}")?;
84 }
85 if let Some(c) = code {
86 write!(f, " code={c}")?;
87 }
88 write!(f, ": {message}")
89 }
90 }
91 }
92}
93
94impl std::error::Error for AiError {}
95
96/// A provider HTTP request produced by an adapter: everything the
97/// transport layer needs to place the call through the provider's
98/// upstream. The URI is a PATH (+query) only — the upstream endpoint
99/// supplies the authority, exactly like a proxy route.
100#[derive(Debug, Clone)]
101pub struct ProviderRequest {
102 /// Always POST for the shipped dialects; carried for shape honesty.
103 pub method: http::Method,
104 /// Request path and query, provider-specific.
105 pub path: String,
106 /// Dialect-required headers (content type, provider version
107 /// stamps). AUTH headers are NOT included — they come from the
108 /// provider's config, applied by the transport so adapters never
109 /// touch credentials.
110 pub headers: Vec<(HeaderName, String)>,
111 /// The provider request body as JSON.
112 pub body: Value,
113}
114
115/// A provider error body normalized for pass-through.
116#[derive(Debug, Clone, PartialEq)]
117pub struct ProviderErrorBody {
118 pub message: String,
119 pub error_type: Option<String>,
120 pub code: Option<String>,
121}
122
123/// The translation contract of one provider dialect (DW-075). Pure
124/// functions over the canonical types; implementations are stateless
125/// singletons (see [`adapter_for`]).
126pub trait ProviderAdapter: Send + Sync {
127 /// The dialect this adapter speaks.
128 fn kind(&self) -> AiProviderKind;
129
130 /// Translate a canonical request into this provider's HTTP shape.
131 /// `provider_model` is the mapped provider model identifier that
132 /// must REPLACE the client's alias in the outbound body.
133 fn build_request(
134 &self,
135 req: &ChatRequest,
136 provider_model: &str,
137 ) -> Result<ProviderRequest, AiError>;
138
139 /// Translate a provider success body (HTTP 200) into the canonical
140 /// [`ChatResponse`].
141 fn parse_response(&self, body: &Value) -> Result<ChatResponse, AiError>;
142
143 /// Extract the error message/type/code from a provider error body
144 /// (any shape the provider uses). Never fails — a best-effort
145 /// generic message is produced when the body is not understood.
146 fn parse_error(&self, body: &Value) -> ProviderErrorBody;
147
148 /// Translate ONE provider SSE data payload into zero or more
149 /// canonical stream events (DW-077 wires the gateway streaming
150 /// path; verified per adapter in DW-075 tests).
151 fn parse_stream_event(&self, data: &Value) -> Result<Vec<StreamEvent>, AiError>;
152
153 /// The dialect's end-of-stream data sentinel, if it has one
154 /// (OpenAI `[DONE]`; Anthropic/Gemini use typed events instead).
155 fn stream_done_sentinel(&self) -> Option<&'static str> {
156 None
157 }
158}
159
160static OPENAI_ADAPTER: OpenAiAdapter = OpenAiAdapter;
161static ANTHROPIC_ADAPTER: AnthropicAdapter = AnthropicAdapter;
162static GEMINI_ADAPTER: GeminiAdapter = GeminiAdapter;
163// DW-114: the A2A adapter singleton (stateless, like the others).
164static A2A_ADAPTER: A2AAdapter = A2AAdapter;
165// AI-01: the Azure OpenAI and AWS Bedrock adapter singletons.
166static AZURE_OPENAI_ADAPTER: AzureOpenAiAdapter = AzureOpenAiAdapter;
167static BEDROCK_ADAPTER: BedrockAdapter = BedrockAdapter;
168
169/// The adapter singleton for a provider kind. Adapters are stateless —
170/// one instance per dialect serves every request.
171pub fn adapter_for(kind: AiProviderKind) -> &'static dyn ProviderAdapter {
172 match kind {
173 AiProviderKind::Openai => &OPENAI_ADAPTER,
174 AiProviderKind::Anthropic => &ANTHROPIC_ADAPTER,
175 AiProviderKind::Gemini => &GEMINI_ADAPTER,
176 AiProviderKind::A2a => &A2A_ADAPTER,
177 AiProviderKind::AzureOpenai => &AZURE_OPENAI_ADAPTER,
178 AiProviderKind::Bedrock => &BEDROCK_ADAPTER,
179 }
180}