client.rs
Language Rust7.4 KB216 lines
//! One client for every OpenAI-compatible server: DeepInfra and local servers//! differ only in base URL, key and pricing.use std::time::{Duration, Instant};use futures_util::StreamExt;use tokio_util::sync::CancellationToken;use super::error::ProviderError;use super::openai::{parse_chunk, ChatRequest};use super::sse::SseParser;use crate::types::Usage;pub const DEEPINFRA_BASE: &str = "https://api.deepinfra.com/v1/openai";/// Something that arrived on the stream, in order.#[derive(Debug, Clone, PartialEq)]pub enum StreamItem {Content(String),Reasoning(String),Usage(Usage),}/// How a stream ended. `content` and `reasoning` hold whatever arrived, even/// when it was cancelled or failed part way, so a partial reply can be kept.#[derive(Debug, Clone, Default, PartialEq)]pub struct StreamOutcome {pub content: String,pub reasoning: String,/// The last usage block seen, if the server sent one.pub usage: Option<Usage>,pub provider_cost_usd: Option<f64>,pub finish_reason: Option<String>,pub cancelled: bool,pub error: Option<ProviderError>,pub ttft_ms: Option<u64>,pub duration_ms: u64,}#[derive(Clone)]pub struct Client {http: reqwest::Client,base_url: String,api_key: Option<String>,}fn is_loopback(url: &str) -> bool {let rest = url.split("://").nth(1).unwrap_or(url);let host = rest.split(['/', ':']).next().unwrap_or("");matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1") || rest.starts_with("[::1]")}impl Client {pub fn new(base_url: &str, api_key: Option<String>) -> Self {let mut b = reqwest::Client::builder().connect_timeout(Duration::from_secs(10)).user_agent(concat!("Cortex/", env!("CARGO_PKG_VERSION")));if is_loopback(base_url) {b = b.no_proxy(); // a local model server is never behind the system proxy}Self {http: b.build().expect("http client"),base_url: base_url.trim_end_matches('/').to_string(),api_key: api_key.filter(|k| !k.is_empty()),}}pub fn base_url(&self) -> &str {&self.base_url}fn auth(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {match &self.api_key {Some(k) => rb.bearer_auth(k),None => rb,}}fn transport_error(&self, e: reqwest::Error) -> ProviderError {if e.is_connect() || e.is_timeout() {ProviderError::Unreachable { url: self.base_url.clone() }} else {ProviderError::Other(e.to_string())}}async fn check(&self, res: reqwest::Response) -> Result<reqwest::Response, ProviderError> {if res.status().is_success() {return Ok(res);}let status = res.status().as_u16();let retry_after =res.headers().get("retry-after").and_then(|v| v.to_str().ok()).and_then(|v| v.trim().parse::<u64>().ok());let body = res.text().await.unwrap_or_default();Err(ProviderError::from_status(status, &body, retry_after))}/// `GET {base}/models`: the raw JSON, for `pricing::parse_priced_models` or/// `pricing::parse_local_models`.pub async fn list_models(&self) -> Result<serde_json::Value, ProviderError> {let res = self.auth(self.http.get(format!("{}/models", self.base_url))).timeout(Duration::from_secs(20)).send().await.map_err(|e| self.transport_error(e))?;let res = self.check(res).await?;res.json().await.map_err(|e| ProviderError::Other(format!("unreadable model list: {e}")))}/// Stream a chat completion. `on` sees every item as it arrives. Cancelling/// `cancel` drops the connection at once, which stops the provider billing.pub async fn stream_chat(&self,req: &ChatRequest,cancel: &CancellationToken,mut on: impl FnMut(StreamItem),) -> StreamOutcome {let started = Instant::now();let mut out = StreamOutcome::default();let send = self.auth(self.http.post(format!("{}/chat/completions", self.base_url))).json(req).send();let res = tokio::select! {_ = cancel.cancelled() => { out.cancelled = true; out.duration_ms = ms(started); return out; }r = send => r,};let res = match res {Ok(r) => match self.check(r).await {Ok(r) => r,Err(e) => {out.error = Some(e);out.duration_ms = ms(started);return out;}},Err(e) => {out.error = Some(self.transport_error(e));out.duration_ms = ms(started);return out;}};let mut body = res.bytes_stream();let mut parser = SseParser::new();'read: loop {let next = tokio::select! {_ = cancel.cancelled() => { out.cancelled = true; break 'read; }n = body.next() => n,};let (events, ended) = match next {Some(Ok(bytes)) => (parser.push(&bytes), false),Some(Err(e)) => {out.error = Some(ProviderError::Stream(e.to_string()));break;}None => (parser.finish(), true),};for ev in events {match parse_chunk(&ev.data) {Ok(c) => {if c.done {break 'read;}if let Some(r) = c.reasoning {out.ttft_ms.get_or_insert(ms(started));out.reasoning.push_str(&r);on(StreamItem::Reasoning(r));}if let Some(t) = c.content {out.ttft_ms.get_or_insert(ms(started));out.content.push_str(&t);on(StreamItem::Content(t));}if let Some(u) = c.usage {out.usage = Some(u);on(StreamItem::Usage(u));}if c.provider_cost_usd.is_some() {out.provider_cost_usd = c.provider_cost_usd;}if c.finish_reason.is_some() {out.finish_reason = c.finish_reason;}}Err(e) => {out.error = Some(e);break 'read;}}}if ended {break;}}drop(body);out.duration_ms = ms(started);out}}fn ms(since: Instant) -> u64 {since.elapsed().as_millis() as u64}#[cfg(test)]mod tests {use super::*;#[test]fn loopback_detection() {assert!(is_loopback("http://localhost:11434/v1"));assert!(is_loopback("http://127.0.0.1:1234/v1"));assert!(is_loopback("http://[::1]:8080/v1"));assert!(!is_loopback(DEEPINFRA_BASE));assert!(!is_loopback("http://localhost.example.com/v1"));}}