Files
weirsoe-party-protocol/party/brain/providers.py
T
agw d149aa323e
CI / test (push) Failing after 39s
Udbyder-agnostisk spilhjerne, rummets mikrofon og rigtig musik
Hjernen (party/brain) skriver kommentarer til løgnene, dømmer løgne
(sandheden eller upassende), personlige drillerier og en slut-anmeldelse,
alt i baggrunden med faste replikker som fallback. Udbyderen vælges med
BRAIN_PROVIDER: anthropic (Claude), openai (OpenAI-kompatibel, fx Codex)
eller ollama (fx Gemma). Rummets mikrofon er eksperimentel: storskærmen
sender korte lydstumper til talegenkendelse (STT_PROVIDER), og kun et kort
resumé af latter og udsagn huskes til hjernen. Musikken er nu lavet med
en musikmodel.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-10-01 21:59:50 +02:00

125 lines
5.7 KiB
Python

"""Sprogmodel-udbydere bag ét fælles interface: `await provider.complete_json(system, user, schema)`.
Spillet kender kun dette interface, så modellen kan skiftes med miljøvariabler:
BRAIN_PROVIDER=anthropic BRAIN_MODEL=claude-opus-5 BRAIN_API_KEY=...
BRAIN_PROVIDER=openai BRAIN_MODEL=<model> BRAIN_API_KEY=... (OpenAI/Codex, vLLM, LM Studio, Ollama /v1)
BRAIN_PROVIDER=ollama BRAIN_MODEL=gemma4:12b-it-qat BRAIN_BASE_URL=http://<ollama>:11434
Alle returnerer et dict efter skemaet, eller None ved timeout/fejl/afvisning. Spillet falder så tilbage
til de faste replikker; en model må aldrig kunne stoppe et spil.
"""
from __future__ import annotations
import asyncio
import json
import logging
import urllib.error
import urllib.request
log = logging.getLogger(__name__)
class Provider:
name = "base"
def __init__(self, model: str, api_key: str = "", base_url: str = "", effort: str = "low"):
self.model, self.api_key, self.base_url, self.effort = model, api_key, base_url.rstrip("/"), effort
async def complete_json(self, system: str, user: str, schema: dict, *, max_tokens: int = 1500,
timeout: float = 8.0) -> dict | None:
try:
return await asyncio.wait_for(self._complete(system, user, schema, max_tokens, timeout), timeout)
except asyncio.TimeoutError:
log.info("%s: timeout efter %.1fs", self.name, timeout)
except Exception: # noqa: BLE001 — modellen må aldrig vælte spillet
log.exception("%s: kald fejlede", self.name)
return None
async def _complete(self, system, user, schema, max_tokens, timeout) -> dict | None:
raise NotImplementedError
class AnthropicProvider(Provider):
"""Claude via den officielle SDK med struktureret output og server-side fallback ved afvisning."""
name = "anthropic"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
import anthropic # kun nødvendig, når denne udbyder er valgt
self._anthropic = anthropic
self.client = anthropic.AsyncAnthropic(api_key=self.api_key or None, max_retries=1)
async def _complete(self, system, user, schema, max_tokens, timeout):
resp = await self.client.with_options(timeout=timeout).beta.messages.create(
model=self.model,
max_tokens=max_tokens,
betas=["server-side-fallback-2026-07-01"],
fallbacks="default",
system=[{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}],
output_config={"effort": self.effort, "format": {"type": "json_schema", "schema": schema}},
messages=[{"role": "user", "content": user}],
)
if resp.stop_reason == "refusal":
log.info("anthropic: afvist (%s)", getattr(resp.stop_details, "category", None))
return None
text = next((b.text for b in resp.content if b.type == "text"), None)
return json.loads(text) if text else None
def _post_json(url: str, body: dict, headers: dict, timeout: float) -> dict:
req = urllib.request.Request(url, json.dumps(body).encode(), {"Content-Type": "application/json", **headers})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.load(r)
class OpenAICompatibleProvider(Provider):
"""Alt der taler OpenAI's chat completions: OpenAI/Codex, vLLM, LM Studio, Ollamas /v1 m.fl."""
name = "openai"
async def _complete(self, system, user, schema, max_tokens, timeout):
url = (self.base_url or "https://api.openai.com/v1") + "/chat/completions"
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
body = {"model": self.model, "max_tokens": max_tokens,
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}],
"response_format": {"type": "json_schema",
"json_schema": {"name": "svar", "schema": schema, "strict": True}}}
try:
data = await asyncio.to_thread(_post_json, url, body, headers, timeout)
except urllib.error.HTTPError as e:
if e.code not in (400, 422):
raise
# Ældre servere kender ikke json_schema: bed om JSON og læg skemaet i prompten.
body["response_format"] = {"type": "json_object"}
body["messages"][0]["content"] += "\n\nSvar kun med JSON efter dette skema:\n" + json.dumps(schema)
data = await asyncio.to_thread(_post_json, url, body, headers, timeout)
return json.loads(data["choices"][0]["message"]["content"])
class OllamaProvider(Provider):
"""Ollamas eget API: skema-styret output og ingen 'tænkning' (Gemma tænker ellers i minutter)."""
name = "ollama"
async def _complete(self, system, user, schema, max_tokens, timeout):
url = (self.base_url or "http://localhost:11434") + "/api/chat"
body = {"model": self.model, "stream": False, "think": False, "format": schema, "keep_alive": "30m",
"options": {"num_predict": max_tokens, "temperature": 0.8},
"messages": [{"role": "system", "content": system}, {"role": "user", "content": user}]}
data = await asyncio.to_thread(_post_json, url, body, {}, timeout)
return json.loads(data["message"]["content"])
PROVIDERS = {"anthropic": AnthropicProvider, "openai": OpenAICompatibleProvider, "ollama": OllamaProvider}
def build_provider(kind: str, model: str, api_key: str = "", base_url: str = "", effort: str = "low") -> Provider | None:
cls = PROVIDERS.get((kind or "").lower())
if not cls or not model:
return None
return cls(model, api_key=api_key, base_url=base_url, effort=effort)