Compare commits

...

1 Commits

Author SHA1 Message Date
Michael Bolin
8e08ab7f80 feat: add a built-in model provider named "oss" 2025-08-05 02:34:12 -07:00
13 changed files with 686 additions and 44 deletions

18
codex-rs/Cargo.lock generated
View File

@@ -838,6 +838,23 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-ollama"
version = "0.0.0"
dependencies = [
"async-stream",
"bytes",
"codex-core",
"futures",
"reqwest",
"serde_json",
"tempfile",
"tokio",
"toml 0.9.4",
"tracing",
"wiremock",
]
[[package]]
name = "codex-tui"
version = "0.0.0"
@@ -852,6 +869,7 @@ dependencies = [
"codex-core",
"codex-file-search",
"codex-login",
"codex-ollama",
"color-eyre",
"crossterm",
"image",

View File

@@ -14,6 +14,7 @@ members = [
"mcp-client",
"mcp-server",
"mcp-types",
"ollama",
"tui",
]
resolver = "2"

View File

@@ -28,6 +28,7 @@ mod mcp_connection_manager;
mod mcp_tool_call;
mod message_history;
mod model_provider_info;
pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;
pub use model_provider_info::ModelProviderInfo;
pub use model_provider_info::WireApi;
pub use model_provider_info::built_in_model_providers;

View File

@@ -226,53 +226,93 @@ impl ModelProviderInfo {
}
}
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<String, ModelProviderInfo> {
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
// provider 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")
// 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()),
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())]
.filter(|v| !v.trim().is_empty())
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(DEFAULT_OLLAMA_PORT)
),
};
// 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(),
),
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_auth: true,
},
)]
),
// 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_auth: true,
},
),
(
BUILT_IN_OSS_MODEL_PROVIDER_ID,
P {
name: "Open Source".into(),
base_url: Some(codex_oss_base_url),
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_auth: false,
},
),
]
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect()

View File

