use std::path::Path;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::app::AgentEvent;
use crate::cli::{ReasoningEffort, ReasoningSummary};
use crate::tools::ToolUseRequest;
pub const PROVIDER_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub const PROVIDER_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
pub const PROVIDER_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
pub const PROVIDER_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
pub mod anthropic;
pub mod codex;
pub mod openai;
pub mod opencode;
pub mod umans;
pub use codex as chatgpt_codex;
pub use opencode::zen as opencode_zen;
pub type Result<T> = std::result::Result<T, ProviderError>;
pub fn provider_http_agent() -> ureq::Agent {
ureq::Agent::config_builder()
.timeout_resolve(Some(PROVIDER_CONNECT_TIMEOUT))
.timeout_connect(Some(PROVIDER_CONNECT_TIMEOUT))
.timeout_send_request(Some(PROVIDER_REQUEST_TIMEOUT))
.timeout_send_body(Some(PROVIDER_REQUEST_TIMEOUT))
.timeout_recv_response(Some(PROVIDER_RESPONSE_TIMEOUT))
.timeout_recv_body(Some(PROVIDER_STREAM_IDLE_TIMEOUT))
.build()
.new_agent()
}
pub fn reasoning_options(model: &str) -> Vec<ReasoningEffort> {
if opencode::is_zen_model_id(model) {
opencode::zen::reasoning_options(model)
} else if opencode::is_go_model_id(model) {
vec![ReasoningEffort::Auto]
} else if codex::is_model_id(model) {
codex::reasoning_options(model)
} else {
umans::reasoning_options(model)
}
}
pub fn reasoning_option_is_supported(model: &str, effort: ReasoningEffort) -> bool {
reasoning_options(model).contains(&effort)
}
pub trait StreamingProvider: Sized {
type Metadata;
fn name(&self) -> &'static str;
fn load_status(&self) -> String;
fn request_status(&self, model: &str) -> String;
fn from_env_or_dotenv(root: &Path) -> Result<Self>;
fn load_metadata(&self) -> Result<Self::Metadata>;
fn metadata_loaded_event(&self, _metadata: &Self::Metadata) -> Option<AgentEvent> {
None
}
fn metadata_status(&self, _model: &str, _metadata: &Self::Metadata) -> Option<String> {
None
}
fn token_budget(&self, model: &str, metadata: Option<&Self::Metadata>) -> u32;
fn serialized_request_body(
&self, model: &str, messages: &[ProviderMessage], request: &StreamingRequest<'_>,
) -> Result<Vec<u8>>;
fn send_streaming_request(
&self, model: &str, messages: &[ProviderMessage], request: &StreamingRequest<'_>,
) -> Result<ureq::http::Response<ureq::Body>>;
fn stream_format(&self, model: &str) -> Result<StreamFormat>;
fn request_error_message(error: &ProviderError) -> String;
fn is_retryable_request_error(error: &ProviderError) -> bool;
}
pub fn serialize_request_body(body: &serde_json::Value) -> Result<Vec<u8>> {
serde_json::to_vec(&body).map_err(|error| ProviderError::Json(error.to_string()))
}
#[derive(Clone, Debug, Default)]
pub enum ProviderContinuation {
#[default]
None,
Responses {
items: Vec<serde_json::Value>,
consumed_messages: usize,
},
}
impl ProviderContinuation {
pub fn responses_items(&self) -> Option<(&[serde_json::Value], usize)> {
match self {
Self::None => None,
Self::Responses { items, consumed_messages } => Some((items, *consumed_messages)),
}
}
pub fn set_responses_items(&mut self, items: Vec<serde_json::Value>, consumed_messages: usize) {
*self = Self::Responses { items, consumed_messages };
}
}
pub struct StreamingRequest<'a> {
pub max_tokens: u32,
pub reasoning_effort: ReasoningEffort,
pub reasoning_summary: ReasoningSummary,
pub tools: &'a serde_json::Value,
pub continuation: &'a ProviderContinuation,
}
#[derive(Debug, thiserror::Error)]
pub enum ProviderError {
#[error("{env} is not set; run `thndrs setup` or store the credential with `thndrs login`")]
MissingApiKey { env: &'static str },
#[error("{provider} model ids must use {prefix}<model-id>, got {model}")]
InvalidModelId {
provider: &'static str,
prefix: &'static str,
model: String,
},
#[error("http error: {0}")]
Http(String),
#[error("HTTP {code}: {body}")]
Status { code: u16, body: String },
#[error("authentication failed: {0}")]
Auth(String),
#[error("authentication verification unavailable: {0}")]
AuthUnavailable(String),
#[error("json error: {0}")]
Json(String),
}
impl ProviderError {
pub fn missing_api_key(env: &'static str) -> Self {
ProviderError::MissingApiKey { env }
}
pub fn invalid_model_id(provider: &'static str, prefix: &'static str, model: &str) -> Self {
ProviderError::InvalidModelId { provider, prefix, model: model.to_string() }
}
pub fn is_credential_rejected(&self) -> bool {
matches!(
self,
ProviderError::Status { code: 401 | 403, .. } | ProviderError::Auth(_)
)
}
pub fn is_retryable(&self) -> bool {
match self {
ProviderError::MissingApiKey { .. }
| ProviderError::InvalidModelId { .. }
| ProviderError::Auth(_)
| ProviderError::Json(_) => false,
ProviderError::AuthUnavailable(_) => true,
ProviderError::Status { code, .. } => *code == 429 || (500..=599).contains(code),
ProviderError::Http(message) => {
let lower = message.to_ascii_lowercase();
!lower.contains("cancel") && !lower.contains("abort")
}
}
}
pub fn failure_message(&self, rate_limit_message: &str) -> String {
match self {
ProviderError::MissingApiKey { .. } | ProviderError::InvalidModelId { .. } => self.to_string(),
ProviderError::Status { code, body } => match code {
401 | 403 => format!("authentication failed (HTTP {code})"),
429 => rate_limit_message.to_string(),
500..=599 => format!("server error (HTTP {code}): {body}"),
_ => format!("HTTP {code}: {body}"),
},
ProviderError::Auth(message) => format!("authentication failed: {message}"),
ProviderError::AuthUnavailable(message) => format!("authentication verification unavailable: {message}"),
ProviderError::Http(e) => format!("network error: {e}"),
ProviderError::Json(e) => format!("response parse error: {e}"),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StreamFormat {
AnthropicMessages,
OpenAiChat,
ChatGptCodexResponses,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ProviderContentBlock {
Text { text: String },
Image { source: ProviderImageSource },
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
ToolResult {
tool_use_id: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ProviderImageSource {
Base64 { media_type: String, data: String },
}
#[derive(Clone, Debug, PartialEq)]
pub enum ProviderMessageContent {
Text(String),
Blocks(Vec<ProviderContentBlock>),
}
impl Serialize for ProviderMessageContent {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
ProviderMessageContent::Text(s) => serializer.serialize_str(s),
ProviderMessageContent::Blocks(blocks) => blocks.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for ProviderMessageContent {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let v = serde_json::Value::deserialize(deserializer)?;
match v {
serde_json::Value::String(s) => Ok(ProviderMessageContent::Text(s)),
serde_json::Value::Array(arr) => {
let blocks: Vec<ProviderContentBlock> =
serde_json::from_value(serde_json::Value::Array(arr)).map_err(serde::de::Error::custom)?;
Ok(ProviderMessageContent::Blocks(blocks))
}
_ => Err(serde::de::Error::custom("expected string or array for message content")),
}
}
}
impl ProviderMessageContent {
pub fn as_text(&self) -> String {
match self {
ProviderMessageContent::Text(s) => s.clone(),
ProviderMessageContent::Blocks(blocks) => blocks
.iter()
.filter_map(|b| match b {
ProviderContentBlock::Text { text } => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join(""),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct KnownModel {
pub id: &'static str,
pub description: &'static str,
}
pub struct ProviderHttpClient {
base_url: String,
api_key: String,
agent: ureq::Agent,
}
impl ProviderHttpClient {
pub fn new(base_url: &str, api_key: &str) -> Self {
ProviderHttpClient {
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
agent: provider_http_agent(),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn api_key(&self) -> &str {
&self.api_key
}
pub fn agent(&self) -> &ureq::Agent {
&self.agent
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ProviderMessage {
pub role: String,
pub content: ProviderMessageContent,
}
impl ProviderMessage {
pub fn user(content: &str) -> Self {
ProviderMessage { role: "user".to_string(), content: ProviderMessageContent::Text(content.to_string()) }
}
pub fn assistant(content: &str) -> Self {
ProviderMessage { role: "assistant".to_string(), content: ProviderMessageContent::Text(content.to_string()) }
}
pub fn tool_result(tool_use_id: &str, content: &str, is_error: bool) -> Self {
ProviderMessage {
role: "user".to_string(),
content: ProviderMessageContent::Blocks(vec![ProviderContentBlock::ToolResult {
tool_use_id: tool_use_id.to_string(),
content: content.to_string(),
is_error: Some(is_error),
}]),
}
}
pub fn assistant_blocks(blocks: Vec<ProviderContentBlock>) -> Self {
ProviderMessage { role: "assistant".to_string(), content: ProviderMessageContent::Blocks(blocks) }
}
pub fn user_blocks(blocks: Vec<ProviderContentBlock>) -> Self {
ProviderMessage { role: "user".to_string(), content: ProviderMessageContent::Blocks(blocks) }
}
pub fn as_text(&self) -> String {
self.content.as_text()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProviderTurn {
pub tool_requests: Vec<ToolUseRequest>,
pub assistant_text: String,
pub stop_reason: Option<String>,
pub response_items: Vec<serde_json::Value>,
pub usage: Option<thndrs_agent::ProviderUsageComponents>,
}
pub fn summarize_error_body(body: &str) -> String {
let trimmed = body.trim();
if trimmed.is_empty() {
return "(empty response body)".to_string();
}
serde_json::from_str::<serde_json::Value>(trimmed)
.ok()
.and_then(|v| {
v.pointer("/error/message")
.or_else(|| v.get("message"))
.and_then(|m| m.as_str())
.map(|m| m.to_string())
})
.unwrap_or_else(|| trimmed.chars().take(500).collect())
}
pub fn api_key_from_dotenv(root: &Path, name: &str) -> Option<String> {
std::fs::read_to_string(root.join(".env"))
.ok()?
.lines()
.find_map(|line| parse_api_key_line(line, name))
}
fn parse_api_key_line(line: &str, env_name: &str) -> Option<String> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let (key, value) = line.split_once('=')?;
if key.trim() != env_name {
return None;
}
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
.unwrap_or(value);
if value.is_empty() { None } else { Some(value.to_string()) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_http_client_bounds_setup_and_streaming_idle_timeouts() {
let client = ProviderHttpClient::new("https://provider.example", "test-key");
let timeouts = client.agent().config().timeouts();
assert_eq!(
timeouts.global, None,
"SSE streams must not have a total lifetime deadline"
);
assert_eq!(timeouts.per_call, None, "SSE streams must not have a per-call deadline");
assert_eq!(timeouts.resolve, Some(PROVIDER_CONNECT_TIMEOUT));
assert_eq!(timeouts.connect, Some(PROVIDER_CONNECT_TIMEOUT));
assert_eq!(timeouts.send_request, Some(PROVIDER_REQUEST_TIMEOUT));
assert_eq!(timeouts.send_body, Some(PROVIDER_REQUEST_TIMEOUT));
assert_eq!(timeouts.recv_response, Some(PROVIDER_RESPONSE_TIMEOUT));
assert_eq!(timeouts.recv_body, Some(PROVIDER_STREAM_IDLE_TIMEOUT));
}
}