Skip to content

Commit 2d38bd4

Browse files
committed
feat(lift): add Ollama provider for free local lifting
Adds a fully-local LlmProvider backed by an Ollama daemon (native /api/chat), default model qwen2.5-coder:3b, zero cost, no API key. Slots into the existing provider trait + create_provider/available_providers behind an `ollama` feature (in the default set). CLI `lift --provider ollama` needs no key. Pure response parser is unit tested without a live daemon.
1 parent 26b24ee commit 2d38bd4

3 files changed

Lines changed: 172 additions & 4 deletions

File tree

crates/rpg-cli/src/main.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,11 @@ enum Commands {
134134
/// Autonomous LLM-driven semantic lifting (fire-and-forget)
135135
#[cfg(feature = "lift")]
136136
Lift {
137-
/// LLM provider: "anthropic" or "openai"
137+
/// LLM provider: "anthropic", "openai", or "ollama" (local, no API key)
138138
#[arg(long, default_value = "anthropic")]
139139
provider: String,
140140

141-
/// Model override (default: haiku for anthropic, gpt-4o-mini for openai)
141+
/// Model override (default: haiku for anthropic, gpt-4o-mini for openai, qwen2.5-coder:3b for ollama)
142142
#[arg(long)]
143143
model: Option<String>,
144144

@@ -1114,12 +1114,13 @@ fn cmd_lift(
11141114
anyhow::bail!("No RPG found. Run `rpg-encoder build` first.");
11151115
}
11161116

1117-
// Resolve API key from arg or environment
1117+
// Resolve API key from arg or environment. Local providers (ollama) need none.
11181118
let api_key = api_key
11191119
.map(String::from)
11201120
.or_else(|| match provider_name {
11211121
"anthropic" => std::env::var("ANTHROPIC_API_KEY").ok(),
11221122
"openai" => std::env::var("OPENAI_API_KEY").ok(),
1123+
"ollama" => Some(String::new()), // local daemon, no key required
11231124
_ => None,
11241125
})
11251126
.ok_or_else(|| {

crates/rpg-lift/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ repository.workspace = true
88
description = "Autonomous LLM-driven semantic lifting for RPG"
99

1010
[features]
11-
default = ["anthropic", "openai"]
11+
default = ["anthropic", "openai", "ollama"]
1212
anthropic = []
1313
openai = []
14+
ollama = []
1415

1516
[dependencies]
1617
rpg-core = { workspace = true }

crates/rpg-lift/src/provider.rs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,109 @@ impl LlmProvider for OpenAiProvider {
297297
}
298298
}
299299

300+
// ---------------------------------------------------------------------------
301+
// Ollama (local) — native /api/chat, no API key, zero cost
302+
// ---------------------------------------------------------------------------
303+
304+
/// Fully-local provider backed by a running Ollama daemon. No API key, no
305+
/// network cost — the lift token cost the agent normally pays is removed
306+
/// entirely (paid once, locally, at index time).
307+
#[cfg(feature = "ollama")]
308+
pub struct OllamaProvider {
309+
model: String,
310+
base_url: String,
311+
agent: ureq::Agent,
312+
}
313+
314+
#[cfg(feature = "ollama")]
315+
impl OllamaProvider {
316+
/// Default model: a small code-tuned local model, fast on CPU.
317+
pub const DEFAULT_MODEL: &str = "qwen2.5-coder:3b";
318+
const DEFAULT_BASE_URL: &str = "http://localhost:11434";
319+
320+
pub fn new(model: Option<String>, base_url: Option<String>) -> Self {
321+
Self {
322+
model: model.unwrap_or_else(|| Self::DEFAULT_MODEL.to_string()),
323+
base_url: base_url.unwrap_or_else(|| Self::DEFAULT_BASE_URL.to_string()),
324+
agent: ureq::Agent::new_with_config(
325+
ureq::config::Config::builder()
326+
// local models can be slow on CPU; allow a generous timeout
327+
.timeout_global(Some(std::time::Duration::from_secs(300)))
328+
.build(),
329+
),
330+
}
331+
}
332+
}
333+
334+
/// Parse an Ollama `/api/chat` (non-streaming) response. Pure, so it is unit
335+
/// tested without a live daemon.
336+
#[cfg(feature = "ollama")]
337+
fn parse_ollama_response(json: &Value) -> Result<LlmResponse, ProviderError> {
338+
if let Some(err) = json.get("error") {
339+
return Err(ProviderError::Api {
340+
status: 400,
341+
message: err.as_str().unwrap_or("unknown error").to_string(),
342+
});
343+
}
344+
let text = json
345+
.get("message")
346+
.and_then(|m| m.get("content"))
347+
.and_then(|c| c.as_str())
348+
.filter(|s| !s.is_empty())
349+
.ok_or(ProviderError::EmptyResponse)?
350+
.to_string();
351+
let input_tokens = json.get("prompt_eval_count").and_then(|t| t.as_u64());
352+
let output_tokens = json.get("eval_count").and_then(|t| t.as_u64());
353+
Ok(LlmResponse {
354+
text,
355+
input_tokens,
356+
output_tokens,
357+
})
358+
}
359+
360+
#[cfg(feature = "ollama")]
361+
impl LlmProvider for OllamaProvider {
362+
fn complete(&self, system: &str, user: &str) -> Result<LlmResponse, ProviderError> {
363+
let url = format!("{}/api/chat", self.base_url.trim_end_matches('/'));
364+
365+
let body = serde_json::json!({
366+
"model": self.model,
367+
"stream": false,
368+
"options": {"temperature": 0},
369+
"messages": [
370+
{"role": "system", "content": system},
371+
{"role": "user", "content": user}
372+
]
373+
});
374+
375+
let mut response = self
376+
.agent
377+
.post(&url)
378+
.header("content-type", "application/json")
379+
.send_json(&body)
380+
.map_err(|e| ProviderError::Http(e.to_string()))?;
381+
382+
let json: Value = response
383+
.body_mut()
384+
.read_json()
385+
.map_err(|e| ProviderError::Parse(e.to_string()))?;
386+
387+
parse_ollama_response(&json)
388+
}
389+
390+
fn model_name(&self) -> &str {
391+
&self.model
392+
}
393+
394+
fn cost_per_mtok_input(&self) -> f64 {
395+
0.0 // local inference is free
396+
}
397+
398+
fn cost_per_mtok_output(&self) -> f64 {
399+
0.0
400+
}
401+
}
402+
300403
/// Create a provider from CLI arguments.
301404
pub fn create_provider(
302405
provider_name: &str,
@@ -316,6 +419,11 @@ pub fn create_provider(
316419
model.map(String::from),
317420
base_url.map(String::from),
318421
))),
422+
#[cfg(feature = "ollama")]
423+
"ollama" => Ok(Box::new(OllamaProvider::new(
424+
model.map(String::from),
425+
base_url.map(String::from),
426+
))),
319427
other => Err(ProviderError::Http(format!(
320428
"unknown provider: '{}'. Available: {}",
321429
other,
@@ -331,5 +439,63 @@ pub fn available_providers() -> Vec<&'static str> {
331439
"anthropic",
332440
#[cfg(feature = "openai")]
333441
"openai",
442+
#[cfg(feature = "ollama")]
443+
"ollama",
334444
]
335445
}
446+
447+
#[cfg(test)]
448+
mod tests {
449+
use super::*;
450+
451+
#[cfg(feature = "ollama")]
452+
#[test]
453+
fn parse_ollama_extracts_text_and_token_counts() {
454+
let json = serde_json::json!({
455+
"message": {"role": "assistant", "content": "fetch user record, validate id"},
456+
"prompt_eval_count": 412,
457+
"eval_count": 17
458+
});
459+
let r = parse_ollama_response(&json).unwrap();
460+
assert_eq!(r.text, "fetch user record, validate id");
461+
assert_eq!(r.input_tokens, Some(412));
462+
assert_eq!(r.output_tokens, Some(17));
463+
}
464+
465+
#[cfg(feature = "ollama")]
466+
#[test]
467+
fn parse_ollama_empty_content_is_empty_response() {
468+
let json = serde_json::json!({"message": {"content": ""}});
469+
assert!(matches!(
470+
parse_ollama_response(&json),
471+
Err(ProviderError::EmptyResponse)
472+
));
473+
}
474+
475+
#[cfg(feature = "ollama")]
476+
#[test]
477+
fn parse_ollama_surfaces_api_error() {
478+
let json = serde_json::json!({"error": "model 'x' not found, try pulling it first"});
479+
assert!(matches!(
480+
parse_ollama_response(&json),
481+
Err(ProviderError::Api { .. })
482+
));
483+
}
484+
485+
#[cfg(feature = "ollama")]
486+
#[test]
487+
fn ollama_provider_defaults_to_qwen_coder_and_zero_cost() {
488+
let p = OllamaProvider::new(None, None);
489+
assert_eq!(p.model_name(), "qwen2.5-coder:3b");
490+
assert_eq!(p.cost_per_mtok_input(), 0.0);
491+
assert_eq!(p.cost_per_mtok_output(), 0.0);
492+
}
493+
494+
#[cfg(feature = "ollama")]
495+
#[test]
496+
fn create_provider_supports_ollama_without_api_key() {
497+
let p = create_provider("ollama", "", None, None).unwrap();
498+
assert_eq!(p.model_name(), "qwen2.5-coder:3b");
499+
assert!(available_providers().contains(&"ollama"));
500+
}
501+
}

0 commit comments

Comments
 (0)