Skip to main content

everruns_openai/
driver.rs

1// OpenAI Chat Drivers
2//
3// This module provides two separate drivers for OpenAI:
4//
5// 1. OpenAIChatDriver - Uses Open Responses API (https://www.openresponses.org/)
6//    Recommended for new projects. Better performance with reasoning models.
7//
8// 2. OpenAICompletionsChatDriver - Uses Chat Completions API
9//    For backward compatibility with /v1/chat/completions endpoint.
10
11use async_trait::async_trait;
12use chrono::TimeZone;
13
14use everruns_provider::OpenAIProtocolChatDriver;
15use everruns_provider::OpenResponsesProtocolChatDriver;
16use everruns_provider::credential_schema::CredentialFormSchema;
17use everruns_provider::driver_registry::{
18    BoxedChatDriver, BoxedEmbeddingsDriver, ChatDriver, DiscoveredModel, DriverDescriptor,
19    DriverId, DriverRegistry, EmbeddingsDriverFactory, LlmCallConfig, LlmMessage,
20    LlmResponseStream, ServiceKind,
21};
22use everruns_provider::error::{AgentLoopError, Result};
23use everruns_provider::openai_protocol::{
24    apply_models_api_auth, is_azure_openai_api_url, is_openai_api_url, models_api_status_error,
25    models_url_for_api_url, normalize_api_url,
26};
27use everruns_provider::{CompactRequest, CompactResponse};
28
29use crate::types::OpenAiModelsResponse;
30
31// ============================================================================
32// OpenAI Chat Driver (Open Responses API)
33// ============================================================================
34
35/// OpenAI Chat Driver using Open Responses API
36///
37/// Production driver for OpenAI using the Open Responses specification
38/// (<https://www.openresponses.org/>). This is the recommended driver for
39/// new projects, offering:
40/// - Better performance with reasoning models (o1, o3, GPT-5)
41/// - Provider-agnostic streaming events
42/// - Native agentic loop support
43/// - 40-80% better cache utilization
44///
45/// For backward compatibility with the Chat Completions API, use
46/// `OpenAICompletionsChatDriver` instead.
47///
48/// # Example
49///
50/// ```ignore
51/// use everruns_openai::OpenAIChatDriver;
52///
53/// let driver = OpenAIChatDriver::new("your-api-key");
54///
55/// // With custom endpoint
56/// let driver = OpenAIChatDriver::with_base_url(
57///     "your-api-key",
58///     "https://api.example.com/v1/responses",
59/// );
60/// ```
61#[derive(Clone)]
62pub struct OpenAIChatDriver {
63    inner: OpenResponsesProtocolChatDriver,
64    /// Whether using a custom base URL (not OpenAI's API)
65    uses_custom_url: bool,
66}
67
68impl OpenAIChatDriver {
69    /// Create a new driver with the given API key
70    pub fn new(api_key: impl Into<String>) -> Self {
71        Self {
72            inner: OpenResponsesProtocolChatDriver::new(api_key),
73            uses_custom_url: false,
74        }
75    }
76
77    /// Create a new driver with a custom API URL
78    pub fn with_base_url(api_key: impl Into<String>, api_url: impl Into<String>) -> Self {
79        let api_url = normalize_api_url(&api_url.into(), "/responses");
80        Self {
81            inner: OpenResponsesProtocolChatDriver::with_base_url(api_key, api_url),
82            uses_custom_url: true,
83        }
84    }
85
86    /// Get the API URL
87    pub fn api_url(&self) -> &str {
88        self.inner.api_url()
89    }
90
91    /// Check if using a custom base URL
92    pub fn uses_custom_url(&self) -> bool {
93        self.uses_custom_url
94    }
95
96    /// Compact a conversation to reduce context size
97    ///
98    /// This method calls the /v1/responses/compact endpoint to compress the conversation
99    /// history. User messages are kept verbatim, while assistant messages, tool calls,
100    /// and tool results are replaced by an encrypted compaction item.
101    ///
102    /// # Arguments
103    ///
104    /// * `request` - The compact request containing the model and input items
105    ///
106    /// # Returns
107    ///
108    /// Returns a `CompactResponse` containing the compacted output items.
109    /// The output can be used directly as input for the next /v1/responses call.
110    pub async fn compact_conversation(&self, request: CompactRequest) -> Result<CompactResponse> {
111        self.inner.compact(request).await
112    }
113
114    /// Check if this driver supports the compact endpoint
115    ///
116    /// Returns true for OpenAI's Responses API. Custom endpoints may or may not
117    /// support compaction.
118    pub fn can_compact(&self) -> bool {
119        self.inner.supports_compact()
120    }
121}
122
123#[async_trait]
124impl ChatDriver for OpenAIChatDriver {
125    async fn chat_completion_stream(
126        &self,
127        messages: Vec<LlmMessage>,
128        config: &LlmCallConfig,
129    ) -> Result<LlmResponseStream> {
130        self.inner.chat_completion_stream(messages, config).await
131    }
132
133    async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
134        // Skip discovery for non-standard custom URLs (proxies, self-hosted)
135        if self.uses_custom_url && !supports_model_listing(self.api_url()) {
136            return Ok(None);
137        }
138
139        let models_url = models_url_for_api_url(self.api_url());
140        list_openai_models(self.inner.client(), self.inner.api_key(), &models_url).await
141    }
142
143    fn supports_compact(&self) -> bool {
144        self.inner.supports_compact()
145    }
146
147    fn supports_parallel_tool_calls(&self, model: &str) -> bool {
148        self.inner.supports_parallel_tool_calls(model)
149    }
150
151    async fn compact(&self, request: CompactRequest) -> Result<Option<CompactResponse>> {
152        Ok(Some(self.inner.compact(request).await?))
153    }
154}
155
156impl std::fmt::Debug for OpenAIChatDriver {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.debug_struct("OpenAIChatDriver")
159            .field("api_url", &self.api_url())
160            .field("api", &"Open Responses")
161            .field("api_key", &"[REDACTED]")
162            .finish()
163    }
164}
165
166// ============================================================================
167// OpenAI Completions Chat Driver (Chat Completions API)
168// ============================================================================
169
170/// OpenAI Chat Driver using Chat Completions API
171///
172/// Driver for OpenAI using the traditional Chat Completions API
173/// (/v1/chat/completions). Use this for backward compatibility with
174/// existing integrations or when Open Responses API is not suitable.
175///
176/// For new projects, prefer `OpenAIChatDriver` which uses the Open Responses
177/// specification (<https://www.openresponses.org/>).
178///
179/// # Example
180///
181/// ```ignore
182/// use everruns_openai::OpenAICompletionsChatDriver;
183///
184/// let driver = OpenAICompletionsChatDriver::new("your-api-key");
185///
186/// // With custom endpoint
187/// let driver = OpenAICompletionsChatDriver::with_base_url(
188///     "your-api-key",
189///     "https://api.example.com/v1/chat/completions",
190/// );
191/// ```
192#[derive(Clone)]
193pub struct OpenAICompletionsChatDriver {
194    inner: OpenAIProtocolChatDriver,
195    /// Whether using a custom base URL (not OpenAI's API)
196    uses_custom_url: bool,
197}
198
199impl OpenAICompletionsChatDriver {
200    /// Create a new driver with the given API key
201    pub fn new(api_key: impl Into<String>) -> Self {
202        Self {
203            inner: OpenAIProtocolChatDriver::new(api_key),
204            uses_custom_url: false,
205        }
206    }
207
208    /// Create a new driver with a custom API URL
209    pub fn with_base_url(api_key: impl Into<String>, api_url: impl Into<String>) -> Self {
210        let api_url = normalize_api_url(&api_url.into(), "/chat/completions");
211        Self {
212            inner: OpenAIProtocolChatDriver::with_base_url(api_key, api_url),
213            uses_custom_url: true,
214        }
215    }
216
217    /// Get the API URL
218    pub fn api_url(&self) -> &str {
219        self.inner.api_url()
220    }
221
222    /// Check if using a custom base URL
223    pub fn uses_custom_url(&self) -> bool {
224        self.uses_custom_url
225    }
226}
227
228#[async_trait]
229impl ChatDriver for OpenAICompletionsChatDriver {
230    async fn chat_completion_stream(
231        &self,
232        messages: Vec<LlmMessage>,
233        config: &LlmCallConfig,
234    ) -> Result<LlmResponseStream> {
235        self.inner.chat_completion_stream(messages, config).await
236    }
237
238    async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
239        // Skip discovery for non-standard custom URLs (proxies, self-hosted)
240        if self.uses_custom_url && !supports_model_listing(self.api_url()) {
241            return Ok(None);
242        }
243
244        let models_url = models_url_for_api_url(self.api_url());
245        list_openai_models(self.inner.client(), self.inner.api_key(), &models_url).await
246    }
247
248    fn supports_parallel_tool_calls(&self, model: &str) -> bool {
249        self.inner.supports_parallel_tool_calls(model)
250    }
251}
252
253impl std::fmt::Debug for OpenAICompletionsChatDriver {
254    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255        f.debug_struct("OpenAICompletionsChatDriver")
256            .field("api_url", &self.api_url())
257            .field("api", &"Chat Completions")
258            .field("api_key", &"[REDACTED]")
259            .finish()
260    }
261}
262
263// ============================================================================
264// Shared Utilities
265// ============================================================================
266
267/// Fetch and filter OpenAI models (shared between both OpenAI drivers)
268async fn list_openai_models(
269    client: &reqwest::Client,
270    api_key: &str,
271    models_url: &str,
272) -> Result<Option<Vec<DiscoveredModel>>> {
273    let response = apply_models_api_auth(client.get(models_url), models_url, api_key)
274        .send()
275        .await
276        .map_err(|e| AgentLoopError::llm(format!("Failed to fetch models: {}", e)))?;
277
278    if !response.status().is_success() {
279        let status = response.status();
280        let _ = response.bytes().await; // drain body to allow connection reuse
281        return Err(models_api_status_error(status));
282    }
283
284    let models_response: OpenAiModelsResponse = response
285        .json()
286        .await
287        .map_err(|e| AgentLoopError::llm(format!("Failed to parse models response: {}", e)))?;
288
289    // Filter to chat models only and convert to DiscoveredModel
290    let discovered: Vec<DiscoveredModel> = models_response
291        .data
292        .into_iter()
293        .filter(|m| m.is_chat_model())
294        .map(|m| DiscoveredModel {
295            model_id: m.id,
296            display_name: None, // OpenAI doesn't provide display names
297            created_at: chrono::Utc.timestamp_opt(m.created, 0).single(),
298            owned_by: Some(m.owned_by),
299            discovered_profile: None,
300        })
301        .collect();
302
303    Ok(Some(discovered))
304}
305
306/// Whether model discovery should run against `api_url`. Only OpenAI's hosted
307/// API and Azure OpenAI expose a `/models` endpoint we can rely on; custom
308/// proxy URLs are skipped to avoid requests against unknown infrastructure.
309fn supports_model_listing(api_url: &str) -> bool {
310    is_openai_api_url(api_url) || is_azure_openai_api_url(api_url)
311}
312
313// ============================================================================
314// Driver Registration
315// ============================================================================
316
317/// Register all OpenAI drivers with the driver registry
318///
319/// This registers:
320/// - `DriverId::OpenAI` - Open Responses API (recommended)
321/// - `DriverId::AzureOpenAI` - Azure OpenAI Responses API
322/// - `DriverId::OpenAICompletions` - Chat Completions API (backward compatibility)
323///
324/// OpenRouter is registered separately by the `everruns-openrouter` crate.
325///
326/// # Example
327///
328/// ```ignore
329/// use everruns_provider::DriverRegistry;
330/// use everruns_openai::register_driver;
331///
332/// let mut registry = DriverRegistry::new();
333/// register_driver(&mut registry);
334/// ```
335pub fn register_driver(registry: &mut DriverRegistry) {
336    // Register OpenAI with Open Responses API (recommended). OpenAI providers
337    // also power realtime voice sessions (specs/voice.md) and text embeddings
338    // (specs/providers.md phase 6), so the descriptor declares those services
339    // alongside Chat.
340    let openai_embeddings_factory: EmbeddingsDriverFactory = std::sync::Arc::new(|config| {
341        let api_key = config.api_key.as_deref().unwrap_or("");
342        let driver = match config.base_url.as_deref() {
343            Some(url) => crate::embeddings::OpenAIEmbeddingsDriver::with_base_url(api_key, url),
344            None => crate::embeddings::OpenAIEmbeddingsDriver::new(api_key),
345        };
346        Box::new(driver) as BoxedEmbeddingsDriver
347    });
348    registry.register_descriptor(DriverDescriptor {
349        services: vec![ServiceKind::Chat, ServiceKind::Realtime, ServiceKind::Embeddings],
350        credential_schema: CredentialFormSchema::api_key(
351            "Create an API key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys).",
352        ),
353        embeddings: Some(openai_embeddings_factory),
354        ..DriverDescriptor::chat_only(DriverId::OpenAI, |config| {
355            let api_key = config.api_key.as_deref().unwrap_or("");
356            let driver = match config.base_url.as_deref() {
357                Some(url) => OpenAIChatDriver::with_base_url(api_key, url),
358                None => OpenAIChatDriver::new(api_key),
359            };
360            Box::new(driver) as BoxedChatDriver
361        })
362    });
363
364    registry.register_descriptor(DriverDescriptor {
365        credential_schema: CredentialFormSchema::api_key(
366            "Use an API key for your Azure OpenAI resource and set the resource endpoint as the base URL.",
367        ),
368        ..DriverDescriptor::chat_only(DriverId::AzureOpenAI, |config| {
369            let api_key = config.api_key.as_deref().unwrap_or("");
370            let driver = match config.base_url.as_deref() {
371                Some(url) => OpenAIChatDriver::with_base_url(api_key, url),
372                None => OpenAIChatDriver::new(api_key),
373            };
374            Box::new(driver) as BoxedChatDriver
375        })
376    });
377
378    // Register OpenAI Completions with Chat Completions API
379    registry.register_descriptor(DriverDescriptor {
380        credential_schema: CredentialFormSchema::api_key(
381            "Create an API key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys).",
382        ),
383        ..DriverDescriptor::chat_only(DriverId::OpenAICompletions, |config| {
384            let api_key = config.api_key.as_deref().unwrap_or("");
385            let driver = match config.base_url.as_deref() {
386                Some(url) => OpenAICompletionsChatDriver::with_base_url(api_key, url),
387                None => OpenAICompletionsChatDriver::new(api_key),
388            };
389            Box::new(driver) as BoxedChatDriver
390        })
391    });
392}
393
394#[cfg(test)]
395mod tests {
396    use super::{is_openai_api_url, supports_model_listing};
397
398    #[test]
399    fn supports_model_listing_for_openai_host_with_port() {
400        assert!(supports_model_listing(
401            "https://api.openai.com:443/v1/responses"
402        ));
403    }
404
405    #[test]
406    fn rejects_non_openai_hosts_for_model_listing() {
407        assert!(!is_openai_api_url("https://example.com/v1/responses"));
408        assert!(!supports_model_listing(
409            "https://openrouter.ai/api/v1/responses"
410        ));
411    }
412}