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::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    /// Release the underlying provider's host-visible transport resources.
328    ///
329    /// This forwards to [`Provider::close`]. Hosts that want a graceful
330    /// transport shutdown (for example, sending WebSocket Close frames on
331    /// cached Codex sessions) retain a clone of the handle they hand to the
332    /// core and call this before process exit. Providers with no reusable
333    /// transport state close as a no-op.
334    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    /// Validate model syntax only.
346    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/// Placeholder provider used by runtime policy defaults before a host resolver
499/// installs the executable provider. Every transport-level method errors;
500/// calling code MUST replace this before executing a turn.
501#[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}