From 502ae9ff542fa0af17886e68d72aae72ad29eda7 Mon Sep 17 00:00:00 2001 From: Devon Rifkin Date: Wed, 17 Dec 2025 17:13:37 -0800 Subject: [PATCH] ollama_oss: default WireApi to Responses, fall back to Chat In Ollama v0.13.4 `/v1/responses` support was added. When Ollama is used via `--oss`, it now defaults to Responses. At runtime we do a version check and if the version is older than when Responses support was added, we fall back to `/v1/chat/completions`. --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 + codex-rs/common/src/oss.rs | 12 ++++- codex-rs/core/src/model_provider_info.rs | 2 +- codex-rs/exec/src/lib.rs | 4 +- codex-rs/ollama/Cargo.toml | 1 + codex-rs/ollama/src/client.rs | 65 +++++++++++++++++++++++- codex-rs/ollama/src/lib.rs | 63 +++++++++++++++++++++++ codex-rs/tui/src/lib.rs | 4 +- codex-rs/tui2/src/lib.rs | 4 +- 10 files changed, 148 insertions(+), 9 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 62b4d4d9b1..cc3ca97c06 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1570,6 +1570,7 @@ dependencies = [ "codex-core", "futures", "reqwest", + "semver", "serde_json", "tokio", "tracing", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 929f74d070..849f44e86f 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -180,6 +180,7 @@ reqwest = "0.12" rmcp = { version = "0.10.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 b2f511e478..a579f72373 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. @@ -16,7 +17,7 @@ pub fn get_default_model_for_oss_provider(provider_id: &str) -> Option<&'static /// 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 +26,15 @@ pub async fn ensure_oss_provider_ready( .map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?; } OLLAMA_OSS_PROVIDER_ID => { + if config.model_provider.wire_api == WireApi::Responses { + match codex_ollama::detect_wire_api(&config.model_provider).await { + Ok(Some(detection)) if detection.wire_api == WireApi::Chat => { + config.model_provider.wire_api = WireApi::Chat; + } + Ok(_) | Err(_) => {} + } + } + 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 9617392237..898723d342 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 98debd5201..5998920a7e 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -202,7 +202,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any additional_writable_roots: add_dir, }; - let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?; + let mut config = Config::load_with_cli_overrides(cli_kv_overrides, overrides).await?; if let Err(err) = enforce_login_restrictions(&config).await { eprintln!("{err}"); @@ -251,7 +251,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 ee16bd5e05..0cb4c5c0c9 100644 --- a/codex-rs/ollama/Cargo.toml +++ b/codex-rs/ollama/Cargo.toml @@ -27,6 +27,7 @@ tokio = { workspace = true, features = [ ] } tracing = { workspace = true, features = ["log"] } wiremock = { workspace = true } +semver = { workspace = true } [dev-dependencies] assert_matches = { workspace = true } diff --git a/codex-rs/ollama/src/client.rs b/codex-rs/ollama/src/client.rs index 93244cc2e5..9f040c4e24 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 4ced3b6276..3412921b8c 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,58 @@ 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 772eb19ee4..bb2daa8844 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -221,7 +221,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) { #[allow(clippy::print_stderr)] @@ -294,7 +294,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")); diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index 7587779234..de2a7204e2 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -222,7 +222,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) { #[allow(clippy::print_stderr)] @@ -295,7 +295,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"));