agent_io/llm/openai_compatible/
mod.rs1mod request;
7mod response;
8mod types;
9
10use async_trait::async_trait;
11use derive_builder::Builder;
12use futures::StreamExt;
13use reqwest::{Client, StatusCode};
14use std::time::Duration;
15
16use crate::llm::{
17 BaseChatModel, ChatCompletion, ChatStream, LlmError, Message, ToolChoice, ToolDefinition,
18};
19
20use types::*;
21
22#[derive(Builder, Clone)]
24#[builder(pattern = "owned", build_fn(skip))]
25pub struct ChatOpenAICompatible {
26 #[builder(setter(into))]
28 pub(super) model: String,
29 #[builder(setter(into), default = "None")]
31 pub(super) api_key: Option<String>,
32 #[builder(setter(into))]
34 pub(super) base_url: String,
35 #[builder(setter(into))]
37 pub(super) provider: String,
38 #[builder(default = "0.2")]
40 pub(super) temperature: f32,
41 #[builder(default = "Some(4096)")]
43 pub(super) max_completion_tokens: Option<u64>,
44 #[builder(setter(skip))]
46 pub(super) client: Client,
47 #[builder(setter(skip))]
49 pub(super) context_window: u64,
50 #[builder(default = "true")]
52 pub(super) use_bearer_auth: bool,
53}
54
55pub(crate) struct OpenAICompatibleProviderConfig<'a> {
56 pub provider: &'a str,
57 pub default_base_url: &'a str,
58 pub api_key_env: Option<&'a str>,
59 pub base_url_env: Option<&'a str>,
60 pub use_bearer_auth: bool,
61 pub default_temperature: f32,
62}
63
64impl ChatOpenAICompatible {
65 pub fn builder() -> ChatOpenAICompatibleBuilder {
67 ChatOpenAICompatibleBuilder::default()
68 }
69
70 pub(crate) fn build_provider(
71 config: OpenAICompatibleProviderConfig<'_>,
72 model: Option<String>,
73 api_key: Option<String>,
74 base_url: Option<String>,
75 temperature: Option<f32>,
76 max_tokens: Option<u64>,
77 ) -> Result<Self, LlmError> {
78 let model = model.ok_or_else(|| LlmError::Config("model is required".into()))?;
79
80 let api_key = match (api_key, config.api_key_env) {
81 (Some(key), _) => Some(key),
82 (None, Some(env_var)) => std::env::var(env_var).ok(),
83 (None, None) => None,
84 };
85
86 if api_key.is_none() && config.api_key_env.is_some() {
87 return Err(LlmError::Config(format!(
88 "{} not set",
89 config.api_key_env.unwrap_or_default()
90 )));
91 }
92
93 let base_url = base_url
94 .or_else(|| {
95 config
96 .base_url_env
97 .and_then(|env_var| std::env::var(env_var).ok())
98 })
99 .unwrap_or_else(|| config.default_base_url.to_string());
100
101 ChatOpenAICompatible::builder()
102 .model(model)
103 .base_url(base_url)
104 .provider(config.provider)
105 .api_key(api_key)
106 .use_bearer_auth(config.use_bearer_auth)
107 .temperature(temperature.unwrap_or(config.default_temperature))
108 .max_completion_tokens(max_tokens)
109 .build()
110 }
111
112 fn map_error_status(status: StatusCode, body: String) -> LlmError {
113 match status {
114 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => LlmError::Auth(body),
115 StatusCode::NOT_FOUND => LlmError::ModelNotFound(body),
116 StatusCode::TOO_MANY_REQUESTS => LlmError::RateLimit,
117 _ => LlmError::Api(format!("API error ({}): {}", status, body)),
118 }
119 }
120
121 fn build_client() -> Client {
123 Client::builder()
124 .timeout(Duration::from_secs(120))
125 .build()
126 .expect("Failed to create HTTP client")
127 }
128
129 fn default_context_window() -> u64 {
131 128_000
132 }
133
134 fn api_url(&self) -> String {
136 format!("{}/chat/completions", self.base_url.trim_end_matches('/'))
137 }
138}
139
140impl ChatOpenAICompatibleBuilder {
141 pub fn build(&self) -> Result<ChatOpenAICompatible, LlmError> {
142 let model = self
143 .model
144 .clone()
145 .ok_or_else(|| LlmError::Config("model is required".into()))?;
146 let base_url = self
147 .base_url
148 .clone()
149 .ok_or_else(|| LlmError::Config("base_url is required".into()))?;
150 let provider = self
151 .provider
152 .clone()
153 .ok_or_else(|| LlmError::Config("provider is required".into()))?;
154
155 Ok(ChatOpenAICompatible {
156 client: ChatOpenAICompatible::build_client(),
157 context_window: ChatOpenAICompatible::default_context_window(),
158 model,
159 api_key: self.api_key.clone().flatten(),
160 base_url,
161 provider,
162 temperature: self.temperature.unwrap_or(0.2),
163 max_completion_tokens: self.max_completion_tokens.flatten(),
164 use_bearer_auth: self.use_bearer_auth.unwrap_or(true),
165 })
166 }
167}
168
169#[async_trait]
170impl BaseChatModel for ChatOpenAICompatible {
171 fn model(&self) -> &str {
172 &self.model
173 }
174
175 fn provider(&self) -> &str {
176 &self.provider
177 }
178
179 fn context_window(&self) -> Option<u64> {
180 Some(self.context_window)
181 }
182
183 async fn invoke(
184 &self,
185 messages: Vec<Message>,
186 tools: Option<Vec<ToolDefinition>>,
187 tool_choice: Option<ToolChoice>,
188 ) -> Result<ChatCompletion, LlmError> {
189 let request = self.build_request(messages, tools, tool_choice, false)?;
190
191 let mut req = self
192 .client
193 .post(self.api_url())
194 .header("Content-Type", "application/json");
195
196 if let Some(ref api_key) = self.api_key {
197 if self.use_bearer_auth {
198 req = req.header("Authorization", format!("Bearer {}", api_key));
199 } else {
200 req = req.header("Authorization", api_key.clone());
201 }
202 }
203
204 let response = req.json(&request).send().await?;
205
206 if !response.status().is_success() {
207 let status = response.status();
208 let body = response.text().await.unwrap_or_default();
209 return Err(Self::map_error_status(
210 status,
211 format!("{}: {}", self.provider, body),
212 ));
213 }
214
215 let completion: OpenAICompatibleResponse = response.json().await?;
216 Ok(Self::parse_response(completion))
217 }
218
219 async fn invoke_stream(
220 &self,
221 messages: Vec<Message>,
222 tools: Option<Vec<ToolDefinition>>,
223 tool_choice: Option<ToolChoice>,
224 ) -> Result<ChatStream, LlmError> {
225 let request = self.build_request(messages, tools, tool_choice, true)?;
226
227 let mut req = self
228 .client
229 .post(self.api_url())
230 .header("Content-Type", "application/json");
231
232 if let Some(ref api_key) = self.api_key {
233 if self.use_bearer_auth {
234 req = req.header("Authorization", format!("Bearer {}", api_key));
235 } else {
236 req = req.header("Authorization", api_key.clone());
237 }
238 }
239
240 let response = req.json(&request).send().await?;
241
242 if !response.status().is_success() {
243 let status = response.status();
244 let body = response.text().await.unwrap_or_default();
245 return Err(Self::map_error_status(
246 status,
247 format!("{}: {}", self.provider, body),
248 ));
249 }
250
251 let stream = response.bytes_stream().filter_map(|result| async move {
252 match result {
253 Ok(bytes) => {
254 let text = String::from_utf8_lossy(&bytes);
255 Self::parse_stream_chunk(&text)
256 }
257 Err(e) => Some(Err(LlmError::Stream(e.to_string()))),
258 }
259 });
260
261 Ok(Box::pin(stream))
262 }
263
264 fn supports_vision(&self) -> bool {
265 true
267 }
268}