Skip to main content

llm_trait/
raw_adapter.rs

1//! Raw adapter trait for low-level provider implementations.
2
3use async_trait::async_trait;
4use serde_json::Value;
5use std::collections::HashMap;
6
7use super::capabilities::{Capabilities, ProviderInfo};
8use super::error::LlmError;
9use super::http_client::HttpClient;
10use super::request::ChatRequest;
11use super::response::{ChatResponse, ChatStream};
12
13/// Call mode.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum CallMode {
16    /// Streaming response
17    Stream,
18    /// Non-streaming response
19    Once,
20}
21
22/// Raw HTTP request.
23///
24/// `headers` normally carry the API key, so the `Debug` impl redacts values for
25/// authentication-related header names.
26#[derive(Clone)]
27pub struct RawRequest {
28    pub url: String,
29    pub method: HttpMethod,
30    pub headers: HashMap<String, String>,
31    pub body: Value,
32    pub stream: bool,
33}
34
35/// Whether a header name looks like it carries a credential.
36fn is_sensitive_header(name: &str) -> bool {
37    let name = name.to_ascii_lowercase();
38    name.contains("authorization")
39        || name.contains("api-key")
40        || name.contains("apikey")
41        || name.contains("x-api-key")
42        || name.contains("token")
43}
44
45impl std::fmt::Debug for RawRequest {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        let headers: HashMap<&str, &str> = self
48            .headers
49            .iter()
50            .map(|(k, v)| {
51                (
52                    k.as_str(),
53                    if is_sensitive_header(k) {
54                        "***"
55                    } else {
56                        v.as_str()
57                    },
58                )
59            })
60            .collect();
61        f.debug_struct("RawRequest")
62            .field("url", &self.url)
63            .field("method", &self.method)
64            .field("headers", &headers)
65            .field("body", &self.body)
66            .field("stream", &self.stream)
67            .finish()
68    }
69}
70
71#[cfg(test)]
72mod debug_tests {
73    use super::*;
74
75    fn request_with(headers: &[(&str, &str)]) -> RawRequest {
76        RawRequest {
77            url: "https://api.example.com/v1/messages".to_string(),
78            method: HttpMethod::Post,
79            headers: headers
80                .iter()
81                .map(|(k, v)| (k.to_string(), v.to_string()))
82                .collect(),
83            body: serde_json::json!({"model": "m"}),
84            stream: true,
85        }
86    }
87
88    #[test]
89    fn credential_headers_are_redacted() {
90        let secret = "sk-super-secret-value";
91        for name in [
92            "authorization",
93            "Authorization",
94            "x-api-key",
95            "X-API-KEY",
96            "api-key",
97            "apikey",
98            "x-goog-api-key",
99            "x-session-token",
100            "bearer-token",
101        ] {
102            let rendered = format!("{:?}", request_with(&[(name, secret)]));
103            assert!(
104                !rendered.contains(secret),
105                "header '{name}' leaked its value: {rendered}"
106            );
107            assert!(
108                rendered.contains("***"),
109                "header '{name}' should be redacted: {rendered}"
110            );
111        }
112    }
113
114    #[test]
115    fn non_credential_headers_stay_visible() {
116        // Content type and version headers are not secrets; hiding them would
117        // make debugging protocol mismatches needlessly hard.
118        let rendered = format!(
119            "{:?}",
120            request_with(&[
121                ("content-type", "application/json"),
122                ("anthropic-version", "2023-06-01"),
123            ])
124        );
125        assert!(rendered.contains("application/json"));
126        assert!(rendered.contains("2023-06-01"));
127        assert!(!rendered.contains("***"));
128    }
129
130    #[test]
131    fn other_fields_remain_visible() {
132        let rendered = format!("{:?}", request_with(&[("x-api-key", "secret")]));
133        assert!(rendered.contains("https://api.example.com/v1/messages"));
134        assert!(rendered.contains("Post"));
135        assert!(rendered.contains("stream: true"));
136        assert!(rendered.contains("model"));
137    }
138
139    #[test]
140    fn is_sensitive_header_recognises_credential_names() {
141        for name in [
142            "authorization",
143            "X-API-Key",
144            "apikey",
145            "refresh_token",
146            "API-KEY",
147        ] {
148            assert!(is_sensitive_header(name), "{name} should be sensitive");
149        }
150        for name in ["content-type", "user-agent", "anthropic-version", "accept"] {
151            assert!(!is_sensitive_header(name), "{name} should not be sensitive");
152        }
153    }
154}
155
156#[derive(Debug, Clone, Default, PartialEq, Eq)]
157pub enum HttpMethod {
158    #[default]
159    Post,
160    Get,
161    Put,
162    Delete,
163}
164
165/// Stream parsing state for adapter-internal incremental assembly.
166///
167/// For example, Anthropic tool calls need to assemble arguments across
168/// multiple SSE events. Adapters can use StreamState to buffer intermediate state.
169#[derive(Debug, Default)]
170#[allow(dead_code)]
171pub struct StreamState {
172    pub data: HashMap<String, Value>,
173}
174
175/// Low-level adapter trait.
176///
177/// Implement this to get full `LlmProvider` functionality,
178/// wrapped automatically by `GenericProvider`.
179///
180/// Implementors must provide:
181/// - [`build_request`](RawAdapter::build_request) - Build HTTP request
182/// - [`execute_stream`](RawAdapter::execute_stream) - Required to implement, but
183///   `GenericProvider` does not call it; it is the hook for adapters that own the
184///   whole send-and-parse flow
185/// - [`parse_sse_stream`](RawAdapter::parse_sse_stream) - Parse SSE from a
186///   pre-fetched response. **Override this for streaming to work**: the default
187///   returns an error, and it is this method `GenericProvider` calls.
188/// - [`capabilities`](RawAdapter::capabilities) - Declare capabilities
189/// - [`info`](RawAdapter::info) - Provide info
190///
191/// Optional:
192/// - [`parse_response`](RawAdapter::parse_response) - Parse non-streaming response
193///   (default returns an error). `chat()` only falls back to streaming when
194///   `supported_modes()` omits `CallMode::Once` — since that is not the default,
195///   an adapter that does not implement `parse_response` must also narrow
196///   `supported_modes`, or `chat()` will return the default error.
197///
198/// Note: the adapter is fully responsible for stream parsing, including SSE frame
199/// parsing and incremental tool call assembly. GenericProvider does not get involved
200/// in stream details.
201#[async_trait]
202pub trait RawAdapter: Send + Sync {
203    /// Build HTTP request.
204    fn build_request(&self, request: &ChatRequest, mode: CallMode) -> Result<RawRequest, LlmError>;
205
206    /// Execute streaming request, fully parse SSE response.
207    ///
208    /// The implementor receives `&dyn HttpClient` and parses the stream protocol,
209    /// returning `ChatStream`. This gives the adapter full control,
210    /// suitable for different providers' SSE format differences.
211    ///
212    /// Typical implementation:
213    /// 1. Send request via `client.send(&request)`
214    /// 2. Check HTTP status code
215    /// 3. Parse SSE stream (according to provider's protocol format)
216    /// 4. Incremental tool call assembly (if needed)
217    /// 5. Return ChatStream
218    async fn execute_stream(
219        &self,
220        client: &dyn HttpClient,
221        request: RawRequest,
222    ) -> Result<ChatStream, LlmError>;
223
224    /// Parse SSE stream from an already-received HTTP response.
225    ///
226    /// This method is called by GenericProvider after a successful HTTP response
227    /// (with retry logic applied). The adapter only needs to parse the SSE stream,
228    /// not send the HTTP request.
229    ///
230    /// Default implementation: returns an error. Adapters must override this —
231    /// GenericProvider calls it rather than [`execute_stream`](Self::execute_stream)
232    /// once a response is in hand, so an unimplemented override surfaces as a
233    /// stream error rather than a silently re-sent request.
234    async fn parse_sse_stream(
235        &self,
236        _client: &dyn HttpClient,
237        _request: RawRequest,
238        _response: super::http_client::HttpResponse,
239    ) -> Result<ChatStream, LlmError> {
240        Err(LlmError::llm("parse_sse_stream not implemented"))
241    }
242
243    /// Parse non-streaming response.
244    ///
245    /// Default: not supported. Override this if the adapter supports non-streaming mode.
246    fn parse_response(&self, _body: &[u8]) -> Result<ChatResponse, LlmError> {
247        Err(LlmError::llm("Non-streaming mode not supported"))
248    }
249
250    /// Get capabilities.
251    fn capabilities(&self) -> Capabilities;
252
253    /// Get info.
254    fn info(&self) -> ProviderInfo;
255
256    /// Supported call modes.
257    fn supported_modes(&self) -> &[CallMode] {
258        &[CallMode::Stream, CallMode::Once]
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn call_mode_equality() {
268        assert_eq!(CallMode::Stream, CallMode::Stream);
269        assert_eq!(CallMode::Once, CallMode::Once);
270        assert_ne!(CallMode::Stream, CallMode::Once);
271    }
272
273    #[test]
274    fn http_method_default_is_post() {
275        assert_eq!(HttpMethod::default(), HttpMethod::Post);
276    }
277
278    #[test]
279    fn raw_request_clone() {
280        let req = RawRequest {
281            url: "https://api.example.com/v1/chat".to_string(),
282            method: HttpMethod::Post,
283            headers: HashMap::from([("Authorization".to_string(), "Bearer sk-xxx".to_string())]),
284            body: serde_json::json!({"model": "test"}),
285            stream: true,
286        };
287        let cloned = req.clone();
288        assert_eq!(cloned.url, req.url);
289        assert_eq!(cloned.method, req.method);
290        assert_eq!(cloned.stream, req.stream);
291    }
292
293    #[test]
294    fn stream_state_default() {
295        let state = StreamState::default();
296        assert!(state.data.is_empty());
297    }
298}