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 mut attempt = 0;
153 let call_id = LlmCallId(uuid::Uuid::new_v4().to_string());
154 let mut records = Vec::new();
155 let throttle_budget = Duration::from_millis(reliability.retry.throttle_wait_budget_ms);
158 let mut throttle_waited = Duration::ZERO;
159 loop {
160 let _permit = self.components.rate_limiter.admit(&request).await;
161 let clock = self.components.rate_limiter.clock();
162 let started_at = clock.timestamp_ms();
163 let started = clock.now();
164 let result = self.components.provider.complete(request.clone()).await;
165 match result {
166 Ok(response) => {
167 let outcome = success_outcome(response.terminal_reason);
168 records.push(AttemptRecord {
169 ordinal: records.len() as u32 + 1,
170 started_at,
171 duration: clock.now().saturating_duration_since(started),
172 outcome,
173 protocol_position: success_protocol_position(&response, outcome),
174 retry_budget_consumed: true,
175 retry_decision: None,
176 error: None,
177 evidence: response.execution_evidence.clone(),
178 usage: response
179 .provider_usage
180 .as_ref()
181 .map(|_| response.usage.clone()),
182 });
183 return Ok(ProviderCompletion {
184 response,
185 call_record: LlmCallRecord {
186 call_id,
187 label: None,
188 attempts: records,
189 },
190 });
191 }
192 Err(failure) => {
193 let failure = self.components.failure_classifier.classify(failure);
194 if failure.retryable
206 && failure.kind == ProviderFailureKind::Quota
207 && let Some(retry_after) = failure.retry_after
208 {
209 let wait = reliability.retry.cap_retry_after(retry_after);
210 let charge = wait.max(MIN_THROTTLE_BUDGET_CHARGE);
211 if throttle_waited.saturating_add(charge) <= throttle_budget {
214 throttle_waited += charge;
215 records.push(failure_attempt_record(
216 records.len() as u32 + 1,
217 started_at,
218 clock.now().saturating_duration_since(started),
219 &failure,
220 false,
221 Some(RetryDecision {
222 scheduled: true,
223 delay: Some(wait),
224 reason: Some("provider_retry_after".to_string()),
225 }),
226 ));
227 tracing::debug!(
228 target: "lash_core::provider::reliability",
229 provider = self.kind(),
230 attempt = attempt + 1,
231 max_attempts = attempts,
232 wait_ms = wait.as_millis() as u64,
233 throttle_waited_ms = throttle_waited.as_millis() as u64,
234 err = %failure.message,
235 "provider throttled with retry-after; waiting without consuming a retry attempt"
236 );
237 if let Some(events) = request.stream_events.as_ref() {
238 events.send(crate::llm::types::LlmStreamEvent::AttemptReset);
239 events.send(crate::llm::types::LlmStreamEvent::RetryStatus {
240 wait_seconds: wait.as_secs(),
241 attempt: (attempt + 1) as usize,
242 max_attempts: attempts as usize,
243 reason: failure.message.clone(),
244 });
245 }
246 self.components.rate_limiter.clock().sleep(wait).await;
247 continue;
248 }
249 }
250 if attempt + 1 >= attempts || !failure.retryable {
251 let reason = if !failure.retryable {
252 "not_retryable"
253 } else {
254 "retry_budget_exhausted"
255 };
256 records.push(failure_attempt_record(
257 records.len() as u32 + 1,
258 started_at,
259 clock.now().saturating_duration_since(started),
260 &failure,
261 true,
262 Some(RetryDecision {
263 scheduled: false,
264 delay: None,
265 reason: Some(reason.to_string()),
266 }),
267 ));
268 return Err(ProviderCompletionError {
269 error: failure,
270 call_record: LlmCallRecord {
271 call_id,
272 label: None,
273 attempts: records,
274 },
275 });
276 }
277 let delay = reliability
278 .retry
279 .delay_for_attempt(attempt, failure.retry_after);
280 records.push(failure_attempt_record(
281 records.len() as u32 + 1,
282 started_at,
283 clock.now().saturating_duration_since(started),
284 &failure,
285 true,
286 Some(RetryDecision {
287 scheduled: true,
288 delay: Some(delay),
289 reason: Some("retryable_failure".to_string()),
290 }),
291 ));
292 tracing::debug!(
293 target: "lash_core::provider::reliability",
294 provider = self.kind(),
295 attempt = attempt + 1,
296 max_attempts = attempts,
297 delay_ms = delay.as_millis() as u64,
298 err = %failure.message,
299 "provider call failed with retryable failure; sleeping before retry"
300 );
301 if let Some(events) = request.stream_events.as_ref() {
302 events.send(crate::llm::types::LlmStreamEvent::AttemptReset);
303 events.send(crate::llm::types::LlmStreamEvent::RetryStatus {
304 wait_seconds: delay.as_secs(),
305 attempt: (attempt + 1) as usize,
306 max_attempts: attempts as usize,
307 reason: failure.message.clone(),
308 });
309 }
310 self.components.rate_limiter.clock().sleep(delay).await;
311 attempt += 1;
312 }
313 }
314 }
315 }
316
317 pub async fn close(&self) -> Result<(), LlmTransportError> {
325 self.components.provider.close().await
326 }
327
328 pub fn to_spec(&self) -> ProviderSpec {
329 ProviderSpec {
330 kind: self.kind().to_string(),
331 config: self.components.provider.serialize_config(),
332 }
333 }
334
335 pub fn validate_model_name(&self, model: &str) -> Result<(), String> {
337 let m = model.trim();
338 if m.is_empty() {
339 return Err("model cannot be empty".to_string());
340 }
341 if m.contains(char::is_whitespace) {
342 return Err("model cannot contain whitespace".to_string());
343 }
344 Ok(())
345 }
346}
347
348fn success_outcome(reason: LlmTerminalReason) -> AttemptOutcome {
349 match reason {
350 LlmTerminalReason::Cancelled => AttemptOutcome::Aborted,
351 LlmTerminalReason::Unknown => AttemptOutcome::Interrupted,
352 _ => AttemptOutcome::Completed,
353 }
354}
355
356fn success_protocol_position(response: &LlmResponse, outcome: AttemptOutcome) -> ProtocolPosition {
357 if outcome == AttemptOutcome::Completed {
358 ProtocolPosition::TerminalObserved
359 } else if !response.full_text.is_empty() || !response.parts.is_empty() {
360 ProtocolPosition::OutputStarted
361 } else {
362 ProtocolPosition::ResponseObserved
363 }
364}
365
366fn failure_attempt_record(
367 ordinal: u32,
368 started_at: u64,
369 duration: Duration,
370 failure: &LlmTransportError,
371 retry_budget_consumed: bool,
372 retry_decision: Option<RetryDecision>,
373) -> AttemptRecord {
374 let partial = failure.partial_response.as_deref();
375 let provider_request_id = header_value(&failure.headers, "x-request-id");
379 let mut evidence = partial.and_then(|response| response.execution_evidence.clone());
380 if let Some(provider_request_id) = provider_request_id.clone() {
381 evidence
382 .get_or_insert_with(ExecutionEvidence::default)
383 .provider_request_id = Some(provider_request_id);
384 }
385 AttemptRecord {
386 ordinal,
387 started_at,
388 duration,
389 outcome: match (failure.terminal_reason, failure.kind) {
390 (LlmTerminalReason::Cancelled, _) => AttemptOutcome::Aborted,
391 (_, ProviderFailureKind::Timeout | ProviderFailureKind::Stream) => {
392 AttemptOutcome::Interrupted
393 }
394 _ => AttemptOutcome::Failed,
395 },
396 protocol_position: partial
397 .map(|response| {
398 if !response.full_text.is_empty() || !response.parts.is_empty() {
399 ProtocolPosition::OutputStarted
400 } else {
401 ProtocolPosition::ResponseObserved
402 }
403 })
404 .unwrap_or_else(|| {
405 if failure.status.is_some() {
406 ProtocolPosition::ResponseObserved
407 } else {
408 ProtocolPosition::NoResponse
409 }
410 }),
411 retry_budget_consumed,
412 retry_decision,
413 error: Some(NormalizedError {
414 class: failure.kind.code().to_string(),
415 provider_code: failure.code.clone(),
416 http_status: failure.status,
417 provider_request_id,
418 retry_after: failure.retry_after,
419 diagnostic: bounded_redacted_diagnostic(&failure.message),
420 }),
421 evidence,
422 usage: partial.and_then(|response| {
423 (response.provider_usage.is_some()
424 || response.usage != crate::llm::types::LlmUsage::default())
425 .then(|| response.usage.clone())
426 }),
427 }
428}
429
430fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
431 headers
432 .iter()
433 .find(|(header, _)| header.eq_ignore_ascii_case(name))
434 .map(|(_, value)| value.clone())
435}
436
437pub(super) const MAX_ATTEMPT_DIAGNOSTIC_CHARS: usize = 1_024;
438
439pub(super) fn bounded_redacted_diagnostic(message: &str) -> Option<String> {
440 let mut redacted = Vec::new();
441 let mut redact_next = false;
442 for word in message.split_whitespace() {
443 let lower = word.to_ascii_lowercase();
444 if redact_next
445 || lower.starts_with("sk-")
446 || lower.contains("api_key=")
447 || lower.contains("api-key=")
448 || lower.contains("authorization:")
449 {
450 redacted.push("[REDACTED]");
451 redact_next = false;
452 } else {
453 redacted.push(word);
454 redact_next =
455 lower == "bearer" || lower.ends_with("api_key=") || lower.ends_with("api-key=");
456 }
457 }
458 let diagnostic: String = redacted
459 .join(" ")
460 .chars()
461 .take(MAX_ATTEMPT_DIAGNOSTIC_CHARS)
462 .collect();
463 (!diagnostic.is_empty()).then_some(diagnostic)
464}
465
466impl std::fmt::Debug for ProviderHandle {
467 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468 self.components.fmt(f)
469 }
470}
471
472impl Clone for ProviderHandle {
473 fn clone(&self) -> Self {
474 Self {
475 components: self.components.clone(),
476 }
477 }
478}
479
480impl PartialEq for ProviderHandle {
481 fn eq(&self, other: &Self) -> bool {
482 self.kind() == other.kind() && self.to_spec().config == other.to_spec().config
483 }
484}
485
486impl Eq for ProviderHandle {}
487
488#[derive(Clone, Debug, Default)]
492pub struct UnconfiguredProvider {
493 options: ProviderOptions,
494}
495
496impl UnconfiguredProvider {
497 fn into_components(self) -> ProviderComponents {
498 ProviderComponents::new(Box::new(self))
499 }
500}
501
502#[async_trait]
503impl Provider for UnconfiguredProvider {
504 fn kind(&self) -> &'static str {
505 "unconfigured"
506 }
507
508 fn options(&self) -> ProviderOptions {
509 self.options.clone()
510 }
511
512 fn set_options(&mut self, options: ProviderOptions) {
513 self.options = options;
514 }
515
516 fn serialize_config(&self) -> serde_json::Value {
517 serde_json::Value::Object(Default::default())
518 }
519
520 async fn complete(&mut self, _request: LlmRequest) -> Result<LlmResponse, LlmTransportError> {
521 Err(LlmTransportError::new(
522 "no provider configured: host must set SessionPolicy.provider before running a turn",
523 ))
524 }
525
526 fn clone_boxed(&self) -> Box<dyn Provider> {
527 Box::new(self.clone())
528 }
529}