use crate::Error;
use async_trait::async_trait;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[derive(Clone, Debug)]
pub struct LlmRequest {
pub model: String,
pub prompt: String,
pub temperature: f64,
pub max_tokens: i64,
}
#[derive(Clone, Debug)]
pub struct LlmResponse {
pub text: String,
pub finish_reason: String,
}
#[async_trait]
pub trait LlmProvider: Send + Sync + 'static {
fn name(&self) -> &str;
async fn call(&self, request: LlmRequest) -> Result<LlmResponse, Error>;
}
#[derive(Default)]
pub struct LlmProviderRegistry {
providers: DashMap<String, Arc<dyn LlmProvider>>,
}
impl LlmProviderRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn with_builtins() -> Self {
let registry = Self::new();
registry.register(Arc::new(MockLlmProvider));
registry
}
pub fn register(&self, provider: Arc<dyn LlmProvider>) {
self.providers
.insert(provider.name().to_string(), provider);
}
pub fn get(&self, name: &str) -> Option<Arc<dyn LlmProvider>> {
self.providers.get(name).map(|r| r.value().clone())
}
}
pub struct MockLlmProvider;
#[async_trait]
impl LlmProvider for MockLlmProvider {
fn name(&self) -> &str {
"mock"
}
async fn call(&self, request: LlmRequest) -> Result<LlmResponse, Error> {
Ok(LlmResponse {
text: format!("echo: {}", request.prompt),
finish_reason: "stop".into(),
})
}
}
pub struct AnthropicProvider {
api_key: String,
base_url: String,
client: reqwest::Client,
}
impl AnthropicProvider {
pub fn new(api_key: String) -> Self {
Self::with_base_url(api_key, "https://api.anthropic.com".into())
}
pub fn with_base_url(api_key: String, base_url: String) -> Self {
Self {
api_key,
base_url,
client: reqwest::Client::new(),
}
}
}
#[derive(Serialize)]
struct AnthropicRequest<'a> {
model: &'a str,
max_tokens: i64,
temperature: f64,
messages: Vec<AnthropicMessage<'a>>,
}
#[derive(Serialize)]
struct AnthropicMessage<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Deserialize)]
struct AnthropicResponse {
content: Vec<AnthropicContentBlock>,
stop_reason: Option<String>,
}
#[derive(Deserialize)]
struct AnthropicContentBlock {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
text: String,
}
#[async_trait]
impl LlmProvider for AnthropicProvider {
fn name(&self) -> &str {
"anthropic"
}
async fn call(&self, req: LlmRequest) -> Result<LlmResponse, Error> {
let body = AnthropicRequest {
model: &req.model,
max_tokens: req.max_tokens,
temperature: req.temperature,
messages: vec![AnthropicMessage {
role: "user",
content: &req.prompt,
}],
};
let resp = self
.client
.post(format!("{}/v1/messages", self.base_url))
.header("x-api-key", &self.api_key)
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await
.map_err(|e| Error::Runtime(format!("anthropic request failed: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(Error::Runtime(format!(
"anthropic returned {status}: {text}"
)));
}
let parsed: AnthropicResponse = resp
.json()
.await
.map_err(|e| Error::Runtime(format!("anthropic response parse failed: {e}")))?;
let text = parsed
.content
.into_iter()
.filter_map(|b| if b.kind == "text" { Some(b.text) } else { None })
.collect::<Vec<_>>()
.join("");
Ok(LlmResponse {
text,
finish_reason: parsed.stop_reason.unwrap_or_else(|| "stop".into()),
})
}
}
pub struct OpenAiCompatProvider {
name: &'static str,
api_key: String,
base_url: String,
client: reqwest::Client,
}
impl OpenAiCompatProvider {
pub fn new(name: &'static str, api_key: String, base_url: String) -> Self {
Self {
name,
api_key,
base_url,
client: reqwest::Client::new(),
}
}
pub fn openai(api_key: String) -> Self {
Self::new("openai", api_key, "https://api.openai.com".into())
}
pub fn openrouter(api_key: String) -> Self {
Self::new("openrouter", api_key, "https://openrouter.ai/api".into())
}
pub fn ollama_local() -> Self {
Self::new("ollama", String::new(), "http://localhost:11434".into())
}
}
#[derive(Serialize)]
struct OpenAiRequest<'a> {
model: &'a str,
messages: Vec<OpenAiMessage<'a>>,
temperature: f64,
max_tokens: i64,
}
#[derive(Serialize)]
struct OpenAiMessage<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Deserialize)]
struct OpenAiResponse {
choices: Vec<OpenAiChoice>,
}
#[derive(Deserialize)]
struct OpenAiChoice {
message: OpenAiResponseMessage,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Deserialize)]
struct OpenAiResponseMessage {
#[serde(default)]
content: String,
}
#[async_trait]
impl LlmProvider for OpenAiCompatProvider {
fn name(&self) -> &str {
self.name
}
async fn call(&self, req: LlmRequest) -> Result<LlmResponse, Error> {
let body = OpenAiRequest {
model: &req.model,
messages: vec![OpenAiMessage {
role: "user",
content: &req.prompt,
}],
temperature: req.temperature,
max_tokens: req.max_tokens,
};
let mut request = self
.client
.post(format!("{}/v1/chat/completions", self.base_url))
.json(&body);
if !self.api_key.is_empty() {
request = request.bearer_auth(&self.api_key);
}
let resp = request
.send()
.await
.map_err(|e| Error::Runtime(format!("{} request failed: {e}", self.name)))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(Error::Runtime(format!(
"{} returned {status}: {text}",
self.name
)));
}
let parsed: OpenAiResponse = resp
.json()
.await
.map_err(|e| Error::Runtime(format!("{} response parse failed: {e}", self.name)))?;
let choice = parsed.choices.into_iter().next().ok_or_else(|| {
Error::Runtime(format!("{} response had no choices", self.name))
})?;
Ok(LlmResponse {
text: choice.message.content,
finish_reason: choice.finish_reason.unwrap_or_else(|| "stop".into()),
})
}
}
use std::path::PathBuf;
pub fn expand_user_path(input: &str) -> PathBuf {
let trimmed = input.trim();
if trimmed == "~" {
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home);
}
}
if let Some(rest) = trimmed.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME") {
let mut p = PathBuf::from(home);
p.push(rest);
return p;
}
}
PathBuf::from(trimmed)
}
pub struct TokenFileProvider {
name: String,
base_url: String,
token_path: PathBuf,
json_path: Option<String>,
client: reqwest::Client,
}
impl TokenFileProvider {
pub fn new(
name: impl Into<String>,
base_url: impl Into<String>,
token_path: PathBuf,
) -> Self {
Self {
name: name.into(),
base_url: base_url.into(),
token_path,
json_path: None,
client: reqwest::Client::new(),
}
}
pub fn with_json_path(mut self, json_path: impl Into<String>) -> Self {
self.json_path = Some(json_path.into());
self
}
pub fn token_path(&self) -> &std::path::Path {
&self.token_path
}
pub fn json_path(&self) -> Option<&str> {
self.json_path.as_deref()
}
async fn read_token(&self) -> Result<String, Error> {
let raw = tokio::fs::read_to_string(&self.token_path)
.await
.map_err(|e| {
Error::Runtime(format!(
"{}: read token file {}: {e}",
self.name,
self.token_path.display()
))
})?;
if let Some(path) = &self.json_path {
let value: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| {
Error::Runtime(format!(
"{}: parse {} as JSON: {e}",
self.name,
self.token_path.display()
))
})?;
let extracted = navigate_json_path(&value, path).map_err(|e| {
Error::Runtime(format!(
"{}: JSON path `{path}` in {}: {e}",
self.name,
self.token_path.display()
))
})?;
match extracted {
serde_json::Value::String(s) if !s.is_empty() => Ok(s),
serde_json::Value::String(_) => Err(Error::Runtime(format!(
"{}: JSON path `{path}` in {} resolved to empty string",
self.name,
self.token_path.display()
))),
other => Err(Error::Runtime(format!(
"{}: JSON path `{path}` in {} resolved to non-string ({})",
self.name,
self.token_path.display(),
json_kind(&other)
))),
}
} else {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(Error::Runtime(format!(
"{}: token file {} is empty",
self.name,
self.token_path.display()
)));
}
Ok(trimmed.to_string())
}
}
}
fn navigate_json_path(
value: &serde_json::Value,
path: &str,
) -> Result<serde_json::Value, String> {
let trimmed = path.trim_start_matches('.');
if trimmed.is_empty() {
return Ok(value.clone());
}
let mut current = value;
for part in trimmed.split('.') {
match current.get(part) {
Some(next) => current = next,
None => {
let available = match current {
serde_json::Value::Object(map) => {
let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
keys.sort();
if keys.is_empty() {
String::from(" (object is empty)")
} else {
format!(" (available keys: {})", keys.join(", "))
}
}
other => format!(" (parent is {}, expected object)", json_kind(other)),
};
return Err(format!("missing key `{part}`{available}"));
}
}
}
Ok(current.clone())
}
fn json_kind(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
#[async_trait]
impl LlmProvider for TokenFileProvider {
fn name(&self) -> &str {
&self.name
}
async fn call(&self, req: LlmRequest) -> Result<LlmResponse, Error> {
let token = self.read_token().await?;
let body = OpenAiRequest {
model: &req.model,
messages: vec![OpenAiMessage {
role: "user",
content: &req.prompt,
}],
temperature: req.temperature,
max_tokens: req.max_tokens,
};
let resp = self
.client
.post(format!("{}/v1/chat/completions", self.base_url))
.bearer_auth(token)
.json(&body)
.send()
.await
.map_err(|e| Error::Runtime(format!("{} request failed: {e}", self.name)))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(Error::Runtime(format!(
"{} returned {status}: {text}",
self.name
)));
}
let parsed: OpenAiResponse = resp
.json()
.await
.map_err(|e| Error::Runtime(format!("{} response parse failed: {e}", self.name)))?;
let choice = parsed.choices.into_iter().next().ok_or_else(|| {
Error::Runtime(format!("{} response had no choices", self.name))
})?;
Ok(LlmResponse {
text: choice.message.content,
finish_reason: choice.finish_reason.unwrap_or_else(|| "stop".into()),
})
}
}
pub struct CodexResponsesProvider {
name: String,
base_url: String,
token_path: PathBuf,
json_path: Option<String>,
client: reqwest::Client,
}
impl CodexResponsesProvider {
pub fn new(
name: impl Into<String>,
base_url: impl Into<String>,
token_path: PathBuf,
) -> Self {
Self {
name: name.into(),
base_url: base_url.into(),
token_path,
json_path: None,
client: reqwest::Client::new(),
}
}
pub fn with_json_path(mut self, json_path: impl Into<String>) -> Self {
self.json_path = Some(json_path.into());
self
}
async fn read_token(&self) -> Result<String, Error> {
let raw = tokio::fs::read_to_string(&self.token_path)
.await
.map_err(|e| {
Error::Runtime(format!(
"{}: read token file {}: {e}",
self.name,
self.token_path.display()
))
})?;
if let Some(path) = &self.json_path {
let value: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| {
Error::Runtime(format!(
"{}: parse {} as JSON: {e}",
self.name,
self.token_path.display()
))
})?;
let extracted = navigate_json_path(&value, path).map_err(|e| {
Error::Runtime(format!(
"{}: JSON path `{path}` in {}: {e}",
self.name,
self.token_path.display()
))
})?;
match extracted {
serde_json::Value::String(s) if !s.is_empty() => Ok(s),
serde_json::Value::String(_) => Err(Error::Runtime(format!(
"{}: JSON path `{path}` resolved to empty string",
self.name
))),
other => Err(Error::Runtime(format!(
"{}: JSON path `{path}` resolved to non-string ({})",
self.name,
json_kind(&other)
))),
}
} else {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(Error::Runtime(format!(
"{}: token file is empty",
self.name
)));
}
Ok(trimmed.to_string())
}
}
}
#[derive(Serialize)]
struct CodexResponsesRequest<'a> {
model: &'a str,
instructions: &'a str,
input: Vec<CodexResponsesInputItem<'a>>,
store: bool,
stream: bool,
}
#[derive(Serialize)]
struct CodexResponsesInputItem<'a> {
role: &'a str,
content: Vec<CodexResponsesContent<'a>>,
}
#[derive(Serialize)]
struct CodexResponsesContent<'a> {
#[serde(rename = "type")]
kind: &'a str,
text: &'a str,
}
#[async_trait]
impl LlmProvider for CodexResponsesProvider {
fn name(&self) -> &str {
&self.name
}
async fn call(&self, req: LlmRequest) -> Result<LlmResponse, Error> {
let token = self.read_token().await?;
let body = CodexResponsesRequest {
model: &req.model,
instructions: "You are a helpful assistant.",
input: vec![CodexResponsesInputItem {
role: "user",
content: vec![CodexResponsesContent {
kind: "input_text",
text: &req.prompt,
}],
}],
store: false,
stream: true,
};
let resp = self
.client
.post(format!("{}/responses", self.base_url.trim_end_matches('/')))
.bearer_auth(token)
.header("Accept", "text/event-stream")
.header("OpenAI-Beta", "responses=experimental")
.json(&body)
.send()
.await
.map_err(|e| Error::Runtime(format!("{} request failed: {e}", self.name)))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(Error::Runtime(format!(
"{} returned {status}: {text}",
self.name
)));
}
let body_text = resp.text().await.map_err(|e| {
Error::Runtime(format!("{}: read SSE stream failed: {e}", self.name))
})?;
let mut accumulated = String::new();
let mut finish = String::from("stop");
let mut saw_completion = false;
for chunk in body_text.split("\n\n") {
let mut data_line: Option<&str> = None;
for line in chunk.lines() {
if let Some(rest) = line.strip_prefix("data:") {
data_line = Some(rest.trim());
}
}
let data = match data_line {
Some(d) if !d.is_empty() && d != "[DONE]" => d,
_ => continue,
};
let parsed: serde_json::Value = match serde_json::from_str(data) {
Ok(v) => v,
Err(_) => continue,
};
let event_type = parsed
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
match event_type {
"response.output_text.delta" => {
if let Some(d) = parsed.get("delta").and_then(|v| v.as_str()) {
accumulated.push_str(d);
}
}
"response.completed" | "response.done" => {
saw_completion = true;
if let Some(status) = parsed
.get("response")
.and_then(|r| r.get("status"))
.and_then(|v| v.as_str())
{
finish = status.to_string();
}
}
"error" => {
let msg = parsed
.get("error")
.and_then(|e| e.get("message"))
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
return Err(Error::Runtime(format!(
"{}: stream error: {msg}",
self.name
)));
}
_ => {}
}
}
if accumulated.is_empty() {
return Err(Error::Runtime(format!(
"{}: stream completed with no text (saw_completion={saw_completion})",
self.name
)));
}
Ok(LlmResponse {
text: accumulated,
finish_reason: finish,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anthropic_request_serializes() {
let body = AnthropicRequest {
model: "claude-opus-4-7",
max_tokens: 1024,
temperature: 0.2,
messages: vec![AnthropicMessage {
role: "user",
content: "hi",
}],
};
let json = serde_json::to_string(&body).expect("serialize");
assert!(json.contains("\"model\":\"claude-opus-4-7\""));
assert!(json.contains("\"max_tokens\":1024"));
assert!(json.contains("\"temperature\":0.2"));
assert!(json.contains("\"role\":\"user\""));
assert!(json.contains("\"content\":\"hi\""));
}
#[test]
fn anthropic_response_parses() {
let json = r#"{
"content": [
{"type": "text", "text": "Hello "},
{"type": "text", "text": "world"}
],
"stop_reason": "end_turn"
}"#;
let parsed: AnthropicResponse = serde_json::from_str(json).expect("parse");
assert_eq!(parsed.content.len(), 2);
assert_eq!(parsed.content[0].text, "Hello ");
assert_eq!(parsed.stop_reason.as_deref(), Some("end_turn"));
}
#[test]
fn openai_request_serializes() {
let body = OpenAiRequest {
model: "gpt-4o-mini",
messages: vec![OpenAiMessage {
role: "user",
content: "hi",
}],
temperature: 0.7,
max_tokens: 256,
};
let json = serde_json::to_string(&body).expect("serialize");
assert!(json.contains("\"model\":\"gpt-4o-mini\""));
assert!(json.contains("\"temperature\":0.7"));
}
#[test]
fn expand_user_path_handles_tilde() {
let prev = std::env::var_os("HOME");
std::env::set_var("HOME", "/home/test");
assert_eq!(
expand_user_path("~/.codex/auth.json"),
std::path::PathBuf::from("/home/test/.codex/auth.json")
);
assert_eq!(
expand_user_path("~"),
std::path::PathBuf::from("/home/test")
);
assert_eq!(
expand_user_path("/abs/path"),
std::path::PathBuf::from("/abs/path")
);
assert_eq!(
expand_user_path("relative/path"),
std::path::PathBuf::from("relative/path")
);
assert_eq!(
expand_user_path(" ~/foo\n"),
std::path::PathBuf::from("/home/test/foo")
);
match prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
#[test]
fn openai_response_parses() {
let json = r#"{
"choices": [
{
"message": {"role": "assistant", "content": "hello there"},
"finish_reason": "stop"
}
]
}"#;
let parsed: OpenAiResponse = serde_json::from_str(json).expect("parse");
assert_eq!(parsed.choices.len(), 1);
assert_eq!(parsed.choices[0].message.content, "hello there");
assert_eq!(parsed.choices[0].finish_reason.as_deref(), Some("stop"));
}
}