@@ -0,0 +1,32 @@
[package]
edition = "2024"
name = "codex-ollama"
version = { workspace = true }
[lib]
name = "codex_ollama"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
async-stream = "0.3"
bytes = "1.10.1"
codex-core = { path = "../core" }
futures = "0.3"
reqwest = { version = "0.12", features = ["json", "stream"] }
serde_json = "1"
tokio = { version = "1", features = [
"io-std",
"macros",
"process",
"rt-multi-thread",
"signal",
] }
toml = "0.9.2"
tracing = { version = "0.1.41", features = ["log"] }
wiremock = "0.6"
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,255 @@
use bytes::BytesMut;
use futures::StreamExt;
use futures::stream::BoxStream;
use serde_json::Value as JsonValue;
use std::collections::VecDeque;
use std::io;
use codex_core::WireApi;
use crate::parser::pull_events_from_value;
use crate::pull::PullEvent;
use crate::pull::PullProgressReporter;
use crate::url::base_url_to_host_root;
use crate::url::is_openai_compatible_base_url;
/// Client for interacting with a local Ollama instance.
pub struct OllamaClient {
client: reqwest::Client,
host_root: String,
uses_openai_compat: bool,
}
impl OllamaClient {
pub fn from_oss_provider() -> Self {
#![allow(clippy::expect_used)]
// Use the built-in OSS provider's base URL.
let built_in_model_providers = codex_core::built_in_model_providers();
let provider = built_in_model_providers
.get(codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID)
.expect("oss provider must exist");
let base_url = provider
.base_url
.as_ref()
.expect("oss provider must have a base_url");
Self::from_provider(base_url, provider.wire_api)
}
/// Build a client from a provider definition. Falls back to the default
/// local URL if no base_url is configured.
fn from_provider(base_url: &str, wire_api: WireApi) -> Self {
let uses_openai_compat = is_openai_compatible_base_url(base_url)
|| matches!(wire_api, WireApi::Chat) && is_openai_compatible_base_url(base_url);
let host_root = base_url_to_host_root(base_url);
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self {
client,
host_root,
uses_openai_compat,
}
}
pub fn get_host(&self) -> &str {
&self.host_root
}
/// Low-level constructor given a raw host root, e.g. "http://localhost:11434".
#[cfg(test)]
fn from_host_root(host_root: impl Into<String>) -> Self {
let client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self {
client,
host_root: host_root.into(),
uses_openai_compat: false,
}
}
/// Probe whether the server is reachable by hitting the appropriate health endpoint.
pub async fn probe_server(&self) -> io::Result<bool> {
let url = if self.uses_openai_compat {
format!("{}/v1/models", self.host_root.trim_end_matches('/'))
} else {
format!("{}/api/tags", self.host_root.trim_end_matches('/'))
};
let resp = self.client.get(url).send().await;
Ok(matches!(resp, Ok(r) if r.status().is_success()))
}
/// Return the list of model names known to the local Ollama instance.
pub async fn fetch_models(&self) -> io::Result<Vec<String>> {
let tags_url = format!("{}/api/tags", self.host_root.trim_end_matches('/'));
let resp = self
.client
.get(tags_url)
.send()
.await
.map_err(io::Error::other)?;
if !resp.status().is_success() {
return Ok(Vec::new());
}
let val = resp.json::<JsonValue>().await.map_err(io::Error::other)?;
let names = val
.get("models")
.and_then(|m| m.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.get("name").and_then(|n| n.as_str()))
.map(|s| s.to_string())
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok(names)
}
/// 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(
&self,
model: &str,
) -> io::Result<BoxStream<'static, PullEvent>> {
let url = format!("{}/api/pull", self.host_root.trim_end_matches('/'));
let resp = self
.client
.post(url)
.json(&serde_json::json!({"model": model, "stream": true}))
.send()
.await
.map_err(io::Error::other)?;
if !resp.status().is_success() {
return Err(io::Error::other(format!(
"failed to start pull: HTTP {}",
resp.status()
)));
}
let mut stream = resp.bytes_stream();
let mut buf = BytesMut::new();
let _pending: VecDeque<PullEvent> = VecDeque::new();
// Using an async stream adaptor backed by unfold-like manual loop.
let s = async_stream::stream! {
while let Some(chunk) = stream.next().await {
match chunk {
Ok(bytes) => {
buf.extend_from_slice(&bytes);
while let Some(pos) = buf.iter().position(|b| *b == b'\n') {
let line = buf.split_to(pos + 1);
if let Ok(text) = std::str::from_utf8(&line) {
let text = text.trim();
if text.is_empty() { continue; }
if let Ok(value) = serde_json::from_str::<JsonValue>(text) {
for ev in pull_events_from_value(&value) { yield ev; }
if let Some(err_msg) = value.get("error").and_then(|e| e.as_str()) {
yield PullEvent::Status(format!("error: {err_msg}"));
return;
}
if let Some(status) = value.get("status").and_then(|s| s.as_str()) {
if status == "success" { yield PullEvent::Success; return; }
}
}
}
}
}
Err(_) => {
// Connection error: end the stream.
return;
}
}
}
};
Ok(Box::pin(s))
}
/// High-level helper to pull a model and drive a progress reporter.
pub async fn pull_with_reporter(
&self,
model: &str,
reporter: &mut dyn PullProgressReporter,
) -> io::Result<()> {
reporter.on_event(&PullEvent::Status(format!("Pulling model {model}...")))?;
let mut stream = self.pull_model_stream(model).await?;
while let Some(event) = stream.next().await {
reporter.on_event(&event)?;
if matches!(event, PullEvent::Success) {
break;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used)]
use super::*;
// Happy-path tests using a mock HTTP server; skip if sandbox network is disabled.
#[tokio::test]
async fn test_fetch_models_happy_path() {
if std::env::var(codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
tracing::info!(
"{} is set; skipping test_fetch_models_happy_path",
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": [ {"name": "llama3.2:3b"}, {"name":"mistral"} ]
})
.to_string(),
"application/json",
),
)
.mount(&server)
.await;
let client = OllamaClient::from_host_root(server.uri());
let models = client.fetch_models().await.expect("fetch models");
assert!(models.contains(&"llama3.2:3b".to_string()));
assert!(models.contains(&"mistral".to_string()));
}
#[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() {
tracing::info!(
"{} set; skipping test_probe_server_happy_path_openai_compat_and_native",
codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR
);
return;
}
let server = wiremock::MockServer::start().await;
// Native endpoint
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api/tags"))
.respond_with(wiremock::ResponseTemplate::new(200))
.mount(&server)
.await;
let native = OllamaClient::from_host_root(server.uri());
assert!(native.probe_server().await.expect("probe native"));
// OpenAI compatibility endpoint
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v1/models"))
.respond_with(wiremock::ResponseTemplate::new(200))
.mount(&server)
.await;
let ollama_client = OllamaClient::from_provider(&server.uri(), WireApi::Chat);
assert!(ollama_client.probe_server().await.expect("probe compat"));
}
}

