use std::time::Duration; use serde::{Deserialize, Serialize}; use tracing::debug; /// Error variants returned by the LLM client (OpenAI-compatible Chat /// Completions API — Ionos, Azure OpenAI, OpenAI proper, Together.ai, etc.). /// /// **Security:** `Display` and `Debug` MUST NOT contain the HTTP response body. /// Provider error bodies can echo back details that include the API key or /// other sensitive context (e.g. `{"error": "invalid api_key sk-…"}`). Only /// the status and a short category string are safe to log. #[derive(Debug)] pub enum LlmError { /// Transport-level failure (DNS, TLS, connect, timeout). `reqwest::Error` /// is intentionally included — its `Display` only reveals URL/method, /// not request headers, so the API key stays out of logs. Http(reqwest::Error), /// Provider returned a non-2xx status. Body is deliberately discarded. Status { status: u16 }, /// Response was 2xx but the expected `choices[0].message.content` /// shape was absent or empty. Parse(&'static str), } impl std::fmt::Display for LlmError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Http(e) => write!(f, "llm http error: {e}"), Self::Status { status } => write!(f, "llm returned status {status}"), Self::Parse(msg) => write!(f, "llm response parse error: {msg}"), } } } impl std::error::Error for LlmError {} /// Grouped provider configuration. Passed by reference to `chat_once`. pub struct LlmSettings<'a> { pub base_url: &'a str, pub api_key: &'a str, pub model: &'a str, pub temperature: f32, } #[derive(Serialize)] struct ChatRequest<'a> { model: &'a str, temperature: f32, stream: bool, messages: Vec>, } #[derive(Serialize)] struct Message<'a> { role: &'a str, content: &'a str, } #[derive(Deserialize)] struct ChatResponse { choices: Vec, } #[derive(Deserialize)] struct Choice { message: ResponseMessage, } #[derive(Deserialize)] struct ResponseMessage { content: Option, } /// Single-shot chat completion against an OpenAI-compatible endpoint. /// The `api_key` in `settings` is used as a Bearer token; do not log it. /// An empty `api_key` is treated as "no auth" — the `Authorization` header /// is omitted entirely, which is the correct mode for Ollama's /// OpenAI-compatible endpoint (some gateways object to a blank Bearer). pub async fn chat_once( client: &reqwest::Client, settings: &LlmSettings<'_>, system_prompt: &str, user_content: &str, timeout: Duration, ) -> Result { let url = format!("{}/v1/chat/completions", settings.base_url.trim_end_matches('/')); debug!(%url, model = settings.model, "calling llm chat completions"); let body = ChatRequest { model: settings.model, temperature: settings.temperature, stream: false, messages: vec![ Message { role: "system", content: system_prompt }, Message { role: "user", content: user_content }, ], }; let mut req = client.post(&url).timeout(timeout).json(&body); if !settings.api_key.is_empty() { req = req.bearer_auth(settings.api_key); } let response = req.send().await.map_err(LlmError::Http)?; let status = response.status(); if !status.is_success() { // Body deliberately dropped — see LlmError docstring. drop(response); return Err(LlmError::Status { status: status.as_u16() }); } let parsed: ChatResponse = response.json().await.map_err(LlmError::Http)?; let content = parsed .choices .into_iter() .next() .and_then(|c| c.message.content) .ok_or(LlmError::Parse("missing choices[0].message.content"))?; let trimmed = content.trim().to_string(); if trimmed.is_empty() { return Err(LlmError::Parse("empty content after trim")); } Ok(trimmed) }