use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, ModelInfo, Provider, ProviderDef, ProviderMetadata}; use super::embedding::{EmbeddingCapable, EmbeddingRequest, EmbeddingResponse}; use super::errors::ProviderError; use super::formats::openai::{create_request, get_usage, response_to_message}; use super::formats::openai_responses::{ create_responses_request, get_responses_usage, responses_api_to_message, responses_api_to_streaming_message, ResponsesApiResponse, }; use super::openai_compatible::{ handle_response_openai_compat, handle_status_openai_compat, stream_openai_compat, }; use super::retry::ProviderRetry; use super::utils::ImageFormat; use crate::config::declarative_providers::DeclarativeProviderConfig; use crate::conversation::message::Message; use anyhow::Result; use async_stream::try_stream; use async_trait::async_trait; use futures::future::BoxFuture; use futures::{StreamExt, TryStreamExt}; use reqwest::StatusCode; use std::collections::HashMap; use std::io; use tokio::pin; use tokio_util::codec::{FramedRead, LinesCodec}; use tokio_util::io::StreamReader; use crate::model::ModelConfig; use crate::providers::base::MessageStream; use crate::providers::utils::RequestLog; use rmcp::model::Tool; const OPEN_AI_PROVIDER_NAME: &str = "openai"; const OPEN_AI_DEFAULT_BASE_PATH: &str = "v1/chat/completions"; const OPEN_AI_DEFAULT_RESPONSES_PATH: &str = "v1/responses"; const OPEN_AI_DEFAULT_MODELS_PATH: &str = "v1/models"; pub const OPEN_AI_DEFAULT_MODEL: &str = "gpt-4o"; pub const OPEN_AI_DEFAULT_FAST_MODEL: &str = "gpt-4o-mini"; pub const OPEN_AI_KNOWN_MODELS: &[(&str, usize)] = &[ ("gpt-4o", 128_000), ("gpt-4o-mini", 128_000), ("gpt-4.1", 128_000), ("gpt-4.1-mini", 128_000), ("o1", 200_000), ("o3", 200_000), ("gpt-3.5-turbo", 16_385), ("gpt-4-turbo", 128_000), ("o4-mini", 128_000), ("gpt-5-nano", 400_000), ("gpt-5.1-codex", 400_000), ("gpt-5-codex", 400_000), ]; pub const OPEN_AI_DOC_URL: &str = "https://platform.openai.com/docs/models"; #[derive(Debug, serde::Serialize)] pub struct OpenAiProvider { #[serde(skip)] api_client: ApiClient, base_path: String, organization: Option, project: Option, model: ModelConfig, custom_headers: Option>, supports_streaming: bool, name: String, } impl OpenAiProvider { pub async fn from_env(model: ModelConfig) -> Result { let model = model.with_fast(OPEN_AI_DEFAULT_FAST_MODEL, OPEN_AI_PROVIDER_NAME)?; let config = crate::config::Config::global(); let host: String = config .get_param("OPENAI_HOST") .unwrap_or_else(|_| "https://api.openai.com".to_string()); let secrets = config .get_secrets("OPENAI_API_KEY", &["OPENAI_CUSTOM_HEADERS"]) .unwrap_or_default(); let api_key: Option = secrets.get("OPENAI_API_KEY").cloned(); let custom_headers: Option> = secrets .get("OPENAI_CUSTOM_HEADERS") .cloned() .map(parse_custom_headers); let base_path: String = config .get_param("OPENAI_BASE_PATH") .unwrap_or_else(|_| OPEN_AI_DEFAULT_BASE_PATH.to_string()); let organization: Option = config.get_param("OPENAI_ORGANIZATION").ok(); let project: Option = config.get_param("OPENAI_PROJECT").ok(); let timeout_secs: u64 = config.get_param("OPENAI_TIMEOUT").unwrap_or(600); let auth = match api_key { Some(key) if !key.is_empty() => AuthMethod::BearerToken(key), _ => AuthMethod::NoAuth, }; let mut api_client = ApiClient::with_timeout(host, auth, std::time::Duration::from_secs(timeout_secs))?; if let Some(org) = &organization { api_client = api_client.with_header("OpenAI-Organization", org)?; } if let Some(project) = &project { api_client = api_client.with_header("OpenAI-Project", project)?; } if let Some(headers) = &custom_headers { let mut header_map = reqwest::header::HeaderMap::new(); for (key, value) in headers { let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; let header_value = reqwest::header::HeaderValue::from_str(value)?; header_map.insert(header_name, header_value); } api_client = api_client.with_headers(header_map)?; } Ok(Self { api_client, base_path, organization, project, model, custom_headers, supports_streaming: true, name: OPEN_AI_PROVIDER_NAME.to_string(), }) } #[doc(hidden)] pub fn new(api_client: ApiClient, model: ModelConfig) -> Self { Self { api_client, base_path: OPEN_AI_DEFAULT_BASE_PATH.to_string(), organization: None, project: None, model, custom_headers: None, supports_streaming: true, name: OPEN_AI_PROVIDER_NAME.to_string(), } } pub fn from_custom_config( model: ModelConfig, config: DeclarativeProviderConfig, ) -> Result { let global_config = crate::config::Config::global(); let api_key: Option = if config.requires_auth && !config.api_key_env.is_empty() { global_config.get_secret(&config.api_key_env).ok() } else { None }; let url = url::Url::parse(&config.base_url) .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; let host = if let Some(port) = url.port() { format!( "{}://{}:{}", url.scheme(), url.host_str().unwrap_or(""), port ) } else { format!("{}://{}", url.scheme(), url.host_str().unwrap_or("")) }; let base_path = if let Some(ref explicit_path) = config.base_path { explicit_path.trim_start_matches('/').to_string() } else { let url_path = url.path().trim_start_matches('/').to_string(); if url_path.is_empty() || url_path == "v1" || url_path == "v1/" { "v1/chat/completions".to_string() } else { url_path } }; let timeout_secs = config.timeout_seconds.unwrap_or(600); let auth = match api_key { Some(key) if !key.is_empty() => AuthMethod::BearerToken(key), _ => AuthMethod::NoAuth, }; let mut api_client = ApiClient::with_timeout(host, auth, std::time::Duration::from_secs(timeout_secs))?; // Add custom headers if present if let Some(headers) = &config.headers { let mut header_map = reqwest::header::HeaderMap::new(); for (key, value) in headers { let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; let header_value = reqwest::header::HeaderValue::from_str(value)?; header_map.insert(header_name, header_value); } api_client = api_client.with_headers(header_map)?; } Ok(Self { api_client, base_path, organization: None, project: None, model, custom_headers: config.headers, supports_streaming: config.supports_streaming.unwrap_or(true), name: config.name.clone(), }) } fn normalize_base_path(base_path: &str) -> String { if let Some(path) = base_path.strip_prefix('/') { format!("/{}", path.trim_end_matches('/')) } else { base_path.trim_end_matches('/').to_string() } } fn is_chat_completions_path(base_path: &str) -> bool { let normalized = Self::normalize_base_path(base_path).to_ascii_lowercase(); normalized.contains("chat/completions") } fn is_responses_path(base_path: &str) -> bool { let normalized = Self::normalize_base_path(base_path).to_ascii_lowercase(); normalized.ends_with("responses") || normalized.contains("/responses") } fn is_responses_model(model_name: &str) -> bool { let normalized_model = model_name.to_ascii_lowercase(); (normalized_model.starts_with("gpt-5") && normalized_model.contains("codex")) || normalized_model.starts_with("gpt-5.2-pro") } fn should_use_responses_api(model_name: &str, base_path: &str) -> bool { let normalized_base_path = Self::normalize_base_path(base_path); let has_custom_base_path = normalized_base_path != OPEN_AI_DEFAULT_BASE_PATH; if has_custom_base_path { if Self::is_responses_path(&normalized_base_path) { return true; } if Self::is_chat_completions_path(&normalized_base_path) { return false; } } Self::is_responses_model(model_name) } fn map_base_path(base_path: &str, target: &str, fallback: &str) -> String { let normalized = Self::normalize_base_path(base_path); if normalized.ends_with(target) || normalized.contains(&format!("/{target}")) { return normalized; } if Self::is_chat_completions_path(&normalized) { return normalized.replacen("chat/completions", target, 1); } if Self::is_responses_path(&normalized) { return normalized.replacen("responses", target, 1); } if normalized.starts_with('/') { format!("/{}", fallback.trim_start_matches('/')) } else { fallback.to_string() } } } impl ProviderDef for OpenAiProvider { type Provider = Self; fn metadata() -> ProviderMetadata { let models = OPEN_AI_KNOWN_MODELS .iter() .map(|(name, limit)| ModelInfo::new(*name, *limit)) .collect(); ProviderMetadata::with_models( OPEN_AI_PROVIDER_NAME, "OpenAI", "GPT-4 and other OpenAI models, including OpenAI compatible ones", OPEN_AI_DEFAULT_MODEL, models, OPEN_AI_DOC_URL, vec![ ConfigKey::new("OPENAI_API_KEY", false, true, None, true), ConfigKey::new( "OPENAI_HOST", true, false, Some("https://api.openai.com"), false, ), ConfigKey::new( "OPENAI_BASE_PATH", true, false, Some("v1/chat/completions"), false, ), ConfigKey::new("OPENAI_ORGANIZATION", false, false, None, false), ConfigKey::new("OPENAI_PROJECT", false, false, None, false), ConfigKey::new("OPENAI_CUSTOM_HEADERS", false, true, None, false), ConfigKey::new("OPENAI_TIMEOUT", false, false, Some("600"), false), ], ) } fn from_env( model: ModelConfig, _extensions: Vec, ) -> BoxFuture<'static, Result> { Box::pin(Self::from_env(model)) } } #[async_trait] impl Provider for OpenAiProvider { fn get_name(&self) -> &str { &self.name } fn get_model_config(&self) -> ModelConfig { self.model.clone() } async fn fetch_supported_models(&self) -> Result, ProviderError> { let models_path = Self::map_base_path(&self.base_path, "models", OPEN_AI_DEFAULT_MODELS_PATH); let response = self .api_client .request(None, &models_path) .response_get() .await?; let json = handle_response_openai_compat(response).await?; if let Some(err_obj) = json.get("error") { let msg = err_obj .get("message") .and_then(|v| v.as_str()) .unwrap_or("unknown error"); return Err(ProviderError::Authentication(msg.to_string())); } let data = json.get("data").and_then(|v| v.as_array()).ok_or_else(|| { ProviderError::UsageError("Missing data field in JSON response".into()) })?; let mut models: Vec = data .iter() .filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string)) .collect(); models.sort(); Ok(models) } fn supports_embeddings(&self) -> bool { true } async fn create_embeddings( &self, session_id: &str, texts: Vec, ) -> Result>, ProviderError> { EmbeddingCapable::create_embeddings(self, session_id, texts) .await .map_err(|e| ProviderError::ExecutionError(e.to_string())) } async fn stream( &self, model_config: &ModelConfig, session_id: &str, system: &str, messages: &[Message], tools: &[Tool], ) -> Result { if Self::should_use_responses_api(&model_config.model_name, &self.base_path) { let mut payload = create_responses_request(model_config, system, messages, tools)?; payload["stream"] = serde_json::Value::Bool(self.supports_streaming); let mut log = RequestLog::start(model_config, &payload)?; let response = self .with_retry(|| async { let payload_clone = payload.clone(); let resp = self .api_client .response_post( Some(session_id), &Self::map_base_path( &self.base_path, "responses", OPEN_AI_DEFAULT_RESPONSES_PATH, ), &payload_clone, ) .await?; handle_status_openai_compat(resp).await }) .await .inspect_err(|e| { let _ = log.error(e); })?; if self.supports_streaming { let stream = response.bytes_stream().map_err(io::Error::other); Ok(Box::pin(try_stream! { let stream_reader = StreamReader::new(stream); let framed = FramedRead::new(stream_reader, LinesCodec::new()).map_err(anyhow::Error::from); let message_stream = responses_api_to_streaming_message(framed); pin!(message_stream); while let Some(message) = message_stream.next().await { let (message, usage) = message.map_err(|e| ProviderError::RequestFailed(format!("Stream decode error: {}", e)))?; log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?; yield (message, usage); } })) } else { let json: serde_json::Value = response.json().await.map_err(|e| { ProviderError::RequestFailed(format!("Failed to parse JSON: {}", e)) })?; let responses_api_response: ResponsesApiResponse = serde_json::from_value(json.clone()).map_err(|e| { ProviderError::ExecutionError(format!( "Failed to parse responses API response: {}", e )) })?; let message = responses_api_to_message(&responses_api_response)?; let usage_data = get_responses_usage(&responses_api_response); let usage = super::base::ProviderUsage::new(model_config.model_name.clone(), usage_data); log.write( &serde_json::to_value(&message).unwrap_or_default(), Some(&usage_data), )?; Ok(super::base::stream_from_single_message(message, usage)) } } else { let payload = create_request( model_config, system, messages, tools, &ImageFormat::OpenAi, self.supports_streaming, )?; let mut log = RequestLog::start(model_config, &payload)?; let response = self .with_retry(|| async { let resp = self .api_client .response_post(Some(session_id), &self.base_path, &payload) .await?; handle_status_openai_compat(resp).await }) .await .inspect_err(|e| { let _ = log.error(e); })?; if self.supports_streaming { stream_openai_compat(response, log) } else { let json: serde_json::Value = response.json().await.map_err(|e| { ProviderError::RequestFailed(format!("Failed to parse JSON: {}", e)) })?; let message = response_to_message(&json).map_err(|e| { ProviderError::RequestFailed(format!("Failed to parse message: {}", e)) })?; let usage_data = get_usage(json.get("usage").unwrap_or(&serde_json::Value::Null)); let usage = super::base::ProviderUsage::new(model_config.model_name.clone(), usage_data); log.write( &serde_json::to_value(&message).unwrap_or_default(), Some(&usage_data), )?; Ok(super::base::stream_from_single_message(message, usage)) } } } } fn parse_custom_headers(s: String) -> HashMap { s.split(',') .filter_map(|header| { let mut parts = header.splitn(2, '='); let key = parts.next().map(|s| s.trim().to_string())?; let value = parts.next().map(|s| s.trim().to_string())?; Some((key, value)) }) .collect() } #[async_trait] impl EmbeddingCapable for OpenAiProvider { async fn create_embeddings( &self, session_id: &str, texts: Vec, ) -> Result>> { if texts.is_empty() { return Ok(vec![]); } let embedding_model = std::env::var("GOOSE_EMBEDDING_MODEL") .unwrap_or_else(|_| "text-embedding-3-small".to_string()); let request = EmbeddingRequest { input: texts, model: embedding_model, }; let response = self .with_retry(|| async { let request_clone = EmbeddingRequest { input: request.input.clone(), model: request.model.clone(), }; let request_value = serde_json::to_value(request_clone) .map_err(|e| ProviderError::ExecutionError(e.to_string()))?; self.api_client .api_post(Some(session_id), "v1/embeddings", &request_value) .await .map_err(|e| ProviderError::ExecutionError(e.to_string())) }) .await?; if response.status != StatusCode::OK { let error_text = response .payload .as_ref() .and_then(|p| p.as_str()) .unwrap_or("Unknown error"); return Err(anyhow::anyhow!("Embedding API error: {}", error_text)); } let embedding_response: EmbeddingResponse = serde_json::from_value( response .payload .ok_or_else(|| anyhow::anyhow!("Empty response body"))?, )?; Ok(embedding_response .data .into_iter() .map(|d| d.embedding) .collect()) } } #[cfg(test)] mod tests { use super::OpenAiProvider; #[test] fn gpt_5_2_codex_uses_responses_when_base_path_is_default() { assert!(OpenAiProvider::should_use_responses_api( "gpt-5.2-codex", "v1/chat/completions" )); } #[test] fn gpt_5_2_pro_uses_responses_when_base_path_is_default() { assert!(OpenAiProvider::should_use_responses_api( "gpt-5.2-pro", "v1/chat/completions" )); } #[test] fn gpt_5_2_pro_with_date_uses_responses() { assert!(OpenAiProvider::should_use_responses_api( "gpt-5.2-pro-2025-12-11", "v1/chat/completions" )); } #[test] fn explicit_chat_path_forces_chat_completions() { assert!(!OpenAiProvider::should_use_responses_api( "gpt-5.2-codex", "openai/v1/chat/completions" )); } #[test] fn gpt_4o_does_not_use_responses() { assert!(!OpenAiProvider::should_use_responses_api( "gpt-4o", "v1/chat/completions" )); } #[test] fn custom_chat_path_maps_to_responses_path() { let responses_path = OpenAiProvider::map_base_path( "openai/v1/chat/completions", "responses", "v1/responses", ); assert_eq!(responses_path, "openai/v1/responses"); } #[test] fn responses_path_maps_to_models_path() { let models_path = OpenAiProvider::map_base_path("openai/v1/responses", "models", "v1/models"); assert_eq!(models_path, "openai/v1/models"); } #[test] fn unknown_path_falls_back_to_default_models_path() { let models_path = OpenAiProvider::map_base_path("custom/path", "models", "v1/models"); assert_eq!(models_path, "v1/models"); } #[test] fn absolute_chat_path_maps_to_absolute_responses_path() { let responses_path = OpenAiProvider::map_base_path("/v1/chat/completions", "responses", "v1/responses"); assert_eq!(responses_path, "/v1/responses"); } #[test] fn unknown_absolute_path_falls_back_to_absolute_models_path() { let models_path = OpenAiProvider::map_base_path("/custom/path", "models", "v1/models"); assert_eq!(models_path, "/v1/models"); } }