diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 6d218c54f9..72afcb890a 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -9,7 +9,7 @@ use chrono::Datelike; use chrono::Local; use chrono::Utc; use codex_async_utils::CancelErr; -use codex_auth::EnvVarError; +pub use codex_auth::EnvVarError; use codex_protocol::ThreadId; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index a99f37c209..2910e76786 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -1 +1,348 @@ -pub use codex_auth::provider::*; +//! 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 crate::error::EnvVarError; +use codex_api::Provider as ApiProvider; +use codex_api::provider::RetryConfig as ApiRetryConfig; +use codex_app_server_protocol::AuthMode as ApiAuthMode; +use http::HeaderMap; +use http::header::HeaderName; +use http::header::HeaderValue; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use std::fmt; +use std::time::Duration; + +const DEFAULT_STREAM_IDLE_TIMEOUT_MS: u64 = 300_000; +const DEFAULT_STREAM_MAX_RETRIES: u64 = 5; +const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4; +pub(crate) const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS: u64 = 15_000; +/// Hard cap for user-configured `stream_max_retries`. +const MAX_STREAM_MAX_RETRIES: u64 = 100; +/// Hard cap for user-configured `request_max_retries`. +const MAX_REQUEST_MAX_RETRIES: u64 = 100; + +const OPENAI_PROVIDER_NAME: &str = "OpenAI"; +pub const OPENAI_PROVIDER_ID: &str = "openai"; +const CHAT_WIRE_API_REMOVED_ERROR: &str = "`wire_api = \"chat\"` is no longer supported.\nHow to fix: set `wire_api = \"responses\"` in your provider config.\nMore info: https://github.com/openai/codex/discussions/7782"; +pub(crate) const LEGACY_OLLAMA_CHAT_PROVIDER_ID: &str = "ollama-chat"; +pub(crate) const OLLAMA_CHAT_PROVIDER_REMOVED_ERROR: &str = "`ollama-chat` is no longer supported.\nHow to fix: replace `ollama-chat` with `ollama` in `model_provider`, `oss_provider`, or `--local-provider`.\nMore info: https://github.com/openai/codex/discussions/7782"; + +/// Wire protocol that the provider speaks. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum WireApi { + /// The Responses API exposed by OpenAI at `/v1/responses`. + #[default] + Responses, +} + +impl fmt::Display for WireApi { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = match self { + Self::Responses => "responses", + }; + f.write_str(value) + } +} + +impl<'de> Deserialize<'de> for WireApi { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "responses" => Ok(Self::Responses), + "chat" => Err(serde::de::Error::custom(CHAT_WIRE_API_REMOVED_ERROR)), + _ => Err(serde::de::Error::unknown_variant(&value, &["responses"])), + } + } +} + +/// Serializable representation of a provider definition. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +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, + + /// Value to use with `Authorization: Bearer ` header. Use of this + /// config is discouraged in favor of `env_key` for security reasons, but + /// this may be necessary when using this programmatically. + pub experimental_bearer_token: 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, + + /// Maximum time (in milliseconds) to wait for a websocket connection attempt before treating + /// it as failed. + pub websocket_connect_timeout_ms: Option, + + /// Does this provider require an OpenAI API Key or ChatGPT login token? If true, + /// user is presented with login screen on first run, and login preference and token/key + /// are stored in auth.json. If false (which is the default), login screen is skipped, + /// and API key (if needed) comes from the "env_key" environment variable. + #[serde(default)] + pub requires_openai_auth: bool, + + /// Whether this provider supports the Responses API WebSocket transport. + #[serde(default)] + pub supports_websockets: bool, +} + +impl ModelProviderInfo { + fn build_header_map(&self) -> crate::error::Result { + let capacity = self.http_headers.as_ref().map_or(0, HashMap::len) + + self.env_http_headers.as_ref().map_or(0, HashMap::len); + let mut headers = HeaderMap::with_capacity(capacity); + if let Some(extra) = &self.http_headers { + for (k, v) in extra { + if let (Ok(name), Ok(value)) = (HeaderName::try_from(k), HeaderValue::try_from(v)) { + headers.insert(name, value); + } + } + } + + 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) + && !val.trim().is_empty() + && let (Ok(name), Ok(value)) = + (HeaderName::try_from(header), HeaderValue::try_from(val)) + { + headers.insert(name, value); + } + } + } + + Ok(headers) + } + + pub(crate) fn to_api_provider( + &self, + auth_mode: Option, + ) -> crate::error::Result { + let default_base_url = if matches!( + auth_mode, + Some(ApiAuthMode::Chatgpt | ApiAuthMode::ChatgptAuthTokens) + ) { + "https://chatgpt.com/backend-api/codex" + } else { + "https://api.openai.com/v1" + }; + let base_url = self + .base_url + .clone() + .unwrap_or_else(|| default_base_url.to_string()); + + let headers = self.build_header_map()?; + let retry = ApiRetryConfig { + max_attempts: self.request_max_retries(), + base_delay: Duration::from_millis(200), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }; + + Ok(ApiProvider { + name: self.name.clone(), + base_url, + query_params: self.query_params.clone(), + headers, + retry, + stream_idle_timeout: self.stream_idle_timeout(), + }) + } + + /// 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 api_key = std::env::var(env_key) + .ok() + .filter(|v| !v.trim().is_empty()) + .ok_or_else(|| { + crate::error::CodexErr::EnvVar(EnvVarError { + var: env_key.clone(), + instructions: self.env_key_instructions.clone(), + }) + })?; + Ok(Some(api_key)) + } + 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) + .min(MAX_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) + .min(MAX_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)) + } + + /// Effective timeout for websocket connect attempts. + pub fn websocket_connect_timeout(&self) -> Duration { + self.websocket_connect_timeout_ms + .map(Duration::from_millis) + .unwrap_or(Duration::from_millis(DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS)) + } + + pub fn create_openai_provider(base_url: Option) -> ModelProviderInfo { + ModelProviderInfo { + name: OPENAI_PROVIDER_NAME.into(), + base_url, + env_key: None, + env_key_instructions: None, + experimental_bearer_token: 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(), + ), + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, + requires_openai_auth: true, + supports_websockets: true, + } + } + + pub fn is_openai(&self) -> bool { + self.name == OPENAI_PROVIDER_NAME + } +} + +pub const DEFAULT_LMSTUDIO_PORT: u16 = 1234; +pub const DEFAULT_OLLAMA_PORT: u16 = 11434; + +pub const LMSTUDIO_OSS_PROVIDER_ID: &str = "lmstudio"; +pub const OLLAMA_OSS_PROVIDER_ID: &str = "ollama"; + +/// Built-in default provider list. +pub fn built_in_model_providers( + openai_base_url: Option, +) -> HashMap { + use ModelProviderInfo as P; + let openai_provider = P::create_openai_provider(openai_base_url); + + [ + (OPENAI_PROVIDER_ID, openai_provider), + ( + OLLAMA_OSS_PROVIDER_ID, + create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Responses), + ), + ( + LMSTUDIO_OSS_PROVIDER_ID, + create_oss_provider(DEFAULT_LMSTUDIO_PORT, WireApi::Responses), + ), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect() +} + +pub fn create_oss_provider(default_provider_port: u16, wire_api: WireApi) -> ModelProviderInfo { + let default_codex_oss_base_url = format!( + "http://localhost:{codex_oss_port}/v1", + codex_oss_port = std::env::var("CODEX_OSS_PORT") + .ok() + .filter(|value| !value.trim().is_empty()) + .and_then(|value| value.parse::().ok()) + .unwrap_or(default_provider_port) + ); + + let codex_oss_base_url = std::env::var("CODEX_OSS_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()) + .unwrap_or(default_codex_oss_base_url); + create_oss_provider_with_base_url(&codex_oss_base_url, wire_api) +} + +pub fn create_oss_provider_with_base_url(base_url: &str, wire_api: WireApi) -> ModelProviderInfo { + ModelProviderInfo { + name: "gpt-oss".into(), + base_url: Some(base_url.into()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + wire_api, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: false, + } +}