Add OpenAI/LiteLLM provider support with settings UI

- Add openai/ provider module with translator, client, convert, request/response translators
- Add shared provider/ types (ConversationMessage, MessageRole, ProviderConfig enum)
- Wire OpenAI-compatible provider dispatch alongside Bedrock in response_stream.rs
- Add ai.openai.* settings (enabled, base_url, api_key, model, models)
- Add OpenAI/LiteLLM settings page with model fetch, picker, and config UI
- Extend model menu items and llms.rs to surface LiteLLM models
- Update WARP.md with OpenAI provider architecture docs
This commit is contained in:
Ryan Ward
2026-06-17 14:14:40 -05:00
parent 59cfd0e2f5
commit 5ea378a38d
32 changed files with 2442 additions and 137 deletions
+92
View File
@@ -0,0 +1,92 @@
use std::fmt;
use bytes::Bytes;
use futures::Stream;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
#[derive(Clone, Debug)]
pub struct OpenAIClientConfig {
pub base_url: String,
pub api_key: Option<String>,
pub model: Option<String>,
}
pub struct OpenAIClient {
http: reqwest::Client,
base_url: String,
api_key: Option<String>,
}
#[derive(Debug)]
pub enum OpenAIError {
ConnectionFailed(String),
AuthenticationFailed(String),
RateLimited(String),
BadRequest(String),
ServerError(String),
#[allow(dead_code)]
StreamError(String),
}
impl fmt::Display for OpenAIError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ConnectionFailed(msg) => write!(f, "Connection failed: {msg}"),
Self::AuthenticationFailed(msg) => write!(f, "Authentication failed: {msg}"),
Self::RateLimited(msg) => write!(f, "Rate limited: {msg}"),
Self::BadRequest(msg) => write!(f, "Bad request: {msg}"),
Self::ServerError(msg) => write!(f, "Server error: {msg}"),
Self::StreamError(msg) => write!(f, "Stream error: {msg}"),
}
}
}
impl OpenAIClient {
pub fn from_config(config: OpenAIClientConfig) -> Self {
let http = reqwest::Client::new();
Self {
http,
base_url: config.base_url,
api_key: config.api_key,
}
}
pub async fn chat_completions_stream(
&self,
request_body: serde_json::Value,
) -> Result<impl Stream<Item = Result<Bytes, reqwest::Error>>, OpenAIError> {
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
if let Some(ref key) = self.api_key {
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {key}"))
.map_err(|e| OpenAIError::BadRequest(format!("Invalid API key header: {e}")))?,
);
}
let response = self
.http
.post(&url)
.headers(headers)
.json(&request_body)
.send()
.await
.map_err(|e| OpenAIError::ConnectionFailed(e.to_string()))?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(match status.as_u16() {
401 => OpenAIError::AuthenticationFailed(body),
429 => OpenAIError::RateLimited(body),
400 => OpenAIError::BadRequest(body),
_ => OpenAIError::ServerError(format!("HTTP {status}: {body}")),
});
}
Ok(response.bytes_stream())
}
}