Skip to main content

thndrs_lib/core/providers/
mod.rs

1//! Provider implementations.
2
3use std::path::Path;
4use std::time::Duration;
5
6use serde::{Deserialize, Serialize};
7
8use crate::app::AgentEvent;
9use crate::cli::{ReasoningEffort, ReasoningSummary};
10use crate::tools::ToolUseRequest;
11
12/// Bound DNS resolution and TCP/TLS setup so a disconnected provider cannot
13/// hold an agent run indefinitely.
14pub const PROVIDER_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
15
16/// Bound request transmission, including request bodies such as tool schemas.
17pub const PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
18
19/// Bound the wait for a provider to begin its HTTP response.
20pub const PROVIDER_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
21
22/// Bound one stalled read from a streaming provider response.
23///
24/// This is deliberately not a global request deadline: active SSE streams may
25/// continue longer than this interval as long as they keep producing data.
26pub const PROVIDER_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
27
28pub mod anthropic;
29pub mod codex;
30pub mod openai;
31pub mod opencode;
32pub mod umans;
33
34pub use codex as chatgpt_codex;
35pub use opencode::zen as opencode_zen;
36
37pub type Result<T> = std::result::Result<T, ProviderError>;
38
39/// Create the shared HTTP transport configuration for provider clients.
40///
41/// `ureq` applies `timeout_recv_body` to each blocking body read. That gives
42/// streaming responses a finite inactivity timeout without imposing a total
43/// lifetime on an otherwise healthy SSE response.
44pub fn provider_http_agent() -> ureq::Agent {
45    ureq::Agent::config_builder()
46        .timeout_resolve(Some(PROVIDER_CONNECT_TIMEOUT))
47        .timeout_connect(Some(PROVIDER_CONNECT_TIMEOUT))
48        .timeout_send_request(Some(PROVIDER_REQUEST_TIMEOUT))
49        .timeout_send_body(Some(PROVIDER_REQUEST_TIMEOUT))
50        .timeout_recv_response(Some(PROVIDER_RESPONSE_TIMEOUT))
51        .timeout_recv_body(Some(PROVIDER_STREAM_IDLE_TIMEOUT))
52        .build()
53        .new_agent()
54}
55
56/// Return the reasoning controls that can be safely selected for `model`.
57///
58/// `Auto` is always available. Provider modules intentionally own the
59/// model-specific compatibility rules rather than relying on a global list.
60pub fn reasoning_options(model: &str) -> Vec<ReasoningEffort> {
61    if opencode::is_zen_model_id(model) {
62        opencode::zen::reasoning_options(model)
63    } else if opencode::is_go_model_id(model) {
64        vec![ReasoningEffort::Auto]
65    } else if codex::is_model_id(model) {
66        codex::reasoning_options(model)
67    } else {
68        umans::reasoning_options(model)
69    }
70}
71
72/// Return whether a requested reasoning control is valid for `model`.
73pub fn reasoning_option_is_supported(model: &str, effort: ReasoningEffort) -> bool {
74    reasoning_options(model).contains(&effort)
75}
76
77/// Minimal provider trait used by the agent loop.
78///
79/// This trait intentionally stops at provider concerns: auth, metadata,
80/// request dispatch, retry/error policy, and which stream format to parse.
81///
82/// The agent loop still owns cancellation, tool dispatch, and transcript events.
83pub trait StreamingProvider: Sized {
84    type Metadata;
85
86    fn name(&self) -> &'static str;
87    fn load_status(&self) -> String;
88    fn request_status(&self, model: &str) -> String;
89    fn from_env_or_dotenv(root: &Path) -> Result<Self>;
90    fn load_metadata(&self) -> Result<Self::Metadata>;
91    fn metadata_loaded_event(&self, _metadata: &Self::Metadata) -> Option<AgentEvent> {
92        None
93    }
94    fn metadata_status(&self, _model: &str, _metadata: &Self::Metadata) -> Option<String> {
95        None
96    }
97    fn token_budget(&self, model: &str, metadata: Option<&Self::Metadata>) -> u32;
98    /// Serialize exactly the request body that the adapter sends.
99    fn serialized_request_body(
100        &self, model: &str, messages: &[ProviderMessage], request: &StreamingRequest<'_>,
101    ) -> Result<Vec<u8>>;
102    fn send_streaming_request(
103        &self, model: &str, messages: &[ProviderMessage], request: &StreamingRequest<'_>,
104    ) -> Result<ureq::http::Response<ureq::Body>>;
105    fn stream_format(&self, model: &str) -> Result<StreamFormat>;
106    fn request_error_message(error: &ProviderError) -> String;
107    fn is_retryable_request_error(error: &ProviderError) -> bool;
108}
109
110/// Serialize a provider request body at the accounting boundary.
111pub fn serialize_request_body(body: &serde_json::Value) -> Result<Vec<u8>> {
112    serde_json::to_vec(&body).map_err(|error| ProviderError::Json(error.to_string()))
113}
114
115/// Provider-private continuation state retained only for one active agent run.
116///
117/// This is intentionally crate-private: response items can contain encrypted
118/// provider data and must not become a library or session-file contract.
119#[derive(Clone, Debug, Default)]
120pub enum ProviderContinuation {
121    #[default]
122    None,
123    Responses {
124        items: Vec<serde_json::Value>,
125        consumed_messages: usize,
126    },
127}
128
129impl ProviderContinuation {
130    pub fn responses_items(&self) -> Option<(&[serde_json::Value], usize)> {
131        match self {
132            Self::None => None,
133            Self::Responses { items, consumed_messages } => Some((items, *consumed_messages)),
134        }
135    }
136
137    pub fn set_responses_items(&mut self, items: Vec<serde_json::Value>, consumed_messages: usize) {
138        *self = Self::Responses { items, consumed_messages };
139    }
140}
141
142/// Per-turn settings passed to an internal streaming provider request.
143pub struct StreamingRequest<'a> {
144    pub max_tokens: u32,
145    /// Model-specific reasoning control, validated by the provider boundary.
146    pub reasoning_effort: ReasoningEffort,
147    /// Whether supporting providers should return reasoning summaries.
148    pub reasoning_summary: ReasoningSummary,
149    pub tools: &'a serde_json::Value,
150    pub continuation: &'a ProviderContinuation,
151}
152
153/// Shared provider request error shape.
154#[derive(Debug, thiserror::Error)]
155pub enum ProviderError {
156    /// Required API key environment variable is not set.
157    #[error("{env} is not set; run `thndrs setup` or store the credential with `thndrs login`")]
158    MissingApiKey { env: &'static str },
159    /// The selected model id is not valid for this provider.
160    #[error("{provider} model ids must use {prefix}<model-id>, got {model}")]
161    InvalidModelId {
162        provider: &'static str,
163        prefix: &'static str,
164        model: String,
165    },
166    /// HTTP transport error.
167    #[error("http error: {0}")]
168    Http(String),
169    /// HTTP status error (non-2xx response).
170    #[error("HTTP {code}: {body}")]
171    Status { code: u16, body: String },
172    /// Authentication error before a provider request can be sent.
173    #[error("authentication failed: {0}")]
174    Auth(String),
175    /// Credential verification could not complete because a dependency is unavailable.
176    #[error("authentication verification unavailable: {0}")]
177    AuthUnavailable(String),
178    /// JSON serialization/deserialization error.
179    #[error("json error: {0}")]
180    Json(String),
181}
182
183impl ProviderError {
184    pub fn missing_api_key(env: &'static str) -> Self {
185        ProviderError::MissingApiKey { env }
186    }
187
188    pub fn invalid_model_id(provider: &'static str, prefix: &'static str, model: &str) -> Self {
189        ProviderError::InvalidModelId { provider, prefix, model: model.to_string() }
190    }
191
192    /// Whether the provider explicitly rejected the current credential.
193    pub fn is_credential_rejected(&self) -> bool {
194        matches!(
195            self,
196            ProviderError::Status { code: 401 | 403, .. } | ProviderError::Auth(_)
197        )
198    }
199
200    pub fn is_retryable(&self) -> bool {
201        match self {
202            ProviderError::MissingApiKey { .. }
203            | ProviderError::InvalidModelId { .. }
204            | ProviderError::Auth(_)
205            | ProviderError::Json(_) => false,
206            ProviderError::AuthUnavailable(_) => true,
207            ProviderError::Status { code, .. } => *code == 429 || (500..=599).contains(code),
208            ProviderError::Http(message) => {
209                let lower = message.to_ascii_lowercase();
210                !lower.contains("cancel") && !lower.contains("abort")
211            }
212        }
213    }
214
215    pub fn failure_message(&self, rate_limit_message: &str) -> String {
216        match self {
217            ProviderError::MissingApiKey { .. } | ProviderError::InvalidModelId { .. } => self.to_string(),
218            ProviderError::Status { code, body } => match code {
219                401 | 403 => format!("authentication failed (HTTP {code})"),
220                429 => rate_limit_message.to_string(),
221                500..=599 => format!("server error (HTTP {code}): {body}"),
222                _ => format!("HTTP {code}: {body}"),
223            },
224            ProviderError::Auth(message) => format!("authentication failed: {message}"),
225            ProviderError::AuthUnavailable(message) => format!("authentication verification unavailable: {message}"),
226            ProviderError::Http(e) => format!("network error: {e}"),
227            ProviderError::Json(e) => format!("response parse error: {e}"),
228        }
229    }
230}
231
232/// Streaming wire format used by a provider request.
233#[derive(Clone, Copy, Debug, Eq, PartialEq)]
234pub enum StreamFormat {
235    AnthropicMessages,
236    OpenAiChat,
237    ChatGptCodexResponses,
238}
239
240/// A structured content block in the provider-neutral Anthropic-style message
241/// format.
242///
243/// New provider routes can either use this directly or convert from it at their
244/// boundary. Umans currently sends it to `/v1/messages`; a future OpenAI
245/// compatible route should convert this shape into chat-completions messages
246/// instead of mixing both wire formats in the agent loop.
247#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
248#[serde(tag = "type", rename_all = "snake_case")]
249pub enum ProviderContentBlock {
250    /// A plain text block.
251    Text { text: String },
252    /// Base64-encoded image content for providers that support vision inputs.
253    Image { source: ProviderImageSource },
254    /// A tool-use request emitted by the assistant.
255    ToolUse {
256        /// Provider-assigned id (e.g. `toolu_01`), echoed back in `tool_result`.
257        id: String,
258        name: String,
259        input: serde_json::Value,
260    },
261    /// A tool result returned to the model in a `user`-role message.
262    ToolResult {
263        /// Must match the `id` of the originating `tool_use` block.
264        tool_use_id: String,
265        content: String,
266        /// Umans/Anthropic accept `is_error` as a bool.
267        #[serde(skip_serializing_if = "Option::is_none")]
268        is_error: Option<bool>,
269    },
270}
271
272/// Provider-neutral image source payload.
273#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
274#[serde(tag = "type", rename_all = "snake_case")]
275pub enum ProviderImageSource {
276    /// Base64 image data and its media type.
277    Base64 { media_type: String, data: String },
278}
279
280/// Message content: either a plain string or structured content blocks.
281#[derive(Clone, Debug, PartialEq)]
282pub enum ProviderMessageContent {
283    /// Plain string content.
284    Text(String),
285    /// Structured content blocks.
286    Blocks(Vec<ProviderContentBlock>),
287}
288
289impl Serialize for ProviderMessageContent {
290    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
291    where
292        S: serde::Serializer,
293    {
294        match self {
295            ProviderMessageContent::Text(s) => serializer.serialize_str(s),
296            ProviderMessageContent::Blocks(blocks) => blocks.serialize(serializer),
297        }
298    }
299}
300
301impl<'de> Deserialize<'de> for ProviderMessageContent {
302    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
303    where
304        D: serde::Deserializer<'de>,
305    {
306        let v = serde_json::Value::deserialize(deserializer)?;
307        match v {
308            serde_json::Value::String(s) => Ok(ProviderMessageContent::Text(s)),
309            serde_json::Value::Array(arr) => {
310                let blocks: Vec<ProviderContentBlock> =
311                    serde_json::from_value(serde_json::Value::Array(arr)).map_err(serde::de::Error::custom)?;
312                Ok(ProviderMessageContent::Blocks(blocks))
313            }
314            _ => Err(serde::de::Error::custom("expected string or array for message content")),
315        }
316    }
317}
318
319impl ProviderMessageContent {
320    /// Return the concatenated text of all `Text` blocks, or the plain string.
321    pub fn as_text(&self) -> String {
322        match self {
323            ProviderMessageContent::Text(s) => s.clone(),
324            ProviderMessageContent::Blocks(blocks) => blocks
325                .iter()
326                .filter_map(|b| match b {
327                    ProviderContentBlock::Text { text } => Some(text.clone()),
328                    _ => None,
329                })
330                .collect::<Vec<_>>()
331                .join(""),
332        }
333    }
334}
335
336/// Static model entry used by offline model pickers.
337#[derive(Clone, Copy, Debug, Eq, PartialEq)]
338pub struct KnownModel {
339    pub id: &'static str,
340    pub description: &'static str,
341}
342
343/// Shared HTTP client state for provider clients.
344pub struct ProviderHttpClient {
345    base_url: String,
346    api_key: String,
347    agent: ureq::Agent,
348}
349
350impl ProviderHttpClient {
351    pub fn new(base_url: &str, api_key: &str) -> Self {
352        ProviderHttpClient {
353            base_url: base_url.trim_end_matches('/').to_string(),
354            api_key: api_key.to_string(),
355            agent: provider_http_agent(),
356        }
357    }
358
359    pub fn base_url(&self) -> &str {
360        &self.base_url
361    }
362
363    pub fn api_key(&self) -> &str {
364        &self.api_key
365    }
366
367    pub fn agent(&self) -> &ureq::Agent {
368        &self.agent
369    }
370}
371
372/// Provider-neutral conversation message used by the agent loop.
373#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
374pub struct ProviderMessage {
375    pub role: String,
376    pub content: ProviderMessageContent,
377}
378
379impl ProviderMessage {
380    pub fn user(content: &str) -> Self {
381        ProviderMessage { role: "user".to_string(), content: ProviderMessageContent::Text(content.to_string()) }
382    }
383
384    pub fn assistant(content: &str) -> Self {
385        ProviderMessage { role: "assistant".to_string(), content: ProviderMessageContent::Text(content.to_string()) }
386    }
387
388    /// Create a `user`-role message containing one `tool_result` block.
389    pub fn tool_result(tool_use_id: &str, content: &str, is_error: bool) -> Self {
390        ProviderMessage {
391            role: "user".to_string(),
392            content: ProviderMessageContent::Blocks(vec![ProviderContentBlock::ToolResult {
393                tool_use_id: tool_use_id.to_string(),
394                content: content.to_string(),
395                is_error: Some(is_error),
396            }]),
397        }
398    }
399
400    /// Create an `assistant`-role message from content blocks.
401    pub fn assistant_blocks(blocks: Vec<ProviderContentBlock>) -> Self {
402        ProviderMessage { role: "assistant".to_string(), content: ProviderMessageContent::Blocks(blocks) }
403    }
404
405    /// Create a `user`-role message from content blocks.
406    pub fn user_blocks(blocks: Vec<ProviderContentBlock>) -> Self {
407        ProviderMessage { role: "user".to_string(), content: ProviderMessageContent::Blocks(blocks) }
408    }
409
410    /// Return the concatenated text content of this message.
411    pub fn as_text(&self) -> String {
412        self.content.as_text()
413    }
414}
415
416/// Provider-neutral result of one streamed model turn.
417#[derive(Clone, Debug, Eq, PartialEq)]
418pub struct ProviderTurn {
419    pub tool_requests: Vec<ToolUseRequest>,
420    pub assistant_text: String,
421    pub stop_reason: Option<String>,
422    pub response_items: Vec<serde_json::Value>,
423    /// Provider usage accumulated across stream updates, if reported.
424    pub usage: Option<thndrs_agent::ProviderUsageComponents>,
425}
426
427pub fn summarize_error_body(body: &str) -> String {
428    let trimmed = body.trim();
429    if trimmed.is_empty() {
430        return "(empty response body)".to_string();
431    }
432
433    serde_json::from_str::<serde_json::Value>(trimmed)
434        .ok()
435        .and_then(|v| {
436            v.pointer("/error/message")
437                .or_else(|| v.get("message"))
438                .and_then(|m| m.as_str())
439                .map(|m| m.to_string())
440        })
441        .unwrap_or_else(|| trimmed.chars().take(500).collect())
442}
443
444pub fn api_key_from_dotenv(root: &Path, name: &str) -> Option<String> {
445    std::fs::read_to_string(root.join(".env"))
446        .ok()?
447        .lines()
448        .find_map(|line| parse_api_key_line(line, name))
449}
450
451fn parse_api_key_line(line: &str, env_name: &str) -> Option<String> {
452    let line = line.trim();
453    if line.is_empty() || line.starts_with('#') {
454        return None;
455    }
456
457    let line = line.strip_prefix("export ").unwrap_or(line);
458    let (key, value) = line.split_once('=')?;
459    if key.trim() != env_name {
460        return None;
461    }
462
463    let value = value.trim();
464    let value = value
465        .strip_prefix('"')
466        .and_then(|v| v.strip_suffix('"'))
467        .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
468        .unwrap_or(value);
469
470    if value.is_empty() { None } else { Some(value.to_string()) }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn provider_http_client_bounds_setup_and_streaming_idle_timeouts() {
479        let client = ProviderHttpClient::new("https://provider.example", "test-key");
480        let timeouts = client.agent().config().timeouts();
481
482        assert_eq!(
483            timeouts.global, None,
484            "SSE streams must not have a total lifetime deadline"
485        );
486        assert_eq!(timeouts.per_call, None, "SSE streams must not have a per-call deadline");
487        assert_eq!(timeouts.resolve, Some(PROVIDER_CONNECT_TIMEOUT));
488        assert_eq!(timeouts.connect, Some(PROVIDER_CONNECT_TIMEOUT));
489        assert_eq!(timeouts.send_request, Some(PROVIDER_REQUEST_TIMEOUT));
490        assert_eq!(timeouts.send_body, Some(PROVIDER_REQUEST_TIMEOUT));
491        assert_eq!(timeouts.recv_response, Some(PROVIDER_RESPONSE_TIMEOUT));
492        assert_eq!(timeouts.recv_body, Some(PROVIDER_STREAM_IDLE_TIMEOUT));
493    }
494}