1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum CallMode {
16 Stream,
18 Once,
20}
21
22#[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
35fn 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 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#[derive(Debug, Default)]
170#[allow(dead_code)]
171pub struct StreamState {
172 pub data: HashMap<String, Value>,
173}
174
175#[async_trait]
202pub trait RawAdapter: Send + Sync {
203 fn build_request(&self, request: &ChatRequest, mode: CallMode) -> Result<RawRequest, LlmError>;
205
206 async fn execute_stream(
219 &self,
220 client: &dyn HttpClient,
221 request: RawRequest,
222 ) -> Result<ChatStream, LlmError>;
223
224 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 fn parse_response(&self, _body: &[u8]) -> Result<ChatResponse, LlmError> {
247 Err(LlmError::llm("Non-streaming mode not supported"))
248 }
249
250 fn capabilities(&self) -> Capabilities;
252
253 fn info(&self) -> ProviderInfo;
255
256 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}