Skip to main content

lucy/
provider.rs

1mod base;
2
3use std::ops::Deref;
4use std::path::Path;
5
6use crate::cancellation::CancellationToken;
7use crate::config::LlmSettings;
8use crate::model::ChatMessage;
9
10pub(crate) use base::ProviderStreamEvent;
11pub use base::{
12    parse_sse, ProviderError, ProviderModel, ProviderTurn, SseParseResult, PROVIDER_TIMEOUT,
13};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub(crate) enum ProviderFailureKind {
17    Cancelled,
18    ContextOverflow,
19    Timeout,
20    RateLimited,
21    Authentication,
22    InvalidRequest,
23    Transient,
24    Other,
25}
26
27impl ProviderError {
28    pub(crate) fn kind(&self) -> ProviderFailureKind {
29        if self.is_cancelled() {
30            return ProviderFailureKind::Cancelled;
31        }
32        let message = self.to_string().to_ascii_lowercase();
33        if contains_context_overflow(&message) {
34            return ProviderFailureKind::ContextOverflow;
35        }
36        if message.contains("http status 401") || message.contains("http status 403") {
37            return ProviderFailureKind::Authentication;
38        }
39        if message.contains("http status 429") {
40            return ProviderFailureKind::RateLimited;
41        }
42        if message.contains("(timeout)") || message.contains("http status 408") {
43            return ProviderFailureKind::Timeout;
44        }
45        if message.contains("http status 400")
46            || message.contains("http status 404")
47            || message.contains("http status 405")
48            || message.contains("http status 422")
49            || message.contains("unsupported")
50            || message.contains("invalid request")
51        {
52            return ProviderFailureKind::InvalidRequest;
53        }
54        if message.contains("(connection)")
55            || message.contains("(body)")
56            || message.contains("(decode)")
57            || message.contains("http status 500")
58            || message.contains("http status 502")
59            || message.contains("http status 503")
60            || message.contains("http status 504")
61        {
62            return ProviderFailureKind::Transient;
63        }
64        ProviderFailureKind::Other
65    }
66}
67
68fn contains_context_overflow(message: &str) -> bool {
69    [
70        "context window",
71        "context length",
72        "maximum context",
73        "max context",
74        "too many tokens",
75        "request too large",
76        "input is too long",
77        "exceeds the model context",
78        "http status 413",
79    ]
80    .iter()
81    .any(|needle| message.contains(needle))
82}
83
84/// Provider facade that keeps normal request behavior in the established
85/// implementation while routing compaction through a provider-neutral plan.
86pub struct Provider(base::Provider);
87
88impl Deref for Provider {
89    type Target = base::Provider;
90
91    fn deref(&self) -> &Self::Target {
92        &self.0
93    }
94}
95
96impl Provider {
97    pub fn new(settings: &LlmSettings) -> Result<Self, ProviderError> {
98        base::Provider::new(settings).map(Self)
99    }
100
101    pub fn new_codex(home: &Path, settings: &LlmSettings) -> Result<Self, ProviderError> {
102        base::Provider::new_codex(home, settings).map(Self)
103    }
104
105    pub(crate) fn with_session_id(self, session_id: &str) -> Self {
106        Self(self.0.with_session_id(session_id))
107    }
108
109    pub(crate) fn summarize_prepared(
110        &self,
111        planned: Vec<ChatMessage>,
112        cancellation: &CancellationToken,
113    ) -> Result<String, ProviderError> {
114        // Context-window metadata is resolved by the interactive harness. The
115        // provider facade deliberately avoids a second catalog request here;
116        // overflow responses still trigger progressively smaller attempts.
117        let attempts = crate::compaction_fallback::summary_attempts(planned, None)
118            .map_err(ProviderError::new)?;
119        let mut attempts = attempts.into_iter().peekable();
120        while let Some(attempt) = attempts.next() {
121            match self.0.summarize(&attempt, cancellation) {
122                Err(error)
123                    if error.kind() == ProviderFailureKind::ContextOverflow
124                        && attempts.peek().is_some() =>
125                {
126                    continue;
127                }
128                result => return result,
129            }
130        }
131        Err(ProviderError::new(
132            "compaction exhausted every bounded provider attempt",
133        ))
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn classifies_provider_failures_for_recovery() {
143        assert_eq!(
144            ProviderError::new("request exceeds the model context window").kind(),
145            ProviderFailureKind::ContextOverflow
146        );
147        assert_eq!(
148            ProviderError::new("provider returned HTTP status 429").kind(),
149            ProviderFailureKind::RateLimited
150        );
151        assert_eq!(
152            ProviderError::new("provider request failed (timeout)").kind(),
153            ProviderFailureKind::Timeout
154        );
155        assert_eq!(
156            ProviderError::new("provider returned HTTP status 401").kind(),
157            ProviderFailureKind::Authentication
158        );
159        assert_eq!(
160            ProviderError::new("provider request failed (connection)").kind(),
161            ProviderFailureKind::Transient
162        );
163    }
164}