kenmandal's picture
Deploy ERP-DocIQ: agentic OCR + IDP (MiniCPM-V 8B, Tesseract)
32b00ed verified
Raw
History Blame Contribute Delete
3.71 kB
"""Provider abstraction: one interface, many model backends.
The whole point: the pipeline depends only on `LLMProvider.complete()`. Swapping
mock ↔ Gemma ↔ Claude ↔ Gemini changes nothing upstream. Each response carries a
full token/cost/cache usage record so every call is measurable.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Optional
from .pricing import compute_cost
@dataclass
class CacheBlock:
"""A prompt segment marked cacheable (placed at the head of the payload)."""
text: str
cacheable: bool = True
@dataclass
class LLMRequest:
"""A model-agnostic completion request.
`system_blocks` are ordered; cacheable blocks form the stable, cached prefix
(system prompt + tool defs + schema). `user_content` is the dynamic suffix
(the document) and is never cached. Keeping these separate is what makes
prompt caching actually work.
"""
system_blocks: list[CacheBlock] = field(default_factory=list)
user_content: str = ""
max_tokens: int = 1024
temperature: float = 0.0
task: str = "generic" # classify|extract|normalize|validate|agent|...
json_schema: Optional[dict] = None # if set, request structured output
context: dict = field(default_factory=dict) # provider hints (doc_type, etc.)
def full_prompt(self) -> str:
"""Concatenation of all blocks + user content (for token estimation/hash)."""
return "\n".join(b.text for b in self.system_blocks) + "\n" + self.user_content
def cacheable_prefix(self) -> str:
return "\n".join(b.text for b in self.system_blocks if b.cacheable)
@dataclass
class Usage:
input_tokens: int = 0
output_tokens: int = 0
cache_read_tokens: int = 0
cache_write_tokens: int = 0
@property
def total_tokens(self) -> int:
return self.input_tokens + self.output_tokens
@dataclass
class LLMResponse:
text: str
model: str
provider: str
usage: Usage
latency_ms: float
cache_hit: bool = False
cost_usd: float = 0.0
routing_reason: str = ""
error: Optional[str] = None
@classmethod
def build(
cls,
text: str,
model: str,
provider: str,
usage: Usage,
latency_ms: float,
cache_hit: bool = False,
routing_reason: str = "",
error: Optional[str] = None,
) -> "LLMResponse":
cost = compute_cost(
model,
usage.input_tokens,
usage.output_tokens,
usage.cache_read_tokens,
usage.cache_write_tokens,
)
return cls(
text=text,
model=model,
provider=provider,
usage=usage,
latency_ms=round(latency_ms, 1),
cache_hit=cache_hit,
cost_usd=cost,
routing_reason=routing_reason,
error=error,
)
def estimate_tokens(text: str) -> int:
"""Cheap heuristic: ~4 chars/token. Used by mock/local providers and as a
fallback when a real API doesn't return usage. Good enough for cost demos."""
if not text:
return 0
return max(1, len(text) // 4)
class LLMProvider:
"""Base class. Subclasses implement `complete`."""
name: str = "base"
tier: str = "base" # offline|local|hosted
def available(self) -> bool: # pragma: no cover - trivial
return True
def complete(self, req: LLMRequest, model: str) -> LLMResponse: # pragma: no cover
raise NotImplementedError
# Helper so subclasses can time themselves consistently.
@staticmethod
def _now() -> float:
return time.perf_counter()