@@ -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.
301404pub 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