View File

@@ -0,0 +1,6 @@
mod client;
mod parser;
mod pull;
mod url;
pub use client::OllamaClient;

View File

@@ -0,0 +1,82 @@
use serde_json::Value as JsonValue;
use crate::pull::PullEvent;
// Convert a single JSON object representing a pull update into one or more events.
pub(crate) fn pull_events_from_value(value: &JsonValue) -> Vec<PullEvent> {
let mut events = Vec::new();
if let Some(status) = value.get("status").and_then(|s| s.as_str()) {
events.push(PullEvent::Status(status.to_string()));
if status == "success" {
events.push(PullEvent::Success);
}
}
let digest = value
.get("digest")
.and_then(|d| d.as_str())
.unwrap_or("")
.to_string();
let total = value.get("total").and_then(|t| t.as_u64());
let completed = value.get("completed").and_then(|t| t.as_u64());
if total.is_some() || completed.is_some() {
events.push(PullEvent::ChunkProgress {
digest,
total,
completed,
});
}
events
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pull_events_decoder_status_and_success() {
let v: JsonValue = serde_json::json!({"status":"verifying"});
let events = pull_events_from_value(&v);
assert!(matches!(events.as_slice(), [PullEvent::Status(s)] if s == "verifying"));
let v2: JsonValue = serde_json::json!({"status":"success"});
let events2 = pull_events_from_value(&v2);
assert_eq!(events2.len(), 2);
assert!(matches!(events2[0], PullEvent::Status(ref s) if s == "success"));
assert!(matches!(events2[1], PullEvent::Success));
}
#[test]
fn test_pull_events_decoder_progress() {
let v: JsonValue = serde_json::json!({"digest":"sha256:abc","total":100});
let events = pull_events_from_value(&v);
assert_eq!(events.len(), 1);
match &events[0] {
PullEvent::ChunkProgress {
digest,
total,
completed,
} => {
assert_eq!(digest, "sha256:abc");
assert_eq!(*total, Some(100));
assert_eq!(*completed, None);
}
_ => panic!("expected ChunkProgress"),
}
let v2: JsonValue = serde_json::json!({"digest":"sha256:def","completed":42});
let events2 = pull_events_from_value(&v2);
assert_eq!(events2.len(), 1);
match &events2[0] {
PullEvent::ChunkProgress {
digest,
total,
completed,
} => {
assert_eq!(digest, "sha256:def");
assert_eq!(*total, None);
assert_eq!(*completed, Some(42));
}
_ => panic!("expected ChunkProgress"),
}
}
}

139
codex-rs/ollama/src/pull.rs Normal file
View File

