mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
264 lines
11 KiB
Python
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)
|