Files
ProxMenux/.github/scripts/ai-models-verifier/providers.py
T

264 lines
11 KiB
Python

"""Self-contained API wrappers for AI-model verification.
Kept independent from the ProxMenux AppImage's ai_providers module so
this tool can live in a private repo with no import coupling to the
public project. Uses only the Python standard library.
"""
from __future__ import annotations
import json
import urllib.error
import urllib.request
from typing import List, Optional
class ProviderError(Exception):
pass
class Provider:
"""Base class. Subclasses implement list_models() and generate()."""
name = "base"
def __init__(self, api_key: str, base_url: Optional[str] = None):
self.api_key = api_key
self.base_url = base_url
def list_models(self) -> List[str]:
raise NotImplementedError
def generate(self, model: str, system: str, user: str,
max_tokens: int = 250, timeout: int = 30) -> str:
raise NotImplementedError
# ── HTTP helpers ────────────────────────────────────────────
# Cloudflare in front of api.groq.com (and probably other providers
# over time) returns 403 "error code: 1010" for the default
# `Python-urllib/3.x` User-Agent — the "browser signature ban" rule.
# A plain identifier is enough to get through; we're not spoofing a
# browser, just avoiding a naive UA fingerprint match.
_USER_AGENT = "ProxMenux-AI-Verifier/1.0"
def _post_json(self, url: str, payload: dict, headers: dict,
timeout: int = 30) -> dict:
data = json.dumps(payload).encode("utf-8")
headers = {**headers, "User-Agent": self._USER_AGENT}
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
def _get_json(self, url: str, headers: dict, timeout: int = 30) -> dict:
headers = {**headers, "User-Agent": self._USER_AGENT}
req = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
# ══════════════════════════════════════════════════════════════════
# OpenAI-compatible family (OpenAI, Groq, OpenRouter, LiteLLM, LM
# Studio, vLLM, LocalAI, etc.). They all speak the same endpoints.
# ══════════════════════════════════════════════════════════════════
class OpenAICompatProvider(Provider):
name = "openai-compat"
default_base = "https://api.openai.com"
models_path = "/v1/models"
chat_path = "/v1/chat/completions"
def _base(self) -> str:
return (self.base_url or self.default_base).rstrip("/")
@staticmethod
def _is_reasoning_model(model: str) -> bool:
"""True for OpenAI reasoning models (o-series + non-chat gpt-5+).
Must be kept in sync with the matching helper in ProxMenux's
openai_provider.py — same rule, same consequence:
- send max_completion_tokens instead of max_tokens
- omit temperature (default is the only accepted value).
"""
m = model.lower()
if len(m) >= 2 and m[0] == "o" and m[1].isdigit():
return True
if m.startswith("gpt-5") and "-chat" not in m:
return True
return False
def list_models(self) -> List[str]:
url = f"{self._base()}{self.models_path}"
headers = {"Authorization": f"Bearer {self.api_key}"}
data = self._get_json(url, headers)
return [m.get("id", "") for m in data.get("data", []) if m.get("id")]
def generate(self, model: str, system: str, user: str,
max_tokens: int = 250, timeout: int = 30) -> str:
url = f"{self._base()}{self.chat_path}"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
}
if self._is_reasoning_model(model):
# Reasoning models spend budget on internal reasoning by
# default, which yields empty replies at small max_tokens.
# reasoning_effort=minimal keeps that overhead low so the
# whole budget reaches the user, aligned with the short
# translate+explain task ProxMenux uses. Mirror this in
# ProxMenux's openai_provider.py.
payload["max_completion_tokens"] = max_tokens
payload["reasoning_effort"] = "minimal"
else:
payload["max_tokens"] = max_tokens
payload["temperature"] = 0.3
data = self._post_json(url, payload, headers, timeout)
try:
return data["choices"][0]["message"]["content"].strip()
except (KeyError, IndexError) as exc:
raise ProviderError(f"unexpected response: {exc} // {str(data)[:200]}")
class OpenAIProvider(OpenAICompatProvider):
name = "openai"
default_base = "https://api.openai.com"
class GroqProvider(OpenAICompatProvider):
name = "groq"
default_base = "https://api.groq.com/openai"
class OpenRouterProvider(OpenAICompatProvider):
name = "openrouter"
default_base = "https://openrouter.ai/api"
# ══════════════════════════════════════════════════════════════════
# Gemini — different endpoint shape, keyed via ?key=... query param.
# ══════════════════════════════════════════════════════════════════
class GeminiProvider(Provider):
name = "gemini"
default_base = "https://generativelanguage.googleapis.com/v1beta"
@staticmethod
def _has_thinking_mode(model: str) -> bool:
"""True for Gemini variants that enable "thinking" by default.
Kept in sync with ProxMenux's gemini_provider.py. 2.5+ pro/flash
and 3.x pro/flash consume output tokens on reasoning, which
yields empty replies when max_tokens is small. We pass
thinkingBudget=0 to disable thinking so the short translate+
explain test sees actual text. Lite variants don't have thinking
enabled and are not flagged here.
"""
m = model.lower()
if "lite" in m:
return False
return m.startswith("gemini-2.5") or m.startswith("gemini-3")
def list_models(self) -> List[str]:
url = f"{self.default_base}/models?key={self.api_key}"
data = self._get_json(url, {})
names = []
for m in data.get("models", []):
raw = m.get("name", "")
if raw.startswith("models/"):
raw = raw[len("models/"):]
# Only keep text-generation capable models.
if "generateContent" in m.get("supportedGenerationMethods", []):
names.append(raw)
return names
def generate(self, model: str, system: str, user: str,
max_tokens: int = 250, timeout: int = 30) -> str:
url = f"{self.default_base}/models/{model}:generateContent?key={self.api_key}"
gen_config = {
"maxOutputTokens": max_tokens,
"temperature": 0.3,
}
if self._has_thinking_mode(model):
gen_config["thinkingConfig"] = {"thinkingBudget": 0}
payload = {
"system_instruction": {"parts": [{"text": system}]},
"contents": [{"parts": [{"text": user}]}],
"generationConfig": gen_config,
}
data = self._post_json(url, payload, {"Content-Type": "application/json"}, timeout)
try:
return data["candidates"][0]["content"]["parts"][0]["text"].strip()
except (KeyError, IndexError) as exc:
raise ProviderError(f"unexpected response: {exc} // {str(data)[:200]}")
# ══════════════════════════════════════════════════════════════════
# Anthropic — own schema and headers.
# ══════════════════════════════════════════════════════════════════
class AnthropicProvider(Provider):
name = "anthropic"
default_base = "https://api.anthropic.com"
version = "2023-06-01"
def list_models(self) -> List[str]:
url = f"{self.default_base}/v1/models"
headers = {
"x-api-key": self.api_key,
"anthropic-version": self.version,
}
try:
data = self._get_json(url, headers)
return [m.get("id", "") for m in data.get("data", []) if m.get("id")]
except urllib.error.HTTPError:
# Older keys don't have the endpoint; caller decides what to do.
return []
def generate(self, model: str, system: str, user: str,
max_tokens: int = 250, timeout: int = 30) -> str:
url = f"{self.default_base}/v1/messages"
headers = {
"x-api-key": self.api_key,
"anthropic-version": self.version,
"Content-Type": "application/json",
}
# ProxMenux's real anthropic_provider.py does NOT send `temperature`,
# and Anthropic's newest generation (Claude Sonnet 5, Opus 4.7 /
# 4.8, Fable 5) now rejects it with:
# invalid_request_error: `temperature` is deprecated for this model.
# Omitting it here aligns the verifier with production behaviour so
# the newest-gen models can be reached during verification.
payload = {
"model": model,
"max_tokens": max_tokens,
"system": system,
"messages": [{"role": "user", "content": user}],
}
data = self._post_json(url, payload, headers, timeout)
try:
return data["content"][0]["text"].strip()
except (KeyError, IndexError) as exc:
raise ProviderError(f"unexpected response: {exc} // {str(data)[:200]}")
PROVIDERS = {
"openai": OpenAIProvider,
"groq": GroqProvider,
"gemini": GeminiProvider,
"anthropic": AnthropicProvider,
"openrouter": OpenRouterProvider,
}
def make_provider(name: str, api_key: str,
base_url: Optional[str] = None) -> Provider:
cls = PROVIDERS.get(name)
if not cls:
raise ValueError(f"unknown provider: {name}")
return cls(api_key, base_url=base_url)