diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 70902b4bd59..732a8f0a71d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1582,6 +1582,7 @@ dependencies = [ "codex-core", "futures", "reqwest", + "semver", "serde_json", "tokio", "tracing", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index d79e87acce2..6f272a4c620 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -183,6 +183,7 @@ reqwest = "0.12" rmcp = { version = "0.12.0", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" +semver = "1.0" sentry = "0.46.0" serde = "1" serde_json = "1" diff --git a/codex-rs/common/src/oss.rs b/codex-rs/common/src/oss.rs index b2f511e4780..9440453b2b3 100644 --- a/codex-rs/common/src/oss.rs +++ b/codex-rs/common/src/oss.rs @@ -2,6 +2,7 @@ use codex_core::LMSTUDIO_OSS_PROVIDER_ID; use codex_core::OLLAMA_OSS_PROVIDER_ID; +use codex_core::WireApi; use codex_core::config::Config; /// Returns the default model for a given OSS provider. @@ -13,10 +14,27 @@ pub fn get_default_model_for_oss_provider(provider_id: &str) -> Option<&'static } } +/// Detect whether the selected Ollama instance supports the responses API and, if not, downgrade +/// to the chat completions wire API. This should run whenever the Ollama provider is selected, +/// even when `--oss` is not in use, so older servers remain compatible. +pub async fn update_ollama_wire_api_if_needed(config: &mut Config) { + if config.model_provider_id != OLLAMA_OSS_PROVIDER_ID + || config.model_provider.wire_api != WireApi::Responses + { + return; + } + + if let Ok(Some(detection)) = codex_ollama::detect_wire_api(&config.model_provider).await + && detection.wire_api == WireApi::Chat + { + config.model_provider.wire_api = WireApi::Chat; + } +} + /// Ensures the specified OSS provider is ready (models downloaded, service reachable). pub async fn ensure_oss_provider_ready( provider_id: &str, - config: &Config, + config: &mut Config, ) -> Result<(), std::io::Error> { match provider_id { LMSTUDIO_OSS_PROVIDER_ID => { @@ -25,6 +43,8 @@ pub async fn ensure_oss_provider_ready( .map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?; } OLLAMA_OSS_PROVIDER_ID => { + update_ollama_wire_api_if_needed(config).await; + codex_ollama::ensure_oss_ready(config) .await .map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?; diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 96173922372..898723d3427 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -273,7 +273,7 @@ pub fn built_in_model_providers() -> HashMap { ("openai", P::create_openai_provider()), ( OLLAMA_OSS_PROVIDER_ID, - create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Chat), + create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Responses), ), ( LMSTUDIO_OSS_PROVIDER_ID, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 93a481b630e..e83d09bf50e 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -15,6 +15,7 @@ pub use cli::Command; pub use cli::ReviewArgs; use codex_common::oss::ensure_oss_provider_ready; use codex_common::oss::get_default_model_for_oss_provider; +use codex_common::oss::update_ollama_wire_api_if_needed; use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::LMSTUDIO_OSS_PROVIDER_ID; @@ -215,9 +216,13 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any additional_writable_roots: add_dir, }; - let config = + let mut config = Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; + if !oss { + update_ollama_wire_api_if_needed(&mut config).await; + } + if let Err(err) = enforce_login_restrictions(&config).await { eprintln!("{err}"); std::process::exit(1); @@ -265,7 +270,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any )); } }; - ensure_oss_provider_ready(provider_id, &config) + ensure_oss_provider_ready(provider_id, &mut config) .await .map_err(|e| anyhow::anyhow!("OSS setup failed: {e}"))?; } diff --git a/codex-rs/ollama/Cargo.toml b/codex-rs/ollama/Cargo.toml index ee16bd5e057..ebf97796cc9 100644 --- a/codex-rs/ollama/Cargo.toml +++ b/codex-rs/ollama/Cargo.toml @@ -17,6 +17,7 @@ bytes = { workspace = true } codex-core = { workspace = true } futures = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } +semver = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = [ "io-std", diff --git a/codex-rs/ollama/src/client.rs b/codex-rs/ollama/src/client.rs index 93244cc2e5d..9f040c4e240 100644 --- a/codex-rs/ollama/src/client.rs +++ b/codex-rs/ollama/src/client.rs @@ -1,6 +1,7 @@ use bytes::BytesMut; use futures::StreamExt; use futures::stream::BoxStream; +use semver::Version; use serde_json::Value as JsonValue; use std::collections::VecDeque; use std::io; @@ -53,7 +54,7 @@ impl OllamaClient { } /// Build a client from a provider definition and verify the server is reachable. - async fn try_from_provider(provider: &ModelProviderInfo) -> io::Result { + pub(crate) async fn try_from_provider(provider: &ModelProviderInfo) -> io::Result { #![expect(clippy::expect_used)] let base_url = provider .base_url @@ -125,6 +126,32 @@ impl OllamaClient { Ok(names) } + /// Query the server for its version string, returning `None` when unavailable. + pub async fn fetch_version(&self) -> io::Result> { + let version_url = format!("{}/api/version", self.host_root.trim_end_matches('/')); + let resp = self + .client + .get(version_url) + .send() + .await + .map_err(io::Error::other)?; + if !resp.status().is_success() { + return Ok(None); + } + let val = resp.json::().await.map_err(io::Error::other)?; + let Some(version_str) = val.get("version").and_then(|v| v.as_str()).map(str::trim) else { + return Ok(None); + }; + let normalized = version_str.trim_start_matches('v'); + match Version::parse(normalized) { + Ok(version) => Ok(Some(version)), + Err(err) => { + tracing::warn!("Failed to parse Ollama version `{version_str}`: {err}"); + Ok(None) + } + } + } + /// Start a model pull and emit streaming events. The returned stream ends when /// a Success event is observed or the server closes the connection. pub async fn pull_model_stream( @@ -269,6 +296,42 @@ mod tests { assert!(models.contains(&"mistral".to_string())); } + #[tokio::test] + async fn test_fetch_version() { + if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + tracing::info!( + "{} is set; skipping test_fetch_version", + codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR + ); + return; + } + + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/api/tags")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_raw( + serde_json::json!({ "models": [] }).to_string(), + "application/json", + )) + .mount(&server) + .await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/api/version")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_raw( + serde_json::json!({ "version": "0.14.1" }).to_string(), + "application/json", + )) + .mount(&server) + .await; + + let client = OllamaClient::try_from_provider_with_base_url(server.uri().as_str()) + .await + .expect("client"); + + let version = client.fetch_version().await.expect("version fetch"); + assert_eq!(version, Some(Version::new(0, 14, 1))); + } + #[tokio::test] async fn test_probe_server_happy_path_openai_compat_and_native() { if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { diff --git a/codex-rs/ollama/src/lib.rs b/codex-rs/ollama/src/lib.rs index 4ced3b62760..f719ed79774 100644 --- a/codex-rs/ollama/src/lib.rs +++ b/codex-rs/ollama/src/lib.rs @@ -4,15 +4,23 @@ mod pull; mod url; pub use client::OllamaClient; +use codex_core::ModelProviderInfo; +use codex_core::WireApi; use codex_core::config::Config; pub use pull::CliProgressReporter; pub use pull::PullEvent; pub use pull::PullProgressReporter; pub use pull::TuiProgressReporter; +use semver::Version; /// Default OSS model to use when `--oss` is passed without an explicit `-m`. pub const DEFAULT_OSS_MODEL: &str = "gpt-oss:20b"; +pub struct WireApiDetection { + pub wire_api: WireApi, + pub version: Option, +} + /// Prepare the local OSS environment when `--oss` is selected. /// /// - Ensures a local Ollama server is reachable. @@ -45,3 +53,67 @@ pub async fn ensure_oss_ready(config: &Config) -> std::io::Result<()> { Ok(()) } + +fn min_responses_version() -> Version { + Version::new(0, 13, 4) +} + +fn wire_api_for_version(version: &Version) -> WireApi { + if *version == Version::new(0, 0, 0) { + return WireApi::Responses; + } + if *version >= min_responses_version() { + WireApi::Responses + } else { + WireApi::Chat + } +} + +/// Detect which wire API the running Ollama server supports based on its version. +/// Returns `Ok(None)` when the version endpoint is missing or unparsable; callers +/// should keep the configured default in that case. +pub async fn detect_wire_api( + provider: &ModelProviderInfo, +) -> std::io::Result> { + let client = crate::OllamaClient::try_from_provider(provider).await?; + let Some(version) = client.fetch_version().await? else { + return Ok(None); + }; + + let wire_api = wire_api_for_version(&version); + + Ok(Some(WireApiDetection { + wire_api, + version: Some(version), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_wire_api_for_version_dev_zero_keeps_responses() { + assert_eq!( + wire_api_for_version(&Version::new(0, 0, 0)), + WireApi::Responses + ); + } + + #[test] + fn test_wire_api_for_version_before_cutoff_is_chat() { + assert_eq!(wire_api_for_version(&Version::new(0, 13, 3)), WireApi::Chat); + } + + #[test] + fn test_wire_api_for_version_at_or_after_cutoff_is_responses() { + assert_eq!( + wire_api_for_version(&Version::new(0, 13, 4)), + WireApi::Responses + ); + assert_eq!( + wire_api_for_version(&Version::new(0, 14, 0)), + WireApi::Responses + ); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index bce9a350f4b..449c3beea61 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -9,6 +9,7 @@ pub use app::AppExitInfo; use codex_app_server_protocol::AuthMode; use codex_common::oss::ensure_oss_provider_ready; use codex_common::oss::get_default_model_for_oss_provider; +use codex_common::oss::update_ollama_wire_api_if_needed; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::INTERACTIVE_SESSION_SOURCES; @@ -226,7 +227,7 @@ pub async fn run_main( ..Default::default() }; - let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; + let mut config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; if let Some(warning) = add_dir_warning_message(&cli.add_dir, config.sandbox_policy.get()) { #[allow(clippy::print_stderr)] @@ -302,7 +303,7 @@ pub async fn run_main( )); } }; - ensure_oss_provider_ready(provider_id, &config).await?; + ensure_oss_provider_ready(provider_id, &mut config).await?; } let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION")); @@ -561,7 +562,10 @@ async fn load_config_or_exit( ) -> Config { #[allow(clippy::print_stderr)] match Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await { - Ok(config) => config, + Ok(mut config) => { + update_ollama_wire_api_if_needed(&mut config).await; + config + } Err(err) => { eprintln!("Error loading configuration: {err}"); std::process::exit(1); diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 4583a5d84af..64e405393f9 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -9,6 +9,7 @@ pub use app::AppExitInfo; use codex_app_server_protocol::AuthMode; use codex_common::oss::ensure_oss_provider_ready; use codex_common::oss::get_default_model_for_oss_provider; +use codex_common::oss::update_ollama_wire_api_if_needed; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::INTERACTIVE_SESSION_SOURCES; @@ -236,7 +237,7 @@ pub async fn run_main( additional_writable_roots: additional_dirs, }; - let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; + let mut config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; if let Some(warning) = add_dir_warning_message(&cli.add_dir, config.sandbox_policy.get()) { #[allow(clippy::print_stderr)] @@ -312,7 +313,7 @@ pub async fn run_main( )); } }; - ensure_oss_provider_ready(provider_id, &config).await?; + ensure_oss_provider_ready(provider_id, &mut config).await?; } let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION")); @@ -594,7 +595,10 @@ async fn load_config_or_exit( ) -> Config { #[allow(clippy::print_stderr)] match Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await { - Ok(config) => config, + Ok(mut config) => { + update_ollama_wire_api_if_needed(&mut config).await; + config + } Err(err) => { eprintln!("Error loading configuration: {err}"); std::process::exit(1);