# ============================================================================ # agent verf - LLM Client (Multi-Provider Waterfall) # Version: 0.2.0 # Last Updated: 2026-01-22 # # CRITICAL: This is the abstraction layer for all LLM interactions. # # Architecture: # - Dual-mode: FREE (public) and VENICE (premium) # - Waterfall fallback: Try providers in sequence until success # - Task-based model selection: TRIAGE (8B fast) vs SYNTHESIS (70B quality) # # Capacity (~591 verifications/day): # - Free Mode: ~450/day (Groq → Google → Cloudflare for triage, # Cerebras → OpenRouter for synthesis) # - Venice Mode: ~141/day (Venice 8B/70B with free fallback) # # Usage: # client = get_llm_client() # result = await client.complete( # task=LLMTask.SYNTHESIS, # messages=[{"role": "user", "content": "..."}], # mode=VerificationMode.FREE, # ) # ============================================================================ import os import json import time import asyncio import structlog from enum import Enum from typing import Optional, Type, Any from pydantic import BaseModel import httpx # Provider SDKs import anthropic import openai import groq import google.generativeai as genai logger = structlog.get_logger() # ============================================================================ # Enums # ============================================================================ class LLMTask(str, Enum): """Tasks that require LLM calls.""" TRIAGE = "triage" # Fast routing (8B models, ~600 tokens) SYNTHESIS = "synthesis" # Verdict generation (70B models, ~2500 tokens) DEEP_DIVE = "deep_dive" # Complex analysis PARAPHRASE = "paraphrase" # Response variation class LLMProvider(str, Enum): """Supported LLM providers.""" # Paid providers (fallback) ANTHROPIC = "anthropic" OPENAI = "openai" # Free triage providers GROQ = "groq" GOOGLE = "google" CLOUDFLARE = "cloudflare" # Free synthesis providers CEREBRAS = "cerebras" OPENROUTER = "openrouter" # Premium provider VENICE = "venice" class VerificationMode(str, Enum): """User verification mode - determines provider priority.""" FREE = "free" # Public users: Groq → Google → Cloudflare, Cerebras → OpenRouter VENICE = "venice" # Premium users: Venice first, then free fallback # ============================================================================ # Provider Configurations # ============================================================================ PROVIDER_CONFIGS = { # === TRIAGE PROVIDERS (8B models, ~600 tokens) === "groq": { "base_url": "https://api.groq.com/openai/v1", "env_key": "GROQ_API_KEY", "task": "triage", "models": { "triage": "llama-3.1-8b-instant", }, "limits": { "rpd": 14_400, # requests per day (rolling 24h) "rpm": 30, # requests per minute - critical to respect "tpd": 500_000, }, "openai_compatible": True, }, "google": { "base_url": None, # Uses SDK directly "env_key": "GOOGLE_AI_API_KEY", "task": "triage", "models": { "triage": "gemini-2.0-flash-lite", }, "limits": { "rpd": 1_000, "rpm": 15, }, "openai_compatible": False, }, "cloudflare": { "base_url": "https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1", "env_key": "CLOUDFLARE_API_TOKEN", "account_id_env": "CLOUDFLARE_ACCOUNT_ID", "task": "triage", "models": { "triage": "@cf/meta/llama-3.1-8b-instruct", }, "limits": { "neurons_per_day": 10_000, # ~200-500 requests "rpm": 30, }, "openai_compatible": True, }, # === SYNTHESIS PROVIDERS (70B models, ~2500 tokens) === "cerebras": { "base_url": "https://api.cerebras.ai/v1", "env_key": "CEREBRAS_API_KEY", "task": "synthesis", "models": { "synthesis": "llama-3.3-70b", }, "limits": { "tpd": 1_000_000, # ~400 synthesis calls at 2500 tokens "rpm": 30, "tpm": 60_000, }, "openai_compatible": True, }, "openrouter": { "base_url": "https://openrouter.ai/api/v1", "env_key": "OPENROUTER_API_KEY", "task": "synthesis", "models": { "synthesis": "meta-llama/llama-3.3-70b-instruct:free", }, "limits": { "rpd": 50, # 50/day without $10, 1000/day with $10 "rpm": 20, }, "openai_compatible": True, }, "venice": { "base_url": "https://api.venice.ai/api/v1", "env_key": "VENICE_API_KEY", "task": "both", # Venice can do both triage and synthesis "models": { "triage": "llama-3.1-8b-instruct", "synthesis": "llama-3.3-70b", }, "limits": { # Stake-dependent, approximately: "rpd": 50, # Conservative estimate for <1% stake "rpm": 10, }, "openai_compatible": True, }, # === PAID FALLBACKS === "anthropic": { "base_url": None, # Uses SDK "env_key": "ANTHROPIC_API_KEY", "task": "both", "models": { "triage": "claude-3-haiku-20240307", "synthesis": "claude-3-5-sonnet-20241022", }, "openai_compatible": False, }, "openai": { "base_url": None, # Uses SDK "env_key": "OPENAI_API_KEY", "task": "both", "models": { "triage": "gpt-4o-mini", "synthesis": "gpt-4o", }, "openai_compatible": False, }, } # ============================================================================ # Provider Chains (Waterfall Order) # ============================================================================ PROVIDER_CHAINS = { VerificationMode.FREE: { LLMTask.TRIAGE: ["groq", "google", "cloudflare"], LLMTask.SYNTHESIS: ["cerebras", "openrouter"], }, VerificationMode.VENICE: { # Venice first, then fall back to free providers LLMTask.TRIAGE: ["venice", "groq", "google", "cloudflare"], LLMTask.SYNTHESIS: ["venice", "cerebras", "openrouter"], }, } # ============================================================================ # LLM Response Model # ============================================================================ class LLMResponse(BaseModel): """Standardized response from LLM calls.""" content: Any # The actual response (parsed if schema provided) raw_content: str # Raw text response model: str # Model that was used provider: str # Provider that was used input_tokens: int # Tokens in prompt output_tokens: int # Tokens in response latency_ms: int # How long the call took cost_usd: float # Estimated cost (0 for free providers) # ============================================================================ # Cost Tracking (only for paid providers) # ============================================================================ MODEL_COSTS = { # Anthropic (per 1M tokens) "claude-3-haiku-20240307": {"input": 0.25, "output": 1.25}, "claude-3-sonnet-20240229": {"input": 3.00, "output": 15.00}, "claude-3-opus-20240229": {"input": 15.00, "output": 75.00}, "claude-3-5-sonnet-20241022": {"input": 3.00, "output": 15.00}, # OpenAI (per 1M tokens) "gpt-4o": {"input": 2.50, "output": 10.00}, "gpt-4o-mini": {"input": 0.15, "output": 0.60}, "gpt-4-turbo": {"input": 10.00, "output": 30.00}, # Free providers - $0 "llama-3.1-8b-instant": {"input": 0, "output": 0}, "llama-3.3-70b": {"input": 0, "output": 0}, "gemini-2.0-flash-lite": {"input": 0, "output": 0}, } def estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float: """Estimate the cost of an LLM call.""" if model not in MODEL_COSTS: return 0.0 # Assume free for unknown models costs = MODEL_COSTS[model] input_cost = (input_tokens / 1_000_000) * costs["input"] output_cost = (output_tokens / 1_000_000) * costs["output"] return input_cost + output_cost # ============================================================================ # Rate Limit Error # ============================================================================ class RateLimitError(Exception): """Raised when a provider returns 429.""" def __init__(self, provider: str, retry_after: Optional[int] = None): self.provider = provider self.retry_after = retry_after super().__init__(f"{provider} rate limited" + (f", retry after {retry_after}s" if retry_after else "")) class ProviderUnavailableError(Exception): """Raised when a provider is not configured or fails.""" pass # ============================================================================ # LLM Client # ============================================================================ class LLMClient: """ Unified LLM client with multi-provider waterfall fallback. Features: - Dual-mode operation (FREE vs VENICE) - Automatic provider fallback on rate limits - OpenAI-compatible API support for most providers - Structured output support (JSON mode) - Cost and latency tracking """ def __init__(self): """Initialize the LLM client with available providers.""" self._clients: dict[str, Any] = {} self._http_client = httpx.AsyncClient(timeout=60.0) # Initialize provider clients based on available API keys self._init_providers() logger.info( "LLM client initialized", available_providers=list(self._clients.keys()), ) def _init_providers(self): """Initialize clients for providers with available API keys.""" # Anthropic (paid fallback) if os.getenv("ANTHROPIC_API_KEY"): self._clients["anthropic"] = anthropic.AsyncAnthropic() # OpenAI (paid fallback) if os.getenv("OPENAI_API_KEY"): self._clients["openai"] = openai.AsyncOpenAI() # Groq (free triage) if os.getenv("GROQ_API_KEY"): self._clients["groq"] = groq.AsyncGroq() # Google AI Studio (free triage) if os.getenv("GOOGLE_AI_API_KEY"): genai.configure(api_key=os.getenv("GOOGLE_AI_API_KEY")) self._clients["google"] = True # Flag that it's configured # Cloudflare (free triage) - uses httpx directly if os.getenv("CLOUDFLARE_API_TOKEN") and os.getenv("CLOUDFLARE_ACCOUNT_ID"): self._clients["cloudflare"] = True # Uses httpx # Cerebras (free synthesis) - OpenAI-compatible if os.getenv("CEREBRAS_API_KEY"): self._clients["cerebras"] = openai.AsyncOpenAI( api_key=os.getenv("CEREBRAS_API_KEY"), base_url="https://api.cerebras.ai/v1", ) # OpenRouter (free synthesis) - OpenAI-compatible if os.getenv("OPENROUTER_API_KEY"): self._clients["openrouter"] = openai.AsyncOpenAI( api_key=os.getenv("OPENROUTER_API_KEY"), base_url="https://openrouter.ai/api/v1", default_headers={ "HTTP-Referer": os.getenv("APP_URL", "https://agentverf.com"), "X-Title": "agent verf", }, ) # Venice (premium) - OpenAI-compatible if os.getenv("VENICE_API_KEY"): self._clients["venice"] = openai.AsyncOpenAI( api_key=os.getenv("VENICE_API_KEY"), base_url="https://api.venice.ai/api/v1", ) def _get_provider_chain( self, task: LLMTask, mode: VerificationMode, ) -> list[str]: """Get the provider chain for a task and mode.""" # Map deep_dive and paraphrase to synthesis/triage effective_task = task if task == LLMTask.DEEP_DIVE: effective_task = LLMTask.SYNTHESIS elif task == LLMTask.PARAPHRASE: effective_task = LLMTask.TRIAGE chain = PROVIDER_CHAINS.get(mode, PROVIDER_CHAINS[VerificationMode.FREE]) providers = chain.get(effective_task, chain.get(LLMTask.SYNTHESIS, [])) # Filter to available providers return [p for p in providers if p in self._clients] def _get_model_for_provider(self, provider: str, task: LLMTask) -> str: """Get the model name for a provider and task.""" config = PROVIDER_CONFIGS.get(provider, {}) models = config.get("models", {}) # Map task to model key if task in [LLMTask.TRIAGE, LLMTask.PARAPHRASE]: return models.get("triage", models.get("synthesis", "unknown")) else: return models.get("synthesis", models.get("triage", "unknown")) async def complete( self, task: LLMTask, messages: list[dict], mode: VerificationMode = VerificationMode.FREE, output_schema: Optional[Type[BaseModel]] = None, system_prompt: Optional[str] = None, temperature: float = 0.3, max_tokens: int = 4096, ) -> LLMResponse: """ Complete an LLM request with automatic provider fallback. Args: task: The task type (determines model size) messages: List of message dicts [{"role": "user", "content": "..."}] mode: FREE or VENICE (determines provider priority) output_schema: Optional Pydantic model for structured output system_prompt: Optional system prompt temperature: Sampling temperature max_tokens: Maximum tokens in response Returns: LLMResponse with content and metadata Raises: Exception: If all providers fail """ providers = self._get_provider_chain(task, mode) if not providers: raise ProviderUnavailableError( f"No providers available for task={task.value}, mode={mode.value}" ) logger.info( "LLM request starting", task=task.value, mode=mode.value, provider_chain=providers, ) start_time = time.time() last_error = None for provider in providers: try: response = await self._call_provider( provider=provider, task=task, messages=messages, system_prompt=system_prompt, temperature=temperature, max_tokens=max_tokens, output_schema=output_schema, ) response.latency_ms = int((time.time() - start_time) * 1000) logger.info( "LLM request completed", task=task.value, provider=provider, model=response.model, latency_ms=response.latency_ms, ) return response except RateLimitError as e: logger.warning( "Provider rate limited, trying next", provider=provider, retry_after=e.retry_after, ) last_error = e continue except Exception as e: logger.warning( "Provider failed, trying next", provider=provider, error=str(e), ) last_error = e continue # All providers failed, try paid fallbacks as last resort if "anthropic" in self._clients and "anthropic" not in providers: try: logger.warning("Falling back to paid Anthropic provider") response = await self._call_anthropic( model=PROVIDER_CONFIGS["anthropic"]["models"].get( "triage" if task == LLMTask.TRIAGE else "synthesis" ), messages=messages, system_prompt=system_prompt, temperature=temperature, max_tokens=max_tokens, output_schema=output_schema, ) response.latency_ms = int((time.time() - start_time) * 1000) return response except Exception as e: logger.error("Anthropic fallback also failed", error=str(e)) raise last_error or Exception("All LLM providers failed") async def _call_provider( self, provider: str, task: LLMTask, messages: list[dict], system_prompt: Optional[str], temperature: float, max_tokens: int, output_schema: Optional[Type[BaseModel]], ) -> LLMResponse: """Route to the appropriate provider handler.""" model = self._get_model_for_provider(provider, task) if provider == "anthropic": return await self._call_anthropic( model, messages, system_prompt, temperature, max_tokens, output_schema ) elif provider == "google": return await self._call_google( model, messages, system_prompt, temperature, max_tokens, output_schema ) elif provider == "cloudflare": return await self._call_cloudflare( model, messages, system_prompt, temperature, max_tokens, output_schema ) elif provider == "groq": return await self._call_groq( model, messages, system_prompt, temperature, max_tokens, output_schema ) elif provider in ["cerebras", "openrouter", "venice"]: return await self._call_openai_compatible( provider, model, messages, system_prompt, temperature, max_tokens, output_schema ) elif provider == "openai": return await self._call_openai( model, messages, system_prompt, temperature, max_tokens, output_schema ) else: raise ProviderUnavailableError(f"Unknown provider: {provider}") async def _call_groq( self, model: str, messages: list[dict], system_prompt: Optional[str], temperature: float, max_tokens: int, output_schema: Optional[Type[BaseModel]], ) -> LLMResponse: """Call Groq API using their native SDK.""" client = self._clients.get("groq") if not client: raise ProviderUnavailableError("Groq client not initialized") # Build messages api_messages = [] if system_prompt: schema_instruction = "" if output_schema: schema_json = json.dumps(output_schema.model_json_schema()) schema_instruction = f"\n\nRespond with valid JSON matching this schema:\n{schema_json}" api_messages.append({"role": "system", "content": system_prompt + schema_instruction}) elif output_schema: schema_json = json.dumps(output_schema.model_json_schema()) api_messages.append({"role": "system", "content": f"Respond with valid JSON matching this schema:\n{schema_json}"}) api_messages.extend(messages) try: response = await client.chat.completions.create( model=model, messages=api_messages, temperature=temperature, max_tokens=max_tokens, response_format={"type": "json_object"} if output_schema else None, ) except groq.RateLimitError as e: raise RateLimitError("groq", getattr(e, "retry_after", None)) raw_content = response.choices[0].message.content content = self._parse_output(raw_content, output_schema) return LLMResponse( content=content, raw_content=raw_content, model=model, provider="groq", input_tokens=response.usage.prompt_tokens, output_tokens=response.usage.completion_tokens, latency_ms=0, cost_usd=0.0, ) async def _call_google( self, model: str, messages: list[dict], system_prompt: Optional[str], temperature: float, max_tokens: int, output_schema: Optional[Type[BaseModel]], ) -> LLMResponse: """Call Google AI Studio (Gemini) API.""" if "google" not in self._clients: raise ProviderUnavailableError("Google AI client not initialized") # Build the prompt prompt_parts = [] if system_prompt: prompt_parts.append(system_prompt) if output_schema: schema_json = json.dumps(output_schema.model_json_schema()) prompt_parts.append(f"\nRespond with valid JSON matching this schema:\n{schema_json}") # Add messages for msg in messages: role = msg.get("role", "user") content = msg.get("content", "") if role == "user": prompt_parts.append(f"\nUser: {content}") elif role == "assistant": prompt_parts.append(f"\nAssistant: {content}") prompt = "\n".join(prompt_parts) try: gemini_model = genai.GenerativeModel(model) response = await asyncio.to_thread( gemini_model.generate_content, prompt, generation_config=genai.GenerationConfig( temperature=temperature, max_output_tokens=max_tokens, ), ) except Exception as e: if "429" in str(e) or "quota" in str(e).lower(): raise RateLimitError("google") raise raw_content = response.text content = self._parse_output(raw_content, output_schema) # Estimate tokens (Google doesn't always return usage) input_tokens = len(prompt) // 4 # Rough estimate output_tokens = len(raw_content) // 4 return LLMResponse( content=content, raw_content=raw_content, model=model, provider="google", input_tokens=input_tokens, output_tokens=output_tokens, latency_ms=0, cost_usd=0.0, ) async def _call_cloudflare( self, model: str, messages: list[dict], system_prompt: Optional[str], temperature: float, max_tokens: int, output_schema: Optional[Type[BaseModel]], ) -> LLMResponse: """Call Cloudflare Workers AI using OpenAI-compatible API.""" if "cloudflare" not in self._clients: raise ProviderUnavailableError("Cloudflare client not initialized") account_id = os.getenv("CLOUDFLARE_ACCOUNT_ID") api_token = os.getenv("CLOUDFLARE_API_TOKEN") base_url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1" # Build messages api_messages = [] if system_prompt: schema_instruction = "" if output_schema: schema_json = json.dumps(output_schema.model_json_schema()) schema_instruction = f"\n\nRespond with valid JSON matching this schema:\n{schema_json}" api_messages.append({"role": "system", "content": system_prompt + schema_instruction}) elif output_schema: schema_json = json.dumps(output_schema.model_json_schema()) api_messages.append({"role": "system", "content": f"Respond with valid JSON matching this schema:\n{schema_json}"}) api_messages.extend(messages) try: response = await self._http_client.post( f"{base_url}/chat/completions", headers={ "Authorization": f"Bearer {api_token}", "Content-Type": "application/json", }, json={ "model": model, "messages": api_messages, "temperature": temperature, "max_tokens": max_tokens, }, ) if response.status_code == 429: raise RateLimitError("cloudflare") response.raise_for_status() data = response.json() except httpx.HTTPStatusError as e: if e.response.status_code == 429: raise RateLimitError("cloudflare") raise raw_content = data["result"]["response"] content = self._parse_output(raw_content, output_schema) return LLMResponse( content=content, raw_content=raw_content, model=model, provider="cloudflare", input_tokens=data.get("result", {}).get("usage", {}).get("prompt_tokens", 0), output_tokens=data.get("result", {}).get("usage", {}).get("completion_tokens", 0), latency_ms=0, cost_usd=0.0, ) async def _call_openai_compatible( self, provider: str, model: str, messages: list[dict], system_prompt: Optional[str], temperature: float, max_tokens: int, output_schema: Optional[Type[BaseModel]], ) -> LLMResponse: """Call OpenAI-compatible APIs (Cerebras, OpenRouter, Venice).""" client = self._clients.get(provider) if not client: raise ProviderUnavailableError(f"{provider} client not initialized") # Build messages api_messages = [] if system_prompt: schema_instruction = "" if output_schema: schema_json = json.dumps(output_schema.model_json_schema()) schema_instruction = f"\n\nRespond with valid JSON matching this schema:\n{schema_json}" api_messages.append({"role": "system", "content": system_prompt + schema_instruction}) elif output_schema: schema_json = json.dumps(output_schema.model_json_schema()) api_messages.append({"role": "system", "content": f"Respond with valid JSON matching this schema:\n{schema_json}"}) api_messages.extend(messages) try: response = await client.chat.completions.create( model=model, messages=api_messages, temperature=temperature, max_tokens=max_tokens, ) except openai.RateLimitError as e: raise RateLimitError(provider, getattr(e, "retry_after", None)) raw_content = response.choices[0].message.content content = self._parse_output(raw_content, output_schema) return LLMResponse( content=content, raw_content=raw_content, model=model, provider=provider, input_tokens=response.usage.prompt_tokens if response.usage else 0, output_tokens=response.usage.completion_tokens if response.usage else 0, latency_ms=0, cost_usd=0.0, ) async def _call_anthropic( self, model: str, messages: list[dict], system_prompt: Optional[str], temperature: float, max_tokens: int, output_schema: Optional[Type[BaseModel]], ) -> LLMResponse: """Call Anthropic's API.""" client = self._clients.get("anthropic") if not client: raise ProviderUnavailableError("Anthropic client not initialized") request_kwargs = { "model": model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens, } if system_prompt: request_kwargs["system"] = system_prompt if output_schema: schema_json = json.dumps(output_schema.model_json_schema()) if "system" in request_kwargs: request_kwargs["system"] += f"\n\nRespond with valid JSON matching this schema:\n{schema_json}" else: request_kwargs["system"] = f"Respond with valid JSON matching this schema:\n{schema_json}" try: response = await client.messages.create(**request_kwargs) except anthropic.RateLimitError as e: raise RateLimitError("anthropic", getattr(e, "retry_after", None)) raw_content = response.content[0].text content = self._parse_output(raw_content, output_schema) return LLMResponse( content=content, raw_content=raw_content, model=model, provider="anthropic", input_tokens=response.usage.input_tokens, output_tokens=response.usage.output_tokens, latency_ms=0, cost_usd=estimate_cost(model, response.usage.input_tokens, response.usage.output_tokens), ) async def _call_openai( self, model: str, messages: list[dict], system_prompt: Optional[str], temperature: float, max_tokens: int, output_schema: Optional[Type[BaseModel]], ) -> LLMResponse: """Call OpenAI's API.""" client = self._clients.get("openai") if not client: raise ProviderUnavailableError("OpenAI client not initialized") api_messages = [] if system_prompt: api_messages.append({"role": "system", "content": system_prompt}) if output_schema: schema_json = json.dumps(output_schema.model_json_schema()) if api_messages: api_messages[0]["content"] += f"\n\nRespond with valid JSON matching this schema:\n{schema_json}" else: api_messages.append({"role": "system", "content": f"Respond with valid JSON matching this schema:\n{schema_json}"}) api_messages.extend(messages) try: response = await client.chat.completions.create( model=model, messages=api_messages, temperature=temperature, max_tokens=max_tokens, response_format={"type": "json_object"} if output_schema else None, ) except openai.RateLimitError as e: raise RateLimitError("openai", getattr(e, "retry_after", None)) raw_content = response.choices[0].message.content content = self._parse_output(raw_content, output_schema) return LLMResponse( content=content, raw_content=raw_content, model=model, provider="openai", input_tokens=response.usage.prompt_tokens, output_tokens=response.usage.completion_tokens, latency_ms=0, cost_usd=estimate_cost(model, response.usage.prompt_tokens, response.usage.completion_tokens), ) def _parse_output( self, raw_content: str, output_schema: Optional[Type[BaseModel]], ) -> Any: """Parse raw content into structured output if schema provided.""" if not output_schema: return raw_content try: json_str = raw_content # Handle markdown code blocks if "```json" in json_str: json_str = json_str.split("```json")[1].split("```")[0] elif "```" in json_str: json_str = json_str.split("```")[1].split("```")[0] return output_schema.model_validate_json(json_str.strip()) except Exception as e: logger.warning("Failed to parse structured output", error=str(e)) return raw_content async def close(self): """Close the HTTP client.""" await self._http_client.aclose() # ============================================================================ # Singleton Instance # ============================================================================ _llm_client: Optional[LLMClient] = None def get_llm_client() -> LLMClient: """Get the singleton LLM client instance.""" global _llm_client if _llm_client is None: _llm_client = LLMClient() return _llm_client