//! Model providers. Two wire protocols cover all targets: //! Anthropic Messages and OpenAI-compatible chat completions. pub mod anthropic; pub mod openai; use crate::config::{ModelConfig, ProviderConfig, ProviderKind, expand_env}; use crate::types::{Message, Usage}; use anyhow::{Result, anyhow}; use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolDef { pub name: String, pub description: String, pub input_schema: Value, } #[derive(Debug, Clone)] pub struct ChatRequest { pub model: ModelConfig, pub system: String, pub messages: Vec, pub tools: Vec, pub max_tokens: u64, /// Enable extended thinking / reasoning when the model supports it. pub reasoning: bool, /// Stable id of the conversation; sent to gateways that route by session /// (opencode: `x-opencode-session`) for routing and prompt caching. pub session_id: Option, } #[derive(Debug, Clone)] pub enum StreamEvent { TextDelta(String), ReasoningDelta(String), ReasoningSignature(String), ToolCall { id: String, name: String, input: Value, extra: Option }, Usage(Usage), Done { stop_reason: String }, } #[async_trait] pub trait Provider: Send + Sync { async fn stream( &self, req: ChatRequest, tx: mpsc::Sender, cancel: CancellationToken, ) -> Result<()>; async fn list_models(&self) -> Result>; } /// Connection settings shared by both protocol implementations. #[derive(Debug, Clone)] pub struct Endpoint { pub base_url: String, pub api_key: String, pub auth_header: String, pub auth_prefix: String, pub headers: BTreeMap, } impl Endpoint { pub fn from_config(p: &ProviderConfig) -> Self { let (default_header, default_prefix) = match p.kind { ProviderKind::Anthropic => ("x-api-key", ""), ProviderKind::Openai => ("Authorization", "Bearer "), }; let base = if p.base_url.is_empty() && p.kind == ProviderKind::Anthropic { "https://api.anthropic.com/v1".to_string() } else { expand_env(&p.base_url) }; Self { base_url: base.trim_end_matches('/').to_string(), api_key: expand_env(&p.api_key), auth_header: p.auth_header.clone().unwrap_or_else(|| default_header.into()), auth_prefix: p.auth_prefix.clone().unwrap_or_else(|| default_prefix.into()), headers: p.headers.iter().map(|(k, v)| (k.clone(), expand_env(v))).collect(), } } /// Session header for gateways that need one (opencode Zen/Go reject requests without it). pub fn with_session(&self, rb: reqwest::RequestBuilder, session_id: Option<&str>) -> reqwest::RequestBuilder { match session_id { Some(id) if self.base_url.contains("opencode.ai") && !self.headers.keys().any(|k| k.eq_ignore_ascii_case("x-opencode-session")) => { rb.header("x-opencode-session", id) } _ => rb, } } pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder { if !self.api_key.is_empty() { rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key)); } for (k, v) in &self.headers { rb = rb.header(k, v); } rb } } /// Shared HTTP client for model requests. pub fn http_client() -> reqwest::Client { reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs(20)) .user_agent(format!("codecraft/{}", crate::version::VERSION)) .build() .expect("http client") } /// Create the provider for a provider configuration. pub fn build(client: reqwest::Client, pc: &ProviderConfig) -> std::sync::Arc { let ep = Endpoint::from_config(pc); match pc.kind { ProviderKind::Anthropic => std::sync::Arc::new(anthropic::Anthropic::new(client, ep)), ProviderKind::Openai => std::sync::Arc::new(openai::OpenAi::new(client, ep)), } } /// Turn an HTTP error response into a readable error. pub async fn http_error(resp: reqwest::Response) -> anyhow::Error { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); let detail = serde_json::from_str::(&body) .ok() .and_then(|v| { v.pointer("/error/message") .or_else(|| v.pointer("/0/error/message")) .or_else(|| v.get("message")) .and_then(|m| m.as_str().map(String::from)) }) .unwrap_or_else(|| body.chars().take(500).collect()); anyhow!("provider returned HTTP {status}: {detail}") } #[cfg(test)] mod session_header_tests { use super::*; fn ep(base: &str) -> Endpoint { Endpoint { base_url: base.into(), api_key: String::new(), auth_header: "Authorization".into(), auth_prefix: "Bearer ".into(), headers: BTreeMap::new() } } fn header(ep: &Endpoint, id: Option<&str>) -> Option { let rb = ep.with_session(reqwest::Client::new().post(format!("{}/chat/completions", ep.base_url)), id); rb.build().unwrap().headers().get("x-opencode-session").map(|v| v.to_str().unwrap().to_string()) } #[test] fn opencode_gets_the_session_header() { assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), Some("s1")).as_deref(), Some("s1")); assert_eq!(header(&ep("https://opencode.ai/zen/go/v1"), None), None); assert_eq!(header(&ep("https://api.openai.com/v1"), Some("s1")), None); let mut custom = ep("https://opencode.ai/zen/v1"); custom.headers.insert("X-Opencode-Session".into(), "fixed".into()); assert_eq!(header(&custom, Some("s1")), None, "a configured header wins"); } }