logic-engine / ace /providers /registry.py
ghostdrive1's picture
Upload folder using huggingface_hub
116524e verified
Raw
History Blame Contribute Delete
10.3 kB
"""Model registry — discovery, validation, and provider detection.
Delegates entirely to LiteLLM for provider detection, environment
validation, and model discovery. No external API calls for discovery —
only ``validate_connection`` makes a real (tiny) LLM call.
"""
from __future__ import annotations
import logging
import os
import time
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
def _litellm():
"""Return the litellm module, importing it on first call."""
global _litellm_mod
try:
return _litellm_mod # type: ignore[name-defined]
except NameError:
pass
try:
import litellm as _mod
_litellm_mod = _mod
return _mod
except ImportError:
_litellm_mod = None
return None
# Example model strings per provider (for user guidance in the CLI)
PROVIDER_MODEL_EXAMPLES: dict[str, str] = {
"openai": "gpt-4o-mini",
"anthropic": "claude-sonnet-4-20250514",
"gemini": "gemini/gemini-2.0-flash",
"deepseek": "deepseek/deepseek-chat",
"groq": "groq/llama-3.1-70b",
"bedrock": "us.anthropic.claude-haiku-4-5-20251001-v1:0",
"ollama": "ollama/llama2",
"azure": "azure/gpt-4",
"openrouter": "openrouter/anthropic/claude-3.5-sonnet",
}
# ---------------------------------------------------------------------------
# Data types
# ---------------------------------------------------------------------------
@dataclass
class ValidationResult:
"""Result of a model + key validation."""
success: bool
model: str = ""
provider: str = ""
latency_ms: int = 0
error: str = ""
@dataclass
class ModelInfo:
"""Metadata about a model from LiteLLM's registry."""
model: str
provider: str
max_input_tokens: int | None = None
max_output_tokens: int | None = None
input_cost_per_m: float | None = None # per million tokens
output_cost_per_m: float | None = None
key_found: bool = False
# ---------------------------------------------------------------------------
# Provider detection (delegated to LiteLLM)
# ---------------------------------------------------------------------------
def get_provider(model: str) -> str:
"""Return the provider name for a model string, or 'unknown'."""
ll = _litellm()
if ll is None:
raise ImportError("LiteLLM is required for model validation.")
try:
_, provider, _, _ = ll.get_llm_provider(model)
except Exception as e:
logger.debug(
"Could not detect provider for %r (%s): %s", model, type(e).__name__, e
)
provider = "unknown"
return provider
def get_missing_keys(model: str) -> list[str]:
"""Return env var names that LiteLLM says are missing for *model*."""
ll = _litellm()
if ll is None:
return []
try:
result = ll.validate_environment(model=model)
return result.get("missing_keys", [])
except Exception as e:
logger.debug(
"Could not validate environment for %r (%s): %s", model, type(e).__name__, e
)
return []
def keys_are_set(model: str) -> bool:
"""Check whether the required keys for *model* are in the environment."""
return len(get_missing_keys(model)) == 0
# ---------------------------------------------------------------------------
# Connection validation
# ---------------------------------------------------------------------------
def validate_connection(model: str, api_key: str | None = None) -> ValidationResult:
"""Make a minimal LLM call to verify model + key work.
Sends a 3-token request ("Say 'ok'") to confirm authentication,
model availability, and network connectivity.
Args:
model: LiteLLM model string.
api_key: Explicit key, or None to use environment.
"""
ll = _litellm()
if ll is None:
return ValidationResult(
success=False, model=model, error="LiteLLM is not installed."
)
call_params: dict[str, Any] = {
"model": model,
"messages": [{"role": "user", "content": "Say 'ok'"}],
"max_tokens": 3,
"temperature": 0.0,
"timeout": 15,
}
if api_key:
call_params["api_key"] = api_key
# Suppress LiteLLM's noisy debug output during validation
prev_verbose = getattr(ll, "suppress_debug_info", False)
ll.suppress_debug_info = True
start = time.monotonic()
try:
response = ll.completion(**call_params)
elapsed_ms = int((time.monotonic() - start) * 1000)
provider = "unknown"
if hasattr(response, "_hidden_params"):
provider = response._hidden_params.get("custom_llm_provider", "unknown")
return ValidationResult(
success=True,
model=model,
provider=provider,
latency_ms=elapsed_ms,
)
except ll.AuthenticationError:
return ValidationResult(success=False, model=model, error="Invalid API key.")
except ll.NotFoundError:
return ValidationResult(
success=False,
model=model,
error=f"Model '{model}' not found at the provider.",
)
except ll.APIConnectionError:
return ValidationResult(
success=False,
model=model,
error="Could not connect to the provider.",
)
except Exception as e:
return ValidationResult(success=False, model=model, error=str(e))
finally:
ll.suppress_debug_info = prev_verbose
# ---------------------------------------------------------------------------
# Model search / discovery
# ---------------------------------------------------------------------------
PROVIDER_KEY_ENV: dict[str, str | list[str]] = {
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"azure": "AZURE_API_KEY",
"gemini": "GEMINI_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
"groq": "GROQ_API_KEY",
"bedrock": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION_NAME"],
"bedrock_converse": [
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_REGION_NAME",
],
"vertex_ai": "GOOGLE_APPLICATION_CREDENTIALS",
"cohere": "COHERE_API_KEY",
"mistral": "MISTRAL_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
"together_ai": "TOGETHERAI_API_KEY",
"fireworks_ai": "FIREWORKS_AI_API_KEY",
"replicate": "REPLICATE_API_KEY",
"huggingface": "HUGGINGFACE_API_KEY",
"perplexity": "PERPLEXITYAI_API_KEY",
"anyscale": "ANYSCALE_API_KEY",
}
_PROVIDER_ALT_KEYS: dict[str, list[str]] = {
"bedrock_converse": ["AWS_BEARER_TOKEN_BEDROCK"],
}
def _quick_key_check(provider: str) -> bool:
"""Fast check: are the required env vars set for this provider?"""
env_var = PROVIDER_KEY_ENV.get(provider)
if env_var is not None:
if isinstance(env_var, list):
if all(bool(os.environ.get(v)) for v in env_var):
return True
elif bool(os.environ.get(env_var)):
return True
# Alternative auth (e.g. bearer token for Bedrock)
alt_vars = _PROVIDER_ALT_KEYS.get(provider)
if alt_vars:
return any(bool(os.environ.get(v)) for v in alt_vars)
return False
def search_models(
query: str = "",
provider: str | None = None,
chat_only: bool = True,
limit: int = 20,
) -> tuple[list[ModelInfo], int]:
"""Search LiteLLM's model registry.
Args:
query: Substring to match against model names.
provider: Filter to a specific provider.
chat_only: Only return chat/completion models.
limit: Maximum results.
Returns:
(results, total_matches) — results capped at *limit*,
total_matches is the full count of matching models.
"""
ll = _litellm()
if ll is None:
return [], 0
results: list[ModelInfo] = []
total = 0
terms = query.lower().split() if query else []
for model_id, info in ll.model_cost.items():
if chat_only and info.get("mode") != "chat":
continue
if provider and info.get("litellm_provider") != provider:
continue
model_lower = model_id.lower()
if terms and not all(t in model_lower for t in terms):
continue
total += 1
if len(results) >= limit:
continue # keep counting total
prov = info.get("litellm_provider", "unknown")
input_cost = info.get("input_cost_per_token")
output_cost = info.get("output_cost_per_token")
# Fast key check — just look for the provider's standard env var.
# We avoid litellm.validate_environment() here because it's slow
# and some providers (e.g. GitHub Copilot) trigger interactive auth.
key_found = _quick_key_check(prov)
results.append(
ModelInfo(
model=model_id,
provider=prov,
max_input_tokens=info.get("max_input_tokens"),
max_output_tokens=info.get("max_output_tokens"),
input_cost_per_m=input_cost * 1_000_000 if input_cost else None,
output_cost_per_m=output_cost * 1_000_000 if output_cost else None,
key_found=key_found,
)
)
return results, total
def suggest_models(typo: str, limit: int = 5) -> list[str]:
"""Return model names similar to *typo* (simple substring matching)."""
ll = _litellm()
if ll is None:
return []
candidates: list[str] = []
typo_lower = typo.lower()
for model_id, info in ll.model_cost.items():
if info.get("mode") != "chat":
continue
if model_id.lower().startswith(typo_lower):
candidates.append(model_id)
elif typo_lower in model_id.lower():
candidates.append(model_id)
if len(candidates) >= limit:
break
return candidates