SPB Git

spb/wp26_uqo

Public
6.1 KB · 162 lines python
1# Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca2"""Provider-neutral interface: one async call that returns a validated Pydantic object."""3from __future__ import annotations45import asyncio6import hashlib7import json8import time9from abc import ABC, abstractmethod10from dataclasses import dataclass, field11from typing import Any, TypeVar1213from pydantic import BaseModel, ValidationError1415from ..config import load_config, model_info, provider_mode16from .ledger import CostLedger1718T = TypeVar("T", bound=BaseModel)192021@dataclass22class ProviderResponse:23    parsed: BaseModel | None24    raw_text: str25    model: str26    provider: str27    input_tokens: int = 028    output_tokens: int = 029    cached_tokens: int = 030    cost_usd: float = 0.031    latency_s: float = 0.032    request_id: str | None = None33    prompt_hash: str = ""34    validation_retries: int = 035    error: str | None = None36    extra: dict[str, Any] = field(default_factory=dict)373839def prompt_hash(system: str, user: str, schema_name: str) -> str:40    return hashlib.sha256(f"{system}\n\x00\n{user}\n\x00\n{schema_name}".encode()).hexdigest()[:16]414243def json_schema_for(schema: type[BaseModel]) -> dict[str, Any]:44    """Strict JSON schema (additionalProperties=false everywhere, all properties required)."""45    js = schema.model_json_schema()4647    def tighten(node: Any) -> None:48        if isinstance(node, dict):49            if node.get("type") == "object" and "properties" in node:50                node["additionalProperties"] = False51                node["required"] = list(node["properties"].keys())52            for k in ("default",):53                node.pop(k, None)54            for v in node.values():55                tighten(v)56        elif isinstance(node, list):57            for v in node:58                tighten(v)5960    tighten(js)61    return js626364class LLMProvider(ABC):65    name: str = "base"6667    def __init__(self, ledger: CostLedger | None = None, concurrency: int | None = None):68        cfg = load_config("models")["request_defaults"]69        self.ledger = ledger or CostLedger()70        self.sem = asyncio.Semaphore(concurrency or cfg.get("concurrency", 6))71        self.max_retries = cfg.get("max_retries", 4)72        self.timeout_s = cfg.get("timeout_s", 180)7374    @abstractmethod75    async def _raw_complete(self, *, system: str, user: str, schema: type[BaseModel], model: str, effort: str, max_tokens: int) -> tuple[str, dict[str, Any]]:76        """Return (json_text, meta) where meta has input_tokens/output_tokens/cached_tokens/request_id."""7778    async def complete(79        self,80        *,81        system: str,82        user: str,83        schema: type[T],84        model: str,85        effort: str = "medium",86        max_tokens: int = 4096,87        tag: str = "",88        validation_retries: int = 2,89    ) -> ProviderResponse:90        ph = prompt_hash(system, user, schema.__name__)91        t0 = time.perf_counter()92        retries = 093        last_err: str | None = None94        user_msg = user95        tot_in = tot_out = tot_cached = 096        text = ""97        meta: dict[str, Any] = {}98        parsed: BaseModel | None = None99        async with self.sem:100            for attempt in range(validation_retries + 1):101                try:102                    text, meta = await asyncio.wait_for(103                        self._raw_complete(system=system, user=user_msg, schema=schema, model=model, effort=effort, max_tokens=max_tokens),104                        timeout=self.timeout_s * 2,105                    )106                except Exception as e:  # provider/network errors after SDK retries107                    last_err = f"{type(e).__name__}: {e}"[:500]108                    retries = attempt109                    break110                tot_in += int(meta.get("input_tokens", 0))111                tot_out += int(meta.get("output_tokens", 0))112                tot_cached += int(meta.get("cached_tokens", 0))113                try:114                    parsed = schema.model_validate_json(_extract_json(text))115                    last_err = None116                    break117                except (ValidationError, ValueError) as e:118                    last_err = f"validation: {str(e)[:400]}"119                    retries = attempt + 1120                    user_msg = user + f"\n\nYour previous answer failed validation ({str(e)[:300]}). Return a corrected JSON object only."121        latency = time.perf_counter() - t0122        cost = CostLedger.estimate_cost(model, tot_in, tot_out, tot_cached)123        self.ledger.record(124            provider=self.name, model=model, tag=tag, prompt_hash=ph, input_tokens=tot_in, output_tokens=tot_out,125            cached_tokens=tot_cached, cost_usd=cost, latency_s=round(latency, 3), ok=parsed is not None,126            validation_retries=retries, error=last_err, request_id=meta.get("request_id"),127        )128        return ProviderResponse(129            parsed=parsed, raw_text=text, model=model, provider=self.name, input_tokens=tot_in, output_tokens=tot_out,130            cached_tokens=tot_cached, cost_usd=cost, latency_s=latency, request_id=meta.get("request_id"), prompt_hash=ph,131            validation_retries=retries, error=last_err,132        )133134135def _extract_json(text: str) -> str:136    text = text.strip()137    if text.startswith("```"):138        text = text.strip("`")139        if text.startswith("json"):140            text = text[4:]141    i, j = text.find("{"), text.rfind("}")142    return text[i : j + 1] if i >= 0 and j > i else text143144145def get_provider(model: str, ledger: CostLedger | None = None, force_mock: bool | None = None) -> LLMProvider:146    """Instantiate the adapter for a registered model id (mock when keys are absent or AV_PROVIDER_MODE=mock)."""147    from .mock import MockProvider148149    mock = force_mock if force_mock is not None else (provider_mode() == "mock")150    if mock or model.startswith("mock"):151        return MockProvider(ledger=ledger)152    vendor = model_info(model)["provider"]153    if vendor == "anthropic":154        from .anthropic_provider import AnthropicProvider155156        return AnthropicProvider(ledger=ledger)157    if vendor == "openai":158        from .openai_provider import OpenAIProvider159160        return OpenAIProvider(ledger=ledger)161    raise ValueError(vendor)162
Files · main
Menu

Theme