//! Registry of model providers supported by Codex. //! //! Providers can be defined in two places: //! 1. Built-in defaults compiled into the binary so Codex works out-of-the-box. //! 2. User-defined entries inside `~/.codex/config.toml` under the `model_providers` //! key. These override or extend the defaults at runtime. use codex_login::AuthMode; use codex_login::CodexAuth; use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; use std::env::VarError; use std::time::Duration; use crate::error::EnvVarError; const DEFAULT_STREAM_IDLE_TIMEOUT_MS: u64 = 300_000; const DEFAULT_STREAM_MAX_RETRIES: u64 = 10; const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4; /// Wire protocol that the provider speaks. Most third-party services only /// implement the classic OpenAI Chat Completions JSON schema, whereas OpenAI /// itself (and a handful of others) additionally expose the more modern /// *Responses* API. The two protocols use different request/response shapes /// and *cannot* be auto-detected at runtime, therefore each provider entry /// must declare which one it expects. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum WireApi { /// The Responses API exposed by OpenAI at `/v1/responses`. Responses, /// Regular Chat Completions compatible with `/v1/chat/completions`. #[default] Chat, } /// Serializable representation of a provider definition. #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct ModelProviderInfo { /// Friendly display name. pub name: String, /// Base URL for the provider's OpenAI-compatible API. pub base_url: Option, /// Environment variable that stores the user's API key for this provider. pub env_key: Option, /// Optional instructions to help the user get a valid value for the /// variable and set it. pub env_key_instructions: Option, /// Which wire protocol this provider expects. #[serde(default)] pub wire_api: WireApi, /// Optional query parameters to append to the base URL. pub query_params: Option>, /// Additional HTTP headers to include in requests to this provider where /// the (key, value) pairs are the header name and value. pub http_headers: Option>, /// Optional HTTP headers to include in requests to this provider where the /// (key, value) pairs are the header name and _environment variable_ whose /// value should be used. If the environment variable is not set, or the /// value is empty, the header will not be included in the request. pub env_http_headers: Option>, /// Maximum number of times to retry a failed HTTP request to this provider. pub request_max_retries: Option, /// Number of times to retry reconnecting a dropped streaming response before failing. pub stream_max_retries: Option, /// Idle timeout (in milliseconds) to wait for activity on a streaming response before treating /// the connection as lost. pub stream_idle_timeout_ms: Option, /// Whether this provider requires some form of standard authentication (API key, ChatGPT token). #[serde(default)] pub requires_openai_auth: bool, } impl ModelProviderInfo { /// Construct a `POST` RequestBuilder for the given URL using the provided /// reqwest Client applying: /// • provider-specific headers (static + env based) /// • Bearer auth header when an API key is available. /// • Auth token for OAuth. /// /// If the provider declares an `env_key` but the variable is missing/empty, returns an [`Err`] identical to the /// one produced by [`ModelProviderInfo::api_key`]. pub async fn create_request_builder<'a>( &'a self, client: &'a reqwest::Client, auth: &Option, ) -> crate::error::Result { let effective_auth = match self.api_key() { Ok(Some(key)) => Some(CodexAuth::from_api_key(key)), Ok(None) => auth.clone(), Err(err) => { if auth.is_some() { auth.clone() } else { return Err(err); } } }; let url = self.get_full_url(&effective_auth); let mut builder = client.post(url); if let Some(auth) = effective_auth.as_ref() { builder = builder.bearer_auth(auth.get_token().await?); } Ok(self.apply_http_headers(builder)) } fn get_query_string(&self) -> String { self.query_params .as_ref() .map_or_else(String::new, |params| { let full_params = params .iter() .map(|(k, v)| format!("{k}={v}")) .collect::>() .join("&"); format!("?{full_params}") }) } pub(crate) fn get_full_url(&self, auth: &Option) -> String { let default_base_url = if matches!( auth, Some(CodexAuth { mode: AuthMode::ChatGPT, .. }) ) { "https://chatgpt.com/backend-api/codex" } else { "https://api.openai.com/v1" }; let query_string = self.get_query_string(); let base_url = self .base_url .clone() .unwrap_or(default_base_url.to_string()); match self.wire_api { WireApi::Responses => format!("{base_url}/responses{query_string}"), WireApi::Chat => format!("{base_url}/chat/completions{query_string}"), } } /// Apply provider-specific HTTP headers (both static and environment-based) /// onto an existing `reqwest::RequestBuilder` and return the updated /// builder. fn apply_http_headers(&self, mut builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { if let Some(extra) = &self.http_headers { for (k, v) in extra { builder = builder.header(k, v); } } if let Some(env_headers) = &self.env_http_headers { for (header, env_var) in env_headers { if let Ok(val) = std::env::var(env_var) { if !val.trim().is_empty() { builder = builder.header(header, val); } } } } builder } /// If `env_key` is Some, returns the API key for this provider if present /// (and non-empty) in the environment. If `env_key` is required but /// cannot be found, returns an error. pub fn api_key(&self) -> crate::error::Result> { match &self.env_key { Some(env_key) => { let env_value = std::env::var(env_key); env_value .and_then(|v| { if v.trim().is_empty() { Err(VarError::NotPresent) } else { Ok(Some(v)) } }) .map_err(|_| { crate::error::CodexErr::EnvVar(EnvVarError { var: env_key.clone(), instructions: self.env_key_instructions.clone(), }) }) } None => Ok(None), } } /// Effective maximum number of request retries for this provider. pub fn request_max_retries(&self) -> u64 { self.request_max_retries .unwrap_or(DEFAULT_REQUEST_MAX_RETRIES) } /// Effective maximum number of stream reconnection attempts for this provider. pub fn stream_max_retries(&self) -> u64 { self.stream_max_retries .unwrap_or(DEFAULT_STREAM_MAX_RETRIES) } /// Effective idle timeout for streaming responses. pub fn stream_idle_timeout(&self) -> Duration { self.stream_idle_timeout_ms .map(Duration::from_millis) .unwrap_or(Duration::from_millis(DEFAULT_STREAM_IDLE_TIMEOUT_MS)) } } const DEFAULT_OLLAMA_PORT: u32 = 11434; pub const BUILT_IN_OSS_MODEL_PROVIDER_ID: &str = "oss"; /// Built-in default provider list. pub fn built_in_model_providers() -> HashMap { use ModelProviderInfo as P; // We do not want to be in the business of adjucating which third-party // providers are bundled with Codex CLI, so we only include the OpenAI and // open source ("oss") providers by default. Users are encouraged to add to // `model_providers` in config.toml to add their own providers. [ ( "openai", P { name: "OpenAI".into(), // Allow users to override the default OpenAI endpoint by // exporting `OPENAI_BASE_URL`. This is useful when pointing // Codex at a proxy, mock server, or Azure-style deployment // without requiring a full TOML override for the built-in // OpenAI provider. base_url: std::env::var("OPENAI_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), env_key: None, env_key_instructions: None, wire_api: WireApi::Responses, query_params: None, http_headers: Some( [("version".to_string(), env!("CARGO_PKG_VERSION").to_string())] .into_iter() .collect(), ), env_http_headers: Some( [ ( "OpenAI-Organization".to_string(), "OPENAI_ORGANIZATION".to_string(), ), ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), ] .into_iter() .collect(), ), // Use global defaults for retry/timeout unless overridden in config.toml. request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: true, }, ), (BUILT_IN_OSS_MODEL_PROVIDER_ID, create_oss_provider()), ] .into_iter() .map(|(k, v)| (k.to_string(), v)) .collect() } pub fn create_oss_provider() -> ModelProviderInfo { // These CODEX_OSS_ environment variables are experimental: we may // switch to reading values from config.toml instead. let codex_oss_base_url = match std::env::var("CODEX_OSS_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()) { Some(url) => url, None => format!( "http://localhost:{port}/v1", port = std::env::var("CODEX_OSS_PORT") .ok() .filter(|v| !v.trim().is_empty()) .and_then(|v| v.parse::().ok()) .unwrap_or(DEFAULT_OLLAMA_PORT) ), }; create_oss_provider_with_base_url(&codex_oss_base_url) } pub fn create_oss_provider_with_base_url(base_url: &str) -> ModelProviderInfo { ModelProviderInfo { name: "gpt-oss".into(), base_url: Some(base_url.into()), env_key: None, env_key_instructions: None, wire_api: WireApi::Chat, query_params: None, http_headers: None, env_http_headers: None, request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, } } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use super::*; use pretty_assertions::assert_eq; #[test] fn test_deserialize_ollama_model_provider_toml() { let azure_provider_toml = r#" name = "Ollama" base_url = "http://localhost:11434/v1" "#; let expected_provider = ModelProviderInfo { name: "Ollama".into(), base_url: Some("http://localhost:11434/v1".into()), env_key: None, env_key_instructions: None, wire_api: WireApi::Chat, query_params: None, http_headers: None, env_http_headers: None, request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); assert_eq!(expected_provider, provider); } #[test] fn test_deserialize_azure_model_provider_toml() { let azure_provider_toml = r#" name = "Azure" base_url = "https://xxxxx.openai.azure.com/openai" env_key = "AZURE_OPENAI_API_KEY" query_params = { api-version = "2025-04-01-preview" } "#; let expected_provider = ModelProviderInfo { name: "Azure".into(), base_url: Some("https://xxxxx.openai.azure.com/openai".into()), env_key: Some("AZURE_OPENAI_API_KEY".into()), env_key_instructions: None, wire_api: WireApi::Chat, query_params: Some(maplit::hashmap! { "api-version".to_string() => "2025-04-01-preview".to_string(), }), http_headers: None, env_http_headers: None, request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); assert_eq!(expected_provider, provider); } #[test] fn test_deserialize_example_model_provider_toml() { let azure_provider_toml = r#" name = "Example" base_url = "https://example.com" env_key = "API_KEY" http_headers = { "X-Example-Header" = "example-value" } env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } "#; let expected_provider = ModelProviderInfo { name: "Example".into(), base_url: Some("https://example.com".into()), env_key: Some("API_KEY".into()), env_key_instructions: None, wire_api: WireApi::Chat, query_params: None, http_headers: Some(maplit::hashmap! { "X-Example-Header".to_string() => "example-value".to_string(), }), env_http_headers: Some(maplit::hashmap! { "X-Example-Env-Header".to_string() => "EXAMPLE_ENV_VAR".to_string(), }), request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); assert_eq!(expected_provider, provider); } }