diff --git a/crates/blue-core/src/lib.rs b/crates/blue-core/src/lib.rs index ec5d1ba..1a748af 100644 --- a/crates/blue-core/src/lib.rs +++ b/crates/blue-core/src/lib.rs @@ -24,7 +24,7 @@ pub mod voice; pub mod workflow; pub use documents::*; -pub use llm::{CompletionOptions, CompletionResult, LlmBackendChoice, LlmConfig, LlmError, LlmProvider, LlmProviderChoice, LocalLlmConfig, ApiLlmConfig, MockLlm}; +pub use llm::{CompletionOptions, CompletionResult, LlmBackendChoice, LlmConfig, LlmError, LlmManager, LlmProvider, LlmProviderChoice, LocalLlmConfig, ApiLlmConfig, KeywordLlm, MockLlm, ProviderStatus}; pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo}; pub use state::{ItemType, ProjectState, StateError, StatusSummary, WorkItem}; pub use store::{DocType, Document, DocumentStore, LinkType, Reminder, ReminderStatus, SearchResult, Session, SessionType, StagingLock, StagingLockQueueEntry, StagingLockResult, StoreError, Task as StoreTask, TaskProgress, Worktree}; diff --git a/crates/blue-core/src/llm.rs b/crates/blue-core/src/llm.rs index 81e4166..1226964 100644 --- a/crates/blue-core/src/llm.rs +++ b/crates/blue-core/src/llm.rs @@ -254,6 +254,183 @@ impl LlmProvider for MockLlm { } } +/// Keyword-based fallback "LLM" +/// +/// Uses simple keyword matching when no real LLM is available. +/// This provides basic functionality for tasks like ADR relevance matching. +pub struct KeywordLlm; + +impl KeywordLlm { + pub fn new() -> Self { + Self + } + + /// Extract keywords from text (simple word tokenization) + fn extract_keywords(text: &str) -> Vec { + text.to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|w| w.len() > 2) + .map(String::from) + .collect() + } + + /// Calculate keyword overlap score between two texts + pub fn keyword_score(text1: &str, text2: &str) -> f64 { + let words1: std::collections::HashSet<_> = Self::extract_keywords(text1).into_iter().collect(); + let words2: std::collections::HashSet<_> = Self::extract_keywords(text2).into_iter().collect(); + + if words1.is_empty() || words2.is_empty() { + return 0.0; + } + + let intersection = words1.intersection(&words2).count(); + let union = words1.union(&words2).count(); + + intersection as f64 / union as f64 + } +} + +impl Default for KeywordLlm { + fn default() -> Self { + Self::new() + } +} + +impl LlmProvider for KeywordLlm { + fn complete(&self, prompt: &str, _options: &CompletionOptions) -> Result { + // KeywordLlm doesn't generate text - it's for scoring/matching only + // Return the prompt keywords as a simple response + let keywords = Self::extract_keywords(prompt); + Ok(CompletionResult { + text: keywords.join(", "), + prompt_tokens: None, + completion_tokens: None, + provider: "keywords".to_string(), + }) + } + + fn name(&self) -> &str { + "keywords" + } + + fn is_ready(&self) -> bool { + true // Always ready - no external dependencies + } +} + +/// LLM Manager with graceful degradation +/// +/// Tries providers in order: Local (Ollama) → API → Keywords +/// Falls back automatically when a provider is unavailable. +pub struct LlmManager { + providers: Vec>, + config: LlmConfig, +} + +impl LlmManager { + /// Create a new LLM manager with the given configuration + pub fn new(config: LlmConfig) -> Self { + Self { + providers: Vec::new(), + config, + } + } + + /// Add a provider to the fallback chain + pub fn add_provider(&mut self, provider: Box) { + self.providers.push(provider); + } + + /// Add the keyword fallback (always available) + pub fn with_keyword_fallback(mut self) -> Self { + self.providers.push(Box::new(KeywordLlm::new())); + self + } + + /// Get the first ready provider + pub fn active_provider(&self) -> Option<&dyn LlmProvider> { + self.providers.iter() + .find(|p| p.is_ready()) + .map(|p| p.as_ref()) + } + + /// Get the active provider name + pub fn active_provider_name(&self) -> &str { + self.active_provider() + .map(|p| p.name()) + .unwrap_or("none") + } + + /// Check if any provider is available + pub fn is_available(&self) -> bool { + self.providers.iter().any(|p| p.is_ready()) + } + + /// Complete a prompt using the first available provider + pub fn complete(&self, prompt: &str, options: &CompletionOptions) -> Result { + // Respect provider preference + match self.config.provider { + LlmProviderChoice::None => { + return Err(LlmError::NotAvailable("LLM disabled by configuration".to_string())); + } + LlmProviderChoice::Local => { + // Only try local providers + for provider in &self.providers { + if provider.name() == "ollama" && provider.is_ready() { + return provider.complete(prompt, options); + } + } + return Err(LlmError::NotAvailable("Local LLM not available".to_string())); + } + LlmProviderChoice::Api => { + // Only try API providers + for provider in &self.providers { + if (provider.name() == "anthropic" || provider.name() == "openai") && provider.is_ready() { + return provider.complete(prompt, options); + } + } + return Err(LlmError::NotAvailable("API LLM not available".to_string())); + } + LlmProviderChoice::Auto => { + // Try all providers in order + } + } + + // Auto mode: try each provider in order + let mut last_error = None; + for provider in &self.providers { + if provider.is_ready() { + match provider.complete(prompt, options) { + Ok(result) => return Ok(result), + Err(e) => { + last_error = Some(e); + continue; + } + } + } + } + + Err(last_error.unwrap_or_else(|| LlmError::NotAvailable("No LLM providers available".to_string()))) + } + + /// Get status of all providers + pub fn status(&self) -> Vec { + self.providers.iter() + .map(|p| ProviderStatus { + name: p.name().to_string(), + ready: p.is_ready(), + }) + .collect() + } +} + +/// Status of a provider +#[derive(Debug, Clone)] +pub struct ProviderStatus { + pub name: String, + pub ready: bool, +} + #[cfg(test)] mod tests { use super::*; @@ -279,4 +456,98 @@ mod tests { assert_eq!(opts.max_tokens, 1024); assert!((opts.temperature - 0.7).abs() < f32::EPSILON); } + + #[test] + fn test_keyword_llm_extract_keywords() { + let keywords = KeywordLlm::extract_keywords("Hello, World! This is a TEST."); + assert!(keywords.contains(&"hello".to_string())); + assert!(keywords.contains(&"world".to_string())); + assert!(keywords.contains(&"this".to_string())); + assert!(keywords.contains(&"test".to_string())); + // Short words filtered out + assert!(!keywords.contains(&"is".to_string())); + assert!(!keywords.contains(&"a".to_string())); + } + + #[test] + fn test_keyword_llm_score() { + // Identical texts should have score 1.0 + let score = KeywordLlm::keyword_score("hello world", "hello world"); + assert!((score - 1.0).abs() < 0.01); + + // Completely different texts should have score 0.0 + let score = KeywordLlm::keyword_score("hello world", "foo bar baz"); + assert!(score < 0.01); + + // Partial overlap + let score = KeywordLlm::keyword_score("hello world test", "hello world foo"); + assert!(score > 0.3 && score < 0.8); + } + + #[test] + fn test_keyword_llm_always_ready() { + let llm = KeywordLlm::new(); + assert!(llm.is_ready()); + assert_eq!(llm.name(), "keywords"); + } + + #[test] + fn test_llm_manager_with_keyword_fallback() { + let config = LlmConfig::default(); + let manager = LlmManager::new(config).with_keyword_fallback(); + + assert!(manager.is_available()); + assert_eq!(manager.active_provider_name(), "keywords"); + } + + #[test] + fn test_llm_manager_complete_with_fallback() { + let config = LlmConfig::default(); + let manager = LlmManager::new(config).with_keyword_fallback(); + + let result = manager.complete("test prompt here", &CompletionOptions::default()); + assert!(result.is_ok()); + assert_eq!(result.unwrap().provider, "keywords"); + } + + #[test] + fn test_llm_manager_provider_order() { + let config = LlmConfig::default(); + let mut manager = LlmManager::new(config); + + // Add mock first, then keywords + manager.add_provider(Box::new(MockLlm::constant("mock response"))); + manager.add_provider(Box::new(KeywordLlm::new())); + + // Mock should be used first since it's ready + assert_eq!(manager.active_provider_name(), "mock"); + + let result = manager.complete("test", &CompletionOptions::default()).unwrap(); + assert_eq!(result.provider, "mock"); + assert_eq!(result.text, "mock response"); + } + + #[test] + fn test_llm_manager_status() { + let config = LlmConfig::default(); + let mut manager = LlmManager::new(config); + manager.add_provider(Box::new(MockLlm::constant("test"))); + manager.add_provider(Box::new(KeywordLlm::new())); + + let status = manager.status(); + assert_eq!(status.len(), 2); + assert!(status.iter().all(|s| s.ready)); + } + + #[test] + fn test_llm_manager_disabled() { + let config = LlmConfig { + provider: LlmProviderChoice::None, + ..Default::default() + }; + let manager = LlmManager::new(config).with_keyword_fallback(); + + let result = manager.complete("test", &CompletionOptions::default()); + assert!(result.is_err()); + } } diff --git a/crates/blue-mcp/src/handlers/llm.rs b/crates/blue-mcp/src/handlers/llm.rs index 8925141..6c8e926 100644 --- a/crates/blue-mcp/src/handlers/llm.rs +++ b/crates/blue-mcp/src/handlers/llm.rs @@ -1,12 +1,12 @@ //! LLM tool handlers //! //! Implements RFC 0005: Local LLM Integration. -//! Provides MCP tools for model management. +//! Provides MCP tools for model management with graceful degradation. use serde_json::{json, Value}; use std::sync::{Arc, Mutex, OnceLock}; -use blue_core::{LocalLlmConfig, LlmProvider}; +use blue_core::{KeywordLlm, LlmConfig, LlmManager, LocalLlmConfig, LlmProvider}; use blue_ollama::{EmbeddedOllama, HealthStatus, OllamaLlm}; use crate::error::ServerError; @@ -270,6 +270,75 @@ pub fn handle_model_remove(args: &Value) -> Result { )) } +/// Get LLM provider chain status (graceful degradation) +pub fn handle_providers() -> Result { + let config = LlmConfig::default(); + let mut manager = LlmManager::new(config); + + // Check Ollama availability + let ollama_config = LocalLlmConfig { + use_external: true, + ..Default::default() + }; + let ollama = EmbeddedOllama::new(&ollama_config); + let ollama_available = ollama.is_ollama_running(); + let ollama_version = if ollama_available { + match ollama.health_check() { + HealthStatus::Healthy { version, .. } => Some(version), + _ => None, + } + } else { + None + }; + + // Check API availability (by checking for API key) + let anthropic_key = std::env::var("ANTHROPIC_API_KEY").ok(); + let openai_key = std::env::var("OPENAI_API_KEY").ok(); + let api_available = anthropic_key.is_some() || openai_key.is_some(); + let api_provider = if anthropic_key.is_some() { + Some("anthropic") + } else if openai_key.is_some() { + Some("openai") + } else { + None + }; + + // Keywords always available + manager.add_provider(Box::new(KeywordLlm::new())); + + let active = if ollama_available { + "ollama" + } else if api_available { + api_provider.unwrap_or("api") + } else { + "keywords" + }; + + Ok(json!({ + "active_provider": active, + "fallback_chain": [ + { + "name": "ollama", + "available": ollama_available, + "version": ollama_version, + "priority": 1 + }, + { + "name": api_provider.unwrap_or("api"), + "available": api_available, + "configured": api_provider.is_some(), + "priority": 2 + }, + { + "name": "keywords", + "available": true, + "priority": 3 + } + ], + "message": format!("Active provider: {}. Fallback: ollama → api → keywords", active) + })) +} + /// Warm up a model (load into memory) pub fn handle_model_warmup(args: &Value) -> Result { let name = args @@ -354,4 +423,22 @@ mod tests { let result = handle_model_remove(&json!({})); assert!(result.is_err()); } + + #[test] + fn test_providers_always_has_keywords() { + let result = handle_providers(); + assert!(result.is_ok()); + let value = result.unwrap(); + + // Should always have an active provider + assert!(value.get("active_provider").is_some()); + + // Should have fallback chain + let chain = value.get("fallback_chain").unwrap().as_array().unwrap(); + assert_eq!(chain.len(), 3); + + // Keywords should always be available + let keywords = chain.iter().find(|p| p.get("name").unwrap() == "keywords").unwrap(); + assert_eq!(keywords.get("available").unwrap(), true); + } } diff --git a/crates/blue-mcp/src/server.rs b/crates/blue-mcp/src/server.rs index 9aa56d5..634f322 100644 --- a/crates/blue-mcp/src/server.rs +++ b/crates/blue-mcp/src/server.rs @@ -1701,6 +1701,14 @@ impl BlueServer { "properties": {} } }, + { + "name": "blue_llm_providers", + "description": "Show LLM provider fallback chain status. Returns availability of: Ollama (local) → API (Anthropic/OpenAI) → Keywords (always available).", + "inputSchema": { + "type": "object", + "properties": {} + } + }, { "name": "blue_model_list", "description": "List available models in the Ollama instance.", @@ -1862,6 +1870,7 @@ impl BlueServer { "blue_llm_start" => crate::handlers::llm::handle_start(&call.arguments.unwrap_or_default()), "blue_llm_stop" => crate::handlers::llm::handle_stop(), "blue_llm_status" => crate::handlers::llm::handle_status(), + "blue_llm_providers" => crate::handlers::llm::handle_providers(), "blue_model_list" => crate::handlers::llm::handle_model_list(), "blue_model_pull" => crate::handlers::llm::handle_model_pull(&call.arguments.unwrap_or_default()), "blue_model_remove" => crate::handlers::llm::handle_model_remove(&call.arguments.unwrap_or_default()),