Files
doctate/server/src/analyze/llm.rs
T
Brummel 3a656bd2a9 Refactor LLM API key handling for Ollama
The LLM client now conditionally adds the `Authorization` header only
when an API key is provided. The `llm_configured` check is updated to
reflect that an API key is not strictly required for Ollama-style
endpoints. A new integration test verifies the functionality against an
Ollama-compatible endpoint without an API key.
2026-04-16 14:19:43 +02:00

127 lines
3.9 KiB
Rust

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<Message<'a>>,
}
#[derive(Serialize)]
struct Message<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<Choice>,
}
#[derive(Deserialize)]
struct Choice {
message: ResponseMessage,
}
#[derive(Deserialize)]
struct ResponseMessage {
content: Option<String>,
}
/// 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<String, LlmError> {
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)
}