use std::env;
use std::fmt::{Debug, Formatter, Result as FmtResult};
use std::time::Duration;
use async_trait::async_trait;
use reqwest::{RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::{debug, info, instrument, warn};
use super::client::{self, map_send_error, DEFAULT_TIMEOUT_SECS};
use super::sse::create_sse_stream;
use crate::types::{
ChatMessage, ChatRequest, ChatResponse, ChatStream, LlmCapabilities, LlmProvider, RunnerError,
StreamChunk, TokenUsage, ToolCallRequest, ToolDefinition,
};
const LOCAL_LLM_BASE_URL_ENV: &str = "LOCAL_LLM_BASE_URL";
const LOCAL_LLM_MODEL_ENV: &str = "LOCAL_LLM_MODEL";
const LOCAL_LLM_API_KEY_ENV: &str = "LOCAL_LLM_API_KEY";
const DEFAULT_BASE_URL: &str = "http://localhost:11434/v1";
const DEFAULT_MODEL: &str = "qwen2.5:14b-instruct";
const LOG_LABEL: &str = "LocalLLM";
const AVAILABLE_MODELS: &[&str] = &[
"qwen2.5:14b-instruct",
"qwen2.5:7b-instruct",
"qwen2.5:32b-instruct",
"llama3.1:8b-instruct",
"llama3.1:70b-instruct",
"llama3.3:70b-instruct",
"mistral:7b-instruct",
"hermes2pro:latest",
];
#[derive(Debug, Serialize)]
struct OpenAiRequest {
model: String,
messages: Vec<OpenAiMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<OpenAiTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
struct OpenAiTool {
#[serde(rename = "type")]
tool_type: String,
function: OpenAiFunction,
}
#[derive(Debug, Clone, Serialize)]
struct OpenAiFunction {
name: String,
description: String,
#[serde(skip_serializing_if = "Option::is_none")]
parameters: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct OpenAiMessage {
role: String,
content: String,
}
impl From<&ChatMessage> for OpenAiMessage {
fn from(msg: &ChatMessage) -> Self {
Self {
role: msg.role.as_str().to_owned(),
content: msg.content.clone(),
}
}
}
#[derive(Debug, Deserialize)]
struct OpenAiResponse {
choices: Vec<OpenAiChoice>,
#[serde(default)]
usage: Option<OpenAiUsage>,
model: String,
}
#[derive(Debug, Deserialize)]
struct OpenAiChoice {
message: OpenAiResponseMessage,
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OpenAiResponseMessage {
content: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<OpenAiToolCall>>,
}
#[derive(Debug, Clone, Deserialize)]
struct OpenAiToolCall {
id: String,
#[serde(rename = "type")]
call_type: String,
function: OpenAiFunctionCall,
}
#[derive(Debug, Clone, Deserialize)]
struct OpenAiFunctionCall {
name: String,
arguments: String,
}
#[derive(Debug, Deserialize)]
struct OpenAiUsage {
#[serde(rename = "prompt_tokens")]
prompt: u32,
#[serde(rename = "completion_tokens")]
completion: u32,
#[serde(rename = "total_tokens")]
total: u32,
#[serde(default)]
prompt_tokens_details: Option<OpenAiUsageDetails>,
#[serde(default)]
completion_tokens_details: Option<OpenAiCompletionDetails>,
}
#[derive(Debug, Deserialize)]
struct OpenAiCompletionDetails {
#[serde(default)]
reasoning_tokens: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct OpenAiUsageDetails {
#[serde(default)]
cached_tokens: Option<u32>,
}
impl OpenAiUsage {
fn into_token_usage(self) -> TokenUsage {
TokenUsage::new(self.prompt, self.completion, self.total)
.with_cache(
self.prompt_tokens_details.and_then(|d| d.cached_tokens),
None,
)
.with_reasoning(
self.completion_tokens_details
.and_then(|d| d.reasoning_tokens),
)
}
}
#[derive(Debug, Deserialize)]
struct OpenAiStreamChunk {
choices: Vec<OpenAiStreamChoice>,
}
#[derive(Debug, Deserialize)]
struct OpenAiStreamChoice {
delta: OpenAiDelta,
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OpenAiDelta {
#[serde(default)]
content: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OpenAiErrorResponse {
error: OpenAiErrorDetail,
}
#[derive(Debug, Deserialize)]
struct OpenAiErrorDetail {
message: String,
#[serde(rename = "type")]
error_type: Option<String>,
}
#[derive(Clone)]
pub struct OpenAiCompatibleConfig {
pub base_url: String,
pub api_key: Option<String>,
pub default_model: String,
pub provider_name: String,
pub display_name: String,
pub capabilities: LlmCapabilities,
}
impl OpenAiCompatibleConfig {
#[must_use]
pub fn ollama(model: &str) -> Self {
Self {
base_url: "http://localhost:11434/v1".to_owned(),
api_key: None,
default_model: model.to_owned(),
provider_name: "ollama".to_owned(),
display_name: "Ollama (Local)".to_owned(),
capabilities: LlmCapabilities::STREAMING
| LlmCapabilities::FUNCTION_CALLING
| LlmCapabilities::SYSTEM_MESSAGES,
}
}
#[must_use]
pub fn vllm(model: &str) -> Self {
Self {
base_url: "http://localhost:8000/v1".to_owned(),
api_key: None,
default_model: model.to_owned(),
provider_name: "vllm".to_owned(),
display_name: "vLLM (Local)".to_owned(),
capabilities: LlmCapabilities::STREAMING
| LlmCapabilities::FUNCTION_CALLING
| LlmCapabilities::SYSTEM_MESSAGES
| LlmCapabilities::JSON_MODE,
}
}
#[must_use]
pub fn local_ai(model: &str) -> Self {
Self {
base_url: "http://localhost:8080/v1".to_owned(),
api_key: None,
default_model: model.to_owned(),
provider_name: "localai".to_owned(),
display_name: "LocalAI".to_owned(),
capabilities: LlmCapabilities::STREAMING
| LlmCapabilities::FUNCTION_CALLING
| LlmCapabilities::SYSTEM_MESSAGES,
}
}
#[must_use]
pub fn from_env() -> Self {
let base_url =
env::var(LOCAL_LLM_BASE_URL_ENV).unwrap_or_else(|_| DEFAULT_BASE_URL.to_owned());
let default_model =
env::var(LOCAL_LLM_MODEL_ENV).unwrap_or_else(|_| DEFAULT_MODEL.to_owned());
let api_key = env::var(LOCAL_LLM_API_KEY_ENV)
.ok()
.filter(|k| !k.is_empty());
let (provider_name, display_name) = if base_url.contains(":11434") {
("ollama", "Ollama (Local)")
} else if base_url.contains(":8000") {
("vllm", "vLLM (Local)")
} else if base_url.contains(":8080") {
("localai", "LocalAI")
} else {
("local", "Local LLM")
};
Self {
base_url,
api_key,
default_model,
provider_name: provider_name.to_owned(),
display_name: display_name.to_owned(),
capabilities: LlmCapabilities::STREAMING
| LlmCapabilities::FUNCTION_CALLING
| LlmCapabilities::SYSTEM_MESSAGES,
}
}
}
impl Default for OpenAiCompatibleConfig {
fn default() -> Self {
Self {
base_url: DEFAULT_BASE_URL.to_owned(),
api_key: None,
default_model: DEFAULT_MODEL.to_owned(),
provider_name: "local".to_owned(),
display_name: "Local LLM".to_owned(),
capabilities: LlmCapabilities::STREAMING
| LlmCapabilities::FUNCTION_CALLING
| LlmCapabilities::SYSTEM_MESSAGES,
}
}
}
impl Debug for OpenAiCompatibleConfig {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("OpenAiCompatibleConfig")
.field("base_url", &self.base_url)
.field("api_key", &self.api_key.as_ref().map(|_| "[REDACTED]"))
.field("default_model", &self.default_model)
.field("provider_name", &self.provider_name)
.field("display_name", &self.display_name)
.field("capabilities", &self.capabilities)
.finish()
}
}
pub struct OpenAiCompatibleProvider {
client: reqwest::Client,
config: OpenAiCompatibleConfig,
available_models: Vec<String>,
}
impl OpenAiCompatibleProvider {
pub fn new(config: OpenAiCompatibleConfig) -> Result<Self, RunnerError> {
let client = client::build_client(Duration::from_secs(DEFAULT_TIMEOUT_SECS))?;
Ok(Self::with_client(config, client))
}
#[must_use]
pub fn with_client(config: OpenAiCompatibleConfig, client: reqwest::Client) -> Self {
Self {
client,
config,
available_models: AVAILABLE_MODELS.iter().map(|s| (*s).to_owned()).collect(),
}
}
#[must_use]
pub fn with_default_model(mut self, model: impl Into<String>) -> Self {
self.config.default_model = model.into();
self
}
pub fn from_env() -> Result<Self, RunnerError> {
let config = OpenAiCompatibleConfig::from_env();
info!(
provider = %config.display_name,
base_url = %config.base_url,
default_model = %config.default_model,
"Initializing OpenAI-compatible provider"
);
Self::new(config)
}
fn api_url(&self, endpoint: &str) -> String {
format!(
"{}/{}",
self.config.base_url.trim_end_matches('/'),
endpoint
)
}
fn convert_messages(messages: &[ChatMessage]) -> Vec<OpenAiMessage> {
messages.iter().map(OpenAiMessage::from).collect()
}
fn log_messages_debug(messages: &[OpenAiMessage], provider_name: &str, has_tools: bool) {
for (i, msg) in messages.iter().enumerate() {
debug!(
"Message[{i}] role={}, content_len={}",
msg.role,
msg.content.len()
);
if msg.role == "system" {
debug!("System prompt present (content redacted for security)");
}
}
debug!(
"Sending chat completion request to {provider_name} with {} messages and tools={has_tools:?}",
messages.len()
);
}
fn parse_error_response(status: StatusCode, body: &str) -> RunnerError {
let Ok(error_response) = serde_json::from_str::<OpenAiErrorResponse>(body) else {
if (502..=504).contains(&status.as_u16()) {
return RunnerError::external_service(
LOG_LABEL,
"Local LLM server is not responding. Is Ollama/vLLM running?",
);
}
debug!(
status = status.as_u16(),
body_preview = %body.chars().take(200).collect::<String>(),
"Non-JSON error response body"
);
return client::map_http_error(LOG_LABEL, status, &format!("HTTP {status}"));
};
let message = error_response.error.message;
match status.as_u16() {
404 => RunnerError::model_unavailable(format!("endpoint or model: {message}")),
503 => RunnerError::external_service(
LOG_LABEL,
format!("Service unavailable (is the local server running?): {message}"),
),
_ => {
let error_type = error_response
.error
.error_type
.unwrap_or_else(|| "unknown".to_owned());
client::map_http_error(LOG_LABEL, status, &format!("{error_type} - {message}"))
}
}
}
fn convert_tools(tools: &[ToolDefinition]) -> Vec<OpenAiTool> {
tools
.iter()
.map(|func| OpenAiTool {
tool_type: "function".to_owned(),
function: OpenAiFunction {
name: func.name.clone(),
description: func.description.clone(),
parameters: func.parameters.clone(),
},
})
.collect()
}
fn convert_tool_calls(tool_calls: &[OpenAiToolCall]) -> Vec<ToolCallRequest> {
tool_calls
.iter()
.map(|call| {
debug!(
tool_call_id = %call.id,
tool_call_type = %call.call_type,
function_name = %call.function.name,
"Converting tool call"
);
ToolCallRequest {
id: call.id.clone(),
function_name: call.function.name.clone(),
arguments: serde_json::from_str(&call.function.arguments).unwrap_or_default(),
}
})
.collect()
}
fn map_send(&self, error: reqwest::Error) -> RunnerError {
if error.is_connect() {
RunnerError::external_service(
LOG_LABEL,
format!(
"Cannot connect to {}. Is the server running at {}?",
self.config.display_name, self.config.base_url
),
)
} else {
map_send_error(LOG_LABEL, error)
}
}
fn add_auth_header(&self, request: RequestBuilder) -> RequestBuilder {
match &self.config.api_key {
Some(api_key) => request.bearer_auth(api_key),
None => request,
}
}
fn build_body(&self, request: &ChatRequest, stream: bool) -> OpenAiRequest {
let model = request
.model
.as_deref()
.unwrap_or(&self.config.default_model);
let messages = Self::convert_messages(&request.messages);
let tools = request
.tools
.as_deref()
.filter(|t| !t.is_empty())
.map(Self::convert_tools);
Self::log_messages_debug(&messages, &self.config.provider_name, tools.is_some());
OpenAiRequest {
model: model.to_owned(),
messages,
temperature: request.temperature,
max_tokens: request.max_tokens,
stream: Some(stream),
tool_choice: tools.as_ref().map(|_| "auto".to_owned()),
tools,
}
}
async fn send(&self, body: &OpenAiRequest) -> Result<reqwest::Response, RunnerError> {
let http_request = self
.client
.post(self.api_url("chat/completions"))
.header("Content-Type", "application/json")
.json(body);
self.add_auth_header(http_request)
.send()
.await
.map_err(|e| self.map_send(e))
}
fn parse_stream_data(json_str: &str) -> Option<Result<StreamChunk, RunnerError>> {
match serde_json::from_str::<OpenAiStreamChunk>(json_str) {
Ok(chunk) => {
let choice = chunk.choices.into_iter().next()?;
let delta = choice.delta.content.unwrap_or_default();
let is_final = choice.finish_reason.is_some();
Some(Ok(StreamChunk {
delta,
is_final,
finish_reason: choice.finish_reason,
}))
}
Err(e) => {
warn!("Failed to parse stream chunk: {e}");
None
}
}
}
}
#[async_trait]
impl LlmProvider for OpenAiCompatibleProvider {
fn name(&self) -> &'static str {
match self.config.provider_name.as_str() {
"ollama" => "ollama",
"vllm" => "vllm",
"localai" => "localai",
_ => "local",
}
}
fn display_name(&self) -> &str {
&self.config.display_name
}
fn capabilities(&self) -> LlmCapabilities {
self.config.capabilities
}
fn default_model(&self) -> &str {
&self.config.default_model
}
fn available_models(&self) -> &[String] {
&self.available_models
}
#[instrument(skip(self, request), fields(model = %request.model.as_deref().unwrap_or(&self.config.default_model)))]
async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
let body = self.build_body(request, false);
let response = self.send(&body).await?;
let status = response.status();
let text = response.text().await.map_err(|e| {
RunnerError::external_service(LOG_LABEL, format!("Failed to read response: {e}"))
})?;
if !status.is_success() {
return Err(Self::parse_error_response(status, &text));
}
let openai_response: OpenAiResponse = serde_json::from_str(&text).map_err(|e| {
RunnerError::external_service(LOG_LABEL, format!("Failed to parse response: {e}"))
})?;
let choice =
openai_response.choices.into_iter().next().ok_or_else(|| {
RunnerError::external_service(LOG_LABEL, "API returned no choices")
})?;
let content = choice.message.content.unwrap_or_default();
let tool_calls = choice.message.tool_calls.map(|calls| {
info!(
"{} returned {} tool calls",
self.config.provider_name,
calls.len()
);
Self::convert_tool_calls(&calls)
});
debug!(
provider = %self.config.provider_name,
content_len = content.len(),
tool_calls = tool_calls.as_ref().map(Vec::len),
finish_reason = ?choice.finish_reason,
"Received response"
);
Ok(ChatResponse {
content,
model: openai_response.model,
usage: openai_response.usage.map(OpenAiUsage::into_token_usage),
finish_reason: choice.finish_reason,
warnings: None,
tool_calls,
})
}
#[instrument(skip(self, request), fields(model = %request.model.as_deref().unwrap_or(&self.config.default_model)))]
async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError> {
let body = self.build_body(request, true);
let response = self.send(&body).await?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
return Err(Self::parse_error_response(status, &text));
}
Ok(create_sse_stream(
response.bytes_stream(),
Self::parse_stream_data,
LOG_LABEL,
))
}
#[instrument(skip(self))]
async fn health_check(&self) -> Result<bool, RunnerError> {
debug!(
provider = %self.config.provider_name,
base_url = %self.config.base_url,
"Performing health check"
);
let http_request = self.client.get(self.api_url("models"));
let response = self
.add_auth_header(http_request)
.send()
.await
.map_err(|e| self.map_send(e))?;
let healthy = response.status().is_success();
if healthy {
debug!("{} health check passed", self.config.provider_name);
} else {
warn!(
provider = %self.config.provider_name,
status = response.status().as_u16(),
"health check failed"
);
}
Ok(healthy)
}
}
impl Debug for OpenAiCompatibleProvider {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
f.debug_struct("OpenAiCompatibleProvider")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ErrorKind;
#[test]
fn a_non_json_gateway_error_says_the_server_is_not_responding() {
let err = OpenAiCompatibleProvider::parse_error_response(StatusCode::BAD_GATEWAY, "<html>");
assert_eq!(err.kind, ErrorKind::ExternalService);
assert!(err.message.contains("not responding"));
}
#[test]
fn a_404_is_an_unavailable_model_a_400_an_invalid_request() {
let body = r#"{"error":{"message":"model 'qwen3' not found","type":"api_error"}}"#;
let err = OpenAiCompatibleProvider::parse_error_response(StatusCode::NOT_FOUND, body);
assert_eq!(err.kind, ErrorKind::ModelUnavailable);
let body = r#"{"error":{"message":"invalid messages","type":"invalid_request_error"}}"#;
let err = OpenAiCompatibleProvider::parse_error_response(StatusCode::BAD_REQUEST, body);
assert_eq!(err.kind, ErrorKind::InvalidRequest);
}
#[test]
fn a_429_is_a_rate_limit_and_a_503_external_service() {
let body = r#"{"error":{"message":"Rate limit reached. Please try again in 20s."}}"#;
let err =
OpenAiCompatibleProvider::parse_error_response(StatusCode::TOO_MANY_REQUESTS, body);
assert_eq!(err.kind, ErrorKind::RateLimit);
assert!(err.message.contains("20 seconds"));
let body = r#"{"error":{"message":"loading model"}}"#;
let err =
OpenAiCompatibleProvider::parse_error_response(StatusCode::SERVICE_UNAVAILABLE, body);
assert_eq!(err.kind, ErrorKind::ExternalService);
assert!(err.message.contains("is the local server running"));
}
#[test]
fn debug_redacts_the_api_key() {
let config = OpenAiCompatibleConfig {
api_key: Some("super-secret".to_owned()),
..OpenAiCompatibleConfig::default()
};
let output = format!("{config:?}");
assert!(!output.contains("super-secret"));
assert!(output.contains("[REDACTED]"));
}
}