use async_trait::async_trait;
use chrono::TimeZone;
use everruns_provider::OpenAIProtocolChatDriver;
use everruns_provider::OpenResponsesProtocolChatDriver;
use everruns_provider::credential_schema::CredentialFormSchema;
use everruns_provider::driver_registry::{
BoxedChatDriver, BoxedEmbeddingsDriver, ChatDriver, DiscoveredModel, DriverDescriptor,
DriverId, DriverRegistry, EmbeddingsDriverFactory, LlmCallConfig, LlmMessage,
LlmResponseStream, ServiceKind,
};
use everruns_provider::error::{AgentLoopError, Result};
use everruns_provider::openai_protocol::{
apply_models_api_auth, is_azure_openai_api_url, is_openai_api_url, models_api_status_error,
models_url_for_api_url, normalize_api_url,
};
use everruns_provider::{CompactRequest, CompactResponse};
use crate::types::OpenAiModelsResponse;
#[derive(Clone)]
pub struct OpenAIChatDriver {
inner: OpenResponsesProtocolChatDriver,
uses_custom_url: bool,
}
impl OpenAIChatDriver {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
inner: OpenResponsesProtocolChatDriver::new(api_key),
uses_custom_url: false,
}
}
pub fn with_base_url(api_key: impl Into<String>, api_url: impl Into<String>) -> Self {
let api_url = normalize_api_url(&api_url.into(), "/responses");
Self {
inner: OpenResponsesProtocolChatDriver::with_base_url(api_key, api_url),
uses_custom_url: true,
}
}
pub fn api_url(&self) -> &str {
self.inner.api_url()
}
pub fn uses_custom_url(&self) -> bool {
self.uses_custom_url
}
pub async fn compact_conversation(&self, request: CompactRequest) -> Result<CompactResponse> {
self.inner.compact(request).await
}
pub fn can_compact(&self) -> bool {
self.inner.supports_compact()
}
}
#[async_trait]
impl ChatDriver for OpenAIChatDriver {
async fn chat_completion_stream(
&self,
messages: Vec<LlmMessage>,
config: &LlmCallConfig,
) -> Result<LlmResponseStream> {
self.inner.chat_completion_stream(messages, config).await
}
async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
if self.uses_custom_url && !supports_model_listing(self.api_url()) {
return Ok(None);
}
let models_url = models_url_for_api_url(self.api_url());
list_openai_models(self.inner.client(), self.inner.api_key(), &models_url).await
}
fn supports_compact(&self) -> bool {
self.inner.supports_compact()
}
fn supports_parallel_tool_calls(&self, model: &str) -> bool {
self.inner.supports_parallel_tool_calls(model)
}
async fn compact(&self, request: CompactRequest) -> Result<Option<CompactResponse>> {
Ok(Some(self.inner.compact(request).await?))
}
}
impl std::fmt::Debug for OpenAIChatDriver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenAIChatDriver")
.field("api_url", &self.api_url())
.field("api", &"Open Responses")
.field("api_key", &"[REDACTED]")
.finish()
}
}
#[derive(Clone)]
pub struct OpenAICompletionsChatDriver {
inner: OpenAIProtocolChatDriver,
uses_custom_url: bool,
}
impl OpenAICompletionsChatDriver {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
inner: OpenAIProtocolChatDriver::new(api_key),
uses_custom_url: false,
}
}
pub fn with_base_url(api_key: impl Into<String>, api_url: impl Into<String>) -> Self {
let api_url = normalize_api_url(&api_url.into(), "/chat/completions");
Self {
inner: OpenAIProtocolChatDriver::with_base_url(api_key, api_url),
uses_custom_url: true,
}
}
pub fn api_url(&self) -> &str {
self.inner.api_url()
}
pub fn uses_custom_url(&self) -> bool {
self.uses_custom_url
}
}
#[async_trait]
impl ChatDriver for OpenAICompletionsChatDriver {
async fn chat_completion_stream(
&self,
messages: Vec<LlmMessage>,
config: &LlmCallConfig,
) -> Result<LlmResponseStream> {
self.inner.chat_completion_stream(messages, config).await
}
async fn list_models(&self) -> Result<Option<Vec<DiscoveredModel>>> {
if self.uses_custom_url && !supports_model_listing(self.api_url()) {
return Ok(None);
}
let models_url = models_url_for_api_url(self.api_url());
list_openai_models(self.inner.client(), self.inner.api_key(), &models_url).await
}
fn supports_parallel_tool_calls(&self, model: &str) -> bool {
self.inner.supports_parallel_tool_calls(model)
}
}
impl std::fmt::Debug for OpenAICompletionsChatDriver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OpenAICompletionsChatDriver")
.field("api_url", &self.api_url())
.field("api", &"Chat Completions")
.field("api_key", &"[REDACTED]")
.finish()
}
}
async fn list_openai_models(
client: &reqwest::Client,
api_key: &str,
models_url: &str,
) -> Result<Option<Vec<DiscoveredModel>>> {
let response = apply_models_api_auth(client.get(models_url), models_url, api_key)
.send()
.await
.map_err(|e| AgentLoopError::llm(format!("Failed to fetch models: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let _ = response.bytes().await; return Err(models_api_status_error(status));
}
let models_response: OpenAiModelsResponse = response
.json()
.await
.map_err(|e| AgentLoopError::llm(format!("Failed to parse models response: {}", e)))?;
let discovered: Vec<DiscoveredModel> = models_response
.data
.into_iter()
.filter(|m| m.is_chat_model())
.map(|m| DiscoveredModel {
model_id: m.id,
display_name: None, created_at: chrono::Utc.timestamp_opt(m.created, 0).single(),
owned_by: Some(m.owned_by),
discovered_profile: None,
})
.collect();
Ok(Some(discovered))
}
fn supports_model_listing(api_url: &str) -> bool {
is_openai_api_url(api_url) || is_azure_openai_api_url(api_url)
}
pub fn register_driver(registry: &mut DriverRegistry) {
let openai_embeddings_factory: EmbeddingsDriverFactory = std::sync::Arc::new(|config| {
let api_key = config.api_key.as_deref().unwrap_or("");
let driver = match config.base_url.as_deref() {
Some(url) => crate::embeddings::OpenAIEmbeddingsDriver::with_base_url(api_key, url),
None => crate::embeddings::OpenAIEmbeddingsDriver::new(api_key),
};
Box::new(driver) as BoxedEmbeddingsDriver
});
registry.register_descriptor(DriverDescriptor {
services: vec![ServiceKind::Chat, ServiceKind::Realtime, ServiceKind::Embeddings],
credential_schema: CredentialFormSchema::api_key(
"Create an API key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys).",
),
embeddings: Some(openai_embeddings_factory),
..DriverDescriptor::chat_only(DriverId::OpenAI, |config| {
let api_key = config.api_key.as_deref().unwrap_or("");
let driver = match config.base_url.as_deref() {
Some(url) => OpenAIChatDriver::with_base_url(api_key, url),
None => OpenAIChatDriver::new(api_key),
};
Box::new(driver) as BoxedChatDriver
})
});
registry.register_descriptor(DriverDescriptor {
credential_schema: CredentialFormSchema::api_key(
"Use an API key for your Azure OpenAI resource and set the resource endpoint as the base URL.",
),
..DriverDescriptor::chat_only(DriverId::AzureOpenAI, |config| {
let api_key = config.api_key.as_deref().unwrap_or("");
let driver = match config.base_url.as_deref() {
Some(url) => OpenAIChatDriver::with_base_url(api_key, url),
None => OpenAIChatDriver::new(api_key),
};
Box::new(driver) as BoxedChatDriver
})
});
registry.register_descriptor(DriverDescriptor {
credential_schema: CredentialFormSchema::api_key(
"Create an API key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys).",
),
..DriverDescriptor::chat_only(DriverId::OpenAICompletions, |config| {
let api_key = config.api_key.as_deref().unwrap_or("");
let driver = match config.base_url.as_deref() {
Some(url) => OpenAICompletionsChatDriver::with_base_url(api_key, url),
None => OpenAICompletionsChatDriver::new(api_key),
};
Box::new(driver) as BoxedChatDriver
})
});
}
#[cfg(test)]
mod tests {
use super::{is_openai_api_url, supports_model_listing};
#[test]
fn supports_model_listing_for_openai_host_with_port() {
assert!(supports_model_listing(
"https://api.openai.com:443/v1/responses"
));
}
#[test]
fn rejects_non_openai_hosts_for_model_listing() {
assert!(!is_openai_api_url("https://example.com/v1/responses"));
assert!(!supports_model_listing(
"https://openrouter.ai/api/v1/responses"
));
}
}