use serde::{Deserialize, Serialize};
use crate::error::{ApiError, ApiResult};
#[derive(Debug, Clone)]
pub struct LlmCall {
pub provider: String,
pub model: String,
pub api_key: String,
pub base_url: Option<String>,
pub system: String,
pub user: String,
pub temperature: f64,
pub max_tokens: u32,
}
#[derive(Debug, Clone)]
pub struct LlmResult {
pub content: String,
pub prompt_tokens: u64,
pub completion_tokens: u64,
}
impl LlmResult {
pub fn total_tokens(&self) -> u64 {
self.prompt_tokens + self.completion_tokens
}
}
pub async fn call_llm(call: LlmCall) -> ApiResult<LlmResult> {
let provider = call.provider.to_lowercase();
match provider.as_str() {
"anthropic" | "claude" => call_anthropic(call).await,
_ => call_openai_compatible(call).await,
}
}
#[derive(Serialize)]
struct OpenAiRequest<'a> {
model: &'a str,
messages: Vec<OpenAiMessage<'a>>,
temperature: f64,
max_tokens: u32,
}
#[derive(Serialize)]
struct OpenAiMessage<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Deserialize)]
struct OpenAiResponse {
choices: Vec<OpenAiChoice>,
#[serde(default)]
usage: OpenAiUsage,
}
#[derive(Deserialize)]
struct OpenAiChoice {
message: OpenAiResponseMessage,
}
#[derive(Deserialize)]
struct OpenAiResponseMessage {
content: String,
}
#[derive(Deserialize, Default)]
struct OpenAiUsage {
#[serde(default)]
prompt_tokens: u64,
#[serde(default)]
completion_tokens: u64,
}
async fn call_openai_compatible(call: LlmCall) -> ApiResult<LlmResult> {
let base = call
.base_url
.as_deref()
.unwrap_or("https://api.openai.com")
.trim_end_matches('/');
let url = format!("{base}/v1/chat/completions");
let body = OpenAiRequest {
model: &call.model,
messages: vec![
OpenAiMessage {
role: "system",
content: &call.system,
},
OpenAiMessage {
role: "user",
content: &call.user,
},
],
temperature: call.temperature,
max_tokens: call.max_tokens,
};
let resp = reqwest::Client::new()
.post(&url)
.bearer_auth(&call.api_key)
.json(&body)
.send()
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("LLM HTTP error: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(ApiError::Internal(anyhow::anyhow!("LLM provider returned {status}: {text}")));
}
let parsed: OpenAiResponse = resp
.json()
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("LLM response parse error: {e}")))?;
let content = parsed
.choices
.into_iter()
.next()
.map(|c| c.message.content)
.ok_or_else(|| ApiError::Internal(anyhow::anyhow!("LLM returned no choices")))?;
Ok(LlmResult {
content,
prompt_tokens: parsed.usage.prompt_tokens,
completion_tokens: parsed.usage.completion_tokens,
})
}
#[derive(Serialize)]
struct AnthropicRequest<'a> {
model: &'a str,
max_tokens: u32,
system: &'a str,
messages: Vec<AnthropicMessage<'a>>,
temperature: f64,
}
#[derive(Serialize)]
struct AnthropicMessage<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Deserialize)]
struct AnthropicResponse {
content: Vec<AnthropicContentBlock>,
#[serde(default)]
usage: AnthropicUsage,
}
#[derive(Deserialize)]
struct AnthropicContentBlock {
#[serde(rename = "type")]
block_type: String,
#[serde(default)]
text: Option<String>,
}
#[derive(Deserialize, Default)]
struct AnthropicUsage {
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
}
async fn call_anthropic(call: LlmCall) -> ApiResult<LlmResult> {
let base = call
.base_url
.as_deref()
.unwrap_or("https://api.anthropic.com")
.trim_end_matches('/');
let url = format!("{base}/v1/messages");
let body = AnthropicRequest {
model: &call.model,
max_tokens: call.max_tokens,
system: &call.system,
messages: vec![AnthropicMessage {
role: "user",
content: &call.user,
}],
temperature: call.temperature,
};
let resp = reqwest::Client::new()
.post(&url)
.header("x-api-key", &call.api_key)
.header("anthropic-version", "2023-06-01")
.json(&body)
.send()
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("LLM HTTP error: {e}")))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(ApiError::Internal(anyhow::anyhow!("LLM provider returned {status}: {text}")));
}
let parsed: AnthropicResponse = resp
.json()
.await
.map_err(|e| ApiError::Internal(anyhow::anyhow!("LLM response parse error: {e}")))?;
let content = parsed
.content
.into_iter()
.find(|b| b.block_type == "text")
.and_then(|b| b.text)
.ok_or_else(|| ApiError::Internal(anyhow::anyhow!("Anthropic returned no text block")))?;
Ok(LlmResult {
content,
prompt_tokens: parsed.usage.input_tokens,
completion_tokens: parsed.usage.output_tokens,
})
}