Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions codex-rs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 21 additions & 1 deletion codex-rs/common/src/oss.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 => {
Expand All @@ -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}")))?;
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/core/src/model_provider_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
("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),
),
Comment on lines 273 to 277
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ensure Ollama fallback runs in all entrypoints

Switching the built‑in Ollama provider default to WireApi::Responses means any binary that loads a Config but does not call detect_ollama_wire_api_if_needed will now send /v1/responses to older Ollama servers (<0.13.4) and fail instead of falling back to /v1/chat/completions. I checked codex-rs/app-server/src/lib.rs and codex-rs/mcp-server/src/lib.rs (and several CLI subcommands) and they load config without the new detection call, so those entrypoints regress while exec/tui do not. Consider moving the detection into a shared config loading path or wiring it into other entrypoints so all Ollama uses get the same compatibility fallback.

Useful? React with 👍 / 👎.

(
LMSTUDIO_OSS_PROVIDER_ID,
Expand Down
9 changes: 7 additions & 2 deletions codex-rs/exec/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -215,9 +216,13 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> 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);
Expand Down Expand Up @@ -265,7 +270,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> 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}"))?;
}
Expand Down
1 change: 1 addition & 0 deletions codex-rs/ollama/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 64 additions & 1 deletion codex-rs/ollama/src/client.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Self> {
pub(crate) async fn try_from_provider(provider: &ModelProviderInfo) -> io::Result<Self> {
#![expect(clippy::expect_used)]
let base_url = provider
.base_url
Expand Down Expand Up @@ -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<Option<Version>> {
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::<JsonValue>().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(
Expand Down Expand Up @@ -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() {
Expand Down
72 changes: 72 additions & 0 deletions codex-rs/ollama/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Version>,
}

/// Prepare the local OSS environment when `--oss` is selected.
///
/// - Ensures a local Ollama server is reachable.
Expand Down Expand Up @@ -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() {
Comment on lines +62 to +65
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if *version == Version::new(0, 0, 0) {
return WireApi::Responses;
}
if *version >= min_responses_version() {
if *version == Version::new(0, 0, 0) {
WireApi::Responses
} else if *version >= min_responses_version() {

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though this should probably be combined with ||?

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<Option<WireApiDetection>> {
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
);
}
}
10 changes: 7 additions & 3 deletions codex-rs/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 7 additions & 3 deletions codex-rs/tui2/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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);
Expand Down
Loading