1use super::support::*;
2
3#[derive(Debug)]
5pub struct ProviderComponents {
6 pub provider: Box<dyn Provider>,
7 pub failure_classifier: Arc<dyn ProviderFailureClassifier>,
8 pub rate_limiter: Arc<ProviderRateLimiter>,
9}
10
11impl ProviderComponents {
12 pub fn new(provider: Box<dyn Provider>) -> Self {
13 let options = provider.options();
14 Self {
15 provider,
16 failure_classifier: Arc::new(DefaultProviderFailureClassifier),
17 rate_limiter: Arc::new(ProviderRateLimiter::new(options.reliability.rate_limits)),
18 }
19 }
20
21 pub fn map_provider(
23 mut self,
24 map: impl FnOnce(Box<dyn Provider>) -> Box<dyn Provider>,
25 ) -> Self {
26 self.provider = map(self.provider);
27 self
28 }
29
30 pub fn with_failure_classifier(
31 mut self,
32 classifier: Arc<dyn ProviderFailureClassifier>,
33 ) -> Self {
34 self.failure_classifier = classifier;
35 self
36 }
37
38 pub fn with_clock(mut self, clock: Arc<dyn crate::Clock>) -> Self {
39 let options = self.provider.options();
40 self.rate_limiter = Arc::new(ProviderRateLimiter::with_clock(
41 options.reliability.rate_limits,
42 clock,
43 ));
44 self
45 }
46}
47
48impl Clone for ProviderComponents {
49 fn clone(&self) -> Self {
50 Self {
51 provider: self.provider.clone_boxed(),
52 failure_classifier: Arc::clone(&self.failure_classifier),
53 rate_limiter: Arc::clone(&self.rate_limiter),
54 }
55 }
56}
57
58pub struct ProviderHandle {
61 components: ProviderComponents,
62}
63
64#[derive(Debug)]
68pub struct ProviderCompletion {
69 pub response: LlmResponse,
70 pub call_record: LlmCallRecord,
71}
72
73impl std::ops::Deref for ProviderCompletion {
74 type Target = LlmResponse;
75
76 fn deref(&self) -> &Self::Target {
77 &self.response
78 }
79}
80
81impl ProviderCompletion {
82 pub fn into_response(self) -> LlmResponse {
83 self.response
84 }
85}
86
87#[derive(Debug, thiserror::Error)]
90#[error("{error}")]
91pub struct ProviderCompletionError {
92 #[source]
93 pub error: LlmTransportError,
94 pub call_record: LlmCallRecord,
95}
96
97impl std::ops::Deref for ProviderCompletionError {
98 type Target = LlmTransportError;
99
100 fn deref(&self) -> &Self::Target {
101 &self.error
102 }
103}
104
105impl ProviderHandle {
106 pub fn new(components: ProviderComponents) -> Self {
107 Self { components }
108 }
109
110 pub fn unconfigured() -> Self {
111 Self::new(UnconfiguredProvider::default().into_components())
112 }
113
114 pub fn components(&self) -> &ProviderComponents {
115 &self.components
116 }
117
118 pub fn components_mut(&mut self) -> &mut ProviderComponents {
119 &mut self.components
120 }
121
122 pub fn with_clock(mut self, clock: Arc<dyn crate::Clock>) -> Self {
123 self.components = self.components.with_clock(clock);
124 self
125 }
126
127 pub fn kind(&self) -> &'static str {
128 self.components.provider.kind()
129 }
130
131 pub fn options(&self) -> ProviderOptions {
132 self.components.provider.options()
133 }
134
135 pub fn set_options(&mut self, options: ProviderOptions) {
136 self.components
137 .rate_limiter
138 .configure(options.reliability.rate_limits.clone());
139 self.components.provider.set_options(options)
140 }
141
142 pub fn requires_streaming(&self) -> bool {
143 self.components.provider.requires_streaming()
144 }
145
146 pub async fn complete(
147 &mut self,
148 request: LlmRequest,
149 ) -> Result<ProviderCompletion, ProviderCompletionError> {
150 let reliability = self.options().reliability;
151 let attempts = reliability.retry.attempts();
152 let observe_execution = matches!(self.kind(), "openai" | "openai-compatible");
153 let mut attempt = 0;
154 let call_id = LlmCallId(uuid::Uuid::new_v4().to_string());
155 let mut records = Vec::new();
156 let throttle_budget = Duration::from_millis(reliability.retry.throttle_wait_budget_ms);
159 let mut throttle_waited = Duration::ZERO;
160 loop {
161 let _permit = self.components.rate_limiter.admit(&request).await;
162 let clock = self.components.rate_limiter.clock();
163 let started_at = clock.timestamp_ms();
164 let started = clock.now();
165 let result = self.components.provider.complete(request.clone()).await;
166 match result {
167 Ok(response) => {
168 let outcome = success_outcome(response.terminal_reason);
169 records.push(AttemptRecord {
170 ordinal: records.len() as u32 + 1,
171 started_at,
172 duration: clock.now().saturating_duration_since(started),
173 outcome,
174 protocol_position: success_protocol_position(&response, outcome),
175 retry_budget_consumed: true,
176 retry_decision: None,
177 error: None,
178 evidence: observe_execution
179 .then(|| response.execution_evidence.clone())
180 .flatten(),
181 usage: observe_execution
182 .then(|| {
183 response
184 .provider_usage
185 .as_ref()
186 .map(|_| response.usage.clone())
187 })
188 .flatten(),
189 });
190 return Ok(ProviderCompletion {
191 response,
192 call_record: LlmCallRecord {
193 call_id,
194 label: None,
195 attempts: records,
196 },
197 });
198 }
199 Err(failure) => {
200 let failure = self.components.failure_classifier.classify(failure);
201 if failure.retryable
213 && failure.kind == ProviderFailureKind::Quota
214 && let Some(retry_after) = failure.retry_after
215 {
216 let wait = reliability.retry.cap_retry_after(retry_after);
217 let charge = wait.max(MIN_THROTTLE_BUDGET_CHARGE);
218 if throttle_waited.saturating_add(charge) <= throttle_budget {
221 throttle_waited += charge;
222 records.push(failure_attempt_record(
223 records.len() as u32 + 1,
224 started_at,
225 clock.now().saturating_duration_since(started),
226 &failure,
227 observe_execution,
228 false,
229 Some(RetryDecision {
230 scheduled: true,
231 delay: Some(wait),
232 reason: Some("provider_retry_after".to_string()),
233 }),
234 ));
235 tracing::debug!(
236 target: "lash_core::provider::reliability",
237 provider = self.kind(),
238 attempt = attempt + 1,
239 max_attempts = attempts,
240 wait_ms = wait.as_millis() as u64,
241 throttle_waited_ms = throttle_waited.as_millis() as u64,
242 err = %failure.message,
243 "provider throttled with retry-after; waiting without consuming a retry attempt"
244 );
245 if let Some(events) = request.stream_events.as_ref() {
246 events.send(crate::llm::types::LlmStreamEvent::RetryStatus {
247 wait_seconds: wait.as_secs(),
248 attempt: (attempt + 1) as usize,
249 max_attempts: attempts as usize,
250 reason: failure.message.clone(),
251 });
252 }
253 self.components.rate_limiter.clock().sleep(wait).await;
254 continue;
255 }
256 }
257 if attempt + 1 >= attempts || !failure.retryable {
258 let reason = if !failure.retryable {
259 "not_retryable"
260 } else {
261 "retry_budget_exhausted"
262 };
263 records.push(failure_attempt_record(
264 records.len() as u32 + 1,
265 started_at,
266 clock.now().saturating_duration_since(started),
267 &failure,
268 observe_execution,
269 true,
270 Some(RetryDecision {
271 scheduled: false,
272 delay: None,
273 reason: Some(reason.to_string()),
274 }),
275 ));
276 return Err(ProviderCompletionError {
277 error: failure,
278 call_record: LlmCallRecord {
279 call_id,
280 label: None,
281 attempts: records,
282 },
283 });
284 }
285 let delay = reliability
286 .retry
287 .delay_for_attempt(attempt, failure.retry_after);
288 records.push(failure_attempt_record(
289 records.len() as u32 + 1,
290 started_at,
291 clock.now().saturating_duration_since(started),
292 &failure,
293 observe_execution,
294 true,
295 Some(RetryDecision {
296 scheduled: true,
297 delay: Some(delay),
298 reason: Some("retryable_failure".to_string()),
299 }),
300 ));
301 tracing::debug!(
302 target: "lash_core::provider::reliability",
303 provider = self.kind(),
304 attempt = attempt + 1,
305 max_attempts = attempts,
306 delay_ms = delay.as_millis() as u64,
307 err = %failure.message,
308 "provider call failed with retryable failure; sleeping before retry"
309 );
310 if let Some(events) = request.stream_events.as_ref() {
311 events.send(crate::llm::types::LlmStreamEvent::RetryStatus {
312 wait_seconds: delay.as_secs(),
313 attempt: (attempt + 1) as usize,
314 max_attempts: attempts as usize,
315 reason: failure.message.clone(),
316 });
317 }
318 self.components.rate_limiter.clock().sleep(delay).await;
319 attempt += 1;
320 }
321 }
322 }
323 }
324
325 pub async fn close(&self) -> Result<(), LlmTransportError> {
333 self.components.provider.close().await
334 }
335
336 pub fn to_spec(&self) -> ProviderSpec {
337 ProviderSpec {
338 kind: self.kind().to_string(),
339 config: self.components.provider.serialize_config(),
340 }
341 }
342
343 pub fn validate_model_name(&self, model: &str) -> Result<(), String> {
345 let m = model.trim();
346 if m.is_empty() {
347 return Err("model cannot be empty".to_string());
348 }
349 if m.contains(char::is_whitespace) {
350 return Err("model cannot contain whitespace".to_string());
351 }
352 Ok(())
353 }
354}
355
356fn success_outcome(reason: LlmTerminalReason) -> AttemptOutcome {
357 match reason {
358 LlmTerminalReason::Cancelled => AttemptOutcome::Aborted,
359 LlmTerminalReason::Unknown => AttemptOutcome::Interrupted,
360 _ => AttemptOutcome::Completed,
361 }
362}
363
364fn success_protocol_position(response: &LlmResponse, outcome: AttemptOutcome) -> ProtocolPosition {
365 if outcome == AttemptOutcome::Completed {
366 ProtocolPosition::TerminalObserved
367 } else if !response.full_text.is_empty() || !response.parts.is_empty() {
368 ProtocolPosition::OutputStarted
369 } else {
370 ProtocolPosition::ResponseObserved
371 }
372}
373
374fn failure_attempt_record(
375 ordinal: u32,
376 started_at: u64,
377 duration: Duration,
378 failure: &LlmTransportError,
379 observe_execution: bool,
380 retry_budget_consumed: bool,
381 retry_decision: Option<RetryDecision>,
382) -> AttemptRecord {
383 let provider_request_id = observe_execution
384 .then(|| header_value(&failure.headers, "x-request-id"))
385 .flatten();
386 let evidence = provider_request_id
387 .clone()
388 .map(|provider_request_id| ExecutionEvidence {
389 provider_request_id: Some(provider_request_id),
390 ..ExecutionEvidence::default()
391 });
392 AttemptRecord {
393 ordinal,
394 started_at,
395 duration,
396 outcome: match (failure.terminal_reason, failure.kind) {
397 (LlmTerminalReason::Cancelled, _) => AttemptOutcome::Aborted,
398 (_, ProviderFailureKind::Timeout | ProviderFailureKind::Stream) => {
399 AttemptOutcome::Interrupted
400 }
401 _ => AttemptOutcome::Failed,
402 },
403 protocol_position: match failure.kind {
404 ProviderFailureKind::Stream => ProtocolPosition::OutputStarted,
405 _ if failure.status.is_some() => ProtocolPosition::ResponseObserved,
406 _ => ProtocolPosition::NoResponse,
407 },
408 retry_budget_consumed,
409 retry_decision,
410 error: Some(NormalizedError {
411 class: failure.kind.code().to_string(),
412 provider_code: failure.code.clone(),
413 http_status: failure.status,
414 provider_request_id,
415 retry_after: failure.retry_after,
416 diagnostic: bounded_redacted_diagnostic(&failure.message),
417 }),
418 evidence,
419 usage: None,
420 }
421}
422
423fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
424 headers
425 .iter()
426 .find(|(header, _)| header.eq_ignore_ascii_case(name))
427 .map(|(_, value)| value.clone())
428}
429
430pub(super) const MAX_ATTEMPT_DIAGNOSTIC_CHARS: usize = 1_024;
431
432pub(super) fn bounded_redacted_diagnostic(message: &str) -> Option<String> {
433 let mut redacted = Vec::new();
434 let mut redact_next = false;
435 for word in message.split_whitespace() {
436 let lower = word.to_ascii_lowercase();
437 if redact_next
438 || lower.starts_with("sk-")
439 || lower.contains("api_key=")
440 || lower.contains("api-key=")
441 || lower.contains("authorization:")
442 {
443 redacted.push("[REDACTED]");
444 redact_next = false;
445 } else {
446 redacted.push(word);
447 redact_next =
448 lower == "bearer" || lower.ends_with("api_key=") || lower.ends_with("api-key=");
449 }
450 }
451 let diagnostic: String = redacted
452 .join(" ")
453 .chars()
454 .take(MAX_ATTEMPT_DIAGNOSTIC_CHARS)
455 .collect();
456 (!diagnostic.is_empty()).then_some(diagnostic)
457}
458
459impl std::fmt::Debug for ProviderHandle {
460 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461 self.components.fmt(f)
462 }
463}
464
465impl Clone for ProviderHandle {
466 fn clone(&self) -> Self {
467 Self {
468 components: self.components.clone(),
469 }
470 }
471}
472
473impl PartialEq for ProviderHandle {
474 fn eq(&self, other: &Self) -> bool {
475 self.kind() == other.kind() && self.to_spec().config == other.to_spec().config
476 }
477}
478
479impl Eq for ProviderHandle {}
480
481#[derive(Clone, Debug, Default)]
485pub struct UnconfiguredProvider {
486 options: ProviderOptions,
487}
488
489impl UnconfiguredProvider {
490 fn into_components(self) -> ProviderComponents {
491 ProviderComponents::new(Box::new(self))
492 }
493}
494
495#[async_trait]
496impl Provider for UnconfiguredProvider {
497 fn kind(&self) -> &'static str {
498 "unconfigured"
499 }
500
501 fn options(&self) -> ProviderOptions {
502 self.options.clone()
503 }
504
505 fn set_options(&mut self, options: ProviderOptions) {
506 self.options = options;
507 }
508
509 fn serialize_config(&self) -> serde_json::Value {
510 serde_json::Value::Object(Default::default())
511 }
512
513 async fn complete(&mut self, _request: LlmRequest) -> Result<LlmResponse, LlmTransportError> {
514 Err(LlmTransportError::new(
515 "no provider configured: host must set SessionPolicy.provider before running a turn",
516 ))
517 }
518
519 fn clone_boxed(&self) -> Box<dyn Provider> {
520 Box::new(self.clone())
521 }
522}