ERP-DocIQ / backend /app /router.py
kenmandal's picture
Add complex invoice (MiniCPM vision OCR) + complex multi-step web automation tab
ae053b7 verified
Raw
History Blame Contribute Delete
6.53 kB
"""Cost-aware model router.
Sends each task to the cheapest *capable* model, with graceful fallback down the
tiers (frontier β†’ local β†’ mock) so a missing key or a dead Ollama never breaks a
run. Consults the semantic cache before spending tokens, and records every call to
the metrics store. This is the component that operationalizes the cost story.
"""
from __future__ import annotations
from typing import Optional
from .caching import SemanticCache
from .config import Settings
from .metrics import MetricsStore
from .providers import LLMProvider, LLMRequest, LLMResponse, ProviderRegistry
from .providers.base import Usage
# Tasks that are safe + worthwhile to semantic-cache (deterministic-ish, repeatable).
_CACHEABLE_TASKS = {"classify", "normalize", "extract"}
class ModelRouter:
def __init__(
self,
registry: ProviderRegistry,
settings: Settings,
metrics: MetricsStore,
semantic_cache: Optional[SemanticCache] = None,
) -> None:
self.registry = registry
self.settings = settings
self.metrics = metrics
self.cache = semantic_cache or SemanticCache(
threshold=settings.semantic_cache_threshold,
enabled=settings.enable_semantic_cache,
)
# --- public API -----------------------------------------------------------
def run(self, req: LLMRequest, run_id: str = "adhoc") -> LLMResponse:
# 1) Semantic cache short-circuit.
if req.task in _CACHEABLE_TASKS and self.settings.enable_semantic_cache:
cache_key = f"{req.task}:{req.cacheable_prefix()}:{req.user_content}"
cached = self.cache.get(cache_key)
if cached is not None:
resp = LLMResponse.build(
text=cached, model="semantic-cache", provider="cache",
usage=Usage(), latency_ms=0.2, cache_hit=True,
routing_reason="semantic cache hit (LLM call skipped)",
)
self.metrics.record_call(run_id, resp, req.task)
return resp
# 2) Route to candidates and execute with fallback.
candidates = self._candidates(req)
resp: Optional[LLMResponse] = None
for provider, model, reason in candidates:
resp = provider.complete(req, model)
resp.routing_reason = reason + (
f" β†’ {resp.routing_reason}" if resp.routing_reason else ""
)
if not resp.error and resp.text:
break # success
if resp is None: # should never happen (mock is always last)
resp = self.registry.mock.complete(req, "mock")
# 3) Record + populate cache.
self.metrics.record_call(run_id, resp, req.task)
if (
req.task in _CACHEABLE_TASKS
and self.settings.enable_semantic_cache
and resp.text
and not resp.error
):
self.cache.put(f"{req.task}:{req.cacheable_prefix()}:{req.user_content}", resp.text)
return resp
# --- routing policy -------------------------------------------------------
def _candidates(self, req: LLMRequest) -> list[tuple[LLMProvider, str, str]]:
"""Ordered (provider, model, reason) list. First success wins; the list
always ends with mock so a run can never hard-fail."""
policy = self.settings.routing_policy
if policy == "offline":
return [(self.registry.mock, "mock", "policy=offline")]
cheap = self._cheap_chain()
smart = self._smart_chain()
if policy == "cheap":
chain = cheap
elif policy == "smart":
chain = smart
else: # auto
chain = self._auto_chain(req, cheap, smart)
# Always guarantee a terminal mock fallback.
if not chain or chain[-1][0] is not self.registry.mock:
chain = chain + [(self.registry.mock, "mock", "fallback=mock")]
return chain
def _auto_chain(self, req, cheap, smart):
task = req.task
if task in {"classify", "normalize", "validate", "format"}:
return cheap # easy β†’ cheapest
if task == "agent":
return smart + cheap # reasoning-heavy β†’ frontier first
if task == "extract":
complexity = req.context.get("complexity", "simple")
if complexity == "hard":
return smart + cheap
return cheap + smart # simple extract β†’ cheap first, frontier as backup
return cheap
def _cheap_chain(self) -> list[tuple[LLMProvider, str, str]]:
chain: list[tuple[LLMProvider, str, str]] = []
if self.registry.local and self.registry.local.available():
chain.append((self.registry.local, self.settings.local_model,
"cheap: local Gemma (β‰ˆ$0)"))
if self.registry.anthropic and self.registry.anthropic.available():
chain.append((self.registry.anthropic, self.settings.anthropic_model_cheap,
"cheap: Claude Haiku"))
if self.registry.gemini and self.registry.gemini.available():
chain.append((self.registry.gemini, self.settings.gemini_model,
"cheap: Gemini Flash"))
if getattr(self.registry, "minicpm", None) and self.registry.minicpm.available():
chain.append((self.registry.minicpm, self.settings.minicpm_model,
"cheap: MiniCPM-V (small model)"))
return chain
def _smart_chain(self) -> list[tuple[LLMProvider, str, str]]:
chain: list[tuple[LLMProvider, str, str]] = []
if self.registry.anthropic and self.registry.anthropic.available():
chain.append((self.registry.anthropic, self.settings.anthropic_model_smart,
"smart: Claude Sonnet"))
if self.registry.gemini and self.registry.gemini.available():
chain.append((self.registry.gemini, self.settings.gemini_model,
"smart: Gemini Flash"))
if getattr(self.registry, "minicpm", None) and self.registry.minicpm.available():
chain.append((self.registry.minicpm, self.settings.minicpm_model,
"smart: MiniCPM-V (small model)"))
if self.registry.local and self.registry.local.available():
chain.append((self.registry.local, self.settings.local_model,
"smart: local Gemma (no frontier available)"))
return chain