Skip to main content

chat_completions/
lib.rs

1//! Generic OpenAI-compatible Chat Completions client.
2//!
3//! Targets the `/v1/chat/completions` wire format implemented by OpenAI,
4//! Ollama, vLLM, llama.cpp, LiteLLM, Cerebras, Groq, Together, Fireworks,
5//! and the rest of the OAI-compatible ecosystem. Bring your own base URL.
6//!
7//! ```no_run
8//! use chat_completions::CompletionsBuilder;
9//!
10//! let client = CompletionsBuilder::new()
11//!     .with_base_url("http://localhost:8000/v1")
12//!     .with_model("my-model")
13//!     .with_api_key("sk-...")
14//!     .build();
15//! ```
16
17mod api;
18mod client;
19
20use std::marker::PhantomData;
21
22use chat_core::types::provider_meta::ProviderMeta;
23
24pub use crate::client::CompletionsClient;
25pub use chat_core::error::{ChatError, ChatFailure};
26pub use chat_core::transport::{Request, ReqwestTransport, Response, Transport, TransportError};
27
28pub struct WithoutModel;
29pub struct WithModel;
30
31pub struct WithoutUrl;
32pub struct WithUrl;
33
34pub struct CompletionsBuilder<M = WithoutModel, U = WithoutUrl, T: Transport = ReqwestTransport> {
35    model_name: Option<String>,
36    api_key: Option<String>,
37    scheme: String,
38    host: String,
39    base_path: String,
40    extra_headers: Vec<(String, String)>,
41    transport: Option<T>,
42    meta: ProviderMeta,
43    _m: PhantomData<M>,
44    _u: PhantomData<U>,
45}
46
47impl Default for CompletionsBuilder<WithoutModel, WithoutUrl, ReqwestTransport> {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl CompletionsBuilder<WithoutModel, WithoutUrl, ReqwestTransport> {
54    pub fn new() -> Self {
55        Self {
56            model_name: None,
57            api_key: None,
58            scheme: String::new(),
59            host: String::new(),
60            base_path: String::new(),
61            extra_headers: Vec::new(),
62            transport: Some(ReqwestTransport::default()),
63            meta: ProviderMeta::default(),
64            _m: PhantomData,
65            _u: PhantomData,
66        }
67    }
68}
69
70impl<M, U, T: Transport> CompletionsBuilder<M, U, T> {
71    pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
72        self.api_key = Some(api_key.into());
73        self
74    }
75
76    pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
77        self.extra_headers.push((key.into(), value.into()));
78        self
79    }
80
81    pub fn with_description(mut self, description: impl Into<String>) -> Self {
82        self.meta.description = Some(description.into());
83        self
84    }
85
86    pub fn with_metadata(
87        mut self,
88        key: impl Into<String>,
89        value: impl std::any::Any + Send + Sync + 'static,
90    ) -> Self {
91        self.meta.data.insert(key.into(), Box::new(value));
92        self
93    }
94
95    /// Supply a custom transport, replacing the default.
96    pub fn with_transport<T2: Transport>(self, transport: T2) -> CompletionsBuilder<M, U, T2> {
97        CompletionsBuilder {
98            model_name: self.model_name,
99            api_key: self.api_key,
100            scheme: self.scheme,
101            host: self.host,
102            base_path: self.base_path,
103            extra_headers: self.extra_headers,
104            transport: Some(transport),
105            meta: self.meta,
106            _m: PhantomData,
107            _u: PhantomData,
108        }
109    }
110}
111
112impl<U, T: Transport> CompletionsBuilder<WithoutModel, U, T> {
113    pub fn with_model(self, model_name: impl Into<String>) -> CompletionsBuilder<WithModel, U, T> {
114        CompletionsBuilder {
115            model_name: Some(model_name.into()),
116            api_key: self.api_key,
117            scheme: self.scheme,
118            host: self.host,
119            base_path: self.base_path,
120            extra_headers: self.extra_headers,
121            transport: self.transport,
122            meta: self.meta,
123            _m: PhantomData,
124            _u: PhantomData,
125        }
126    }
127}
128
129impl<M, T: Transport> CompletionsBuilder<M, WithoutUrl, T> {
130    /// Set the base URL of the OpenAI-compatible server.
131    ///
132    /// Example: `http://localhost:11434/v1`, `https://api.cerebras.ai/v1`.
133    /// The path portion is preserved as the base, and completion/embedding
134    /// endpoints are appended (`/chat/completions`, `/embeddings`).
135    pub fn with_base_url(self, base_url: impl AsRef<str>) -> CompletionsBuilder<M, WithUrl, T> {
136        let parsed = url::Url::parse(base_url.as_ref()).expect("Invalid base URL");
137        let scheme = parsed.scheme().to_string();
138        let host = parsed
139            .host_str()
140            .expect("base URL missing host")
141            .to_string()
142            + &parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
143        let base_path = parsed.path().trim_end_matches('/').to_string();
144
145        CompletionsBuilder {
146            model_name: self.model_name,
147            api_key: self.api_key,
148            scheme,
149            host,
150            base_path,
151            extra_headers: self.extra_headers,
152            transport: self.transport,
153            meta: self.meta,
154            _m: PhantomData,
155            _u: PhantomData,
156        }
157    }
158}
159
160impl<T: Transport> CompletionsBuilder<WithModel, WithUrl, T> {
161    /// Build the client.
162    ///
163    /// Panics if no transport is set and `T` is not the default `ReqwestTransport`.
164    pub fn build(self) -> CompletionsClient<T> {
165        let transport = self.transport.expect(
166            "No transport provided. Call .with_transport() or rely on the default ReqwestTransport.",
167        );
168
169        CompletionsClient {
170            model_name: self.model_name.unwrap(),
171            api_key: self.api_key,
172            scheme: self.scheme,
173            host: self.host,
174            base_path: self.base_path,
175            extra_headers: self.extra_headers,
176            transport,
177            meta: self.meta,
178        }
179    }
180}