use std::fmt;
use std::time::Duration;
use super::config::ExploreConfig;
use super::wire::{ChatRequest, ChatResponse};
pub(crate) const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const CHAT_TIMEOUT: Duration = Duration::from_secs(300);
pub trait ChatClient {
fn chat(&self, req: ChatRequest) -> Result<ChatResponse, ClientError>;
}
#[derive(Debug)]
pub enum ClientError {
Connection {
url: String,
detail: String,
},
Http {
url: String,
status: u16,
body: String,
},
Protocol {
url: String,
detail: String,
body: String,
},
Encode(String),
}
impl ClientError {
pub fn is_connection(&self) -> bool {
matches!(self, ClientError::Connection { .. })
}
}
impl fmt::Display for ClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ClientError::Connection { url, detail } => {
write!(f, "could not reach the inference server at {url}: {detail}")
}
ClientError::Http { url, status, body } => {
write!(f, "{url} returned HTTP {status}: {}", truncate(body))
}
ClientError::Protocol { url, detail, .. } => {
write!(f, "unexpected response from {url}: {detail}")
}
ClientError::Encode(detail) => {
write!(f, "failed to encode chat request: {detail}")
}
}
}
}
impl std::error::Error for ClientError {}
pub(crate) fn truncate(s: &str) -> String {
const MAX: usize = 500;
if s.len() <= MAX {
s.to_string()
} else {
let mut cut = MAX;
while cut > 0 && !s.is_char_boundary(cut) {
cut -= 1;
}
format!("{}… ({} bytes)", &s[..cut], s.len())
}
}
pub struct OpenAiCompatClient {
base_url: String,
model: String,
agent: ureq::Agent,
}
impl OpenAiCompatClient {
pub fn new(cfg: &ExploreConfig) -> Self {
let agent = ureq::AgentBuilder::new()
.timeout_connect(CONNECT_TIMEOUT)
.timeout(CHAT_TIMEOUT)
.build();
OpenAiCompatClient {
base_url: cfg.base_url.trim_end_matches('/').to_string(),
model: cfg.model.clone(),
agent,
}
}
}
impl ChatClient for OpenAiCompatClient {
fn chat(&self, req: ChatRequest) -> Result<ChatResponse, ClientError> {
let mut req = req;
req.model = self.model.clone();
let url = format!("{}/chat/completions", self.base_url);
let body = serde_json::to_string(&req).map_err(|e| ClientError::Encode(e.to_string()))?;
let resp = self
.agent
.post(&url)
.set("Content-Type", "application/json")
.send_string(&body);
let resp = match resp {
Ok(r) => r,
Err(ureq::Error::Status(status, r)) => {
let body = r.into_string().unwrap_or_default();
return Err(ClientError::Http { url, status, body });
}
Err(ureq::Error::Transport(t)) => {
return Err(ClientError::Connection { url, detail: t.to_string() });
}
};
let raw = resp
.into_string()
.map_err(|e| ClientError::Connection { url: url.clone(), detail: e.to_string() })?;
serde_json::from_str(&raw).map_err(|e| ClientError::Protocol {
url,
detail: e.to_string(),
body: raw,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::explore::wire::Message;
fn unreachable_config() -> ExploreConfig {
ExploreConfig {
base_url: "http://127.0.0.1:1/v1".to_string(),
model: "test-model".to_string(),
..ExploreConfig::default()
}
}
#[test]
fn chat_against_unreachable_url_is_connection_error() {
let client = OpenAiCompatClient::new(&unreachable_config());
let err = client
.chat(ChatRequest::new(vec![Message::user("hi")]))
.expect_err("a closed port must not yield a response");
assert!(err.is_connection(), "expected Connection, got {err:?}");
match err {
ClientError::Connection { url, .. } => {
assert!(url.contains("127.0.0.1:1"), "message names the endpoint: {url}");
assert!(url.ends_with("/chat/completions"));
}
other => panic!("expected Connection, got {other:?}"),
}
}
#[test]
fn truncate_does_not_panic_on_multibyte_boundary() {
let s = format!("{}{}", "a".repeat(499), "é".repeat(50));
let out = truncate(&s); assert!(out.ends_with(&format!("({} bytes)", s.len())));
let body = out.split('…').next().unwrap();
assert!(body.len() <= 500);
assert!(s.starts_with(body));
assert_eq!(truncate("héllo"), "héllo");
}
}