MessageWeave/src/ai.rs

179 lines
5.0 KiB
Rust

//! HTTPS OpenAI-compatible summary client. It never persists prompts or results.
use crate::state::{runtime_provider, OutboundConfig, RuntimeConfigProvider};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
#[derive(Debug)]
pub enum AiError {
InvalidEndpoint,
Request,
Response,
}
pub struct LlmClient {
http: reqwest::Client,
endpoint: String,
key: SecretString,
model: String,
max_chars: usize,
runtime: RuntimeConfigProvider,
}
impl LlmClient {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "stable client constructor retained for adapter tests"
)
)]
pub fn new(
endpoint: String,
key: SecretString,
model: String,
max_retries: u8,
max_chars: usize,
) -> Result<Self, AiError> {
Self::new_with_timeout(endpoint, key, model, max_retries, max_chars, 30_000)
}
pub fn new_with_timeout(
endpoint: String,
key: SecretString,
model: String,
max_retries: u8,
max_chars: usize,
timeout_ms: u64,
) -> Result<Self, AiError> {
let url = url::Url::parse(&endpoint).map_err(|_| AiError::InvalidEndpoint)?;
if url.scheme() != "https" {
return Err(AiError::InvalidEndpoint);
}
Ok(Self {
http: reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(timeout_ms.max(100)))
.build()
.map_err(|_| AiError::Request)?,
endpoint: endpoint.trim_end_matches('/').into(),
key,
model,
max_chars: max_chars.max(1),
runtime: runtime_provider(OutboundConfig {
llm_timeout_ms: timeout_ms.max(100),
max_retries: max_retries.min(5),
..OutboundConfig::default()
}),
})
}
pub fn with_runtime(
endpoint: String,
key: SecretString,
model: String,
max_chars: usize,
runtime: RuntimeConfigProvider,
) -> Result<Self, AiError> {
let url = url::Url::parse(&endpoint).map_err(|_| AiError::InvalidEndpoint)?;
if url.scheme() != "https" {
return Err(AiError::InvalidEndpoint);
}
Ok(Self {
http: reqwest::Client::new(),
endpoint: endpoint.trim_end_matches('/').into(),
key,
model,
max_chars: max_chars.max(1),
runtime,
})
}
pub async fn summarize(&self, body: &str) -> Result<String, AiError> {
let request = ChatRequest {
model: &self.model,
messages: vec![ChatMessage {
role: "user",
content: body,
}],
};
let (timeout_ms, max_retries) = self
.runtime
.read()
.map(|config| (config.llm_timeout_ms, config.max_retries.min(5)))
.unwrap_or((30_000, 3));
for _ in 0..=max_retries {
let response = match tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms.max(100)),
self.http
.post(format!("{}/chat/completions", self.endpoint))
.bearer_auth(self.key.expose_secret())
.json(&request)
.send(),
)
.await
{
Ok(Ok(response)) => response,
_ => continue,
};
let status = response.status();
if status.is_success() {
let value = response
.json::<ChatResponse>()
.await
.map_err(|_| AiError::Response)?;
let text = value
.choices
.into_iter()
.next()
.map(|choice| choice.message.content)
.ok_or(AiError::Response)?;
return Ok(text.chars().take(self.max_chars).collect());
}
if !(status.as_u16() == 429 || status.is_server_error()) {
return Err(AiError::Request);
}
}
Err(AiError::Response)
}
}
#[derive(Serialize)]
struct ChatRequest<'a> {
model: &'a str,
messages: Vec<ChatMessage<'a>>,
}
#[derive(Serialize)]
struct ChatMessage<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<Choice>,
}
#[derive(Deserialize)]
struct Choice {
message: ChatMessageOwned,
}
#[derive(Deserialize)]
struct ChatMessageOwned {
content: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn llm_requires_https() {
assert!(matches!(
LlmClient::new(
"http://localhost".into(),
SecretString::new("x".into()),
"m".into(),
1,
300,
),
Err(AiError::InvalidEndpoint)
));
}
}