PlainSQL / backend /app /llm /router.py
LalitChaudhari3's picture
feat: synchronize text-to-sql-bot codebase with Hugging Face Space repository, including Docker build configurations
6086e71
Raw
History Blame Contribute Delete
20.8 kB
"""
Model Router — Routes LLM requests to the optimal provider with fallback.
Supports routing by task type (fast vs accurate), automatic failover,
circuit breaker protection, and retry with exponential backoff.
"""
import time
import threading
import structlog
from app.llm.base import BaseLLMProvider
from app.llm.providers import (
HuggingFaceProvider,
OpenAIProvider,
AnthropicProvider,
OllamaProvider,
GroqProvider,
)
logger = structlog.get_logger()
class CircuitBreaker:
"""
Circuit breaker for LLM providers.
States:
- CLOSED: Normal operation, requests pass through
- OPEN: Provider is failing, requests are short-circuited
- HALF_OPEN: Testing if provider has recovered
Trips after `failure_threshold` consecutive failures.
Resets after `recovery_timeout` seconds.
"""
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
def __init__(self, failure_threshold: int = 3, recovery_timeout: int = 60):
self.failure_threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self._state = self.CLOSED
self._failure_count = 0
self._last_failure_time: float = 0
self._lock = threading.Lock()
@property
def state(self) -> str:
with self._lock:
if self._state == self.OPEN:
# Check if recovery timeout has elapsed
if time.time() - self._last_failure_time >= self.recovery_timeout:
self._state = self.HALF_OPEN
return self._state
def record_success(self):
"""Record a successful call — resets the breaker."""
with self._lock:
self._failure_count = 0
self._state = self.CLOSED
def record_failure(self):
"""Record a failed call — may trip the breaker."""
with self._lock:
self._failure_count += 1
self._last_failure_time = time.time()
if self._failure_count >= self.failure_threshold:
self._state = self.OPEN
logger.warning(
"circuit_breaker_opened",
failures=self._failure_count,
recovery_in_seconds=self.recovery_timeout,
)
def is_available(self) -> bool:
"""Check if requests can pass through."""
return self.state != self.OPEN
class TokenTracker:
"""
Tracks token usage per LLM request for cost estimation and observability.
Uses tiktoken for accurate counting when available, falls back to
character-based heuristic (~4 chars per token). Thread-safe.
"""
def __init__(self):
self._encoder = None
self._total_input = 0
self._total_output = 0
self._total_cost_usd = 0.0
self._request_count = 0
self._lock = threading.Lock()
try:
import tiktoken
self._encoder = tiktoken.get_encoding("cl100k_base")
logger.info("token_tracker_initialized", encoder="tiktoken_cl100k")
except ImportError:
logger.info("token_tracker_initialized", encoder="char_heuristic",
hint="pip install tiktoken for accurate token counts")
# Pricing per token (approximate, GPT-4o-mini rates as baseline)
_PRICING = {
"input_per_token": 0.00000015, # $0.15 / 1M input tokens
"output_per_token": 0.0000006, # $0.60 / 1M output tokens
}
def count_tokens(self, text: str) -> int:
"""Count tokens in a text string."""
if self._encoder:
return len(self._encoder.encode(text))
return max(1, len(text) // 4) # ~4 chars per token heuristic
def track(self, messages: list[dict], response: str, provider: str = "unknown") -> dict:
"""
Track token usage for a single request/response pair.
Returns a dict with token counts and estimated cost.
"""
input_text = " ".join(m.get("content", "") for m in messages)
input_tokens = self.count_tokens(input_text)
output_tokens = self.count_tokens(response)
total = input_tokens + output_tokens
cost = (
input_tokens * self._PRICING["input_per_token"]
+ output_tokens * self._PRICING["output_per_token"]
)
with self._lock:
self._total_input += input_tokens
self._total_output += output_tokens
self._total_cost_usd += cost
self._request_count += 1
return {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"total_tokens": total,
"estimated_cost_usd": round(cost, 6),
"provider": provider,
}
def get_totals(self) -> dict:
"""Get aggregate token usage stats."""
with self._lock:
return {
"total_input_tokens": self._total_input,
"total_output_tokens": self._total_output,
"total_tokens": self._total_input + self._total_output,
"total_cost_usd": round(self._total_cost_usd, 4),
"total_requests": self._request_count,
"avg_tokens_per_request": round(
(self._total_input + self._total_output) / max(self._request_count, 1), 1
),
}
class ModelRouter:
"""
Intelligent model router with fallback chains and circuit breakers.
Routing strategies:
- "fast": Use the quickest available model (intent classification, simple queries)
- "accurate": Use the most capable model (complex SQL generation)
- "default": Use the configured default provider
"""
def __init__(self, config: dict):
"""
Initialize with provider configs.
config = {
"default_provider": "huggingface",
"huggingface_token": "...",
"huggingface_model": "Qwen/Qwen2.5-Coder-32B-Instruct",
"openai_api_key": "...", # optional
"anthropic_api_key": "...", # optional
"ollama_base_url": "...", # optional
}
"""
self.providers: dict[str, BaseLLMProvider] = {}
self.breakers: dict[str, CircuitBreaker] = {}
self.default_provider = config.get("default_provider", "groq")
self.token_tracker = TokenTracker()
self._init_providers(config)
# Auto-detect default if configured provider isn't available
if self.default_provider not in self.providers and self.providers:
self.default_provider = next(iter(self.providers))
logger.warning("default_provider_unavailable", fallback=self.default_provider)
# Routing preferences — Groq excels at both speed and accuracy
self.routing = {
"fast": self.default_provider, # Fast model for classification
"accurate": self.default_provider, # Best model for SQL generation
"default": self.default_provider, # Default
}
# Groq has a dedicated fast model (8B) for lightweight tasks
if "groq" in self.providers:
self.routing["fast"] = "groq" # Uses fast_model via model_override
self.routing["accurate"] = "groq" # Uses primary model (70B)
self.routing["default"] = "groq"
if "openai" in self.providers and "groq" not in self.providers:
self.routing["accurate"] = "openai"
if "anthropic" in self.providers and "groq" not in self.providers:
self.routing["accurate"] = "anthropic"
logger.info(
"model_router_initialized",
providers=list(self.providers.keys()),
default=self.default_provider,
routing=self.routing,
)
def _init_providers(self, config: dict):
"""Initialize available providers based on config."""
# Groq (primary — ultra-low-latency LPU inference)
groq_key = config.get("groq_api_key")
if groq_key:
try:
self.providers["groq"] = GroqProvider(
api_key=groq_key,
model=config.get("groq_model_primary", "llama-3.3-70b-versatile"),
fast_model=config.get("groq_model_fast", "llama-3.1-8b-instant"),
base_url=config.get("groq_base_url", "https://api.groq.com/openai/v1"),
)
self.breakers["groq"] = CircuitBreaker()
logger.info("provider_initialized", provider="groq",
model=config.get("groq_model_primary", "llama-3.3-70b-versatile"))
except Exception as e:
logger.warning("provider_init_failed", provider="groq", error=str(e))
# HuggingFace (fallback)
hf_token = config.get("huggingface_token")
if hf_token:
try:
self.providers["huggingface"] = HuggingFaceProvider(
api_token=hf_token,
model=config.get("huggingface_model", "Qwen/Qwen2.5-Coder-32B-Instruct"),
)
self.breakers["huggingface"] = CircuitBreaker()
logger.info("provider_initialized", provider="huggingface")
except Exception as e:
logger.warning("provider_init_failed", provider="huggingface", error=str(e))
# OpenAI (fallback)
openai_key = config.get("openai_api_key")
if openai_key:
try:
self.providers["openai"] = OpenAIProvider(
api_key=openai_key,
model=config.get("openai_model", "gpt-4o-mini"),
)
self.breakers["openai"] = CircuitBreaker()
logger.info("provider_initialized", provider="openai")
except Exception as e:
logger.warning("provider_init_failed", provider="openai", error=str(e))
# Anthropic (fallback)
anthropic_key = config.get("anthropic_api_key")
if anthropic_key:
try:
self.providers["anthropic"] = AnthropicProvider(
api_key=anthropic_key,
model=config.get("anthropic_model", "claude-sonnet-4-20250514"),
)
self.breakers["anthropic"] = CircuitBreaker()
logger.info("provider_initialized", provider="anthropic")
except Exception as e:
logger.warning("provider_init_failed", provider="anthropic", error=str(e))
# Ollama (local fallback)
ollama_url = config.get("ollama_base_url")
if ollama_url:
try:
provider = OllamaProvider(
base_url=ollama_url,
model=config.get("ollama_model", "llama3"),
)
if provider.health_check():
self.providers["ollama"] = provider
self.breakers["ollama"] = CircuitBreaker()
logger.info("provider_initialized", provider="ollama")
else:
logger.warning("provider_unavailable", provider="ollama")
except Exception as e:
logger.warning("provider_init_failed", provider="ollama", error=str(e))
if not self.providers:
raise RuntimeError(
"No LLM providers configured. Set at least one of: "
"GROQ_API_KEY, HUGGINGFACEHUB_API_TOKEN, OPENAI_API_KEY in .env"
)
def generate(
self,
messages: list[dict],
model_preference: str = "default",
max_retries: int = 2,
timeout: float = 15.0,
**kwargs,
) -> str:
"""
Route a generation request to the best available provider.
Falls back through providers if the primary one fails.
Applies circuit breaker, retry logic, and a total timeout per request.
"""
# Total deadline prevents thread pool exhaustion under LLM degradation
deadline = time.monotonic() + timeout
# Determine target provider
target = self.routing.get(model_preference, self.default_provider)
# Build fallback chain: target → default → all others
fallback_chain = [target]
if self.default_provider not in fallback_chain:
fallback_chain.append(self.default_provider)
for name in self.providers:
if name not in fallback_chain:
fallback_chain.append(name)
# Try each provider in order
last_error = None
for provider_name in fallback_chain:
provider = self.providers.get(provider_name)
breaker = self.breakers.get(provider_name)
if not provider:
continue
# Abort if total deadline exceeded
if time.monotonic() > deadline:
logger.warning("llm_request_timeout", elapsed_providers=len(fallback_chain))
break
# Circuit breaker check
if breaker and not breaker.is_available():
logger.info("circuit_breaker_skipped", provider=provider_name, state=breaker.state)
continue
# Retry loop per provider
for attempt in range(1, max_retries + 1):
try:
start_time = time.perf_counter()
response = provider.generate(messages, **kwargs)
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2)
# Record success
if breaker:
breaker.record_success()
if provider_name != target:
logger.info("fallback_used", target=target, actual=provider_name)
# Track token usage
token_info = self.token_tracker.track(messages, response, provider=provider_name)
logger.info(
"llm_call_success",
provider=provider_name,
elapsed_ms=elapsed_ms,
attempt=attempt,
input_tokens=token_info["input_tokens"],
output_tokens=token_info["output_tokens"],
cost_usd=token_info["estimated_cost_usd"],
)
return response
except Exception as e:
last_error = e
elapsed_ms = round((time.perf_counter() - start_time) * 1000, 2)
logger.warning(
"llm_call_failed",
provider=provider_name,
attempt=attempt,
max_retries=max_retries,
elapsed_ms=elapsed_ms,
error=str(e),
)
# Don't retry on last attempt — fall through to next provider
if attempt == max_retries:
if breaker:
breaker.record_failure()
break
# Exponential backoff — but respect the deadline
backoff = 0.5 * attempt
if time.monotonic() + backoff > deadline:
logger.warning("llm_backoff_skipped_deadline", provider=provider_name)
if breaker:
breaker.record_failure()
break
time.sleep(backoff)
raise RuntimeError(f"All LLM providers failed. Last error: {last_error}")
def get_provider_status(self) -> dict[str, dict]:
"""Check health and circuit breaker state of all registered providers."""
status = {}
for name, provider in self.providers.items():
breaker = self.breakers.get(name)
status[name] = {
"healthy": provider.health_check(),
"circuit_breaker": breaker.state if breaker else "unknown",
}
return status
def list_providers(self) -> list[str]:
"""List all available provider names."""
return list(self.providers.keys())
def get_token_usage(self) -> dict:
"""Get aggregate token usage and cost stats."""
return self.token_tracker.get_totals()
async def agenerate(
self,
messages: list[dict],
model_preference: str = "default",
**kwargs,
) -> str:
"""
Async version of generate().
If the target provider supports native async (e.g. Groq), uses it directly
for zero thread-pool overhead. Otherwise falls back to asyncio.to_thread().
"""
import asyncio
# Resolve target provider
target = self.routing.get(model_preference, self.default_provider)
provider = self.providers.get(target)
breaker = self.breakers.get(target)
# Try native async on Groq first
if provider and hasattr(provider, 'agenerate') and (not breaker or breaker.is_available()):
try:
import time
start = time.perf_counter()
# For 'fast' routing, use the fast model
if model_preference == "fast" and hasattr(provider, 'fast_model'):
kwargs["model_override"] = provider.fast_model
response = await provider.agenerate(messages, **kwargs)
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
if breaker:
breaker.record_success()
# Track tokens
token_info = self.token_tracker.track(messages, response, provider=target)
logger.info(
"async_llm_call_success",
provider=target,
elapsed_ms=elapsed_ms,
input_tokens=token_info["input_tokens"],
output_tokens=token_info["output_tokens"],
native_async=True,
)
return response
except Exception as e:
logger.warning("async_native_failed", provider=target, error=str(e))
if breaker:
breaker.record_failure()
# Fall through to thread-wrapped sync
# Fallback: thread-wrapped sync generate() with full fallback chain
return await asyncio.to_thread(
self.generate,
messages=messages,
model_preference=model_preference,
**kwargs,
)
async def astream_tokens(
self,
messages: list[dict],
model: str = None,
**kwargs,
):
"""
Async generator that yields tokens as they stream from the LLM.
Priority order:
1. Groq native streaming (fastest — LPU hardware)
2. OpenAI native streaming
3. Fallback: generate full response + yield in word chunks
"""
# 1. Try Groq native streaming first
if "groq" in self.providers:
try:
provider = self.providers["groq"]
async for token in provider.astream(messages, **kwargs):
yield token
return
except Exception as e:
logger.warning("groq_streaming_failed", error=str(e))
# 2. Try OpenAI native streaming
if "openai" in self.providers:
try:
from openai import AsyncOpenAI
provider = self.providers["openai"]
api_key = getattr(provider, "api_key", None)
if api_key:
client = AsyncOpenAI(api_key=api_key)
stream = await client.chat.completions.create(
model=model or "gpt-4o-mini",
messages=messages,
stream=True,
**kwargs,
)
async for chunk in stream:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
return
except ImportError:
logger.debug("openai_async_not_available", hint="pip install openai>=1.0")
except Exception as e:
logger.warning("openai_streaming_failed", error=str(e))
# 3. Fallback: generate full response, yield in word chunks
import asyncio
response = await self.agenerate(messages, **kwargs)
for word in response.split(" "):
yield word + " "
await asyncio.sleep(0.01) # Small delay for streaming UX