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::AttemptReset);
247 events.send(crate::llm::types::LlmStreamEvent::RetryStatus {
248 wait_seconds: wait.as_secs(),
249 attempt: (attempt + 1) as usize,
250 max_attempts: attempts as usize,
251 reason: failure.message.clone(),
252 });
253 }
254 self.components.rate_limiter.clock().sleep(wait).await;
255 continue;
256 }
257 }
258 if attempt + 1 >= attempts || !failure.retryable {
259 let reason = if !failure.retryable {
260 "not_retryable"
261 } else {
262 "retry_budget_exhausted"
263 };
264 records.push(failure_attempt_record(
265 records.len() as u32 + 1,
266 started_at,
267 clock.now().saturating_duration_since(started),
268 &failure,
269 observe_execution,
270 true,
271 Some(RetryDecision {
272 scheduled: false,
273 delay: None,
274 reason: Some(reason.to_string()),
275 }),
276 ));
277 return Err(ProviderCompletionError {
278 error: failure,
279 call_record: LlmCallRecord {
280 call_id,
281 label: None,
282 attempts: records,
283 },
284 });
285 }
286 let delay = reliability
287 .retry
288 .delay_for_attempt(attempt, failure.retry_after);
289 records.push(failure_attempt_record(
290 records.len() as u32 + 1,
291 started_at,
292 clock.now().saturating_duration_since(started),
293 &failure,
294 observe_execution,
295 true,
296 Some(RetryDecision {
297 scheduled: true,
298 delay: Some(delay),
299 reason: Some("retryable_failure".to_string()),
300 }),
301 ));
302 tracing::debug!(
303 target: "lash_core::provider::reliability",
304 provider = self.kind(),
305 attempt = attempt + 1,
306 max_attempts = attempts,
307 delay_ms = delay.as_millis() as u64,
308 err = %failure.message,
309 "provider call failed with retryable failure; sleeping before retry"
310 );
311 if let Some(events) = request.stream_events.as_ref() {
312 events.send(crate::llm::types::LlmStreamEvent::AttemptReset);
313 events.send(crate::llm::types::LlmStreamEvent::RetryStatus {
314 wait_seconds: delay.as_secs(),
315 attempt: (attempt + 1) as usize,
316 max_attempts: attempts as usize,
317 reason: failure.message.clone(),
318 });
319 }
320 self.components.rate_limiter.clock().sleep(delay).await;
321 attempt += 1;
322 }
323 }
324 }
325 }
326
327 pub async fn close(&self) -> Result<(), LlmTransportError> {
335 self.components.provider.close().await
336 }
337
338 pub fn to_spec(&self) -> ProviderSpec {
339 ProviderSpec {
340 kind: self.kind().to_string(),
341 config: self.components.provider.serialize_config(),
342 }
343 }
344
345 pub fn validate_model_name(&self, model: &str) -> Result<(), String> {
347 let m = model.trim();
348 if m.is_empty() {
349 return Err("model cannot be empty".to_string());
350 }
351 if m.contains(char::is_whitespace) {
352 return Err("model cannot contain whitespace".to_string());
353 }
354 Ok(())
355 }
356}
357
358fn success_outcome(reason: LlmTerminalReason) -> AttemptOutcome {
359 match reason {
360 LlmTerminalReason::Cancelled => AttemptOutcome::Aborted,
361 LlmTerminalReason::Unknown => AttemptOutcome::Interrupted,
362 _ => AttemptOutcome::Completed,
363 }
364}
365
366fn success_protocol_position(response: &LlmResponse, outcome: AttemptOutcome) -> ProtocolPosition {
367 if outcome == AttemptOutcome::Completed {
368 ProtocolPosition::TerminalObserved
369 } else if !response.full_text.is_empty() || !response.parts.is_empty() {
370 ProtocolPosition::OutputStarted
371 } else {
372 ProtocolPosition::ResponseObserved
373 }
374}
375
376fn failure_attempt_record(
377 ordinal: u32,
378 started_at: u64,
379 duration: Duration,
380 failure: &LlmTransportError,
381 observe_execution: bool,
382 retry_budget_consumed: bool,
383 retry_decision: Option<RetryDecision>,
384) -> AttemptRecord {
385 let partial = failure.partial_response.as_deref();
386 let provider_request_id = observe_execution
387 .then(|| header_value(&failure.headers, "x-request-id"))
388 .flatten();
389 let mut evidence = partial.and_then(|response| response.execution_evidence.clone());
390 if let Some(provider_request_id) = provider_request_id.clone() {
391 evidence
392 .get_or_insert_with(ExecutionEvidence::default)
393 .provider_request_id = Some(provider_request_id);
394 }
395 AttemptRecord {
396 ordinal,
397 started_at,
398 duration,
399 outcome: match (failure.terminal_reason, failure.kind) {
400 (LlmTerminalReason::Cancelled, _) => AttemptOutcome::Aborted,
401 (_, ProviderFailureKind::Timeout | ProviderFailureKind::Stream) => {
402 AttemptOutcome::Interrupted
403 }
404 _ => AttemptOutcome::Failed,
405 },
406 protocol_position: partial
407 .map(|response| {
408 if !response.full_text.is_empty() || !response.parts.is_empty() {
409 ProtocolPosition::OutputStarted
410 } else {
411 ProtocolPosition::ResponseObserved
412 }
413 })
414 .unwrap_or_else(|| {
415 if failure.status.is_some() {
416 ProtocolPosition::ResponseObserved
417 } else {
418 ProtocolPosition::NoResponse
419 }
420 }),
421 retry_budget_consumed,
422 retry_decision,
423 error: Some(NormalizedError {
424 class: failure.kind.code().to_string(),
425 provider_code: failure.code.clone(),
426 http_status: failure.status,
427 provider_request_id,
428 retry_after: failure.retry_after,
429 diagnostic: bounded_redacted_diagnostic(&failure.message),
430 }),
431 evidence,
432 usage: partial.and_then(|response| {
433 (response.provider_usage.is_some()
434 || response.usage != crate::llm::types::LlmUsage::default())
435 .then(|| response.usage.clone())
436 }),
437 }
438}
439
440fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
441 headers
442 .iter()
443 .find(|(header, _)| header.eq_ignore_ascii_case(name))
444 .map(|(_, value)| value.clone())
445}
446
447pub(super) const MAX_ATTEMPT_DIAGNOSTIC_CHARS: usize = 1_024;
448
449pub(super) fn bounded_redacted_diagnostic(message: &str) -> Option<String> {
450 let mut redacted = Vec::new();
451 let mut redact_next = false;
452 for word in message.split_whitespace() {
453 let lower = word.to_ascii_lowercase();
454 if redact_next
455 || lower.starts_with("sk-")
456 || lower.contains("api_key=")
457 || lower.contains("api-key=")
458 || lower.contains("authorization:")
459 {
460 redacted.push("[REDACTED]");
461 redact_next = false;
462 } else {
463 redacted.push(word);
464 redact_next =
465 lower == "bearer" || lower.ends_with("api_key=") || lower.ends_with("api-key=");
466 }
467 }
468 let diagnostic: String = redacted
469 .join(" ")
470 .chars()
471 .take(MAX_ATTEMPT_DIAGNOSTIC_CHARS)
472 .collect();
473 (!diagnostic.is_empty()).then_some(diagnostic)
474}
475
476impl std::fmt::Debug for ProviderHandle {
477 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
478 self.components.fmt(f)
479 }
480}
481
482impl Clone for ProviderHandle {
483 fn clone(&self) -> Self {
484 Self {
485 components: self.components.clone(),
486 }
487 }
488}
489
490impl PartialEq for ProviderHandle {
491 fn eq(&self, other: &Self) -> bool {
492 self.kind() == other.kind() && self.to_spec().config == other.to_spec().config
493 }
494}
495
496impl Eq for ProviderHandle {}
497
498#[derive(Clone, Debug, Default)]
502pub struct UnconfiguredProvider {
503 options: ProviderOptions,
504}
505
506impl UnconfiguredProvider {
507 fn into_components(self) -> ProviderComponents {
508 ProviderComponents::new(Box::new(self))
509 }
510}
511
512#[async_trait]
513impl Provider for UnconfiguredProvider {
514 fn kind(&self) -> &'static str {
515 "unconfigured"
516 }
517
518 fn options(&self) -> ProviderOptions {
519 self.options.clone()
520 }
521
522 fn set_options(&mut self, options: ProviderOptions) {
523 self.options = options;
524 }
525
526 fn serialize_config(&self) -> serde_json::Value {
527 serde_json::Value::Object(Default::default())
528 }
529
530 async fn complete(&mut self, _request: LlmRequest) -> Result<LlmResponse, LlmTransportError> {
531 Err(LlmTransportError::new(
532 "no provider configured: host must set SessionPolicy.provider before running a turn",
533 ))
534 }
535
536 fn clone_boxed(&self) -> Box<dyn Provider> {
537 Box::new(self.clone())
538 }
539}