@@ -0,0 +1,139 @@
use std::collections::HashMap;
use std::io;
use std::io::Write;
/// Events emitted while pulling a model from Ollama.
#[derive(Debug, Clone)]
pub enum PullEvent {
/// A human-readable status message (e.g., "verifying", "writing").
Status(String),
/// Byte-level progress update for a specific layer digest.
ChunkProgress {
digest: String,
total: Option<u64>,
completed: Option<u64>,
},
/// The pull finished successfully.
Success,
}
/// A simple observer for pull progress events. Implementations decide how to
/// render progress (CLI, TUI, logs, ...).
pub trait PullProgressReporter {
fn on_event(&mut self, event: &PullEvent) -> io::Result<()>;
}
/// A minimal CLI reporter that writes inline progress to stderr.
pub struct CliProgressReporter {
printed_header: bool,
last_line_len: usize,
last_completed_sum: u64,
last_instant: std::time::Instant,
totals_by_digest: HashMap<String, (u64, u64)>,
}
impl Default for CliProgressReporter {
fn default() -> Self {
Self::new()
}
}
impl CliProgressReporter {
pub fn new() -> Self {
Self {
printed_header: false,
last_line_len: 0,
last_completed_sum: 0,
last_instant: std::time::Instant::now(),
totals_by_digest: HashMap::new(),
}
}
}
impl PullProgressReporter for CliProgressReporter {
fn on_event(&mut self, event: &PullEvent) -> io::Result<()> {
let mut out = std::io::stderr();
match event {
PullEvent::Status(status) => {
// Avoid noisy manifest messages; otherwise show status inline.
if status.eq_ignore_ascii_case("pulling manifest") {
return Ok(());
}
let pad = self.last_line_len.saturating_sub(status.len());
let line = format!("\r{status}{}", " ".repeat(pad));
self.last_line_len = status.len();
out.write_all(line.as_bytes())?;
out.flush()
}
PullEvent::ChunkProgress {
digest,
total,
completed,
} => {
if let Some(t) = *total {
self.totals_by_digest
.entry(digest.clone())
.or_insert((0, 0))
.0 = t;
}
if let Some(c) = *completed {
self.totals_by_digest
.entry(digest.clone())
.or_insert((0, 0))
.1 = c;
}
let (sum_total, sum_completed) = self
.totals_by_digest
.values()
.fold((0u64, 0u64), |acc, (t, c)| (acc.0 + *t, acc.1 + *c));
if sum_total > 0 {
if !self.printed_header {
let gb = (sum_total as f64) / (1024.0 * 1024.0 * 1024.0);
let header = format!("Downloading model: total {gb:.2} GB\n");
out.write_all(b"\r\x1b[2K")?;
out.write_all(header.as_bytes())?;
self.printed_header = true;
}
let now = std::time::Instant::now();
let dt = now
.duration_since(self.last_instant)
.as_secs_f64()
.max(0.001);
let dbytes = sum_completed.saturating_sub(self.last_completed_sum) as f64;
let speed_mb_s = dbytes / (1024.0 * 1024.0) / dt;
self.last_completed_sum = sum_completed;
self.last_instant = now;
let done_gb = (sum_completed as f64) / (1024.0 * 1024.0 * 1024.0);
let total_gb = (sum_total as f64) / (1024.0 * 1024.0 * 1024.0);
let pct = (sum_completed as f64) * 100.0 / (sum_total as f64);
let text =
format!("{done_gb:.2}/{total_gb:.2} GB ({pct:.1}%) {speed_mb_s:.1} MB/s");
let pad = self.last_line_len.saturating_sub(text.len());
let line = format!("\r{text}{}", " ".repeat(pad));
self.last_line_len = text.len();
out.write_all(line.as_bytes())?;
out.flush()
} else {
Ok(())
}
}
PullEvent::Success => {
out.write_all(b"\n")?;
out.flush()
}
}
}
}
/// For now the TUI reporter delegates to the CLI reporter. This keeps UI and
/// CLI behavior aligned until a dedicated TUI integration is implemented.
#[derive(Default)]
pub struct TuiProgressReporter(CliProgressReporter);
impl PullProgressReporter for TuiProgressReporter {
fn on_event(&mut self, event: &PullEvent) -> io::Result<()> {
self.0.on_event(event)
}
}

View File

