mattrguerra/cortexmainPrivate on GitHub, published here in full

client.rs

Language Rust7.4 KB216 lines

crates/cortex-core/src/provider/client.rs

  1. //! One client for every OpenAI-compatible server: DeepInfra and local servers
  2. //! differ only in base URL, key and pricing.
  3. use std::time::{Duration, Instant};
  4. use futures_util::StreamExt;
  5. use tokio_util::sync::CancellationToken;
  6. use super::error::ProviderError;
  7. use super::openai::{parse_chunk, ChatRequest};
  8. use super::sse::SseParser;
  9. use crate::types::Usage;
  10. pub const DEEPINFRA_BASE: &str = "https://api.deepinfra.com/v1/openai";
  11. /// Something that arrived on the stream, in order.
  12. #[derive(Debug, Clone, PartialEq)]
  13. pub enum StreamItem {
  14. Content(String),
  15. Reasoning(String),
  16. Usage(Usage),
  17. }
  18. /// How a stream ended. `content` and `reasoning` hold whatever arrived, even
  19. /// when it was cancelled or failed part way, so a partial reply can be kept.
  20. #[derive(Debug, Clone, Default, PartialEq)]
  21. pub struct StreamOutcome {
  22. pub content: String,
  23. pub reasoning: String,
  24. /// The last usage block seen, if the server sent one.
  25. pub usage: Option<Usage>,
  26. pub provider_cost_usd: Option<f64>,
  27. pub finish_reason: Option<String>,
  28. pub cancelled: bool,
  29. pub error: Option<ProviderError>,
  30. pub ttft_ms: Option<u64>,
  31. pub duration_ms: u64,
  32. }
  33. #[derive(Clone)]
  34. pub struct Client {
  35. http: reqwest::Client,
  36. base_url: String,
  37. api_key: Option<String>,
  38. }
  39. fn is_loopback(url: &str) -> bool {
  40. let rest = url.split("://").nth(1).unwrap_or(url);
  41. let host = rest.split(['/', ':']).next().unwrap_or("");
  42. matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1") || rest.starts_with("[::1]")
  43. }
  44. impl Client {
  45. pub fn new(base_url: &str, api_key: Option<String>) -> Self {
  46. let mut b = reqwest::Client::builder()
  47. .connect_timeout(Duration::from_secs(10))
  48. .user_agent(concat!("Cortex/", env!("CARGO_PKG_VERSION")));
  49. if is_loopback(base_url) {
  50. b = b.no_proxy(); // a local model server is never behind the system proxy
  51. }
  52. Self {
  53. http: b.build().expect("http client"),
  54. base_url: base_url.trim_end_matches('/').to_string(),
  55. api_key: api_key.filter(|k| !k.is_empty()),
  56. }
  57. }
  58. pub fn base_url(&self) -> &str {
  59. &self.base_url
  60. }
  61. fn auth(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
  62. match &self.api_key {
  63. Some(k) => rb.bearer_auth(k),
  64. None => rb,
  65. }
  66. }
  67. fn transport_error(&self, e: reqwest::Error) -> ProviderError {
  68. if e.is_connect() || e.is_timeout() {
  69. ProviderError::Unreachable { url: self.base_url.clone() }
  70. } else {
  71. ProviderError::Other(e.to_string())
  72. }
  73. }
  74. async fn check(&self, res: reqwest::Response) -> Result<reqwest::Response, ProviderError> {
  75. if res.status().is_success() {
  76. return Ok(res);
  77. }
  78. let status = res.status().as_u16();
  79. let retry_after =
  80. res.headers().get("retry-after").and_then(|v| v.to_str().ok()).and_then(|v| v.trim().parse::<u64>().ok());
  81. let body = res.text().await.unwrap_or_default();
  82. Err(ProviderError::from_status(status, &body, retry_after))
  83. }
  84. /// `GET {base}/models`: the raw JSON, for `pricing::parse_priced_models` or
  85. /// `pricing::parse_local_models`.
  86. pub async fn list_models(&self) -> Result<serde_json::Value, ProviderError> {
  87. let res = self
  88. .auth(self.http.get(format!("{}/models", self.base_url)))
  89. .timeout(Duration::from_secs(20))
  90. .send()
  91. .await
  92. .map_err(|e| self.transport_error(e))?;
  93. let res = self.check(res).await?;
  94. res.json().await.map_err(|e| ProviderError::Other(format!("unreadable model list: {e}")))
  95. }
  96. /// Stream a chat completion. `on` sees every item as it arrives. Cancelling
  97. /// `cancel` drops the connection at once, which stops the provider billing.
  98. pub async fn stream_chat(
  99. &self,
  100. req: &ChatRequest,
  101. cancel: &CancellationToken,
  102. mut on: impl FnMut(StreamItem),
  103. ) -> StreamOutcome {
  104. let started = Instant::now();
  105. let mut out = StreamOutcome::default();
  106. let send = self.auth(self.http.post(format!("{}/chat/completions", self.base_url))).json(req).send();
  107. let res = tokio::select! {
  108. _ = cancel.cancelled() => { out.cancelled = true; out.duration_ms = ms(started); return out; }
  109. r = send => r,
  110. };
  111. let res = match res {
  112. Ok(r) => match self.check(r).await {
  113. Ok(r) => r,
  114. Err(e) => {
  115. out.error = Some(e);
  116. out.duration_ms = ms(started);
  117. return out;
  118. }
  119. },
  120. Err(e) => {
  121. out.error = Some(self.transport_error(e));
  122. out.duration_ms = ms(started);
  123. return out;
  124. }
  125. };
  126. let mut body = res.bytes_stream();
  127. let mut parser = SseParser::new();
  128. 'read: loop {
  129. let next = tokio::select! {
  130. _ = cancel.cancelled() => { out.cancelled = true; break 'read; }
  131. n = body.next() => n,
  132. };
  133. let (events, ended) = match next {
  134. Some(Ok(bytes)) => (parser.push(&bytes), false),
  135. Some(Err(e)) => {
  136. out.error = Some(ProviderError::Stream(e.to_string()));
  137. break;
  138. }
  139. None => (parser.finish(), true),
  140. };
  141. for ev in events {
  142. match parse_chunk(&ev.data) {
  143. Ok(c) => {
  144. if c.done {
  145. break 'read;
  146. }
  147. if let Some(r) = c.reasoning {
  148. out.ttft_ms.get_or_insert(ms(started));
  149. out.reasoning.push_str(&r);
  150. on(StreamItem::Reasoning(r));
  151. }
  152. if let Some(t) = c.content {
  153. out.ttft_ms.get_or_insert(ms(started));
  154. out.content.push_str(&t);
  155. on(StreamItem::Content(t));
  156. }
  157. if let Some(u) = c.usage {
  158. out.usage = Some(u);
  159. on(StreamItem::Usage(u));
  160. }
  161. if c.provider_cost_usd.is_some() {
  162. out.provider_cost_usd = c.provider_cost_usd;
  163. }
  164. if c.finish_reason.is_some() {
  165. out.finish_reason = c.finish_reason;
  166. }
  167. }
  168. Err(e) => {
  169. out.error = Some(e);
  170. break 'read;
  171. }
  172. }
  173. }
  174. if ended {
  175. break;
  176. }
  177. }
  178. drop(body);
  179. out.duration_ms = ms(started);
  180. out
  181. }
  182. }
  183. fn ms(since: Instant) -> u64 {
  184. since.elapsed().as_millis() as u64
  185. }
  186. #[cfg(test)]
  187. mod tests {
  188. use super::*;
  189. #[test]
  190. fn loopback_detection() {
  191. assert!(is_loopback("http://localhost:11434/v1"));
  192. assert!(is_loopback("http://127.0.0.1:1234/v1"));
  193. assert!(is_loopback("http://[::1]:8080/v1"));
  194. assert!(!is_loopback(DEEPINFRA_BASE));
  195. assert!(!is_loopback("http://localhost.example.com/v1"));
  196. }
  197. }