Spaces:
Running
Running
Download app/providers.py from codey-lab/Universal-AI-Hub: direct link, hf CLI and curl.
- Browser
- Download file 38.6 kB
-
https://huggingface.co/spaces/codey-lab/Universal-AI-Hub/resolve/main/app/providers.py
- Command line
-
hf download hf://spaces/codey-lab/Universal-AI-Hub/app/providers.py
-
curl -L -o providers.py https://huggingface.co/spaces/codey-lab/Universal-AI-Hub/resolve/main/app/providers.py
38.6 kB
| # ============================================================================= | |
| # app/providers.py | |
| # 09.03.2026 | updated 23.03.2026 | reworked 10.11.2026 | |
| # LLM + Search Provider Registry + Fallback Chain | |
| # Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture | |
| # Copyright 2026 - Volkan KΓΌcΓΌkbudak | |
| # Apache License V. 2 + ESOL 1.1 | |
| # Repo: https://github.com/VolkanSah/Universal-MCP-Hub-sandboxed | |
| # ============================================================================= | |
| # ARCHITECTURE NOTE: | |
| # This file lives exclusively in app/ and is ONLY started by app/app.py. | |
| # NO direct access to fundaments/*, .env, or Guardian (main.py). | |
| # All config comes from app/.pyfun via app/config.py. | |
| # | |
| # PROVIDER PRINCIPLE: | |
| # No key = no provider = no tool = no crash. | |
| # Server always starts, just with fewer providers. | |
| # | |
| # HOW TO ADD A NEW LLM PROVIDER: | |
| # OpenAI-compatible API (Mistral, OpenAI, xAI, Groq, Together, DeepSeek, ...): | |
| # β ONLY a [LLM_PROVIDER.xyz] block in app/.pyfun with type = "openai_compat" | |
| # + the key in .env. NO code needed. See examples in .pyfun. | |
| # Different API format (like Anthropic / Gemini): | |
| # 1. Add a class below (subclass BaseProvider, implement complete()) | |
| # 2. Register it in _TYPE_CLASSES | |
| # 3. Add the [LLM_PROVIDER.xyz] block in .pyfun with type = "<your type>" | |
| # | |
| # FALLBACK CHAIN: | |
| # Defined in .pyfun per provider via fallback_to field. | |
| # anthropic β fails β gemini β fails β openrouter β fails β RuntimeError | |
| # Visited set prevents infinite loops. | |
| # A model chosen explicitly applies to the FIRST provider only β every | |
| # fallback provider uses its own default_model (model IDs are provider-specific). | |
| # | |
| # LIMITS (from .pyfun [HUB_LIMITS]): | |
| # MAX_PARALLEL_REQUESTS β semaphore around every provider call | |
| # RETRY_COUNT / RETRY_DELAY_SEC β attempts per provider on 429/5xx/connect errors | |
| # (timeouts are NOT retried β straight to fallback) | |
| # MAX_TOKENS_CAP β hard ceiling for max_tokens per call (0 = off) | |
| # Per-model max_output_tokens (manual or auto from pricing) clamps further. | |
| # | |
| # USAGE / COST: | |
| # complete() returns (text, usage). llm_complete() logs usage + estimated cost | |
| # into db_sync (usage_log) after every successful call. Never breaks a call. | |
| # | |
| # SECURITY NOTE: | |
| # API keys are NEVER logged or included in exception messages. | |
| # All errors are sanitized before propagation β only HTTP status codes | |
| # and safe_url (query params stripped) are ever exposed in logs. | |
| # | |
| # CACHING NOTE: | |
| # Anthropic β prompt_caching (cache_control: ephemeral) for long system/user prompts. | |
| # Enable per provider in .pyfun: supports_cache = "true" | |
| # Gemini β implicit caching (automatic, server-side). supports_cache = log hint only. | |
| # | |
| # DEPENDENCY CHAIN (app/* only, no fundaments!): | |
| # config.py β parses app/.pyfun β single source of truth | |
| # providers.py β LLM + Search registry + fallback chain | |
| # tools.py β calls providers.llm_complete() / providers.search() | |
| # mcp.py β calls providers.list_active_llm() / list_active_search() | |
| # ============================================================================= | |
| import os | |
| import re | |
| import time | |
| import asyncio | |
| import logging | |
| import httpx | |
| from html.parser import HTMLParser | |
| from urllib.parse import urlparse, parse_qs | |
| from typing import Optional, Tuple, Dict, Any | |
| from . import config | |
| from . import models as models_mod | |
| from . import pricing | |
| from . import db_sync | |
| logging.getLogger("httpx").setLevel(logging.WARNING) | |
| logger = logging.getLogger("providers") | |
| # ============================================================================= | |
| # SECTION 0 β Errors, Limits, Retry | |
| # ============================================================================= | |
| class ProviderError(RuntimeError): | |
| """Sanitized provider error. RuntimeError subclass β app.py maps it to 502.""" | |
| def __init__(self, message: str, status: Optional[int] = None): | |
| super().__init__(message) | |
| self.status = status | |
| _TRANSIENT_STATUS = {408, 425, 429, 500, 502, 503, 504, 529} | |
| def _limit(key: str, default, cast=int): | |
| """Read one value from .pyfun [HUB_LIMITS] with safe fallback.""" | |
| try: | |
| return cast(config.get_limits().get(key, default)) | |
| except (TypeError, ValueError): | |
| return cast(default) | |
| _sem: Optional[asyncio.Semaphore] = None | |
| def _get_sem() -> asyncio.Semaphore: | |
| """One global semaphore (MAX_PARALLEL_REQUESTS) β created lazily inside the loop.""" | |
| global _sem | |
| if _sem is None: | |
| _sem = asyncio.Semaphore(max(1, _limit("MAX_PARALLEL_REQUESTS", 5))) | |
| return _sem | |
| def _is_transient(e: Exception) -> bool: | |
| """429/5xx and connect errors are worth a retry. Timeouts are NOT (60s x N = forever).""" | |
| if isinstance(e, ProviderError): | |
| return e.status in _TRANSIENT_STATUS | |
| return isinstance(e, httpx.ConnectError) | |
| async def _with_retry(name: str, call): | |
| """ | |
| Run `call()` (async callable) under the global semaphore. | |
| Attempts = RETRY_COUNT (incl. the first), flat RETRY_DELAY_SEC between them. | |
| """ | |
| attempts = max(1, _limit("RETRY_COUNT", 3)) | |
| delay = max(0.0, _limit("RETRY_DELAY_SEC", 2, float)) | |
| for i in range(1, attempts + 1): | |
| try: | |
| async with _get_sem(): | |
| return await call() | |
| except Exception as e: | |
| if i >= attempts or not _is_transient(e): | |
| raise | |
| logger.info(f"'{name}' transient error ({type(e).__name__}) β retry {i}/{attempts - 1} in {delay}s") | |
| await asyncio.sleep(delay) | |
| def _usage(tokens_in: Any, tokens_out: Any) -> Dict[str, Optional[int]]: | |
| """Normalize token counts to int|None.""" | |
| def _i(v): | |
| try: | |
| return int(v) | |
| except (TypeError, ValueError): | |
| return None | |
| return {"input": _i(tokens_in), "output": _i(tokens_out)} | |
| # ============================================================================= | |
| # SECTION 1 β Base Provider | |
| # Shared HTTP logic β implemented ONCE, reused by all providers. | |
| # ============================================================================= | |
| class BaseProvider: | |
| """ | |
| Base class for all LLM providers. | |
| Subclasses only implement complete() β HTTP logic lives here. | |
| complete() returns (text, usage) with usage = {"input": int|None, "output": int|None}. | |
| """ | |
| def __init__(self, name: str, cfg: dict): | |
| self.name = name | |
| self.cfg = cfg | |
| self.key = os.getenv(cfg.get("env_key", "")) | |
| self.base_url = cfg.get("base_url", "") | |
| self.fallback = cfg.get("fallback_to", "") | |
| self.timeout = int(config.get_limits().get("REQUEST_TIMEOUT_SEC", "60")) | |
| self.model = cfg.get("default_model", "") | |
| self.supports_cache = cfg.get("supports_cache", "false").lower() == "true" | |
| # Safe key hint for debug logs β never log the full key | |
| self._key_hint = ( | |
| f"{self.key[:4]}...{self.key[-4:]}" | |
| if self.key and len(self.key) > 8 | |
| else "***" | |
| ) | |
| async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024, | |
| system: str = None) -> Tuple[str, Dict[str, Optional[int]]]: | |
| """Override in each provider subclass.""" | |
| raise NotImplementedError | |
| async def _post(self, url: str, headers: dict, payload: dict) -> dict: | |
| """ | |
| Shared HTTP POST β used by all providers. | |
| Raises ProviderError with sanitized message on non-2xx responses. | |
| API keys are never included in raised exceptions or log output. | |
| """ | |
| safe_url = url.split("?")[0] # strip query params (may contain API keys) | |
| logger.debug(f"POST β {safe_url}") | |
| async with httpx.AsyncClient() as client: | |
| r = await client.post( | |
| url, | |
| headers=headers, | |
| json=payload, | |
| timeout=self.timeout, | |
| ) | |
| try: | |
| r.raise_for_status() | |
| except httpx.HTTPStatusError as e: | |
| # Sanitize: only status code + safe_url, never headers or body | |
| raise ProviderError( | |
| f"HTTP {e.response.status_code} from {safe_url}", | |
| e.response.status_code, | |
| ) from None | |
| return r.json() | |
| # ============================================================================= | |
| # SECTION 2 β LLM Provider Implementations | |
| # Only the API-specific parsing logic differs per provider. | |
| # ============================================================================= | |
| # --- OpenAI-compatible (GENERIC β no code needed for new providers) ----------- | |
| class OpenAICompatProvider(BaseProvider): | |
| """ | |
| Generic OpenAI-compatible /chat/completions provider. | |
| Covers smollm, openrouter, huggingface, mistral, openai, xai, groq, ... | |
| Everything provider-specific lives in the .pyfun block: | |
| [LLM_PROVIDER.mistral] | |
| active = "true" | |
| type = "openai_compat" | |
| base_url = "https://api.mistral.ai/v1" | |
| env_key = "MISTRAL_API_KEY" | |
| default_model = "mistral-small-latest" | |
| fallback_to = "" | |
| # optional: | |
| # auth_header = "Authorization" (default) | |
| # auth_scheme = "Bearer" (default; "" = send the raw key) | |
| # chat_path = "/chat/completions" (default) | |
| # header.X-Foo = "{key}" extra header; {key} = API key, | |
| # {env:NAME|fallback} = ENV value | |
| [LLM_PROVIDER.mistral_END] | |
| """ | |
| _PLACEHOLDER = re.compile(r"\{(key|env:([A-Za-z0-9_]+)(?:\|([^}]*))?)\}") | |
| def __init__(self, name: str, cfg: dict): | |
| super().__init__(name, cfg) | |
| self.auth_header = cfg.get("auth_header", "Authorization") | |
| self.auth_scheme = cfg.get("auth_scheme", "Bearer") | |
| self.chat_path = cfg.get("chat_path", "/chat/completions") | |
| def _fill(self, value: str) -> str: | |
| def sub(m): | |
| if m.group(1) == "key": | |
| return self.key or "" | |
| return os.getenv(m.group(2)) or (m.group(3) or "") | |
| return self._PLACEHOLDER.sub(sub, value) | |
| def _headers(self) -> dict: | |
| headers = { | |
| self.auth_header: f"{self.auth_scheme} {self.key}".strip(), | |
| "content-type": "application/json", | |
| } | |
| for k, v in self.cfg.items(): | |
| if k.startswith("header."): | |
| headers[k[len("header."):]] = self._fill(v) | |
| return headers | |
| async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024, | |
| system: str = None): | |
| messages = [] | |
| if system: | |
| messages.append({"role": "system", "content": system}) | |
| messages.append({"role": "user", "content": prompt}) | |
| data = await self._post( | |
| f"{self.base_url}{self.chat_path}", | |
| headers=self._headers(), | |
| payload={ | |
| "model": model or self.model, | |
| "max_tokens": max_tokens, | |
| "messages": messages, | |
| }, | |
| ) | |
| text = data["choices"][0]["message"]["content"] | |
| if text is None: | |
| raise ProviderError("empty content in response") | |
| u = data.get("usage") or {} | |
| return text, _usage(u.get("prompt_tokens"), u.get("completion_tokens")) | |
| # --- Anthropic ---------------------------------------------------------------- | |
| class AnthropicProvider(BaseProvider): | |
| """ | |
| Anthropic Claude API β Messages endpoint. | |
| Prompt Caching (supports_cache = "true" in .pyfun): | |
| Uses cache_control: ephemeral on system prompt and long user prompts. | |
| Cache TTL: 5 minutes, extended on each cache hit. | |
| Min tokens to cache: ~1024 (Anthropic requirement). | |
| Cost: cache write ~25% more, cache read ~90% less than normal input. | |
| Usage: input = input + cache_creation + cache_read tokens (upper bound for cost). | |
| .pyfun block: | |
| [LLM_PROVIDER.anthropic] | |
| active = "true" | |
| type = "anthropic" | |
| base_url = "https://api.anthropic.com/v1" | |
| env_key = "ANTHROPIC_API_KEY" | |
| api_version_header = "2023-06-01" | |
| default_model = "claude-haiku-5-5" | |
| supports_cache = "true" | |
| fallback_to = "gemini" | |
| [LLM_PROVIDER.anthropic_END] | |
| """ | |
| # Rough chars-per-token estimate β avoids importing tiktoken in sandbox | |
| _CHARS_PER_TOKEN = 4 | |
| _CACHE_MIN_TOKENS = 1024 | |
| def __init__(self, name: str, cfg: dict): | |
| super().__init__(name, cfg) | |
| self.api_version = cfg.get("api_version_header", "2023-06-01") | |
| def _is_cacheable(self, text: str) -> bool: | |
| """Estimate if text is long enough to benefit from caching.""" | |
| return len(text) >= self._CACHE_MIN_TOKENS * self._CHARS_PER_TOKEN | |
| async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024, | |
| system: str = None): | |
| headers = { | |
| "x-api-key": self.key, | |
| "anthropic-version": self.api_version, | |
| "content-type": "application/json", | |
| } | |
| # --- Build user content (cache_control if enabled + long enough) --- | |
| if self.supports_cache and self._is_cacheable(prompt): | |
| user_content = [{"type": "text", "text": prompt, | |
| "cache_control": {"type": "ephemeral"}}] | |
| headers["anthropic-beta"] = "prompt-caching-2024-07-31" | |
| logger.debug("Anthropic: prompt cache_control applied to user message.") | |
| else: | |
| user_content = prompt # short prompt β plain string, no overhead | |
| payload = { | |
| "model": model or self.model, | |
| "max_tokens": max_tokens, | |
| "messages": [{"role": "user", "content": user_content}], | |
| } | |
| # --- Optional system prompt with cache_control --- | |
| if system: | |
| if self.supports_cache and self._is_cacheable(system): | |
| payload["system"] = [{"type": "text", "text": system, | |
| "cache_control": {"type": "ephemeral"}}] | |
| headers["anthropic-beta"] = "prompt-caching-2024-07-31" | |
| logger.debug("Anthropic: prompt cache_control applied to system prompt.") | |
| else: | |
| payload["system"] = system | |
| data = await self._post(f"{self.base_url}/messages", headers, payload) | |
| text = data["content"][0]["text"] | |
| u = data.get("usage") or {} | |
| tin = None | |
| if u: | |
| tin = sum(int(u.get(k) or 0) for k in | |
| ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")) | |
| return text, _usage(tin, u.get("output_tokens")) | |
| # --- Gemini ------------------------------------------------------------------ | |
| class GeminiProvider(BaseProvider): | |
| """ | |
| Google Gemini API β generateContent endpoint. | |
| Implicit Caching (always active on Gemini side, no code needed). | |
| Explicit Context Caching is NOT implemented (only worth it for 32k+ static contexts). | |
| .pyfun block: | |
| [LLM_PROVIDER.gemini] | |
| active = "true" | |
| type = "gemini" | |
| base_url = "https://generativelanguage.googleapis.com/v1beta" | |
| env_key = "GEMINI_API_KEY" | |
| default_model = "gemini-2.5-flash" | |
| supports_cache = "true" | |
| fallback_to = "openrouter" | |
| [LLM_PROVIDER.gemini_END] | |
| """ | |
| async def complete(self, prompt: str, model: str = None, max_tokens: int = 1024, | |
| system: str = None): | |
| m = model or self.model | |
| safe_url = f"{self.base_url}/models/{m}:generateContent" | |
| if self.supports_cache: | |
| logger.debug(f"Gemini: implicit caching active for model {m} (server-side, automatic).") | |
| body = { | |
| "contents": [{"parts": [{"text": prompt}]}], | |
| "generationConfig": {"maxOutputTokens": max_tokens}, | |
| } | |
| if system: | |
| body["systemInstruction"] = {"parts": [{"text": system}]} | |
| async with httpx.AsyncClient() as client: | |
| r = await client.post( | |
| safe_url, | |
| params={"key": self.key}, # key in query param, never in logs | |
| json=body, | |
| timeout=self.timeout, | |
| ) | |
| try: | |
| r.raise_for_status() | |
| except httpx.HTTPStatusError as e: | |
| raise ProviderError( | |
| f"HTTP {e.response.status_code} from {safe_url}", | |
| e.response.status_code, | |
| ) from None | |
| data = r.json() | |
| text = data["candidates"][0]["content"]["parts"][0]["text"] | |
| meta = data.get("usageMetadata") or {} | |
| return text, _usage(meta.get("promptTokenCount"), meta.get("candidatesTokenCount")) | |
| # ============================================================================= | |
| # SECTION 3 β Provider Registry | |
| # Built from .pyfun [LLM_PROVIDERS] at initialize(). | |
| # Handler is chosen by the `type` field of the block (default: the block name). | |
| # type = "openai_compat" β OpenAICompatProvider (generic, no code per provider) | |
| # type = "anthropic" β AnthropicProvider | |
| # type = "gemini" β GeminiProvider | |
| # New API format = new class above + one line here. | |
| # ============================================================================= | |
| _TYPE_CLASSES = { | |
| "openai_compat": OpenAICompatProvider, | |
| "anthropic": AnthropicProvider, | |
| "gemini": GeminiProvider, | |
| } | |
| _registry: dict = {} | |
| def initialize() -> None: | |
| """ | |
| Build provider registries from .pyfun [LLM_PROVIDERS] + [SEARCH_PROVIDERS]. | |
| Called once by mcp.py during startup sequence. | |
| Skips providers with missing ENV keys β no crash, just fewer tools. | |
| """ | |
| global _registry | |
| active = config.get_active_llm_providers() | |
| for name, cfg in active.items(): | |
| env_key = cfg.get("env_key", "") | |
| if not env_key or not os.getenv(env_key): | |
| logger.info(f"Provider '{name}' skipped β ENV key not set.") | |
| continue | |
| ptype = (cfg.get("type") or name).strip().lower() | |
| cls = _TYPE_CLASSES.get(ptype) | |
| if not cls: | |
| logger.info(f"Provider '{name}' has no handler (type '{ptype}') β skipped.") | |
| continue | |
| _registry[name] = cls(name, cfg) | |
| cache_hint = " [cache: ON]" if cfg.get("supports_cache", "false") == "true" else "" | |
| logger.info(f"Provider registered: {name} (type: {ptype}){cache_hint}") | |
| # Search providers β same key-gating principle | |
| _initialize_search() | |
| # ============================================================================= | |
| # SECTION 4 β LLM Execution + Fallback Chain | |
| # ============================================================================= | |
| def provider_for_model(model: str) -> Optional[str]: | |
| """ | |
| Provider that owns `model` according to .pyfun [MODELS] (provider field). | |
| None if the model is unknown β caller keeps its default provider. | |
| """ | |
| if not model: | |
| return None | |
| entry = models_mod.get(model) or {} | |
| return entry.get("provider") or None | |
| def _clamp_tokens(model: str, max_tokens: int) -> int: | |
| """Hard cap (MAX_TOKENS_CAP) + per-model max_output_tokens (manual or auto-pricing).""" | |
| try: | |
| n = max(1, int(max_tokens)) | |
| except (TypeError, ValueError): | |
| n = 1024 | |
| cap = _limit("MAX_TOKENS_CAP", 8192) | |
| if cap > 0: | |
| n = min(n, cap) | |
| lim = pricing.max_output_for(model) | |
| if lim: | |
| n = min(n, lim) | |
| return n | |
| async def _record_usage(tool: str, provider: str, model: str, usage: dict, latency_ms: int) -> None: | |
| """Log usage + estimated cost. NEVER raises β accounting must not break a call.""" | |
| try: | |
| tin, tout = usage.get("input"), usage.get("output") | |
| cost = None | |
| if tin is not None or tout is not None: | |
| cost = await pricing.cost_for(model, tin, tout) | |
| await db_sync.log_usage(tool, provider, model, tin, tout, cost, latency_ms) | |
| cost_txt = f"${cost:.6f}" if cost is not None else "n/a" | |
| logger.info(f"Usage: {provider}/{model} in={tin} out={tout} costβ{cost_txt} ({latency_ms} ms)") | |
| except Exception as e: | |
| logger.debug(f"Usage logging skipped: {type(e).__name__}") | |
| async def llm_complete( | |
| prompt: str, | |
| provider_name: str = None, | |
| model: str = None, | |
| max_tokens: int = 1024, | |
| system: str = None, | |
| tool_name: str = "llm_complete", | |
| ) -> str: | |
| """ | |
| Send prompt to LLM provider with automatic fallback chain. | |
| Fallback order is defined in .pyfun via fallback_to field. | |
| Raises RuntimeError if all providers in the chain fail. | |
| Args: | |
| prompt: Input text to send to the model. | |
| provider_name: Provider override. Unknown / non-LLM names are ignored. | |
| If only `model` is given, the provider is derived from [MODELS]. | |
| model: Model override β applies to the FIRST provider only. | |
| max_tokens: Max tokens in response (clamped, see header). | |
| system: Optional system prompt β sent as real system prompt to every provider. | |
| tool_name: Only used for the usage log. | |
| Returns: | |
| "[provider] text" | |
| """ | |
| model = (model or "").strip() or None | |
| if provider_name and provider_name not in config.get_llm_providers(): | |
| logger.debug(f"'{provider_name}' is not an LLM provider β using default.") | |
| provider_name = None | |
| if not provider_name: | |
| provider_name = provider_for_model(model) | |
| if not provider_name: | |
| tools_cfg = config.get_active_tools() | |
| provider_name = tools_cfg.get("llm_complete", {}).get("default_provider", "anthropic") | |
| visited = set() | |
| current = provider_name | |
| first = True | |
| while current and current not in visited: | |
| visited.add(current) | |
| provider = _registry.get(current) | |
| use_model = model if first else None # explicit model belongs to the first provider only | |
| first = False | |
| if not provider: | |
| logger.warning(f"Provider '{current}' not in registry β trying fallback.") | |
| else: | |
| m = use_model or provider.model | |
| mt = _clamp_tokens(m, max_tokens) | |
| try: | |
| t0 = time.monotonic() | |
| text, usage = await _with_retry( | |
| current, | |
| lambda p=provider, um=use_model, n=mt: p.complete(prompt, um, n, system=system), | |
| ) | |
| latency_ms = int((time.monotonic() - t0) * 1000) | |
| logger.info(f"Response from provider: '{current}'") | |
| await _record_usage(tool_name, current, m, usage, latency_ms) | |
| return f"[{current}] {text}" | |
| except Exception as e: | |
| # Log only exception type + sanitized message β never raw headers/keys/bodies | |
| logger.warning( | |
| f"Provider '{current}' failed: {type(e).__name__}: {e} β trying fallback." | |
| ) | |
| cfg = config.get_active_llm_providers().get(current, {}) | |
| current = cfg.get("fallback_to", "") | |
| raise RuntimeError("All providers failed β no fallback available.") | |
| # Alias β used internally by tools.py | |
| complete = llm_complete | |
| # ============================================================================= | |
| # SECTION 5 β Search Execution | |
| # Same pattern as LLM providers: class + _SEARCH_PROVIDER_CLASSES + fallback. | |
| # Adding a search provider = add class + register + .pyfun block. Nothing else. | |
| # ============================================================================= | |
| class BaseSearchProvider: | |
| """ | |
| Base class for all search providers. | |
| Subclasses only implement search() β HTTP logic lives here. | |
| """ | |
| def __init__(self, name: str, cfg: dict): | |
| self.name = name | |
| self.key = os.getenv(cfg.get("env_key", "")) | |
| self.base_url = cfg.get("base_url", "") | |
| self.fallback = cfg.get("fallback_to", "") | |
| self.timeout = int(config.get_limits().get("SEARCH_TIMEOUT_SEC", "30")) | |
| self.default_results = int(cfg.get("default_results", "5")) | |
| self.max_results = int(cfg.get("max_results", "20")) | |
| self._key_hint = ( | |
| f"{self.key[:4]}...{self.key[-4:]}" | |
| if self.key and len(self.key) > 8 | |
| else "***" | |
| ) | |
| async def search(self, query: str, max_results: int) -> str: | |
| """Override in each search provider subclass.""" | |
| raise NotImplementedError | |
| async def _get(self, url: str, headers: dict, params: dict) -> dict: | |
| """Shared HTTP GET with sanitized errors (never leak keys/bodies).""" | |
| safe_url = url.split("?")[0] | |
| async with httpx.AsyncClient() as client: | |
| r = await client.get(url, headers=headers, params=params, timeout=self.timeout) | |
| try: | |
| r.raise_for_status() | |
| except httpx.HTTPStatusError as e: | |
| raise ProviderError(f"HTTP {e.response.status_code} from {safe_url}", e.response.status_code) from None | |
| return r.json() | |
| async def _get_text(self, url: str, headers: dict, params: dict) -> str: | |
| """Shared HTTP GET returning the raw body (HTML) β sanitized errors.""" | |
| safe_url = url.split("?")[0] | |
| async with httpx.AsyncClient() as client: | |
| r = await client.get(url, headers=headers, params=params, timeout=self.timeout) | |
| try: | |
| r.raise_for_status() | |
| except httpx.HTTPStatusError as e: | |
| raise ProviderError(f"HTTP {e.response.status_code} from {safe_url}", e.response.status_code) from None | |
| return r.text | |
| async def _post(self, url: str, headers: dict, payload: dict) -> dict: | |
| """Shared HTTP POST with sanitized errors.""" | |
| safe_url = url.split("?")[0] | |
| async with httpx.AsyncClient() as client: | |
| r = await client.post(url, headers=headers, json=payload, timeout=self.timeout) | |
| try: | |
| r.raise_for_status() | |
| except httpx.HTTPStatusError as e: | |
| raise ProviderError(f"HTTP {e.response.status_code} from {safe_url}", e.response.status_code) from None | |
| return r.json() | |
| def _clip(self, max_results: int) -> int: | |
| """Clamp requested results into [1, max_results].""" | |
| try: | |
| n = int(max_results) | |
| except (TypeError, ValueError): | |
| n = self.default_results | |
| return max(1, min(n, self.max_results)) | |
| # --- Brave Search --------------------------------------------------------------- | |
| class BraveSearchProvider(BaseSearchProvider): | |
| """ | |
| Brave Search API β web search endpoint. | |
| Auth via X-Subscription-Token header (NOT Bearer!). | |
| .pyfun block: | |
| [SEARCH_PROVIDER.brave] | |
| active = "true" | |
| base_url = "https://api.search.brave.com/res/v1/web/search" | |
| env_key = "BRAVE_API_KEY" | |
| default_results = "5" | |
| max_results = "20" | |
| fallback_to = "tavily" | |
| [SEARCH_PROVIDER.brave_END] | |
| """ | |
| async def search(self, query: str, max_results: int = 5) -> str: | |
| data = await self._get( | |
| self.base_url, | |
| headers={ | |
| "X-Subscription-Token": self.key, | |
| "Accept": "application/json", | |
| "Accept-Encoding": "gzip", | |
| }, | |
| params={ | |
| "q": query, | |
| "count": self._clip(max_results), | |
| }, | |
| ) | |
| results = data.get("web", {}).get("results", []) | |
| if not results: | |
| raise RuntimeError("Brave returned no results") | |
| lines = [f"[{self.name}] Search results for: '{query}'"] | |
| for i, r in enumerate(results, 1): | |
| lines.append(f"{i}. {r.get('title', '')}\n {r.get('url', '')}\n {r.get('description', '')}") | |
| return "\n\n".join(lines) | |
| # --- Tavily --------------------------------------------------------------------- | |
| class TavilySearchProvider(BaseSearchProvider): | |
| """ | |
| Tavily Search API β AI-optimized web search. | |
| include_answer = "true" in .pyfun β adds AI-synthesized answer on top. | |
| .pyfun block: | |
| [SEARCH_PROVIDER.tavily] | |
| active = "true" | |
| base_url = "https://api.tavily.com/search" | |
| env_key = "TAVILY_API_KEY" | |
| default_results = "5" | |
| max_results = "10" | |
| include_answer = "true" | |
| fallback_to = "" | |
| [SEARCH_PROVIDER.tavily_END] | |
| """ | |
| def __init__(self, name: str, cfg: dict): | |
| super().__init__(name, cfg) | |
| self.include_answer = cfg.get("include_answer", "false").lower() == "true" | |
| async def search(self, query: str, max_results: int = 5) -> str: | |
| data = await self._post( | |
| self.base_url, | |
| headers={"Content-Type": "application/json"}, | |
| payload={ | |
| "api_key": self.key, # Tavily takes the key in the payload | |
| "query": query, | |
| "max_results": self._clip(max_results), | |
| "include_answer": self.include_answer, | |
| }, | |
| ) | |
| results = data.get("results", []) | |
| if not results and not data.get("answer"): | |
| raise RuntimeError("Tavily returned no results") | |
| lines = [f"[{self.name}] Search results for: '{query}'"] | |
| if self.include_answer and data.get("answer"): | |
| lines.append(f"AI answer: {data['answer']}") | |
| for i, r in enumerate(results, 1): | |
| lines.append(f"{i}. {r.get('title', '')}\n {r.get('url', '')}\n {r.get('content', '')}") | |
| return "\n\n".join(lines) | |
| # --- DuckDuckGo (keyless, gentle last-resort fallback) -------------------------- | |
| class _DDGParser(HTMLParser): | |
| """Minimal parser for html.duckduckgo.com result pages (stdlib only, no BeautifulSoup).""" | |
| def __init__(self): | |
| super().__init__() | |
| self.results: list = [] | |
| self._mode = None | |
| self._cur = None | |
| self._buf: list = [] | |
| def _unwrap(href: str) -> str: | |
| """DDG wraps targets as //duckduckgo.com/l/?uddg=<urlencoded>&rut=β¦ β real URL.""" | |
| target = (parse_qs(urlparse(href).query).get("uddg") or [href])[0] | |
| return "https:" + target if target.startswith("//") else target | |
| def handle_starttag(self, tag, attrs): | |
| if tag != "a": | |
| return | |
| a = dict(attrs) | |
| cls = (a.get("class") or "").split() | |
| if "result__a" in cls: | |
| self._cur = {"title": "", "url": self._unwrap(a.get("href") or ""), "snippet": ""} | |
| self.results.append(self._cur) | |
| self._mode, self._buf = "title", [] | |
| elif "result__snippet" in cls and self.results: | |
| self._cur = self.results[-1] | |
| self._mode, self._buf = "snippet", [] | |
| def handle_data(self, data): | |
| if self._mode: | |
| self._buf.append(data) | |
| def handle_endtag(self, tag): | |
| if tag == "a" and self._mode: | |
| self._cur[self._mode] = " ".join("".join(self._buf).split()) | |
| self._mode = None | |
| class DuckDuckGoSearchProvider(BaseSearchProvider): | |
| """ | |
| Keyless search fallback (idea: "GentleSearchProvider"). | |
| DuckDuckGo has NO official search API β this reads the public HTML endpoint, so it is | |
| fragile by nature and gentle by design: | |
| - global cooldown (min_interval_sec, default 300): called too early it RAISES, so the | |
| fallback chain reports an honest failure instead of a fake "result" string | |
| - the attempt counts for the cooldown even if it fails (no hammering a blocked endpoint) | |
| - honest User-Agent, no evasion tricks, no retries | |
| - captcha / layout change = no parsable results = clear error | |
| .pyfun block (no key needed β requires_key = "false"): | |
| [SEARCH_PROVIDER.duckduckgo] | |
| active = "false" requires_key = "false" | |
| base_url = "https://html.duckduckgo.com/html/" | |
| min_interval_sec = "300" # optional user_agent = "..." # optional | |
| """ | |
| def __init__(self, name: str, cfg: dict): | |
| super().__init__(name, cfg) | |
| self.base_url = cfg.get("base_url") or "https://html.duckduckgo.com/html/" | |
| self.min_interval = float(cfg.get("min_interval_sec", "300")) | |
| self.user_agent = cfg.get("user_agent") or "UniversalMCPHub/1.0 (+https://github.com/VolkanSah)" | |
| self._last_call: Optional[float] = None | |
| async def search(self, query: str, max_results: int) -> str: | |
| now = time.monotonic() | |
| if self._last_call is not None and now - self._last_call < self.min_interval: | |
| wait = int(self.min_interval - (now - self._last_call)) | |
| raise ProviderError(f"DuckDuckGo cooldown β wait {wait}s (min_interval_sec={int(self.min_interval)})") | |
| self._last_call = now # counts even if the request fails | |
| try: | |
| html = await self._get_text( | |
| self.base_url, | |
| headers={ | |
| "User-Agent": self.user_agent, | |
| "Accept": "text/html,application/xhtml+xml", | |
| "Accept-Language": "de,en-US;q=0.7,en;q=0.3", | |
| "DNT": "1", | |
| }, | |
| params={"q": query}, | |
| ) | |
| except ProviderError as e: | |
| raise ProviderError(str(e)) from None # drop status β never retried (gentle) | |
| parser = _DDGParser() | |
| parser.feed(html) | |
| hits = [r for r in parser.results if r["url"]][: self._clip(max_results)] | |
| if not hits: | |
| raise ProviderError("DuckDuckGo: no parsable results (blocked/captcha or layout changed)") | |
| lines = [f"[{self.name}] Search results for: '{query}'"] | |
| for i, r in enumerate(hits, 1): | |
| lines.append(f"{i}. {r['title']}\n {r['url']}\n {r['snippet']}") | |
| return "\n\n".join(lines) | |
| # ============================================================================= | |
| # Search Registry β built from .pyfun [SEARCH_PROVIDERS] at initialize() | |
| # ============================================================================= | |
| _SEARCH_PROVIDER_CLASSES = { | |
| "brave": BraveSearchProvider, | |
| "tavily": TavilySearchProvider, | |
| "duckduckgo": DuckDuckGoSearchProvider, # keyless (requires_key = "false" in .pyfun) | |
| # "serper": SerperSearchProvider, # β uncomment to activate | |
| } | |
| _search_registry: dict = {} | |
| def _initialize_search() -> None: | |
| """ | |
| Build search provider registry from .pyfun [SEARCH_PROVIDERS]. | |
| Called inside initialize() β same key-gating as LLM providers: | |
| no ENV key = provider skipped, no crash. | |
| """ | |
| global _search_registry | |
| for name, cfg in config.get_active_search_providers().items(): | |
| env_key = cfg.get("env_key", "") | |
| needs_key = str(cfg.get("requires_key", "true")).lower() != "false" | |
| if needs_key and (not env_key or not os.getenv(env_key)): | |
| logger.info(f"Search provider '{name}' skipped β ENV key not set.") | |
| continue | |
| cls = _SEARCH_PROVIDER_CLASSES.get(name) | |
| if not cls: | |
| logger.info(f"Search provider '{name}' has no handler yet β skipped.") | |
| continue | |
| _search_registry[name] = cls(name, cfg) | |
| logger.info(f"Search provider registered: {name}") | |
| async def search( | |
| query: str, | |
| provider_name: str = None, | |
| max_results: int = 5, | |
| ) -> str: | |
| """ | |
| Search the web via configured search provider with automatic fallback chain. | |
| Fallback order is defined in .pyfun via fallback_to field. | |
| Raises RuntimeError if all providers in the chain fail. | |
| Args: | |
| query: Search query string. | |
| provider_name: Provider name override (e.g. 'brave', 'tavily'). | |
| Defaults to default_provider from .pyfun [TOOL.web_search]. | |
| max_results: Maximum number of results. Default: 5. | |
| Returns: | |
| Formatted search results as plain text string. | |
| """ | |
| if provider_name and provider_name not in config.get_search_providers(): | |
| logger.debug(f"'{provider_name}' is not a search provider β using default.") | |
| provider_name = None | |
| if not provider_name: | |
| tools_cfg = config.get_active_tools() | |
| provider_name = tools_cfg.get("web_search", {}).get("default_provider", "brave") | |
| visited = set() | |
| current = provider_name | |
| while current and current not in visited: | |
| visited.add(current) | |
| provider = _search_registry.get(current) | |
| if not provider: | |
| logger.warning(f"Search provider '{current}' not in registry β trying fallback.") | |
| else: | |
| try: | |
| result = await _with_retry(current, lambda p=provider: p.search(query, max_results)) | |
| logger.info(f"Search response from provider: '{current}'") | |
| return result | |
| except Exception as e: | |
| logger.warning( | |
| f"Search provider '{current}' failed: {type(e).__name__}: {e} β trying fallback." | |
| ) | |
| cfg = config.get_active_search_providers().get(current, {}) | |
| current = cfg.get("fallback_to", "") | |
| raise RuntimeError("All search providers failed β no fallback available.") | |
| # ============================================================================= | |
| # SECTION 6 β Registry Helpers | |
| # Used by mcp.py for tool registration decisions. | |
| # ============================================================================= | |
| def list_active_llm() -> list: | |
| """Returns list of active LLM provider names.""" | |
| return list(_registry.keys()) | |
| def list_active_search() -> list: | |
| """Returns list of active search provider names.""" | |
| return list(_search_registry.keys()) | |
| def get(name: str) -> BaseProvider: | |
| """Get a specific provider instance by name.""" | |
| return _registry.get(name) | |
| # ============================================================================= | |
| # Direct execution guard | |
| # ============================================================================= | |
| if __name__ == "__main__": | |
| print("WARNING: Run via main.py β app.py, not directly.") |