1use std::fmt;
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use futures::stream::StreamExt;
13
14use crate::circuit_breaker::CircuitBreaker;
15use crate::error::{LlmError, Result};
16use crate::stream::{SseDecoder, StreamAssembler};
17use crate::types::{CompletionRequest, CompletionResponse};
18
19pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
23
24#[derive(Clone)]
26pub struct LlmConfig {
27 pub base_url: String,
29 pub api_key: String,
31 pub timeout: Duration,
33}
34
35impl fmt::Debug for LlmConfig {
36 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37 formatter
38 .debug_struct("LlmConfig")
39 .field("base_url", &self.base_url)
40 .field("api_key", &"[REDACTED]")
41 .field("timeout", &self.timeout)
42 .finish()
43 }
44}
45
46impl LlmConfig {
47 pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Self {
49 Self {
50 base_url: base_url.into(),
51 api_key: api_key.into(),
52 timeout: DEFAULT_TIMEOUT,
53 }
54 }
55}
56
57#[derive(Clone)]
60pub struct LlmClient {
61 http: reqwest::Client,
62 base_url: Arc<str>,
63 api_key: Arc<str>,
64 breaker: Arc<CircuitBreaker>,
65 timeout: Duration,
66}
67
68impl LlmClient {
69 pub fn new(config: LlmConfig) -> Result<Self> {
71 Self::with_breaker(config, Arc::new(CircuitBreaker::default()))
72 }
73
74 pub fn with_breaker(config: LlmConfig, breaker: Arc<CircuitBreaker>) -> Result<Self> {
77 let http = reqwest::Client::builder().build()?;
78 Ok(Self {
79 http,
80 base_url: config.base_url.trim_end_matches('/').into(),
81 api_key: config.api_key.into(),
82 breaker,
83 timeout: config.timeout,
84 })
85 }
86
87 pub fn breaker(&self) -> &Arc<CircuitBreaker> {
89 &self.breaker
90 }
91
92 pub async fn complete_stream_single_attempt<F>(
95 &self,
96 request: &CompletionRequest,
97 on_delta: F,
98 ) -> Result<CompletionResponse>
99 where
100 F: FnMut(&str, bool) + Send,
101 {
102 let cancellation = tokio_util::sync::CancellationToken::new();
103 self.complete_stream_single_attempt_controlled(
104 request,
105 &cancellation,
106 Instant::now() + self.timeout,
107 on_delta,
108 )
109 .await
110 }
111
112 pub async fn complete_stream_single_attempt_controlled<F>(
114 &self,
115 request: &CompletionRequest,
116 cancellation: &tokio_util::sync::CancellationToken,
117 deadline: Instant,
118 mut on_delta: F,
119 ) -> Result<CompletionResponse>
120 where
121 F: FnMut(&str, bool) + Send,
122 {
123 if self.breaker.is_open() {
124 return Err(LlmError::CircuitOpen);
125 }
126 let url = format!("{}/chat/completions", self.base_url);
127 let result = tokio::select! {
128 biased;
129 _ = cancellation.cancelled() => Err(LlmError::Cancelled),
130 _ = tokio::time::sleep_until(deadline.into()) => Err(LlmError::DeadlineExceeded),
131 result = self.stream_collect_once(&url, request, &mut on_delta) => result,
132 };
133 match &result {
134 Ok(_) => self.breaker.record_success(),
135 Err(LlmError::Cancelled | LlmError::DeadlineExceeded) => {}
136 Err(_) => self.breaker.record_failure(),
137 }
138 result
139 }
140
141 async fn stream_collect_once<F>(
142 &self,
143 url: &str,
144 request: &CompletionRequest,
145 on_delta: &mut F,
146 ) -> Result<CompletionResponse>
147 where
148 F: FnMut(&str, bool) + Send,
149 {
150 let mut req = self.http.post(url).json(request);
151 if let Some(attempt_id) = &request.provider_attempt_id {
152 req = req
153 .header("Idempotency-Key", attempt_id)
154 .header("X-Agent-Factory-Attempt-Id", attempt_id);
155 }
156 if !self.api_key.is_empty() {
157 req = req.bearer_auth(self.api_key.as_ref());
158 }
159 let response = req.send().await?;
160 let status = response.status();
161 if !status.is_success() {
162 let body = response.text().await.unwrap_or_default();
163 return Err(LlmError::Api {
164 status: status.as_u16(),
165 body: redact_and_truncate(&body, self.api_key.as_ref()),
166 });
167 }
168
169 let mut assembler = StreamAssembler::default();
170 let mut decoder = SseDecoder::default();
171 let mut bytes = response.bytes_stream();
172 while let Some(chunk) = bytes.next().await {
173 for data in decoder.push(&chunk?)? {
174 if data == "[DONE]" {
175 decoder.finish()?;
176 return assembler.finish();
177 }
178 if let Some(delta) = assembler.apply_json(&data)? {
179 on_delta(&delta.content, delta.has_tool_calls);
180 }
181 }
182 }
183 decoder.finish()?;
184 Err(LlmError::StreamProtocol(
185 "stream ended before [DONE]".into(),
186 ))
187 }
188}
189
190const MAX_ERROR_BODY_BYTES: usize = 4 * 1024;
191
192fn redact_and_truncate(body: &str, secret: &str) -> String {
193 let redacted = if secret.is_empty() {
194 body.to_owned()
195 } else {
196 body.replace(secret, "[REDACTED]")
197 };
198 if redacted.len() <= MAX_ERROR_BODY_BYTES {
199 return redacted;
200 }
201 let mut end = MAX_ERROR_BODY_BYTES;
202 while !redacted.is_char_boundary(end) {
203 end -= 1;
204 }
205 format!("{}...[truncated]", &redacted[..end])
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn prepared_streaming_request_is_wire_stable() {
214 let mut request = CompletionRequest::new("model", vec![]).stream(true);
215 request.stream_options = Some(crate::StreamOptions {
216 include_usage: true,
217 });
218 let wire = serde_json::to_value(&request).unwrap();
219 assert_eq!(wire["stream"], true);
220 assert_eq!(wire["stream_options"]["include_usage"], true);
221 }
222
223 #[test]
224 fn config_debug_and_provider_errors_hide_credentials() {
225 let config = LlmConfig::new("https://provider.invalid/v1", "top-secret");
226 assert!(!format!("{config:?}").contains("top-secret"));
227
228 let body = format!("token=top-secret {}", "界".repeat(MAX_ERROR_BODY_BYTES));
229 let safe = redact_and_truncate(&body, "top-secret");
230 assert!(!safe.contains("top-secret"));
231 assert!(safe.len() <= MAX_ERROR_BODY_BYTES + "...[truncated]".len());
232 }
233
234 #[tokio::test]
235 async fn caller_cancellation_wins_before_network_io() {
236 let client =
237 LlmClient::new(LlmConfig::new("https://provider.invalid/v1", "secret")).unwrap();
238 let cancellation = tokio_util::sync::CancellationToken::new();
239 cancellation.cancel();
240 let result = client
241 .complete_stream_single_attempt_controlled(
242 &CompletionRequest::new("model", vec![]),
243 &cancellation,
244 Instant::now() + Duration::from_secs(1),
245 |_, _| {},
246 )
247 .await;
248 assert!(matches!(result, Err(LlmError::Cancelled)));
249 assert_eq!(client.breaker().status().failure_count, 0);
250 }
251}