Skip to main content

lash_core/provider/
handle.rs

1use super::support::*;
2
3/// Component bundle returned by provider factories.
4#[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    /// Install a transport-level decorator that wraps the provider.
22    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
58/// Owning handle to provider components. This is an executable transport
59/// handle supplied by the host, not a persistence format.
60pub struct ProviderHandle {
61    components: ProviderComponents,
62}
63
64/// Successful provider-handle outcome with the sealed attempt history that
65/// produced it. The inner provider response remains available through
66/// `Deref` for source-compatible field access.
67#[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/// Failed provider-handle outcome. The transport error is preserved intact,
88/// and `call_record` makes all sealed attempts observable at this seam.
89#[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        // Cumulative time already spent deferring to provider throttles
157        // without consuming attempts, bounded by the policy's budget.
158        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                    // Throttle deference: when the provider signals a throttle
202                    // (retryable `Quota`) AND states how long to back off
203                    // (`Retry-After`), honor the wait without consuming a
204                    // retry attempt — the provider is asking us to come back,
205                    // not failing. The courtesy is bounded: each deferred wait
206                    // charges at least `MIN_THROTTLE_BUDGET_CHARGE` against
207                    // the cumulative `throttle_wait_budget_ms`, and once the
208                    // budget is spent a throttle counts as an ordinary
209                    // retryable failure. A throttle WITHOUT `Retry-After`
210                    // never defers: there is no server-stated wait to honor,
211                    // so the normal backoff-and-count ladder applies.
212                    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                        // Saturating: an absurd uncapped `Retry-After` must
219                        // overflow the budget check, not panic the ladder.
220                        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    /// Release the underlying provider's host-visible transport resources.
326    ///
327    /// This forwards to [`Provider::close`]. Hosts that want a graceful
328    /// transport shutdown (for example, sending WebSocket Close frames on
329    /// cached Codex sessions) retain a clone of the handle they hand to the
330    /// core and call this before process exit. Providers with no reusable
331    /// transport state close as a no-op.
332    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    /// Validate model syntax only.
344    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/// Placeholder provider used by runtime policy defaults before a host resolver
482/// installs the executable provider. Every transport-level method errors;
483/// calling code MUST replace this before executing a turn.
484#[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}