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 mut attempt = 0;
153        let call_id = LlmCallId(uuid::Uuid::new_v4().to_string());
154        let mut records = Vec::new();
155        // Cumulative time already spent deferring to provider throttles
156        // without consuming attempts, bounded by the policy's budget.
157        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                    // Throttle deference: when the provider signals a throttle
195                    // (retryable `Quota`) AND states how long to back off
196                    // (`Retry-After`), honor the wait without consuming a
197                    // retry attempt — the provider is asking us to come back,
198                    // not failing. The courtesy is bounded: each deferred wait
199                    // charges at least `MIN_THROTTLE_BUDGET_CHARGE` against
200                    // the cumulative `throttle_wait_budget_ms`, and once the
201                    // budget is spent a throttle counts as an ordinary
202                    // retryable failure. A throttle WITHOUT `Retry-After`
203                    // never defers: there is no server-stated wait to honor,
204                    // so the normal backoff-and-count ladder applies.
205                    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                        // Saturating: an absurd uncapped `Retry-After` must
212                        // overflow the budget check, not panic the ladder.
213                        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    /// Release the underlying provider's host-visible transport resources.
318    ///
319    /// This forwards to [`Provider::close`]. Hosts that want a graceful
320    /// transport shutdown (for example, sending WebSocket Close frames on
321    /// cached Codex sessions) retain a clone of the handle they hand to the
322    /// core and call this before process exit. Providers with no reusable
323    /// transport state close as a no-op.
324    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    /// Validate model syntax only.
336    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    // Providers that do not send this header simply report nothing, which is
376    // the honest result. Header-name variance across vendors (Anthropic uses
377    // `request-id`) is a separate concern.
378    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/// Placeholder provider used by runtime policy defaults before a host resolver
489/// installs the executable provider. Every transport-level method errors;
490/// calling code MUST replace this before executing a turn.
491#[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}