@@ -0,0 +1,39 @@
/// Identify whether a base_url points at an OpenAI-compatible root (".../v1").
pub(crate) fn is_openai_compatible_base_url(base_url: &str) -> bool {
base_url.trim_end_matches('/').ends_with("/v1")
}
/// Convert a provider base_url into the native Ollama host root.
/// For example, "http://localhost:11434/v1" -> "http://localhost:11434".
pub fn base_url_to_host_root(base_url: &str) -> String {
let trimmed = base_url.trim_end_matches('/');
if trimmed.ends_with("/v1") {
trimmed
.trim_end_matches("/v1")
.trim_end_matches('/')
.to_string()
} else {
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_base_url_to_host_root() {
assert_eq!(
base_url_to_host_root("http://localhost:11434/v1"),
"http://localhost:11434"
);
assert_eq!(
base_url_to_host_root("http://localhost:11434"),
"http://localhost:11434"
);
assert_eq!(
base_url_to_host_root("http://localhost:11434/"),
"http://localhost:11434"
);
}
}

View File

@@ -33,6 +33,7 @@ codex-common = { path = "../common", features = [
codex-core = { path = "../core" }
codex-file-search = { path = "../file-search" }
codex-login = { path = "../login" }
codex-ollama = { path = "../ollama" }
color-eyre = "0.6.3"
crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
image = { version = "^0.25.6", default-features = false, features = ["jpeg"] }
@@ -70,11 +71,9 @@ unicode-segmentation = "1.12.0"
unicode-width = "0.1"
uuid = "1"
[dev-dependencies]
chrono = { version = "0.4", features = ["serde"] }
insta = "1.43.1"
pretty_assertions = "1"
rand = "0.8"
chrono = { version = "0.4", features = ["serde"] }
vt100 = "0.16.2"

View File

@@ -17,6 +17,12 @@ pub struct Cli {
#[arg(long, short = 'm')]
pub model: Option<String>,
/// Convenience flag to select the local open source model provider.
/// Equivalent to -c model_provider=oss; verifies a local Ollama server is
/// running.
#[arg(long = "oss", default_value_t = false)]
pub oss: bool,
/// Configuration profile from config.toml to specify default options.
#[arg(long = "profile", short = 'p')]
pub config_profile: Option<String>,

View File

@@ -3,12 +3,14 @@
// alternatescreen mode starts; that file optsout locally via `allow`.
#![deny(clippy::print_stdout, clippy::print_stderr)]
use app::App;
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config_types::SandboxMode;
use codex_core::protocol::AskForApproval;
use codex_core::util::is_inside_git_repo;
use codex_login::load_auth;
use codex_ollama::OllamaClient;
use log_layer::TuiLogLayer;
use std::fs::OpenOptions;
use std::io::Write;
@@ -70,6 +72,11 @@ pub async fn run_main(
)
};
let model_provider_override = if cli.oss {
Some(BUILT_IN_OSS_MODEL_PROVIDER_ID.to_owned())
} else {
None
};
let config = {
// Load configuration and support CLI overrides.
let overrides = ConfigOverrides {
@@ -77,7 +84,7 @@ pub async fn run_main(
approval_policy,
sandbox_mode,
cwd: cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)),
model_provider: None,
model_provider: model_provider_override,
config_profile: cli.config_profile.clone(),
codex_linux_sandbox_exe,
base_instructions: None,
@@ -177,6 +184,23 @@ pub async fn run_main(
eprintln!("");
}
if cli.oss {
// Should maybe load the client using `config.model_provider`?
let ollama_client = OllamaClient::from_oss_provider();
let is_ollama_available = ollama_client.probe_server().await?;
#[allow(clippy::print_stderr)]
if !is_ollama_available {
eprintln!(
"Ollama server is not reachable at {}. Please ensure Ollama is running.",
ollama_client.get_host()
);
std::process::exit(1);
}
// TODO(easong): Check if the model is available, and if not, prompt the
// user to pull it.
}
let show_login_screen = should_show_login_screen(&config);
if show_login_screen {
std::io::stdout()