1use 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
12pub const PROVIDER_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
15
16pub const PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
18
19pub const PROVIDER_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
21
22pub 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
39pub 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
56pub 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
72pub fn reasoning_option_is_supported(model: &str, effort: ReasoningEffort) -> bool {
74 reasoning_options(model).contains(&effort)
75}
76
77pub 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 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
110pub 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#[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
142pub struct StreamingRequest<'a> {
144 pub max_tokens: u32,
145 pub reasoning_effort: ReasoningEffort,
147 pub reasoning_summary: ReasoningSummary,
149 pub tools: &'a serde_json::Value,
150 pub continuation: &'a ProviderContinuation,
151}
152
153#[derive(Debug, thiserror::Error)]
155pub enum ProviderError {
156 #[error("{env} is not set; run `thndrs setup` or store the credential with `thndrs login`")]
158 MissingApiKey { env: &'static str },
159 #[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 #[error("http error: {0}")]
168 Http(String),
169 #[error("HTTP {code}: {body}")]
171 Status { code: u16, body: String },
172 #[error("authentication failed: {0}")]
174 Auth(String),
175 #[error("authentication verification unavailable: {0}")]
177 AuthUnavailable(String),
178 #[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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
234pub enum StreamFormat {
235 AnthropicMessages,
236 OpenAiChat,
237 ChatGptCodexResponses,
238}
239
240#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
248#[serde(tag = "type", rename_all = "snake_case")]
249pub enum ProviderContentBlock {
250 Text { text: String },
252 Image { source: ProviderImageSource },
254 ToolUse {
256 id: String,
258 name: String,
259 input: serde_json::Value,
260 },
261 ToolResult {
263 tool_use_id: String,
265 content: String,
266 #[serde(skip_serializing_if = "Option::is_none")]
268 is_error: Option<bool>,
269 },
270}
271
272#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
274#[serde(tag = "type", rename_all = "snake_case")]
275pub enum ProviderImageSource {
276 Base64 { media_type: String, data: String },
278}
279
280#[derive(Clone, Debug, PartialEq)]
282pub enum ProviderMessageContent {
283 Text(String),
285 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
338pub struct KnownModel {
339 pub id: &'static str,
340 pub description: &'static str,
341}
342
343pub 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#[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 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 pub fn assistant_blocks(blocks: Vec<ProviderContentBlock>) -> Self {
402 ProviderMessage { role: "assistant".to_string(), content: ProviderMessageContent::Blocks(blocks) }
403 }
404
405 pub fn user_blocks(blocks: Vec<ProviderContentBlock>) -> Self {
407 ProviderMessage { role: "user".to_string(), content: ProviderMessageContent::Blocks(blocks) }
408 }
409
410 pub fn as_text(&self) -> String {
412 self.content.as_text()
413 }
414}
415
416#[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 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}