use crate::ai::oauth::{self, Service};
use crate::ai::sse::SseEvent;
use crate::ai::types::{AiError, Event, Request};
use crate::ai::{anthropic, codex, http, openai};
use crate::core::CancelToken;
use serde_json::Value;
pub trait Decoder {
fn feed(&mut self, event: &SseEvent, out: &mut Vec<Event>) -> Result<(), AiError>;
fn finish(&mut self, out: &mut Vec<Event>);
fn is_complete(&self) -> bool;
}
type Call = (String, Vec<(String, String)>, Value, Box<dyn Decoder>);
pub trait LanguageModel: Send + Sync {
fn stream(&self, request: &Request, cancel: &CancelToken, emit: &mut dyn FnMut(Event)) -> Result<(), AiError>;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProviderKind {
Anthropic,
OpenAiCompatible,
ClaudeSubscription,
ChatGptSubscription,
}
#[derive(Clone, Debug)]
pub struct Provider {
pub kind: ProviderKind,
pub base_url: String,
pub api_key: Option<String>,
pub effort: Option<Effort>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Effort {
Off,
Low,
Medium,
High,
Max,
}
impl Effort {
pub fn from_setting(value: &str) -> Option<Self> {
match value {
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"max" => Some(Self::Max),
_ => None,
}
}
fn anthropic(self) -> &'static str {
match self {
Self::Off | Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Max => "max",
}
}
fn openai(self) -> &'static str {
match self {
Self::Off => "none",
Self::Low => "low",
Self::Medium => "medium",
Self::High | Self::Max => "high",
}
}
}
impl Provider {
pub fn anthropic(api_key: String) -> Self {
Self {
kind: ProviderKind::Anthropic,
base_url: "https://api.anthropic.com".into(),
api_key: Some(api_key),
effort: None,
}
}
pub fn openai(api_key: String) -> Self {
Self {
kind: ProviderKind::OpenAiCompatible,
base_url: "https://api.openai.com/v1".into(),
api_key: Some(api_key),
effort: None,
}
}
pub fn ollama() -> Self {
Self {
kind: ProviderKind::OpenAiCompatible,
base_url: "http://localhost:11434/v1".into(),
api_key: None,
effort: None,
}
}
pub fn subscription(service: Service) -> Self {
match service {
Service::Claude => Self {
kind: ProviderKind::ClaudeSubscription,
base_url: "https://api.anthropic.com".into(),
api_key: None,
effort: None,
},
Service::ChatGpt => Self {
kind: ProviderKind::ChatGptSubscription,
base_url: codex::BASE_URL.into(),
api_key: None,
effort: None,
},
}
}
fn auth_headers(&self) -> Vec<(String, String)> {
match self.kind {
ProviderKind::Anthropic | ProviderKind::ClaudeSubscription => {
let mut headers = vec![("anthropic-version".to_owned(), "2023-06-01".to_owned())];
headers.extend(self.api_key.iter().map(|key| ("x-api-key".to_owned(), key.clone())));
headers
}
ProviderKind::OpenAiCompatible | ProviderKind::ChatGptSubscription => {
self.api_key.iter().map(|key| ("authorization".to_owned(), format!("Bearer {key}"))).collect()
}
}
}
pub fn models_endpoint(&self) -> (String, Vec<(String, String)>) {
if let Some(url) = crate::ai::opencode::models_url(&self.base_url) {
return (url, self.auth_headers());
}
let base = self.base_url.trim_end_matches('/');
let path = if matches!(self.kind, ProviderKind::Anthropic | ProviderKind::ClaudeSubscription) {
"v1/models?limit=100"
} else {
"models"
};
(format!("{base}/{path}"), self.auth_headers())
}
fn endpoint(&self) -> (String, Vec<(String, String)>) {
let base = self.base_url.trim_end_matches('/');
match self.kind {
ProviderKind::ChatGptSubscription => (base.to_owned(), Vec::new()),
ProviderKind::Anthropic | ProviderKind::ClaudeSubscription => {
(format!("{base}/v1/messages"), self.auth_headers())
}
ProviderKind::OpenAiCompatible => (format!("{base}/chat/completions"), self.auth_headers()),
}
}
}
impl Provider {
fn with_effort(&self, mut body: Value) -> Value {
let Some(effort) = self.effort else { return body };
match self.kind {
ProviderKind::Anthropic | ProviderKind::ClaudeSubscription => {
body["output_config"] = serde_json::json!({ "effort": effort.anthropic() });
}
ProviderKind::OpenAiCompatible => body["reasoning_effort"] = effort.openai().into(),
ProviderKind::ChatGptSubscription => body["reasoning"] = serde_json::json!({ "effort": effort.openai() }),
}
body
}
}
impl LanguageModel for Provider {
fn stream(&self, request: &Request, cancel: &CancelToken, emit: &mut dyn FnMut(Event)) -> Result<(), AiError> {
self.stream_with(http::RetryPolicy::default(), request, cancel, emit)
}
}
impl Provider {
pub fn stream_once(
&self,
request: &Request,
cancel: &CancelToken,
emit: &mut dyn FnMut(Event),
) -> Result<(), AiError> {
self.stream_with(http::RetryPolicy { retries: 0, ..Default::default() }, request, cancel, emit)
}
fn stream_with(
&self,
policy: http::RetryPolicy,
request: &Request,
cancel: &CancelToken,
emit: &mut dyn FnMut(Event),
) -> Result<(), AiError> {
let request = &*crate::ai::wire::prepare(request);
let (url, headers) = self.endpoint();
let (url, mut headers, body, mut decoder): Call = match self.kind {
ProviderKind::Anthropic => {
(url, headers, anthropic::body(request), Box::new(anthropic::AnthropicDecoder::default()))
}
ProviderKind::OpenAiCompatible => {
(url, headers, openai::body(request), Box::new(openai::OpenAiDecoder::default()))
}
ProviderKind::ClaudeSubscription => {
let session = oauth::fresh(Service::Claude)?;
let body = anthropic::subscription_body(request);
let headers = anthropic::subscription_headers(&session.access);
(url, headers, body, Box::new(anthropic::AnthropicDecoder::default()))
}
ProviderKind::ChatGptSubscription => {
let session = oauth::fresh(Service::ChatGpt)?;
let headers = codex::headers(&session.access, session.account_id.as_deref().unwrap_or_default());
(url, headers, codex::body(request), Box::new(codex::CodexDecoder::default()))
}
};
if crate::ai::opencode::is_opencode(&self.base_url) {
headers.extend(crate::ai::opencode::headers(request));
}
let mut body = self.with_effort(body);
if self.kind == ProviderKind::OpenAiCompatible && self.base_url.contains("api.openai.com") {
if let Some(limit) = body.as_object_mut().and_then(|body| body.remove("max_tokens")) {
body["max_completion_tokens"] = limit;
}
}
http::stream_sse_with(policy, &url, &headers, &body, cancel, decoder.as_mut(), emit)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn el_esfuerzo_usa_el_nombre_de_cada_api() {
let with = |mut provider: Provider, effort| {
provider.effort = effort;
provider.with_effort(serde_json::json!({ "model": "m" }))
};
let claude = with(Provider::anthropic("k".into()), Some(Effort::Max));
assert_eq!(claude["output_config"]["effort"], "max");
let openai = with(Provider::openai("k".into()), Some(Effort::Max));
assert_eq!(openai["reasoning_effort"], "high");
let codex = with(Provider::subscription(Service::ChatGpt), Some(Effort::Low));
assert_eq!(codex["reasoning"]["effort"], "low");
assert_eq!(with(Provider::anthropic("k".into()), None), serde_json::json!({ "model": "m" }));
assert_eq!(Effort::from_setting("auto"), None);
assert_eq!(Effort::from_setting("medium"), Some(Effort::Medium));
}
}