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