feat(llm): Add graceful degradation fallback chain
Implements fallback: Ollama → API → Keywords - Add KeywordLlm provider (always available, keyword-based matching) - Add LlmManager for managing provider fallback chain - Add blue_llm_providers tool to show chain status - Keywords provider uses Jaccard similarity for text matching The system now gracefully degrades when LLM providers are unavailable: 1. Try local Ollama first (best quality) 2. Fall back to API if configured (ANTHROPIC_API_KEY/OPENAI_API_KEY) 3. Fall back to keyword matching (always works, basic functionality) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
59476fc72b
commit
f1612b9b0c
4 changed files with 370 additions and 3 deletions
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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<CompletionResult, LlmError> {
|
||||
// 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<Box<dyn LlmProvider>>,
|
||||
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<dyn LlmProvider>) {
|
||||
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<CompletionResult, LlmError> {
|
||||
// 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<ProviderStatus> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Value, ServerError> {
|
|||
))
|
||||
}
|
||||
|
||||
/// Get LLM provider chain status (graceful degradation)
|
||||
pub fn handle_providers() -> Result<Value, ServerError> {
|
||||
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<Value, ServerError> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
Loading…
Reference in a new issue