CI / test (push) Failing after 39s
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
125 lines
5.7 KiB
Python
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)
|