diff --git a/.gitignore b/.gitignore index de9e815..9f79d5b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,9 @@ tmp/ .env .env.* -# Generated files -.ctx/ +# Generated files (index, embeddings) — but keep the committed project config +.ctx/* +!.ctx/config.toml .fastembed_cache/ # AI specific diff --git a/README.md b/README.md index 9f239df..dac275a 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,8 @@ ctx --max-tokens 8000 "src/**/*.rs" ``` Semantic search and `ctx smart`/`ctx similar` need embeddings first. Generate them with -`ctx embed` (a ~90 MB local model by default, or `--openai` with `OPENAI_API_KEY`). See +`ctx embed` — `--provider local` (default, a ~90 MB fastembed model), `--provider ollama` +(any local Ollama model, offline and free), or `--provider openai` (needs `OPENAI_API_KEY`). See [Index & embed first](https://docs.agentis.tools/docs/guides/indexing). ### Govern: guardrails on what changes @@ -253,7 +254,7 @@ token counting, and output formatting. - **Tree-sitter** parses every supported language into symbols and relationship edges. - **SQLite** (with FTS5 and `sqlite-vec`) is the persistent, single-file store. - **DuckDB** runs the recursive graph and analytical queries (default-on; not available on Windows). -- **fastembed** generates local embeddings offline (all-MiniLM-L6-v2, 384-dim); OpenAI is optional. +- **fastembed** generates local embeddings offline (all-MiniLM-L6-v2, 384-dim); **Ollama** (any local model) and **OpenAI** are optional via `--provider`. Indexing respects `.gitignore`, an optional `.contextignore`, and 170+ built-in patterns. See [Configuration](https://docs.agentis.tools/docs/configuration) and @@ -261,7 +262,9 @@ Indexing respects `.gitignore`, an optional `.contextignore`, and 170+ built-in | Variable | Description | |----------|-------------| -| `OPENAI_API_KEY` | Required for the `--openai` flag on `embed` / `semantic` / `smart` / `similar` | +| `OPENAI_API_KEY` | Required for `--provider openai` on `embed` / `semantic` / `smart` / `similar` | +| `OLLAMA_HOST` | Ollama server URL for `--provider ollama` (default `http://localhost:11434`) | +| `OLLAMA_EMBED_MODEL` | Ollama embedding model (default `nomic-embed-text`) | | `GITHUB_TOKEN` | Optional for `review` (uses `gh` CLI auth by default) | | `CTX_NO_UPDATE_CHECK` | Silence the passive "new release available" notice | diff --git a/docs/website/docs/code-intelligence.md b/docs/website/docs/code-intelligence.md index 9e899ce..ae740a6 100644 --- a/docs/website/docs/code-intelligence.md +++ b/docs/website/docs/code-intelligence.md @@ -211,24 +211,34 @@ This finds symbols based on **meaning**, not just keywords. For example, "authen ctx search "query" --limit 10 # Limit results ctx search "query" --output json # JSON output -ctx semantic "query" --limit 20 # Semantic search with limit -ctx semantic "query" --output json # JSON output -ctx semantic "query" --openai # Use OpenAI embeddings +ctx semantic "query" --limit 20 # Semantic search with limit +ctx semantic "query" --output json # JSON output +ctx semantic "query" --provider ollama # Use an Ollama embedding model ``` ## Generating Embeddings ### Basic Embedding Generation +Choose a backend with `--provider ` (default `local`): + ```bash -# Local embeddings (no API key required) +# Local embeddings (fastembed, no API key; ~90 MB model on first run) ctx embed +# Ollama embeddings (fully offline, free; start `ollama serve` + pull a model) +ollama pull nomic-embed-text +ctx embed --provider ollama +OLLAMA_EMBED_MODEL=qwen3-embedding:8b ctx embed --provider ollama + # OpenAI embeddings (requires OPENAI_API_KEY) export OPENAI_API_KEY=sk-... -ctx embed --openai +ctx embed --provider openai # `--openai` is a deprecated alias ``` +Embeddings from different providers/models live in different vector spaces, so +switching providers requires re-embedding (`--force`); ctx warns on a mismatch. + This generates embeddings for all symbols. Embeddings are stored in SQLite and only need to be generated once (or when new symbols are added). ### Embedding Providers diff --git a/docs/website/docs/configuration.md b/docs/website/docs/configuration.md index 477b337..b8fad5f 100644 --- a/docs/website/docs/configuration.md +++ b/docs/website/docs/configuration.md @@ -467,7 +467,56 @@ ctx uses minimal environment variables: | Variable | Purpose | Required | |----------|---------|----------| -| `OPENAI_API_KEY` | OpenAI embeddings via `ctx embed --openai` | Only for OpenAI provider | +| `OPENAI_API_KEY` | OpenAI embeddings via `ctx embed --provider openai` | Only for the OpenAI provider | +| `OLLAMA_HOST` | Ollama server URL (default `http://localhost:11434`) | Only for the Ollama provider | +| `OLLAMA_EMBED_MODEL` | Ollama embedding model (default `nomic-embed-text`) | Only for the Ollama provider | +| `OLLAMA_API_KEY` | Optional bearer token for a remote/authenticated Ollama host | No | + +### Project config (`.ctx/config.toml`) + +Per-project defaults live in an optional, **committed** `.ctx/config.toml` so a +team shares one setup instead of passing flags/env vars every time. Today it +configures the embedding backend: + +```toml +[embedding] +provider = "ollama" # local (default) | openai | ollama +model = "qwen3-embedding:8b" # Ollama/OpenAI model name +# host = "http://localhost:11434" # Ollama only +``` + +Resolution is always **CLI flag > environment variable > `.ctx/config.toml` > +built-in default**, so the file never overrides an explicit request. `.ctx/` is +otherwise git-ignored; the repo's `.gitignore` keeps `config.toml` tracked. + +### Embedding providers + +`ctx embed`, `ctx semantic`, `ctx smart`, and `ctx similar` accept +`--provider ` (or set a default in `.ctx/config.toml`): + +- **`local`** (default) — [fastembed](https://github.com/Anush008/fastembed-rs) + `all-MiniLM-L6-v2`, 384-dim. Offline; downloads a ~90 MB model on first run. +- **`openai`** — `text-embedding-3-small`, 1536-dim. Requires `OPENAI_API_KEY`. +- **`ollama`** — any local [Ollama](https://ollama.com) embedding model + (`nomic-embed-text`, `mxbai-embed-large`, `qwen3-embedding:8b`, …). Fully + offline and free; dimension is derived from the model. + +```bash +# Ollama (start the daemon and pull a model first) +ollama pull nomic-embed-text +ctx embed --provider ollama +ctx smart --provider ollama "add a new output format" + +# A different model / remote host +OLLAMA_EMBED_MODEL=qwen3-embedding:8b ctx embed --provider ollama +OLLAMA_HOST=http://gpu-box:11434 ctx embed --provider ollama +``` + +> Embeddings from different providers/models occupy different vector spaces, so +> switching providers requires re-embedding (`ctx embed --provider … --force`). +> ctx warns when the query provider/dimension doesn't match the index. + +`--openai` is still accepted as a deprecated alias for `--provider openai`. ### Setting OPENAI_API_KEY diff --git a/src/cli.rs b/src/cli.rs index cfc18d8..832da06 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use crate::commands::hotspots::HotspotBy; +use ctx::embeddings::Provider; /// CLI output format (with clap integration). #[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq)] @@ -261,7 +262,9 @@ internal and unstable. Access is read-only and engine-hardened. /// Generate embeddings for semantic search Embed { /// Force re-embedding of all symbols - #[arg(long, short)] + // No `short`: `-f` is the global `--format` flag (other subcommands' + // `--force` are also long-only). Use `--force`. + #[arg(long)] force: bool, /// Show verbose output @@ -272,7 +275,11 @@ internal and unstable. Access is read-only and engine-hardened. #[arg(long, default_value = "50")] batch_size: usize, - /// Use OpenAI API instead of local model (requires OPENAI_API_KEY) + /// Embedding backend: local (default), openai, or ollama + #[arg(long, value_enum)] + provider: Option, + + /// Deprecated alias for `--provider openai` (requires OPENAI_API_KEY) #[arg(long)] openai: bool, @@ -294,7 +301,11 @@ internal and unstable. Access is read-only and engine-hardened. #[arg(long, default_value = "table")] output: String, - /// Use OpenAI API instead of local model (requires OPENAI_API_KEY) + /// Embedding backend: local (default), openai, or ollama + #[arg(long, value_enum)] + provider: Option, + + /// Deprecated alias for `--provider openai` (requires OPENAI_API_KEY) #[arg(long)] openai: bool, }, @@ -321,7 +332,11 @@ internal and unstable. Access is read-only and engine-hardened. #[arg(long)] keyword: bool, - /// Use OpenAI API instead of local model (requires OPENAI_API_KEY) + /// Embedding backend: local (default), openai, or ollama + #[arg(long, value_enum)] + provider: Option, + + /// Deprecated alias for `--provider openai` (requires OPENAI_API_KEY) #[arg(long)] openai: bool, }, @@ -442,7 +457,11 @@ internal and unstable. Access is read-only and engine-hardened. #[arg(long)] dry_run: bool, - /// Use OpenAI API instead of local model (requires OPENAI_API_KEY) + /// Embedding backend: local (default), openai, or ollama + #[arg(long, value_enum)] + provider: Option, + + /// Deprecated alias for `--provider openai` (requires OPENAI_API_KEY) #[arg(long)] openai: bool, diff --git a/src/commands/embed.rs b/src/commands/embed.rs index 9549ccd..f5dc85f 100644 --- a/src/commands/embed.rs +++ b/src/commands/embed.rs @@ -5,30 +5,26 @@ use std::env; use std::time::Instant; -use ctx::embeddings::{self, EmbeddingProvider}; +use ctx::embeddings::{self, Provider}; use ctx::error::Result; use ctx::index; use ctx::utils::{truncate_path, truncate_str}; +/// Emit the one-time hint before the local model is (down)loaded. +fn local_model_hint(provider: Provider) { + if provider == Provider::Local { + eprintln!("Initializing local embedding model (first run downloads ~90MB)..."); + } +} + /// Generate embeddings for all symbols. -pub fn run_embed(force: bool, verbose: bool, batch_size: usize, use_openai: bool) -> Result<()> { +pub fn run_embed(force: bool, verbose: bool, batch_size: usize, provider: Provider) -> Result<()> { let root = env::current_dir()?; let db = index::open_database(&root)?; - // Create provider based on flag - let provider: Box = if use_openai { - use embeddings::openai::OpenAIProvider; - let p = OpenAIProvider::from_env().map_err(|_| { - "OPENAI_API_KEY environment variable not set.\n\ - Set it with: export OPENAI_API_KEY=sk-..." - })?; - Box::new(p) - } else { - use embeddings::local::LocalProvider; - eprintln!("Initializing local embedding model (first run downloads ~90MB)..."); - let p = LocalProvider::new()?; - Box::new(p) - }; + local_model_hint(provider); + let provider = + embeddings::build_provider(provider, &ctx::config::CtxConfig::load(&root).embedding)?; if verbose { println!( @@ -95,7 +91,7 @@ pub fn run_embed(force: bool, verbose: bool, batch_size: usize, use_openai: bool } /// Watch for index changes and auto-embed new symbols. -pub fn run_embed_watch(verbose: bool, batch_size: usize, use_openai: bool) -> Result<()> { +pub fn run_embed_watch(verbose: bool, batch_size: usize, provider: Provider) -> Result<()> { use notify::RecursiveMode; use notify_debouncer_mini::{new_debouncer, DebouncedEventKind}; use std::sync::mpsc::channel; @@ -105,20 +101,9 @@ pub fn run_embed_watch(verbose: bool, batch_size: usize, use_openai: bool) -> Re let ctx_dir = root.join(".ctx"); let _db_path = ctx_dir.join("codebase.sqlite"); - // Create provider based on flag - let provider: Box = if use_openai { - use embeddings::openai::OpenAIProvider; - let p = OpenAIProvider::from_env().map_err(|_| { - "OPENAI_API_KEY environment variable not set.\n\ - Set it with: export OPENAI_API_KEY=sk-..." - })?; - Box::new(p) - } else { - use embeddings::local::LocalProvider; - eprintln!("Initializing local embedding model (first run downloads ~90MB)..."); - let p = LocalProvider::new()?; - Box::new(p) - }; + local_model_hint(provider); + let provider = + embeddings::build_provider(provider, &ctx::config::CtxConfig::load(&root).embedding)?; println!( "Using embedding provider: {} (dim={})", @@ -231,7 +216,7 @@ pub fn run_embed_watch(verbose: bool, batch_size: usize, use_openai: bool) -> Re } /// Run semantic search using embeddings. -pub fn run_semantic(query: &str, limit: usize, output: &str, use_openai: bool) -> Result<()> { +pub fn run_semantic(query: &str, limit: usize, output: &str, provider: Provider) -> Result<()> { let root = env::current_dir()?; let db = index::open_database(&root)?; @@ -245,44 +230,12 @@ pub fn run_semantic(query: &str, limit: usize, output: &str, use_openai: bool) - return Ok(()); } - // Create provider based on flag - let provider: Box = if use_openai { - use embeddings::openai::OpenAIProvider; - let p = OpenAIProvider::from_env().map_err(|_| { - "OPENAI_API_KEY environment variable not set.\n\ - Set it with: export OPENAI_API_KEY=sk-..." - })?; - Box::new(p) - } else { - use embeddings::local::LocalProvider; - let p = LocalProvider::new()?; - Box::new(p) - }; + local_model_hint(provider); + let provider = + embeddings::build_provider(provider, &ctx::config::CtxConfig::load(&root).embedding)?; - // Check for embedding dimension mismatch - let query_dim = provider.dimension(); - if let Ok(metadata) = db.get_embedding_metadata() { - for (stored_provider, _model, stored_dim, count) in &metadata { - let stored_dim = *stored_dim as usize; - if stored_dim != query_dim { - eprintln!("Warning: Embedding dimension mismatch detected!"); - eprintln!( - " Stored: {} embeddings from '{}' with dimension {}", - count, stored_provider, stored_dim - ); - eprintln!( - " Query: Using '{}' with dimension {}", - provider.name(), - query_dim - ); - eprintln!( - " Results may be inaccurate. Re-run 'ctx embed{}' to regenerate embeddings.", - if use_openai { " --openai" } else { "" } - ); - eprintln!(); - } - } - } + // Warn if the query provider/dimension differs from the index. + embeddings::warn_index_mismatch(&db, provider.as_ref()); // Embed the query let query_embedding = provider.embed(query)?; diff --git a/src/commands/similar.rs b/src/commands/similar.rs index 6252edc..9696e21 100644 --- a/src/commands/similar.rs +++ b/src/commands/similar.rs @@ -8,7 +8,7 @@ use std::env; use ctx::db::{Database, Symbol, SymbolKind}; -use ctx::embeddings::{self, Embedding, EmbeddingProvider}; +use ctx::embeddings::{self, Embedding, Provider}; use ctx::error::{CtxError, Result}; use ctx::exit::Outcome; use ctx::index; @@ -36,7 +36,7 @@ pub fn run_similar( query: &str, limit: usize, keyword: bool, - use_openai: bool, + provider: Provider, json: bool, ) -> Result { let root = env::current_dir()?; @@ -46,7 +46,9 @@ pub fn run_similar( (keyword_hits(&db, query, limit)?, "keyword") } else { ensure_embeddings(&db)?; - let provider = build_provider(use_openai)?; + let provider = + embeddings::build_provider(provider, &ctx::config::CtxConfig::load(&root).embedding)?; + embeddings::warn_index_mismatch(&db, provider.as_ref()); let query_embedding = provider.embed(query)?; (semantic_hits(&db, &query_embedding, limit)?, "semantic") }; @@ -78,20 +80,6 @@ fn ensure_embeddings(db: &Database) -> Result<()> { Ok(()) } -/// Build the embedding provider (local fastembed by default, OpenAI on flag). -fn build_provider(use_openai: bool) -> Result> { - if use_openai { - let p = embeddings::openai::OpenAIProvider::from_env().map_err(|_| { - "OPENAI_API_KEY environment variable not set.\n\ - Set it with: export OPENAI_API_KEY=sk-..." - })?; - Ok(Box::new(p)) - } else { - let p = embeddings::local::LocalProvider::new()?; - Ok(Box::new(p)) - } -} - /// Is this symbol kind in scope for `ctx similar`? fn is_callable(kind: SymbolKind) -> bool { matches!(kind, SymbolKind::Function | SymbolKind::Method) diff --git a/src/commands/smart_cmd.rs b/src/commands/smart_cmd.rs index f6418e1..7a42118 100644 --- a/src/commands/smart_cmd.rs +++ b/src/commands/smart_cmd.rs @@ -7,7 +7,7 @@ use std::env; use crate::cli::OutputFormat; use crate::commands::format_token_count; use ctx::analytics; -use ctx::embeddings::EmbeddingProvider; +use ctx::embeddings::{self, Provider}; use ctx::error::Result; use ctx::index; use ctx::output; @@ -24,13 +24,11 @@ pub fn run_smart( top: usize, explain: bool, dry_run: bool, - use_openai: bool, + provider: Provider, format: OutputFormat, show_sizes: bool, no_tree: bool, ) -> Result<()> { - use ctx::embeddings; - let root = env::current_dir()?; let db = index::open_database(&root)?; @@ -41,44 +39,14 @@ pub fn run_smart( return Ok(()); } - // Create embedding provider - let provider: Box = if use_openai { - use embeddings::openai::OpenAIProvider; - let p = OpenAIProvider::from_env().map_err(|_| { - "OPENAI_API_KEY environment variable not set.\n\ - Set it with: export OPENAI_API_KEY=sk-..." - })?; - Box::new(p) - } else { - use embeddings::local::LocalProvider; - let p = LocalProvider::new()?; - Box::new(p) - }; - - // Check for embedding dimension mismatch - let query_dim = provider.dimension(); - if let Ok(metadata) = db.get_embedding_metadata() { - for (stored_provider, _model, stored_dim, count) in &metadata { - let stored_dim = *stored_dim as usize; - if stored_dim != query_dim { - eprintln!("Warning: Embedding dimension mismatch detected!"); - eprintln!( - " Stored: {} embeddings from '{}' with dimension {}", - count, stored_provider, stored_dim - ); - eprintln!( - " Query: Using '{}' with dimension {}", - provider.name(), - query_dim - ); - eprintln!( - " Results may be inaccurate. Re-run 'ctx embed{}' to regenerate embeddings.", - if use_openai { " --openai" } else { "" } - ); - eprintln!(); - } - } + if provider == Provider::Local { + eprintln!("Initializing local embedding model (first run downloads ~90MB)..."); } + let provider = + embeddings::build_provider(provider, &ctx::config::CtxConfig::load(&root).embedding)?; + + // Warn if the query provider/dimension differs from the index. + embeddings::warn_index_mismatch(&db, provider.as_ref()); // Open analytics for call graph expansion let analytics = analytics::Analytics::open(&root)?; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8abd5ff --- /dev/null +++ b/src/config.rs @@ -0,0 +1,146 @@ +//! Project configuration loaded from `.ctx/config.toml`. +//! +//! An optional, committed TOML file that sets per-project defaults so teams +//! don't have to pass the same flags/env vars on every invocation. Currently it +//! configures the embedding backend; more sections can be added over time. +//! +//! ```toml +//! [embedding] +//! provider = "ollama" # local | openai | ollama +//! model = "qwen3-embedding:8b" # provider-specific (Ollama/OpenAI model) +//! # host = "http://localhost:11434" # Ollama only +//! ``` +//! +//! Precedence for the resolved settings is always **CLI flag > environment +//! variable > this file > built-in default**, so the config never overrides an +//! explicit request. + +use std::path::Path; + +use serde::Deserialize; + +use crate::embeddings::Provider; +use crate::index::CTX_DIR; + +/// Config file name inside `.ctx/`. +pub const CONFIG_FILE: &str = "config.toml"; + +/// Top-level `.ctx/config.toml` contents. Unknown keys are ignored so older +/// binaries tolerate newer config files. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct CtxConfig { + /// Embedding backend defaults. + pub embedding: EmbeddingConfig, +} + +/// `[embedding]` section: default provider and provider-specific settings. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct EmbeddingConfig { + /// Default provider when `--provider`/`--openai` are not given. + pub provider: Option, + /// Model name (Ollama/OpenAI). For Ollama this overrides the built-in + /// default but is itself overridden by `OLLAMA_EMBED_MODEL`. + pub model: Option, + /// Ollama host URL; overridden by `OLLAMA_HOST`. + pub host: Option, +} + +impl CtxConfig { + /// Load `/.ctx/config.toml`. A missing file yields defaults; a malformed + /// file yields defaults with a warning (never fatal — config is optional). + pub fn load(root: &Path) -> Self { + Self::load_file(&root.join(CTX_DIR).join(CONFIG_FILE)) + } + + /// Load from an explicit path (used by tests and [`CtxConfig::load`]). + pub fn load_file(path: &Path) -> Self { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(_) => return Self::default(), // absent/unreadable → defaults + }; + match toml::from_str(&text) { + Ok(config) => config, + Err(e) => { + eprintln!("Warning: ignoring malformed {} ({e})", path.display()); + Self::default() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write_temp(contents: &str) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(contents.as_bytes()).unwrap(); + f + } + + #[test] + fn missing_file_is_default() { + let cfg = CtxConfig::load_file(Path::new("/nonexistent/.ctx/config.toml")); + assert!(cfg.embedding.provider.is_none()); + assert!(cfg.embedding.model.is_none()); + } + + #[test] + fn parses_embedding_section() { + let f = write_temp( + r#" +[embedding] +provider = "ollama" +model = "qwen3-embedding:8b" +"#, + ); + let cfg = CtxConfig::load_file(f.path()); + assert_eq!(cfg.embedding.provider, Some(Provider::Ollama)); + assert_eq!(cfg.embedding.model.as_deref(), Some("qwen3-embedding:8b")); + assert!(cfg.embedding.host.is_none()); + } + + #[test] + fn unknown_keys_ignored() { + let f = write_temp( + r#" +[embedding] +provider = "openai" + +[future_section] +whatever = true +"#, + ); + let cfg = CtxConfig::load_file(f.path()); + assert_eq!(cfg.embedding.provider, Some(Provider::Openai)); + } + + #[test] + fn malformed_file_is_default() { + let f = write_temp("this is not valid toml : : :"); + let cfg = CtxConfig::load_file(f.path()); + assert!(cfg.embedding.provider.is_none()); + } + + #[test] + fn config_provides_resolution_default() { + // Flag wins over config; config wins over built-in default. + assert_eq!( + Provider::resolve(Some(Provider::Local), false, Some(Provider::Ollama)), + Provider::Local + ); + assert_eq!( + Provider::resolve(None, false, Some(Provider::Ollama)), + Provider::Ollama + ); + assert_eq!(Provider::resolve(None, false, None), Provider::Local); + // Deprecated --openai still beats config. + assert_eq!( + Provider::resolve(None, true, Some(Provider::Ollama)), + Provider::Openai + ); + } +} diff --git a/src/embeddings/mod.rs b/src/embeddings/mod.rs index c4a870b..f1ea46d 100644 --- a/src/embeddings/mod.rs +++ b/src/embeddings/mod.rs @@ -22,18 +22,120 @@ //! ``` pub mod local; +pub mod ollama; pub mod openai; // Re-export providers for convenience pub use local::LocalProvider; +pub use ollama::OllamaProvider; pub use openai::OpenAIProvider; -use crate::error::Result; +use crate::error::{CtxError, Result}; /// Embedding dimension for different models pub const OPENAI_EMBEDDING_DIM: usize = 1536; // text-embedding-3-small pub const LOCAL_EMBEDDING_DIM: usize = 384; // all-MiniLM-L6-v2 +/// Which embedding backend to use. +/// +/// `local` (fastembed) is the zero-config default; `openai` needs `OPENAI_API_KEY`; +/// `ollama` talks to a local/remote Ollama server (`OLLAMA_HOST`, +/// `OLLAMA_EMBED_MODEL`). Embeddings from different providers/models live in +/// different vector spaces, so switching provider requires re-embedding. +#[derive(clap::ValueEnum, serde::Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] +#[value(rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum Provider { + /// fastembed, local + offline (all-MiniLM-L6-v2, 384-dim). + #[default] + Local, + /// OpenAI API (text-embedding-3-small, 1536-dim). Requires `OPENAI_API_KEY`. + Openai, + /// Ollama server (model-dependent dimension). Local + offline. + Ollama, +} + +impl Provider { + /// Resolve the effective provider by precedence: + /// `--provider` flag > deprecated `--openai` flag > `.ctx/config.toml` + /// (`[embedding].provider`) > built-in default (`local`). + pub fn resolve( + provider: Option, + openai_flag: bool, + config_default: Option, + ) -> Provider { + match provider { + Some(p) => p, + None if openai_flag => Provider::Openai, + None => config_default.unwrap_or_default(), + } + } + + /// Human-readable name matching `EmbeddingProvider::name()`. + pub fn as_str(&self) -> &'static str { + match self { + Provider::Local => "local", + Provider::Openai => "openai", + Provider::Ollama => "ollama", + } + } +} + +/// Build the embedding provider for the given backend, applying any +/// provider-specific settings from `.ctx/config.toml` (`embedding`). This is the +/// single place providers are constructed, so a new backend wires in once. +/// +/// Env vars still take precedence over the config values (see the Ollama +/// resolvers); pass `&EmbeddingConfig::default()` when there is no config. +pub fn build_provider( + provider: Provider, + embedding: &crate::config::EmbeddingConfig, +) -> Result> { + match provider { + Provider::Local => Ok(Box::new(local::LocalProvider::new()?)), + Provider::Openai => { + let p = openai::OpenAIProvider::from_env().map_err(|_| { + CtxError::embedding( + "OPENAI_API_KEY environment variable not set.\n\ + Set it with: export OPENAI_API_KEY=sk-...", + ) + })?; + Ok(Box::new(p)) + } + Provider::Ollama => Ok(Box::new(ollama::OllamaProvider::from_config( + embedding.model.as_deref(), + embedding.host.as_deref(), + )?)), + } +} + +/// Warn (to stderr) when the query provider/dimension differs from what the index +/// was embedded with. Embeddings from different providers/models occupy different +/// vector spaces, so mixing them yields meaningless similarities — the fix is to +/// re-embed. No-op when the index is empty or consistent. +pub fn warn_index_mismatch(db: &crate::db::Database, provider: &dyn EmbeddingProvider) { + let query_dim = provider.dimension(); + let query_name = provider.name(); + if let Ok(metadata) = db.get_embedding_metadata() { + for (stored_provider, _model, stored_dim, count) in &metadata { + let stored_dim = *stored_dim as usize; + if stored_dim != query_dim || stored_provider != query_name { + eprintln!("Warning: embedding provider/dimension mismatch with the index!"); + eprintln!( + " Index: {count} embeddings from '{stored_provider}' (dim {stored_dim})" + ); + eprintln!(" Query: '{query_name}' (dim {query_dim})"); + eprintln!( + " Results may be inaccurate. Re-run `ctx embed --provider {query_name}` \ + to regenerate embeddings." + ); + eprintln!(); + break; + } + } + } +} + /// A vector embedding. #[derive(Debug, Clone)] pub struct Embedding { diff --git a/src/embeddings/ollama.rs b/src/embeddings/ollama.rs new file mode 100644 index 0000000..47d8594 --- /dev/null +++ b/src/embeddings/ollama.rs @@ -0,0 +1,439 @@ +//! Ollama embedding provider. +//! +//! Uses a local (or remote) [Ollama](https://ollama.com) server to generate +//! embeddings via its `/api/embed` endpoint. This gives high-quality embeddings +//! that run fully offline, without the fastembed model-download constraints and +//! without OpenAI's per-call cost. +//! +//! Unlike OpenAI/fastembed, the embedding dimension is model-dependent +//! (`nomic-embed-text` = 768, `mxbai-embed-large` = 1024, `qwen3-embedding:8b` +//! = 4096, …), so it is probed from the model on construction rather than being +//! a compile-time constant. + +use std::sync::OnceLock; +use std::time::Duration; + +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE}; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use tokio::runtime::Runtime; + +use super::{Embedding, EmbeddingProvider}; +use crate::error::{CtxError, Result}; + +/// Default Ollama host when `OLLAMA_HOST` is unset. +const DEFAULT_HOST: &str = "http://localhost:11434"; + +/// Default embedding model when `OLLAMA_EMBED_MODEL` is unset. +const DEFAULT_MODEL: &str = "nomic-embed-text"; + +const REQUEST_TIMEOUT_SECS: u64 = 60; +const CONNECT_TIMEOUT_SECS: u64 = 10; +const MAX_RETRIES: u32 = 3; +const RETRY_BASE_DELAY_MS: u64 = 500; + +/// Global runtime for the sync API when not already in an async context. +static GLOBAL_RUNTIME: OnceLock = OnceLock::new(); + +fn get_or_create_runtime() -> &'static Runtime { + GLOBAL_RUNTIME.get_or_init(|| { + Runtime::new().expect("Failed to create global tokio runtime for Ollama provider") + }) +} + +/// Ollama `/api/embed` request body. `input` accepts one or many texts. +#[derive(Serialize)] +struct OllamaEmbedRequest<'a> { + model: &'a str, + input: Vec<&'a str>, +} + +/// Ollama `/api/embed` response body. +#[derive(Deserialize)] +struct OllamaEmbedResponse { + embeddings: Option>>, + error: Option, +} + +/// Ollama embedding provider. +pub struct OllamaProvider { + client: Client, + host: String, + model: String, + /// Embedding dimension, probed from the model at construction. + dimension: usize, +} + +/// Resolve the host by precedence `OLLAMA_HOST` env > config > default, and +/// normalize a bare `host:port` (Ollama's own convention) into a URL. +fn resolve_host(config_host: Option<&str>) -> String { + let raw = std::env::var("OLLAMA_HOST") + .ok() + .filter(|h| !h.is_empty()) + .or_else(|| config_host.map(str::to_string)); + match raw { + Some(h) if h.starts_with("http://") || h.starts_with("https://") => h, + Some(h) => format!("http://{}", h), + None => DEFAULT_HOST.to_string(), + } +} + +/// Resolve the model by precedence `OLLAMA_EMBED_MODEL` env > config > default. +fn resolve_model(config_model: Option<&str>) -> String { + std::env::var("OLLAMA_EMBED_MODEL") + .ok() + .filter(|m| !m.is_empty()) + .or_else(|| config_model.map(str::to_string)) + .unwrap_or_else(|| DEFAULT_MODEL.to_string()) +} + +impl OllamaProvider { + /// Create a provider from the environment (`OLLAMA_HOST`, `OLLAMA_EMBED_MODEL`, + /// optional `OLLAMA_API_KEY` bearer token), probing the model's dimension + /// synchronously. Use [`OllamaProvider::from_env_async`] from async contexts. + pub fn from_env() -> Result { + Self::from_config(None, None) + } + + /// Create a provider applying config-file `model`/`host` fallbacks (env vars + /// still win), probing the dimension synchronously. + pub fn from_config(config_model: Option<&str>, config_host: Option<&str>) -> Result { + let mut provider = Self::new_unprobed(config_model, config_host)?; + let probe = provider.request(&["dimension probe"])?; + provider.dimension = Self::dimension_from_probe(&provider.model, probe)?; + Ok(provider) + } + + /// Async constructor for use inside an async runtime (e.g. the MCP server), + /// where the synchronous probe would deadlock. + pub async fn from_env_async() -> Result { + Self::from_config_async(None, None).await + } + + /// Async variant of [`OllamaProvider::from_config`]. + pub async fn from_config_async( + config_model: Option<&str>, + config_host: Option<&str>, + ) -> Result { + let mut provider = Self::new_unprobed(config_model, config_host)?; + let probe = provider.request_async(&["dimension probe"]).await?; + provider.dimension = Self::dimension_from_probe(&provider.model, probe)?; + Ok(provider) + } + + /// Build the client/config without probing the dimension (left as 0). + fn new_unprobed(config_model: Option<&str>, config_host: Option<&str>) -> Result { + let model = resolve_model(config_model); + let host = resolve_host(config_host); + + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + // Optional bearer token for authenticated / remote Ollama hosts. + if let Ok(token) = std::env::var("OLLAMA_API_KEY") { + if !token.is_empty() { + let value = HeaderValue::from_str(&format!("Bearer {}", token)).map_err(|e| { + CtxError::embedding(format!("Invalid OLLAMA_API_KEY format: {}", e)) + })?; + headers.insert(AUTHORIZATION, value); + } + } + + let client = Client::builder() + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) + .connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS)) + .default_headers(headers) + .build() + .map_err(|e| CtxError::embedding(format!("Failed to build HTTP client: {}", e)))?; + + Ok(Self { + client, + host, + model, + dimension: 0, + }) + } + + fn dimension_from_probe(model: &str, probe: Vec) -> Result { + probe + .first() + .map(|e| e.vector.len()) + .filter(|d| *d > 0) + .ok_or_else(|| { + CtxError::embedding(format!( + "Ollama model '{}' returned no embedding on probe", + model + )) + }) + } + + /// Async single-text embedding for use inside an async runtime. + pub async fn embed_async(&self, text: &str) -> Result { + self.request_async(&[text]) + .await? + .pop() + .ok_or_else(|| CtxError::embedding("Empty response")) + } + + /// The endpoint URL for embeddings. + fn embed_url(&self) -> String { + format!("{}/api/embed", self.host.trim_end_matches('/')) + } + + /// Synchronous request with retry. Errors (rather than deadlocking) if called + /// from within an async runtime. + fn request(&self, texts: &[&str]) -> Result> { + if tokio::runtime::Handle::try_current().is_ok() { + return Err(CtxError::embedding( + "Cannot call sync embed() from async context. Use request_async() instead.", + )); + } + get_or_create_runtime().block_on(self.request_async(texts)) + } + + /// Async request with retry/backoff for transient failures. + pub async fn request_async(&self, texts: &[&str]) -> Result> { + let body = OllamaEmbedRequest { + model: &self.model, + input: texts.to_vec(), + }; + + let mut last_error = None; + for attempt in 0..MAX_RETRIES { + match self.send_request(&body).await { + Ok(embeddings) => return Ok(embeddings), + Err(e) => { + // Retry transient connection / server errors, not "model not + // found" or malformed input. + let retryable = matches!(&e, CtxError::Embedding(msg) + if msg.contains("server error") + || msg.contains("timed out") + || msg.contains("Connection")); + if retryable && attempt < MAX_RETRIES - 1 { + let delay = RETRY_BASE_DELAY_MS * (1 << attempt); + tokio::time::sleep(Duration::from_millis(delay)).await; + last_error = Some(e); + continue; + } + return Err(e); + } + } + } + Err(last_error.unwrap_or_else(|| CtxError::embedding("Max retries exceeded"))) + } + + /// Send a single `/api/embed` request and map the outcome. + async fn send_request(&self, body: &OllamaEmbedRequest<'_>) -> Result> { + let response = self + .client + .post(self.embed_url()) + .json(body) + .send() + .await + .map_err(|e| { + if e.is_timeout() { + CtxError::embedding(format!("Request timed out: {}", e)) + } else if e.is_connect() { + CtxError::embedding(format!( + "Connection to Ollama at {} failed: {}. Is `ollama serve` running?", + self.host, e + )) + } else { + CtxError::embedding(e.to_string()) + } + })?; + + let status = response.status(); + match status { + StatusCode::OK => { + let parsed: OllamaEmbedResponse = response + .json() + .await + .map_err(|e| CtxError::embedding(format!("Failed to parse response: {}", e)))?; + self.parse_response(parsed) + } + StatusCode::NOT_FOUND => { + // Model not pulled (Ollama returns 404 with an error body). + Err(CtxError::ModelNotFound(format!( + "Ollama model '{}' not found. Pull it with: ollama pull {}", + self.model, self.model + ))) + } + s if s.is_server_error() => { + let body = response.text().await.unwrap_or_default(); + Err(CtxError::embedding(format!( + "server error ({}): {}", + status, body + ))) + } + _ => { + let body = response.text().await.unwrap_or_default(); + // Prefer a structured {"error": ...} message when present. + if let Ok(parsed) = serde_json::from_str::(&body) { + if let Some(err) = parsed.error { + return Err(Self::classify_error(&self.model, err)); + } + } + Err(CtxError::embedding(format!("HTTP {}: {}", status, body))) + } + } + } + + /// Map an Ollama error string to the most specific `CtxError`. + fn classify_error(model: &str, message: String) -> CtxError { + if message.contains("not found") || message.contains("try pulling") { + CtxError::ModelNotFound(format!( + "Ollama model '{}' not found. Pull it with: ollama pull {}", + model, model + )) + } else { + CtxError::embedding(message) + } + } + + /// Parse a successful `/api/embed` body into embeddings. + fn parse_response(&self, response: OllamaEmbedResponse) -> Result> { + if let Some(err) = response.error { + return Err(Self::classify_error(&self.model, err)); + } + let embeddings = response + .embeddings + .ok_or_else(|| CtxError::embedding("No embeddings in Ollama response"))?; + if embeddings.is_empty() { + return Err(CtxError::embedding( + "Ollama returned an empty embeddings list", + )); + } + // Once the dimension is known, enforce consistency across responses. + if self.dimension != 0 { + for vector in &embeddings { + if vector.len() != self.dimension { + return Err(CtxError::DimensionMismatch { + expected: self.dimension, + actual: vector.len(), + }); + } + } + } + Ok(embeddings.into_iter().map(Embedding::new).collect()) + } +} + +impl EmbeddingProvider for OllamaProvider { + fn name(&self) -> &str { + "ollama" + } + + fn dimension(&self) -> usize { + self.dimension + } + + fn embed(&self, text: &str) -> Result { + self.request(&[text])? + .pop() + .ok_or_else(|| CtxError::embedding("Empty response")) + } + + fn embed_batch(&self, texts: &[&str]) -> Result> { + // Ollama accepts an array input directly; chunk defensively for very + // large batches to bound request size. + const BATCH_SIZE: usize = 64; + let mut all = Vec::with_capacity(texts.len()); + for chunk in texts.chunks(BATCH_SIZE) { + all.extend(self.request(chunk)?); + } + Ok(all) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_precedence_env_over_config_over_default() { + std::env::set_var("OLLAMA_HOST", "localhost:11434"); + assert_eq!(resolve_host(None), "http://localhost:11434"); // bare host normalized + assert_eq!(resolve_host(Some("http://cfg:1")), "http://localhost:11434"); // env wins + std::env::remove_var("OLLAMA_HOST"); + assert_eq!(resolve_host(Some("gpu-box:11434")), "http://gpu-box:11434"); // config used + assert_eq!(resolve_host(None), DEFAULT_HOST); // default + } + + #[test] + fn model_precedence_env_over_config_over_default() { + std::env::remove_var("OLLAMA_EMBED_MODEL"); + assert_eq!(resolve_model(None), DEFAULT_MODEL); + assert_eq!( + resolve_model(Some("qwen3-embedding:8b")), + "qwen3-embedding:8b" + ); // config + std::env::set_var("OLLAMA_EMBED_MODEL", "mxbai-embed-large"); + assert_eq!( + resolve_model(Some("qwen3-embedding:8b")), + "mxbai-embed-large" + ); // env wins + std::env::remove_var("OLLAMA_EMBED_MODEL"); + } + + /// Build a provider without a probe so `parse_response`/dimension logic can be + /// unit-tested offline. + fn offline_provider(dimension: usize) -> OllamaProvider { + OllamaProvider { + client: Client::new(), + host: DEFAULT_HOST.to_string(), + model: "test-model".to_string(), + dimension, + } + } + + #[test] + fn parse_response_success() { + let provider = offline_provider(3); + let parsed = OllamaEmbedResponse { + embeddings: Some(vec![vec![0.1, 0.2, 0.3], vec![0.4, 0.5, 0.6]]), + error: None, + }; + let out = provider.parse_response(parsed).unwrap(); + assert_eq!(out.len(), 2); + assert_eq!(out[0].vector, vec![0.1, 0.2, 0.3]); + } + + #[test] + fn parse_response_dimension_mismatch() { + let provider = offline_provider(3); + let parsed = OllamaEmbedResponse { + embeddings: Some(vec![vec![0.1, 0.2]]), // wrong dim + error: None, + }; + assert!(matches!( + provider.parse_response(parsed).unwrap_err(), + CtxError::DimensionMismatch { + expected: 3, + actual: 2 + } + )); + } + + #[test] + fn parse_response_model_not_found() { + let provider = offline_provider(0); + let parsed = OllamaEmbedResponse { + embeddings: None, + error: Some("model \"foo\" not found, try pulling it first".to_string()), + }; + assert!(matches!( + provider.parse_response(parsed).unwrap_err(), + CtxError::ModelNotFound(_) + )); + } + + #[test] + fn parse_response_empty() { + let provider = offline_provider(0); + let parsed = OllamaEmbedResponse { + embeddings: Some(vec![]), + error: None, + }; + assert!(provider.parse_response(parsed).is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8d6fa28..dacfe77 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -151,6 +151,9 @@ pub mod formatter; pub mod output; pub mod tree; +// Project configuration (.ctx/config.toml) +pub mod config; + // Architecture rules (ctx check) pub mod check; pub mod rules; diff --git a/src/main.rs b/src/main.rs index 3b83726..fadf945 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,18 @@ use commands::MapFormat; use ctx::error::Result; use ctx::exit::Outcome; +/// Resolve the embedding provider from the CLI flags and the project's +/// `.ctx/config.toml` default (flag > `--openai` > config > built-in default). +fn resolve_embed_provider( + flag: Option, + openai: bool, +) -> ctx::embeddings::Provider { + let config_default = std::env::current_dir() + .ok() + .and_then(|cwd| ctx::config::CtxConfig::load(&cwd).embedding.provider); + ctx::embeddings::Provider::resolve(flag, openai, config_default) +} + /// Exit codes: 0 = clean, 1 = findings, 2 = operational error, /// 3 = version requirement not met (`ctx harness compat` only). fn main() -> ExitCode { @@ -139,33 +151,39 @@ fn run(args: Args) -> Result { force, verbose, batch_size, + provider, openai, watch, }) => { + let provider = resolve_embed_provider(provider, openai); if watch { - commands::run_embed_watch(verbose, batch_size, openai) + commands::run_embed_watch(verbose, batch_size, provider) } else { - commands::run_embed(force, verbose, batch_size, openai) + commands::run_embed(force, verbose, batch_size, provider) } } Some(Command::Semantic { query, limit, output, + provider, openai, }) => { + let provider = resolve_embed_provider(provider, openai); let output = if json { "json".to_string() } else { output }; - commands::run_semantic(&query, limit, &output, openai) + commands::run_semantic(&query, limit, &output, provider) } Some(Command::Similar { query, limit, keyword, + provider, openai, }) => { + let provider = resolve_embed_provider(provider, openai); // `similar` participates in the Outcome convention directly: // Clean on success, Err (exit 2) when embeddings are missing. - return commands::run_similar(&query, limit, keyword, openai, json); + return commands::run_similar(&query, limit, keyword, provider, json); } Some(Command::Complexity { threshold, @@ -221,13 +239,18 @@ fn run(args: Args) -> Result { top, explain, dry_run, + provider, openai, format, show_sizes, no_tree, - }) => commands::run_smart( - &task, max_tokens, depth, top, explain, dry_run, openai, format, show_sizes, no_tree, - ), + }) => { + let provider = resolve_embed_provider(provider, openai); + commands::run_smart( + &task, max_tokens, depth, top, explain, dry_run, provider, format, show_sizes, + no_tree, + ) + } Some(Command::Diff { revision, max_tokens, diff --git a/src/mcp/tools/analysis.rs b/src/mcp/tools/analysis.rs index b78c89d..262aaf9 100644 --- a/src/mcp/tools/analysis.rs +++ b/src/mcp/tools/analysis.rs @@ -164,8 +164,9 @@ pub async fn smart_context( args: Option<&serde_json::Map>, ) -> Result { use crate::embeddings::local::LocalProvider; + use crate::embeddings::ollama::OllamaProvider; use crate::embeddings::openai::OpenAIProvider; - use crate::embeddings::{Embedding, EmbeddingProvider}; + use crate::embeddings::{Embedding, EmbeddingProvider, Provider}; use crate::smart::{smart_context_with_embedding, SmartConfig}; use crate::tokens::Encoding; @@ -198,27 +199,61 @@ pub async fn smart_context( encoding: Encoding::default(), }; - // Compute task embedding - use async for OpenAI to avoid blocking - let use_openai = params.use_openai.unwrap_or(false); - let task_embedding: Embedding = if use_openai { - // Use OpenAI with async embedding to avoid blocking the async runtime - let provider = OpenAIProvider::from_env().map_err(|e| { - internal_error(format!( - "Failed to initialize OpenAI provider: {}. Set OPENAI_API_KEY environment variable.", - e - )) - })?; - provider - .embed_async(¶ms.task) + // Resolve provider: explicit `provider` string wins, else the deprecated + // `use_openai` bool, else the `.ctx/config.toml` default, else local. Network + // providers embed asynchronously so they don't block the async runtime. + let config = crate::config::CtxConfig::load(&std::env::current_dir().unwrap_or_default()); + let provider = match params.provider.as_deref() { + Some("openai") => Provider::Openai, + Some("ollama") => Provider::Ollama, + Some("local") => Provider::Local, + None => Provider::resolve( + None, + params.use_openai.unwrap_or(false), + config.embedding.provider, + ), + Some(other) => { + return Err(internal_error(format!( + "Unknown provider '{}'. Expected: local, openai, or ollama.", + other + ))) + } + }; + + let task_embedding: Embedding = match provider { + Provider::Openai => { + let provider = OpenAIProvider::from_env().map_err(|e| { + internal_error(format!( + "Failed to initialize OpenAI provider: {}. Set OPENAI_API_KEY environment variable.", + e + )) + })?; + provider + .embed_async(¶ms.task) + .await + .map_err(|e| internal_error(format!("Failed to generate embedding: {}", e)))? + } + Provider::Ollama => { + let provider = OllamaProvider::from_config_async( + config.embedding.model.as_deref(), + config.embedding.host.as_deref(), + ) .await - .map_err(|e| internal_error(format!("Failed to generate embedding: {}", e)))? - } else { - // Use local provider (sync is fine for CPU-bound fastembed) - let provider = LocalProvider::new() - .map_err(|e| internal_error(format!("Failed to initialize embedding model: {}", e)))?; - provider - .embed(¶ms.task) - .map_err(|e| internal_error(format!("Failed to generate embedding: {}", e)))? + .map_err(|e| internal_error(format!("Failed to initialize Ollama provider: {}", e)))?; + provider + .embed_async(¶ms.task) + .await + .map_err(|e| internal_error(format!("Failed to generate embedding: {}", e)))? + } + Provider::Local => { + // Local fastembed is CPU-bound; sync embed is fine. + let provider = LocalProvider::new().map_err(|e| { + internal_error(format!("Failed to initialize embedding model: {}", e)) + })?; + provider + .embed(¶ms.task) + .map_err(|e| internal_error(format!("Failed to generate embedding: {}", e)))? + } }; // Run smart context selection with pre-computed embedding diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index 1a1f6d5..708f586 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -132,7 +132,10 @@ pub struct SmartContextParams { /// Number of initial semantic matches (default: 10). #[serde(default = "default_top")] pub top: Option, - /// Use OpenAI embeddings instead of local (requires OPENAI_API_KEY). + /// Embedding backend: "local" (default), "openai", or "ollama". + #[serde(default)] + pub provider: Option, + /// Deprecated: use `provider: "openai"`. Kept for backward compatibility. #[serde(default)] pub use_openai: Option, } diff --git a/tests/ollama_smart_e2e.rs b/tests/ollama_smart_e2e.rs new file mode 100644 index 0000000..307aada --- /dev/null +++ b/tests/ollama_smart_e2e.rs @@ -0,0 +1,190 @@ +//! Real end-to-end `ctx smart` relevance test driven by a live Ollama server. +//! +//! This is the "last mile" that the deterministic seeded-vector guard +//! (`tests/smart_relevance.rs`) can't cover: it indexes and embeds a real +//! fixture with a real embedding model, then runs the real `ctx smart` CLI and +//! checks it selects the on-topic file. +//! +//! It is **gated** and never runs in normal CI: it is a no-op unless +//! `CTX_TEST_OLLAMA=1` is set AND an Ollama daemon is reachable. Locally: +//! +//! ```sh +//! ollama pull qwen3-embedding:8b # or set CTX_TEST_OLLAMA_MODEL +//! CTX_TEST_OLLAMA=1 cargo test --test ollama_smart_e2e -- --nocapture +//! ``` +//! +//! Assertions are on relative file *ranking*, not on exact vectors/scores, so +//! they survive model/version drift. + +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::Path; +use std::time::Duration; + +use assert_cmd::Command; +use tempfile::TempDir; + +/// Default model — overridable with `CTX_TEST_OLLAMA_MODEL`. +const DEFAULT_MODEL: &str = "qwen3-embedding:8b"; + +/// Return the model to use, or `None` if the test should be skipped (gate unset +/// or the Ollama daemon is unreachable). +fn skip_or_model() -> Option { + if std::env::var("CTX_TEST_OLLAMA").ok().as_deref() != Some("1") { + eprintln!("skipping ollama e2e: set CTX_TEST_OLLAMA=1 to run"); + return None; + } + // Probe reachability so we skip (not fail) when nothing is listening. + let host = std::env::var("OLLAMA_HOST").unwrap_or_else(|_| "localhost:11434".to_string()); + let hostport = host + .trim_start_matches("http://") + .trim_start_matches("https://") + .trim_end_matches('/') + .to_string(); + let reachable = hostport + .to_socket_addrs() + .ok() + .and_then(|mut addrs| addrs.next()) + .map(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(750)).is_ok()) + .unwrap_or(false); + if !reachable { + eprintln!("skipping ollama e2e: no Ollama daemon reachable at {host}"); + return None; + } + Some(std::env::var("CTX_TEST_OLLAMA_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string())) +} + +/// Write a small fixture repo with three topically-distinct Rust files so the +/// embedding model has real signal to separate them. +fn write_fixture(root: &Path) { + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + + std::fs::write( + src.join("parser.rs"), + r#" +/// Parse Solidity contract source code into an abstract syntax tree. +/// +/// Walks the token stream and builds AST nodes for contracts, functions, +/// and state variables so downstream passes can analyze the syntax tree. +pub fn parse_contract_source(source: &str) -> Vec { + let mut nodes = Vec::new(); + for line in source.lines() { + nodes.push(AstNode::from_line(line)); + } + nodes +} + +pub struct AstNode { + pub kind: String, +} + +impl AstNode { + pub fn from_line(line: &str) -> Self { + AstNode { kind: line.trim().to_string() } + } +} +"#, + ) + .unwrap(); + + std::fs::write( + src.join("http_client.rs"), + r#" +/// Send an authenticated HTTP request to a remote server and return the body. +pub fn send_request(url: &str, token: &str) -> String { + format!("GET {url} with bearer {token}") +} +"#, + ) + .unwrap(); + + std::fs::write( + src.join("math_utils.rs"), + r#" +/// Compute the greatest common divisor of two integers. +pub fn gcd(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + let t = b; + b = a % b; + a = t; + } + a +} +"#, + ) + .unwrap(); +} + +fn ctx(dir: &Path) -> Command { + let mut c = Command::cargo_bin("ctx").unwrap(); + c.current_dir(dir); + c +} + +#[test] +fn smart_selects_on_topic_file_with_ollama() { + let Some(model) = skip_or_model() else { + return; + }; + + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write_fixture(root); + + // 1. Index the fixture. + ctx(root).arg("index").assert().success(); + + // 2. Embed it with the real Ollama model. + ctx(root) + .args(["embed", "--provider", "ollama"]) + .env("OLLAMA_EMBED_MODEL", &model) + .assert() + .success(); + + // 3. Ask `ctx smart` for a clearly parser-related task and inspect the + // ranked candidate list (dry-run shows all candidates, budget aside). + let output = ctx(root) + .args([ + "smart", + "--provider", + "ollama", + "--dry-run", + "parse solidity contract source into a syntax tree", + ]) + .env("OLLAMA_EMBED_MODEL", &model) + .assert() + .success() + .get_output() + .stdout + .clone(); + let stdout = String::from_utf8(output).unwrap(); + eprintln!("--- ctx smart --dry-run output ---\n{stdout}"); + + // Collect selected paths in ranked order from the " (N tokens) - ..." lines. + let ranked: Vec = stdout + .lines() + .filter_map(|l| l.split_whitespace().next()) + .filter(|tok| tok.ends_with(".rs")) + .map(|s| s.to_string()) + .collect(); + + let rank = |needle: &str| ranked.iter().position(|p| p.contains(needle)); + + assert!( + rank("parser.rs").is_some(), + "parser.rs must be selected for a parsing task; got {ranked:?}" + ); + // The on-topic parser file must rank above the clearly off-topic files. + if let (Some(p), Some(h)) = (rank("parser.rs"), rank("http_client.rs")) { + assert!( + p < h, + "parser.rs must outrank http_client.rs for a parsing task; got {ranked:?}" + ); + } + if let (Some(p), Some(m)) = (rank("parser.rs"), rank("math_utils.rs")) { + assert!( + p < m, + "parser.rs must outrank math_utils.rs for a parsing task; got {ranked:?}" + ); + } +}