1use crate::api::{ModelAliasSpec, ModelTask};
2use crate::error::{Result, RuntimeError};
3use crate::provider::remote_common::{
4 RemoteProviderBase, build_openai_chat_messages, check_http_status,
5 parse_openai_embeddings_response, resolve_api_key,
6};
7use crate::traits::{
8 EmbedResult, EmbeddingModel, GenerationOptions, GenerationResult, GeneratorModel,
9 LoadedModelHandle, Message, ModelProvider, ProviderCapabilities, ProviderHealth, TokenUsage,
10};
11use async_trait::async_trait;
12use reqwest::Client;
13use serde_json::json;
14use std::sync::Arc;
15
16pub struct RemoteOpenAIProvider {
27 base: RemoteProviderBase,
28}
29
30impl Default for RemoteOpenAIProvider {
31 fn default() -> Self {
32 Self {
33 base: RemoteProviderBase::new(),
34 }
35 }
36}
37
38impl RemoteOpenAIProvider {
39 pub fn new() -> Self {
40 Self::default()
41 }
42
43 #[cfg(test)]
44 fn insert_test_breaker(&self, key: crate::api::ModelRuntimeKey, age: std::time::Duration) {
45 self.base.insert_test_breaker(key, age);
46 }
47
48 #[cfg(test)]
49 fn breaker_count(&self) -> usize {
50 self.base.breaker_count()
51 }
52
53 #[cfg(test)]
54 fn force_cleanup_now_for_test(&self) {
55 self.base.force_cleanup_now_for_test();
56 }
57}
58
59#[async_trait]
60impl ModelProvider for RemoteOpenAIProvider {
61 fn provider_id(&self) -> &'static str {
62 "remote/openai"
63 }
64
65 fn capabilities(&self) -> ProviderCapabilities {
66 ProviderCapabilities {
67 supported_tasks: vec![ModelTask::Embed, ModelTask::Generate],
68 }
69 }
70
71 async fn load(&self, spec: &ModelAliasSpec) -> Result<LoadedModelHandle> {
72 match spec.task {
73 ModelTask::Embed => {
74 let api_key = resolve_api_key(&spec.options, "api_key_env", "OPENAI_API_KEY")?;
75 let base_url = resolve_base_url(&spec.options);
76 let embedding_dimensions = spec
77 .options
78 .get("embedding_dimensions")
79 .and_then(|v| v.as_u64())
80 .map(|v| v as u32);
81 let default_dims = match spec.model_id.as_str() {
82 "text-embedding-3-large" => 3072,
83 _ => 1536,
84 };
85 let model = OpenAIEmbeddingModel {
86 client: self.base.client.clone(),
87 cb: self.base.circuit_breaker_for(spec),
88 model_id: spec.model_id.clone(),
89 api_key,
90 base_url,
91 dimensions: embedding_dimensions.unwrap_or(default_dims),
92 };
93 let handle: Arc<dyn EmbeddingModel> = Arc::new(model);
94 Ok(Arc::new(handle) as LoadedModelHandle)
95 }
96 ModelTask::Generate => {
97 let api_key = resolve_api_key(&spec.options, "api_key_env", "OPENAI_API_KEY")?;
98 let base_url = resolve_base_url(&spec.options);
99 let model = OpenAIGeneratorModel {
100 client: self.base.client.clone(),
101 cb: self.base.circuit_breaker_for(spec),
102 model_id: spec.model_id.clone(),
103 api_key,
104 base_url,
105 };
106 let handle: Arc<dyn GeneratorModel> = Arc::new(model);
107 Ok(Arc::new(handle) as LoadedModelHandle)
108 }
109 ModelTask::Raw => Err(RuntimeError::CapabilityMismatch(
110 "OpenAI provider does not support task Raw".to_string(),
111 )),
112 _ => Err(RuntimeError::CapabilityMismatch(format!(
113 "OpenAI provider does not support task {:?}",
114 spec.task
115 ))),
116 }
117 }
118
119 async fn health(&self) -> ProviderHealth {
120 ProviderHealth::Healthy
121 }
122}
123
124const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
125
126fn resolve_base_url(options: &serde_json::Value) -> String {
129 let raw = options
130 .get("base_url")
131 .and_then(|v| v.as_str())
132 .unwrap_or(DEFAULT_BASE_URL);
133 raw.trim_end_matches('/').to_string()
134}
135
136pub struct OpenAIEmbeddingModel {
138 client: Client,
139 cb: crate::reliability::CircuitBreakerWrapper,
140 model_id: String,
141 api_key: String,
142 base_url: String,
143 dimensions: u32,
144}
145
146#[async_trait]
147impl EmbeddingModel for OpenAIEmbeddingModel {
148 async fn embed(&self, texts: &[&str]) -> Result<EmbedResult> {
149 let texts: Vec<String> = texts.iter().map(|s| s.to_string()).collect();
150
151 self.cb
152 .call(move || async move {
153 let response = self
154 .client
155 .post(format!("{}/embeddings", self.base_url))
156 .header("Authorization", format!("Bearer {}", self.api_key))
157 .json(&json!({
158 "model": self.model_id,
159 "input": texts
160 }))
161 .send()
162 .await
163 .map_err(|e| RuntimeError::ApiError(e.to_string()))?;
164
165 let body: serde_json::Value = check_http_status("OpenAI", response)?
166 .json()
167 .await
168 .map_err(|e| RuntimeError::ApiError(e.to_string()))?;
169
170 parse_openai_embeddings_response("OpenAI", &body, None)
173 })
174 .await
175 }
176
177 fn dimensions(&self) -> u32 {
178 self.dimensions
179 }
180}
181
182impl crate::traits::ModelInfo for OpenAIEmbeddingModel {
183 fn model_id(&self) -> &str {
184 &self.model_id
185 }
186}
187
188struct OpenAIGeneratorModel {
193 client: Client,
194 cb: crate::reliability::CircuitBreakerWrapper,
195 model_id: String,
196 api_key: String,
197 base_url: String,
198}
199
200impl crate::traits::ModelInfo for OpenAIGeneratorModel {
201 fn model_id(&self) -> &str {
202 &self.model_id
203 }
204}
205
206#[async_trait]
207impl GeneratorModel for OpenAIGeneratorModel {
208 async fn generate(
209 &self,
210 messages: &[Message],
211 options: GenerationOptions,
212 ) -> Result<GenerationResult> {
213 let messages = build_openai_chat_messages(messages);
214
215 self.cb
216 .call(move || async move {
217 let mut body = json!({
218 "model": self.model_id,
219 "messages": messages,
220 });
221
222 if let Some(max_tokens) = options.max_tokens {
223 body["max_completion_tokens"] = json!(max_tokens);
224 }
225 if let Some(temperature) = options.temperature {
226 body["temperature"] = json!(temperature);
227 }
228 if let Some(top_p) = options.top_p {
229 body["top_p"] = json!(top_p);
230 }
231
232 let response = self
233 .client
234 .post(format!("{}/chat/completions", self.base_url))
235 .header("Authorization", format!("Bearer {}", self.api_key))
236 .json(&body)
237 .send()
238 .await
239 .map_err(|e| RuntimeError::ApiError(e.to_string()))?;
240
241 let body: serde_json::Value = check_http_status("OpenAI", response)?
242 .json()
243 .await
244 .map_err(|e| RuntimeError::ApiError(e.to_string()))?;
245
246 let text = body["choices"][0]["message"]["content"]
247 .as_str()
248 .unwrap_or("")
249 .to_string();
250
251 let usage = body.get("usage").map(|u| TokenUsage {
252 prompt_tokens: u["prompt_tokens"].as_u64().unwrap_or(0) as usize,
253 completion_tokens: u["completion_tokens"].as_u64().unwrap_or(0) as usize,
254 total_tokens: u["total_tokens"].as_u64().unwrap_or(0) as usize,
255 });
256
257 Ok(GenerationResult {
258 text,
259 usage,
260 images: vec![],
261 audio: None,
262 })
263 })
264 .await
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::api::ModelRuntimeKey;
272 use crate::provider::remote_common::RemoteProviderBase;
273 use crate::traits::ModelProvider;
274 use std::time::Duration;
275
276 static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
277
278 fn spec(alias: &str, task: ModelTask, model_id: &str) -> ModelAliasSpec {
279 ModelAliasSpec {
280 alias: alias.to_string(),
281 task,
282 provider_id: "remote/openai".to_string(),
283 model_id: model_id.to_string(),
284 revision: None,
285 warmup: crate::api::WarmupPolicy::Lazy,
286 required: false,
287 timeout: None,
288 load_timeout: None,
289 retry: None,
290 options: serde_json::Value::Null,
291 }
292 }
293
294 #[tokio::test]
295 async fn breaker_reused_for_same_runtime_key() {
296 let _lock = ENV_LOCK.lock().await;
297 unsafe { std::env::set_var("OPENAI_API_KEY", "test-key") };
299
300 let provider = RemoteOpenAIProvider::new();
301 let s1 = spec("embed/a", ModelTask::Embed, "text-embedding-3-small");
302 let s2 = spec("embed/b", ModelTask::Embed, "text-embedding-3-small");
303
304 let _ = provider.load(&s1).await.unwrap();
305 let _ = provider.load(&s2).await.unwrap();
306
307 assert_eq!(provider.breaker_count(), 1);
308
309 unsafe { std::env::remove_var("OPENAI_API_KEY") };
311 }
312
313 #[tokio::test]
314 async fn breaker_isolated_by_task_and_model() {
315 let _lock = ENV_LOCK.lock().await;
316 unsafe { std::env::set_var("OPENAI_API_KEY", "test-key") };
318
319 let provider = RemoteOpenAIProvider::new();
320 let embed = spec("embed/a", ModelTask::Embed, "text-embedding-3-small");
321 let gen_spec = spec("chat/a", ModelTask::Generate, "gpt-4o-mini");
322
323 let _ = provider.load(&embed).await.unwrap();
324 let _ = provider.load(&gen_spec).await.unwrap();
325
326 assert_eq!(provider.breaker_count(), 2);
327
328 unsafe { std::env::remove_var("OPENAI_API_KEY") };
330 }
331
332 #[tokio::test]
333 async fn breaker_cleanup_evicts_stale_entries() {
334 let _lock = ENV_LOCK.lock().await;
335 unsafe { std::env::set_var("OPENAI_API_KEY", "test-key") };
337
338 let provider = RemoteOpenAIProvider::new();
339 let stale = spec("embed/stale", ModelTask::Embed, "text-embedding-3-small");
340 let fresh = spec("embed/fresh", ModelTask::Embed, "text-embedding-3-large");
341 provider.insert_test_breaker(
342 ModelRuntimeKey::new(&stale),
343 RemoteProviderBase::BREAKER_TTL + Duration::from_secs(5),
344 );
345 provider.insert_test_breaker(ModelRuntimeKey::new(&fresh), Duration::from_secs(1));
346 assert_eq!(provider.breaker_count(), 2);
347
348 provider.force_cleanup_now_for_test();
349 let _ = provider.load(&fresh).await.unwrap();
350
351 assert_eq!(provider.breaker_count(), 1);
352
353 unsafe { std::env::remove_var("OPENAI_API_KEY") };
355 }
356
357 #[tokio::test]
358 async fn default_embedding_dimensions() {
359 let _lock = ENV_LOCK.lock().await;
360 unsafe { std::env::set_var("OPENAI_API_KEY", "test-key") };
362
363 let provider = RemoteOpenAIProvider::new();
364 let s = spec("embed/dim", ModelTask::Embed, "text-embedding-3-small");
365
366 let handle = provider.load(&s).await.unwrap();
367 let model = handle.downcast_ref::<Arc<dyn EmbeddingModel>>().unwrap();
368 assert_eq!(model.dimensions(), 1536);
369
370 unsafe { std::env::remove_var("OPENAI_API_KEY") };
372 }
373
374 #[tokio::test]
375 async fn custom_embedding_dimensions() {
376 let _lock = ENV_LOCK.lock().await;
377 unsafe { std::env::set_var("OPENAI_API_KEY", "test-key") };
379
380 let provider = RemoteOpenAIProvider::new();
381 let mut s = spec(
382 "embed/dim-custom",
383 ModelTask::Embed,
384 "text-embedding-3-small",
385 );
386 s.options = serde_json::json!({"embedding_dimensions": 256});
387
388 let handle = provider.load(&s).await.unwrap();
389 let model = handle.downcast_ref::<Arc<dyn EmbeddingModel>>().unwrap();
390 assert_eq!(model.dimensions(), 256);
391
392 unsafe { std::env::remove_var("OPENAI_API_KEY") };
394 }
395
396 #[test]
397 fn resolve_base_url_defaults_to_openai() {
398 assert_eq!(
399 resolve_base_url(&serde_json::Value::Null),
400 "https://api.openai.com/v1"
401 );
402 assert_eq!(
403 resolve_base_url(&serde_json::json!({})),
404 "https://api.openai.com/v1"
405 );
406 }
407
408 #[test]
409 fn resolve_base_url_uses_custom_value() {
410 assert_eq!(
411 resolve_base_url(&serde_json::json!({"base_url": "http://localhost:8000/v1"})),
412 "http://localhost:8000/v1"
413 );
414 }
415
416 #[test]
417 fn resolve_base_url_strips_trailing_slash() {
418 assert_eq!(
419 resolve_base_url(&serde_json::json!({"base_url": "http://localhost:8000/v1/"})),
420 "http://localhost:8000/v1"
421 );
422 }
423
424 #[tokio::test]
425 async fn load_accepts_custom_base_url() {
426 let _lock = ENV_LOCK.lock().await;
427 unsafe { std::env::set_var("OPENAI_API_KEY", "test-key") };
429
430 let provider = RemoteOpenAIProvider::new();
431 let mut s = spec("embed/local", ModelTask::Embed, "text-embedding-3-small");
432 s.options = serde_json::json!({"base_url": "http://localhost:8000/v1"});
433
434 assert!(provider.load(&s).await.is_ok());
435
436 unsafe { std::env::remove_var("OPENAI_API_KEY") };
438 }
439}