PyCatan-AI / examples /ai_testing /play_with_openrouter.py
shon
1
525124a
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Browser setup entrypoint for PyCatan with per-agent OpenRouter models."""
import html as html_lib
import json
import os
import ssl
import sys
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib.parse import parse_qs, urlparse
try:
import certifi
os.environ.setdefault("SSL_CERT_FILE", certifi.where())
os.environ.setdefault("REQUESTS_CA_BUNDLE", certifi.where())
os.environ.setdefault("GRPC_DEFAULT_SSL_ROOTS_FILE_PATH", certifi.where())
ssl._create_default_https_context = ssl._create_unverified_context
except Exception:
pass
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from examples.ai_testing.play_with_ai import (
DEFAULT_PLAYER_NAMES,
ELEVENLABS_TTS_MODELS,
LOGS_DIR,
PLAYER_COLORS,
annotate_replay_session,
create_game,
group_replay_decisions,
infer_players_from_decisions,
infer_players_from_session,
list_replay_marker_options,
load_ai_config,
load_env_file,
load_replay_decision_chain,
resolve_session_path,
run_game,
run_replay_viewer,
)
from pycatan.ai.config import normalize_chat_language
OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
MIN_CONTEXT_LENGTH = 32000
MIN_COMPLETION_TOKENS = 4096
PLAYER_GENDERS = {1: "female", 2: "male", 3: "male", 4: "female"}
GEMINI_TTS_MODELS = [
"gemini-2.5-flash-preview-tts",
"gemini-2.5-pro-preview-tts",
"gemini-3.1-flash-tts-preview",
"gemini-3.1-pro-tts-preview",
]
GEMINI_TTS_VOICES = [
"Kore", "Puck", "Zephyr", "Charon", "Fenrir", "Leda", "Orus",
"Aoede", "Callirrhoe", "Autonoe", "Enceladus", "Iapetus",
"Umbriel", "Algieba", "Despina", "Erinome", "Algenib",
"Rasalgethi", "Laomedeia", "Achernar", "Alnilam", "Schedar",
"Gacrux", "Pulcherrima", "Achird", "Zubenelgenubi",
"Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafat",
]
FALLBACK_MODELS = [
{
"id": "openai/gpt-4o",
"name": "OpenAI: GPT-4o",
"provider": "openai",
"context_length": 128000,
"max_completion_tokens": 16384,
"output_modalities": ["text"],
"supported_parameters": ["tools", "response_format", "temperature", "max_tokens"],
"pricing": {"prompt": "", "completion": ""},
},
{
"id": "openai/gpt-4o-mini",
"name": "OpenAI: GPT-4o Mini",
"provider": "openai",
"context_length": 128000,
"max_completion_tokens": 16384,
"output_modalities": ["text"],
"supported_parameters": ["tools", "response_format", "temperature", "max_tokens"],
"pricing": {"prompt": "", "completion": ""},
},
{
"id": "anthropic/claude-sonnet-4.5",
"name": "Anthropic: Claude Sonnet 4.5",
"provider": "anthropic",
"context_length": 200000,
"max_completion_tokens": 8192,
"output_modalities": ["text"],
"supported_parameters": ["tools", "response_format", "temperature", "max_tokens"],
"pricing": {"prompt": "", "completion": ""},
},
{
"id": "google/gemini-2.5-pro",
"name": "Google: Gemini 2.5 Pro",
"provider": "google",
"context_length": 1048576,
"max_completion_tokens": 65536,
"output_modalities": ["text"],
"supported_parameters": ["tools", "response_format", "temperature", "max_tokens"],
"pricing": {"prompt": "", "completion": ""},
},
{
"id": "google/gemini-2.5-flash",
"name": "Google: Gemini 2.5 Flash",
"provider": "google",
"context_length": 1048576,
"max_completion_tokens": 65536,
"output_modalities": ["text"],
"supported_parameters": ["tools", "response_format", "temperature", "max_tokens"],
"pricing": {"prompt": "", "completion": ""},
},
]
def provider_from_model_id(model_id: str) -> str:
return (model_id.split("/", 1)[0] if "/" in model_id else "custom").strip().lower()
def normalize_model(raw: Dict[str, Any]) -> Dict[str, Any]:
model_id = str(raw.get("id", "")).strip()
pricing = raw.get("pricing") or {}
architecture = raw.get("architecture") or {}
top_provider = raw.get("top_provider") or {}
return {
"id": model_id,
"name": raw.get("name") or model_id,
"provider": provider_from_model_id(model_id),
"context_length": raw.get("context_length") or 0,
"max_completion_tokens": top_provider.get("max_completion_tokens") or 0,
"output_modalities": architecture.get("output_modalities") or ["text"],
"supported_parameters": raw.get("supported_parameters") or [],
"pricing": {
"prompt": pricing.get("prompt", ""),
"completion": pricing.get("completion", ""),
},
}
def is_suitable_openrouter_model(model: Dict[str, Any]) -> bool:
supported = set(model.get("supported_parameters") or [])
output_modalities = set(model.get("output_modalities") or ["text"])
context_length = int(model.get("context_length") or 0)
max_completion_tokens = int(model.get("max_completion_tokens") or 0)
return (
bool(model.get("id"))
and "tools" in supported
and "response_format" in supported
and "text" in output_modalities
and context_length >= MIN_CONTEXT_LENGTH
and (max_completion_tokens == 0 or max_completion_tokens >= MIN_COMPLETION_TOKENS)
)
def fetch_openrouter_models(api_key: str = "") -> List[Dict[str, Any]]:
try:
import requests
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
request_kwargs = {
"headers": headers,
"params": {"output_modalities": "text"},
"timeout": 12,
"verify": os.environ.get("REQUESTS_CA_BUNDLE") or True,
}
try:
response = requests.get(OPENROUTER_MODELS_URL, **request_kwargs)
except requests.exceptions.SSLError:
requests.packages.urllib3.disable_warnings() # type: ignore[attr-defined]
response = requests.get(OPENROUTER_MODELS_URL, **{**request_kwargs, "verify": False})
response.raise_for_status()
models = [normalize_model(item) for item in response.json().get("data", [])]
except Exception as exc:
print(f"[OPENROUTER] Could not fetch model list, using fallback models: {exc}")
models = list(FALLBACK_MODELS)
usable = [model for model in models if is_suitable_openrouter_model(model)]
usable = usable or [model for model in FALLBACK_MODELS if is_suitable_openrouter_model(model)]
usable.sort(key=lambda item: (item["provider"], item["name"].lower()))
return usable
def _env_has(name: str) -> bool:
return bool(os.environ.get(name))
def _options(values: List[str], selected: str = "") -> str:
return "".join(
f'<option value="{html_lib.escape(value)}" {"selected" if value == selected else ""}>'
f"{html_lib.escape(value)}</option>"
for value in values
)
def _tts_model_options(selected: str = "eleven_v3") -> str:
return "".join(
f'<option value="{html_lib.escape(model["id"])}" {"selected" if model["id"] == selected else ""}>'
f'{html_lib.escape(model["label"])} - {html_lib.escape(model["id"])}</option>'
for model in ELEVENLABS_TTS_MODELS
)
def _recent_session_options() -> str:
if not LOGS_DIR.exists():
return ""
sessions = sorted(
[path.name for path in LOGS_DIR.iterdir() if path.is_dir() and path.name.startswith("session_")],
reverse=True,
)[:30]
return "".join(f'<option value="{html_lib.escape(session)}"></option>' for session in sessions)
def _price_label(model: Dict[str, Any]) -> str:
pricing = model.get("pricing") or {}
prompt = pricing.get("prompt") or "?"
completion = pricing.get("completion") or "?"
return f"prompt {prompt} / completion {completion}"
def _slot_llm_from_selected(selected: Dict[str, Any], slot: int, models: List[Dict[str, Any]]) -> Dict[str, str]:
providers = sorted({model["provider"] for model in models})
provider = selected.get(f"provider_{slot}") or (providers[0] if providers else "openai")
model_id = (
selected.get(f"manual_model_{slot}")
or selected.get(f"model_{slot}")
or next((model["id"] for model in models if model["provider"] == provider), models[0]["id"])
)
return {
"provider": "openrouter",
"model_provider": provider_from_model_id(model_id),
"model_name": model_id,
"api_key_env_var": "OPENROUTER_API_KEY",
}
def render_landing_page() -> bytes:
recent_sessions = []
if LOGS_DIR.exists():
recent_sessions = sorted(
[path.name for path in LOGS_DIR.iterdir() if path.is_dir() and path.name.startswith("session_")],
reverse=True,
)[:6]
replay_links = "".join(
"<a class=\"session-link\" href=\"/settings?mode=watch_replay"
f"&session={html_lib.escape(session)}\">{html_lib.escape(session)}</a>"
for session in recent_sessions
)
if not replay_links:
replay_links = "<p class=\"muted\">No public replay sessions are bundled yet.</p>"
return f"""<!doctype html>
<html lang="he" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PyCatan AI</title>
<style>
:root {{
color-scheme: light;
--ink: #17202a;
--muted: #536273;
--line: #d9e0e8;
--surface: #ffffff;
--wash: #f3f6f8;
--accent: #226f68;
--accent-dark: #18544f;
--gold: #b7791f;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
font-family: "Segoe UI", Arial, sans-serif;
color: var(--ink);
background: var(--wash);
}}
.page {{
width: min(1120px, calc(100% - 32px));
margin: 0 auto;
padding: 42px 0 34px;
}}
.hero {{
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(300px, 0.95fr);
gap: 28px;
align-items: stretch;
}}
.intro {{
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 34px;
}}
.eyebrow {{
color: var(--gold);
font-size: 13px;
font-weight: 700;
letter-spacing: 0;
margin-bottom: 12px;
}}
h1 {{
margin: 0 0 14px;
font-size: clamp(32px, 6vw, 60px);
line-height: 1.02;
letter-spacing: 0;
}}
.lead {{
color: var(--muted);
font-size: 18px;
line-height: 1.65;
max-width: 720px;
margin: 0 0 26px;
}}
.actions {{
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}}
.button {{
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 18px;
border-radius: 6px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 700;
}}
.button.secondary {{
background: #fff;
color: var(--accent-dark);
}}
.panel {{
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 24px;
}}
.panel h2 {{
margin: 0 0 12px;
font-size: 22px;
letter-spacing: 0;
}}
.panel p {{
color: var(--muted);
line-height: 1.55;
}}
.session-list {{
display: grid;
gap: 8px;
margin-top: 16px;
}}
.session-link {{
display: block;
direction: ltr;
text-align: left;
color: var(--accent-dark);
border: 1px solid var(--line);
border-radius: 6px;
padding: 10px 12px;
text-decoration: none;
background: #fbfcfd;
font-family: Consolas, monospace;
font-size: 13px;
}}
.grid {{
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 18px;
}}
.step {{
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
}}
.step strong {{ display: block; margin-bottom: 8px; }}
.step span {{ color: var(--muted); line-height: 1.5; }}
.muted {{ color: var(--muted); }}
@media (max-width: 860px) {{
.hero, .grid {{ grid-template-columns: 1fr; }}
.intro {{ padding: 26px; }}
}}
</style>
</head>
<body>
<main class="page">
<section class="hero">
<div class="intro">
<div class="eyebrow">Public AI table</div>
<h1>PyCatan AI</h1>
<p class="lead">
משחק קטאן עם שחקני AI, ריפליי של סשנים ציבוריים, וניתוח החלטות.
אפשר לצפות בלי מפתח, או להתחיל משחק חי עם מפתח OpenRouter משלך.
</p>
<div class="actions">
<a class="button" href="/settings">התחל משחק או ריפליי</a>
<a class="button secondary" href="/settings?mode=watch_replay">צפה בריפליי</a>
</div>
</div>
<aside class="panel">
<h2>סשנים ציבוריים אחרונים</h2>
<p>בחר סשן מהרשימה או עבור למסך ההגדרות כדי לבחור סשן אחר.</p>
<div class="session-list">{replay_links}</div>
</aside>
</section>
<section class="grid" aria-label="How it works">
<div class="step"><strong>1. צופים</strong><span>ריפליי וניתוח החלטות זמינים בלי להזין מפתחות.</span></div>
<div class="step"><strong>2. משחקים</strong><span>משחק חי עם LLM משתמש במפתח OpenRouter שהמשתמש מזין בעצמו.</span></div>
<div class="step"><strong>3. משתפים</strong><span>הסשנים נשמרים כתוכן ציבורי של הדמו, בהתאם לאחסון הזמין.</span></div>
</section>
</main>
</body>
</html>""".encode("utf-8")
def render_settings_page(
models: List[Dict[str, Any]],
key_mode: str,
errors: Optional[List[str]] = None,
selected: Optional[Dict[str, Any]] = None,
) -> str:
selected = selected or {}
player_count = int(selected.get("player_count", 4) or 4)
providers = sorted({model["provider"] for model in models})
provider_options = "".join(
f'<option value="{html_lib.escape(provider)}">{html_lib.escape(provider.title())}</option>'
for provider in providers
)
errors_html = ""
if errors:
errors_html = "<div class=\"errors\"><ul>" + "".join(
f"<li>{html_lib.escape(error)}</li>" for error in errors
) + "</ul></div>"
player_blocks = []
for index in range(4):
slot = index + 1
slot_llm = _slot_llm_from_selected(selected, slot, models)
name = selected.get(f"player_{slot}") or DEFAULT_PLAYER_NAMES[index]
player_blocks.append(f"""
<fieldset class="player-card" data-player-card="{slot}">
<legend>P{slot} - {PLAYER_COLORS[index]} - {PLAYER_GENDERS[slot]}</legend>
<label class="player-name-label">Name<input name="player_{slot}" data-player-name="{slot}" value="{html_lib.escape(name)}" maxlength="32"></label>
<div class="form-grid">
<label>Provider<select name="provider_{slot}" data-provider-select="{slot}">{provider_options}</select></label>
<label>Model<select name="model_{slot}" data-model-select="{slot}"></select></label>
</div>
<label>Manual model id<input name="manual_model_{slot}" value="{html_lib.escape(selected.get(f'manual_model_{slot}', ''))}" placeholder="anthropic/claude-sonnet-4.5"></label>
<input type="hidden" data-selected-provider="{slot}" value="{html_lib.escape(slot_llm['model_provider'])}">
<input type="hidden" data-selected-model="{slot}" value="{html_lib.escape(slot_llm['model_name'])}">
</fieldset>
""")
model_cards = []
for model in models[:28]:
model_cards.append(
"<li>"
f"<strong>{html_lib.escape(model['id'])}</strong>"
f"<span>{html_lib.escape(model['name'])}</span>"
f"<small>{int(model.get('context_length') or 0):,} ctx | {_price_label(model)}</small>"
"</li>"
)
models_json = json.dumps(models, ensure_ascii=False)
# Server-side validation below knows the selected run mode/TTS provider.
# Avoid static browser "required" on fields that may be hidden by the UI.
openrouter_required = ""
gemini_required = ""
eleven_key_required = ""
eleven_voice_required = ""
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PyCatan OpenRouter Setup</title>
<style>
:root {{ --bg:#eef2f5; --ink:#18222d; --muted:#66717e; --panel:#fff; --line:#c9d3de; --accent:#1e6f5c; --danger:#b42318; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; min-height:100vh; font-family:"Segoe UI",Arial,sans-serif; background:var(--bg); color:var(--ink); padding:24px; }}
main {{ width:min(1180px,100%); margin:0 auto; display:grid; grid-template-columns:minmax(0,1.25fr) minmax(310px,.75fr); gap:22px; align-items:start; }}
section, aside {{ background:var(--panel); border:1px solid var(--line); border-radius:8px; box-shadow:0 16px 38px rgba(24,34,45,.12); }}
section, aside {{ padding:24px; }}
aside {{ position:sticky; top:24px; }}
h1,h2 {{ margin:0; }}
p {{ color:var(--muted); line-height:1.5; }}
form {{ display:grid; gap:18px; margin-top:20px; }}
fieldset {{ border:1px solid var(--line); border-radius:8px; padding:16px; }}
legend {{ padding:0 8px; font-weight:800; }}
label {{ display:grid; gap:7px; font-weight:700; }}
input,select,textarea {{ width:100%; border:1px solid #b8c2cc; border-radius:6px; padding:10px 12px; font:inherit; background:#fbfcfd; color:var(--ink); }}
textarea {{ min-height:92px; resize:vertical; }}
.form-grid {{ display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; margin-top:12px; }}
.player-grid {{ display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:14px; }}
.player-card[data-hidden="true"], .hidden {{ display:none; }}
.locked-control {{ background:#eef2f5; color:var(--muted); }}
.player-name-label input[readonly] {{ background:#eef2f5; color:var(--muted); }}
.checkbox-row {{ display:flex; align-items:center; gap:8px; }}
.checkbox-row input {{ width:auto; }}
.errors {{ color:var(--danger); background:#fff0ed; border:1px solid #f4b4aa; border-radius:8px; padding:10px 14px; }}
button {{ border:0; border-radius:7px; padding:13px 18px; font:inherit; font-weight:850; color:white; background:var(--accent); cursor:pointer; }}
.model-list {{ margin:0; padding-left:18px; display:grid; gap:10px; color:var(--muted); font-size:13px; }}
.model-list strong,.model-list span,.model-list small {{ display:block; }}
.hint {{ margin:6px 0 0; font-size:13px; }}
@media (max-width:900px) {{ main {{ grid-template-columns:1fr; }} aside {{ position:static; }} .player-grid,.form-grid {{ grid-template-columns:1fr; }} }}
</style>
</head>
<body>
<main>
<section>
<h1>PyCatan OpenRouter Setup</h1>
<p>Full setup mode: run/replay, per-agent OpenRouter models, TTS, reactions, and player slots. TTS stays mapped to PLAYER_1 through PLAYER_4. P1 and P4 are female; P2 and P3 are male.</p>
{errors_html}
<form method="post" action="/start">
<fieldset>
<legend>Run Mode</legend>
<div class="form-grid">
<label>Mode<select name="run_mode" id="run-mode">
<option value="new_game" {"selected" if selected.get("run_mode", "new_game") == "new_game" else ""}>New live game</option>
<option value="resume_session" {"selected" if selected.get("run_mode") == "resume_session" else ""}>Fast replay, then continue live</option>
<option value="watch_replay" {"selected" if selected.get("run_mode") == "watch_replay" else ""}>Watch recorded session only</option>
<option value="analyse_game" {"selected" if selected.get("run_mode") == "analyse_game" else ""}>Analyse recorded session</option>
</select></label>
<label>Config file<input name="config_path" value="{html_lib.escape(selected.get('config_path', ''))}" placeholder="pycatan/ai/config_dev.yaml"></label>
</div>
<div id="replay-fields" class="form-grid">
<label>Session<input name="replay_session" list="recent-sessions" value="{html_lib.escape(selected.get('replay_session', ''))}" placeholder="session_YYYYMMDD_HHMMSS"><datalist id="recent-sessions">{_recent_session_options()}</datalist></label>
<label>Stop before<input name="replay_stop_before" list="replay-marker-options" value="{html_lib.escape(selected.get('replay_stop_before', ''))}" placeholder="Shon:4"></label>
<label>Replay through<input name="replay_through" list="replay-marker-options" value="{html_lib.escape(selected.get('replay_through', ''))}" placeholder="Ziv:8"></label>
<datalist id="replay-marker-options"></datalist>
<label>Max decisions<input name="replay_max_decisions" type="number" min="1" step="1" value="{html_lib.escape(selected.get('replay_max_decisions', ''))}"></label>
<label>Replay delay<input name="replay_delay" type="number" min="0" step="0.1" value="{html_lib.escape(selected.get('replay_delay', '2.5'))}"></label>
<label>Text lead<input name="replay_text_lead" type="number" min="0" step="0.05" value="{html_lib.escape(selected.get('replay_text_lead', '0.25'))}"></label>
</div>
<label class="checkbox-row"><input name="replay_skip_chat" type="checkbox" {"checked" if selected.get("replay_skip_chat") else ""}>Skip recorded chat during fast replay</label>
<label class="checkbox-row"><input name="replay_speak" type="checkbox" {"checked" if selected.get("replay_speak") else ""}>Speak recorded replay chat from cache</label>
<p class="hint" id="replay-marker-status">Replay markers are loaded from recorded game actions only.</p>
</fieldset>
<fieldset>
<legend>Execution</legend>
<div class="form-grid">
<label>OpenRouter API key<input name="openrouter_api_key" type="password" autocomplete="off" {openrouter_required} data-env-optional="{'true' if _env_has('OPENROUTER_API_KEY') else 'false'}" placeholder="{'ENV key available' if _env_has('OPENROUTER_API_KEY') else 'sk-or-...'}"></label>
<label>Table-talk language<select name="chat_language"><option value="hebrew" {"selected" if selected.get("chat_language", "hebrew") == "hebrew" else ""}>Hebrew</option><option value="english" {"selected" if selected.get("chat_language") == "english" else ""}>English</option></select></label>
<label>Off-turn reactions<select name="reaction_mode"><option value="default" {"selected" if selected.get("reaction_mode", "default") == "default" else ""}>Use config default</option><option value="off" {"selected" if selected.get("reaction_mode") == "off" else ""}>Off</option><option value="sync" {"selected" if selected.get("reaction_mode") == "sync" else ""}>Sync</option><option value="async" {"selected" if selected.get("reaction_mode") == "async" else ""}>Async</option></select></label>
<label>Reaction batch size<input name="reaction_batch_size" type="number" min="1" step="1" value="{html_lib.escape(selected.get('reaction_batch_size', ''))}" placeholder="5"></label>
<label>Victory points to win<input name="victory_points" type="number" min="1" step="1" value="{html_lib.escape(selected.get('victory_points', '5'))}" placeholder="5"></label>
<label>Random seed<input name="random_seed" type="number" step="1" value="{html_lib.escape(selected.get('random_seed', ''))}" placeholder="0"></label>
</div>
<label>Additional game context<textarea name="game_context" maxlength="4000" placeholder="Optional table-wide context that every agent should know.">{html_lib.escape(selected.get('game_context', ''))}</textarea></label>
<label class="checkbox-row"><input name="no_llm" type="checkbox" {"checked" if selected.get("no_llm") else ""}>Offline mode, no new LLM calls</label>
<p class="hint">Victory points are written into the game state and prompts. Leave random seed blank to use the current deterministic default: 0.</p>
</fieldset>
<fieldset id="voice-section">
<legend>Voice</legend>
<div class="form-grid">
<label>Provider<select name="tts_provider" id="tts-provider"><option value="gemini" {"selected" if selected.get("tts_provider", "gemini") == "gemini" else ""}>Gemini TTS</option><option value="elevenlabs" {"selected" if selected.get("tts_provider") == "elevenlabs" else ""}>ElevenLabs</option><option value="off" {"selected" if selected.get("tts_provider") == "off" else ""}>Off</option></select></label>
<label class="gemini-tts">Gemini API key<input name="gemini_api_key" type="password" autocomplete="off" {gemini_required} data-env-optional="{'true' if _env_has('GEMINI_API_KEY') else 'false'}" placeholder="{'ENV key available' if _env_has('GEMINI_API_KEY') else ''}"></label>
<label class="gemini-tts">Gemini TTS model<select name="gemini_tts_model">{_options(GEMINI_TTS_MODELS, selected.get('gemini_tts_model', 'gemini-2.5-flash-preview-tts'))}</select></label>
<label class="gemini-tts">Gemini voice<select name="gemini_tts_voice">{_options(GEMINI_TTS_VOICES, selected.get('gemini_tts_voice', 'Kore'))}</select></label>
<label class="eleven-tts">ElevenLabs model<select name="elevenlabs_tts_model">{_tts_model_options(selected.get('elevenlabs_tts_model', 'eleven_v3'))}</select></label>
<label class="eleven-tts">ElevenLabs API key<input name="elevenlabs_api_key" type="password" autocomplete="off" {eleven_key_required} data-env-optional="{'true' if _env_has('ELEVENLABS_API_KEY') else 'false'}" placeholder="{'ENV key available' if _env_has('ELEVENLABS_API_KEY') else ''}"></label>
<label class="eleven-tts">ElevenLabs default voice id<input name="elevenlabs_default_voice_id" autocomplete="off" {eleven_voice_required} data-env-optional="{'true' if _env_has('ELEVENLABS_DEFAULT_VOICE_ID') else 'false'}" placeholder="{'ENV voice available' if _env_has('ELEVENLABS_DEFAULT_VOICE_ID') else ''}"></label>
</div>
</fieldset>
<fieldset id="players-section">
<legend>Players and models</legend>
<label>Player count<select name="player_count" id="player-count"><option value="2" {"selected" if player_count == 2 else ""}>2</option><option value="3" {"selected" if player_count == 3 else ""}>3</option><option value="4" {"selected" if player_count == 4 else ""}>4</option></select></label>
<input type="hidden" name="player_count" id="locked-player-count" disabled>
<p class="hint" id="player-lock-status">For replay/resume modes, player names are loaded from the selected session; only models remain editable.</p>
<div class="player-grid">{''.join(player_blocks)}</div>
</fieldset>
<button type="submit">Start game</button>
</form>
</section>
<aside>
<h2>Suitable OpenRouter Models</h2>
<p>Only models with tools, response_format, text output, at least {MIN_CONTEXT_LENGTH:,} context, and at least {MIN_COMPLETION_TOKENS:,} output tokens are listed and accepted.</p>
<ol class="model-list">{''.join(model_cards)}</ol>
</aside>
</main>
<script>
const models = {models_json};
const runModeSelect = document.querySelector('#run-mode');
const replaySessionInput = document.querySelector('input[name="replay_session"]');
const replayMarkerOptions = document.getElementById('replay-marker-options');
const replayMarkerStatus = document.getElementById('replay-marker-status');
const playerCountSelect = document.querySelector('#player-count');
const lockedPlayerCount = document.getElementById('locked-player-count');
const playerLockStatus = document.getElementById('player-lock-status');
function optionText(model) {{
const ctx = model.context_length ? ` - ${{Number(model.context_length).toLocaleString()}} ctx` : '';
return `${{model.name}} - ${{model.id}}${{ctx}}`;
}}
function refreshPlayer(slot) {{
const providerSelect = document.querySelector(`[data-provider-select="${{slot}}"]`);
const modelSelect = document.querySelector(`[data-model-select="${{slot}}"]`);
const selectedProvider = document.querySelector(`[data-selected-provider="${{slot}}"]`).value;
const selectedModel = document.querySelector(`[data-selected-model="${{slot}}"]`).value;
if (providerSelect.dataset.initialized !== 'true') {{ providerSelect.value = selectedProvider; providerSelect.dataset.initialized = 'true'; }}
const filtered = models.filter((model) => model.provider === providerSelect.value);
modelSelect.innerHTML = '';
filtered.forEach((model) => {{
const option = document.createElement('option');
option.value = model.id;
option.textContent = optionText(model);
if (model.id === selectedModel) option.selected = true;
modelSelect.appendChild(option);
}});
}}
function refreshPlayers() {{
const count = Number(playerCountSelect.value);
for (let slot = 1; slot <= 4; slot++) {{
document.querySelector(`[data-player-card="${{slot}}"]`).dataset.hidden = slot > count ? 'true' : 'false';
refreshPlayer(slot);
}}
}}
function setPlayerNamesLocked(locked) {{
playerCountSelect.disabled = locked;
playerCountSelect.classList.toggle('locked-control', locked);
lockedPlayerCount.disabled = !locked;
lockedPlayerCount.value = playerCountSelect.value;
for (let slot = 1; slot <= 4; slot++) {{
const input = document.querySelector(`[data-player-name="${{slot}}"]`);
input.readOnly = locked;
}}
playerLockStatus.classList.toggle('hidden', !locked);
}}
async function loadSessionPlayers() {{
if (runModeSelect.value === 'new_game') {{
setPlayerNamesLocked(false);
refreshPlayers();
return;
}}
setPlayerNamesLocked(true);
const sessionName = replaySessionInput.value.trim();
if (!sessionName) {{
playerLockStatus.textContent = 'Choose a session to lock player names from that recording.';
refreshPlayers();
return;
}}
playerLockStatus.textContent = 'Loading player names from session...';
try {{
const response = await fetch(`/session-players?session=${{encodeURIComponent(sessionName)}}`);
const payload = await response.json();
if (!response.ok) {{
playerLockStatus.textContent = payload.error || 'Could not load player names from session.';
refreshPlayers();
return;
}}
const names = (payload.players || []).slice(0, 4);
if (names.length) {{
const count = Math.min(4, Math.max(2, names.length));
playerCountSelect.value = String(count);
lockedPlayerCount.value = String(count);
names.forEach((name, index) => {{
const input = document.querySelector(`[data-player-name="${{index + 1}}"]`);
if (input) input.value = name;
}});
playerLockStatus.textContent = `Locked to session players: ${{names.join(', ')}}. You can change models only.`;
}} else {{
playerLockStatus.textContent = 'No player names were found in this session yet.';
}}
}} catch (error) {{
playerLockStatus.textContent = 'Could not load player names from session.';
}}
refreshPlayers();
}}
function refreshMode() {{
const mode = runModeSelect.value;
const needsReplay = mode !== 'new_game';
document.querySelector('#replay-fields').classList.toggle('hidden', !needsReplay);
replaySessionInput.required = needsReplay;
if (needsReplay) {{
loadReplayMarkers();
loadSessionPlayers();
}} else {{
setPlayerNamesLocked(false);
}}
refreshPlayers();
}}
let replaySessionLoadTimer = null;
function scheduleReplaySessionLoad() {{
clearTimeout(replaySessionLoadTimer);
replaySessionLoadTimer = setTimeout(() => {{
loadReplayMarkers();
loadSessionPlayers();
}}, 200);
}}
async function loadReplayMarkers() {{
const sessionName = replaySessionInput.value.trim();
replayMarkerOptions.innerHTML = '';
if (!sessionName) {{
replayMarkerStatus.textContent = 'Choose a session to see valid replay markers.';
return;
}}
replayMarkerStatus.textContent = 'Loading replay markers...';
try {{
const response = await fetch(`/replay-markers?session=${{encodeURIComponent(sessionName)}}`);
const payload = await response.json();
if (!response.ok) {{
replayMarkerStatus.textContent = payload.error || 'Could not load replay markers.';
return;
}}
payload.markers.forEach((marker) => {{
const option = document.createElement('option');
option.value = marker.value;
option.label = marker.label;
replayMarkerOptions.appendChild(option);
}});
replayMarkerStatus.textContent = payload.markers.length
? `${{payload.markers.length}} valid action markers available. Reactions/table talk are intentionally excluded.`
: 'No action markers were found in this session.';
}} catch (error) {{
replayMarkerStatus.textContent = 'Could not load replay markers.';
}}
}}
function refreshTts() {{
const provider = document.querySelector('#tts-provider').value;
document.querySelectorAll('.gemini-tts').forEach((el) => el.classList.toggle('hidden', provider !== 'gemini'));
document.querySelectorAll('.eleven-tts').forEach((el) => el.classList.toggle('hidden', provider !== 'elevenlabs'));
}}
for (let slot = 1; slot <= 4; slot++) document.querySelector(`[data-provider-select="${{slot}}"]`).addEventListener('change', () => refreshPlayer(slot));
playerCountSelect.addEventListener('change', () => {{
lockedPlayerCount.value = playerCountSelect.value;
refreshPlayers();
}});
runModeSelect.addEventListener('change', refreshMode);
document.querySelector('#tts-provider').addEventListener('change', refreshTts);
replaySessionInput.addEventListener('input', scheduleReplaySessionLoad);
replaySessionInput.addEventListener('change', () => {{
loadReplayMarkers();
loadSessionPlayers();
}});
refreshPlayers(); refreshMode(); refreshTts();
</script>
</body>
</html>"""
def render_starting_page(player_configs: List[Dict[str, Any]], run_mode: str) -> bytes:
rows = "".join(
f"<li>P{index + 1}: {html_lib.escape(player['name'])} - "
f"{html_lib.escape(player.get('llm', {}).get('model_name', 'offline'))}</li>"
for index, player in enumerate(player_configs)
)
return f"""<!doctype html><html><head><meta charset="utf-8"><title>Starting PyCatan</title></head>
<body style="font-family:Segoe UI,Arial,sans-serif;background:#eef2f5;color:#18222d;display:grid;place-items:center;min-height:100vh;">
<main style="background:white;border:1px solid #c9d3de;border-radius:8px;padding:28px;width:min(620px,calc(100% - 32px));">
<h1>Starting game...</h1><p><strong>Mode:</strong> {html_lib.escape(run_mode)}</p><ul>{rows}</ul>
<p>The board will open here as soon as the game server is ready.</p>
<script>async function waitForGame(){{try{{const r=await fetch('/api/game-state',{{cache:'no-store'}});if(r.ok){{window.location.href='/unified';return;}}}}catch(e){{}}setTimeout(waitForGame,1000);}}setTimeout(waitForGame,1000);</script>
</main></body></html>""".encode("utf-8")
def _as_bool_field(fields: Dict[str, str], name: str) -> bool:
return name in fields
def _parse_optional_int(value: str, errors: List[str], label: str) -> Optional[int]:
if not value:
return None
try:
parsed = int(value)
if parsed < 1:
errors.append(f"{label} must be at least 1.")
return parsed
except ValueError:
errors.append(f"{label} must be a number.")
return None
def _parse_float(value: str, default: float, errors: List[str], label: str) -> float:
try:
parsed = float(value or default)
if parsed < 0:
errors.append(f"{label} cannot be negative.")
return parsed
except ValueError:
errors.append(f"{label} must be a number.")
return default
def _settings_selection_from_query(query: Dict[str, List[str]]) -> Dict[str, Any]:
mode = (query.get("mode") or query.get("run_mode") or [""])[0].strip()
session = (query.get("session") or query.get("replay_session") or [""])[0].strip()
selected: Dict[str, Any] = {}
if mode in {"new_game", "resume_session", "watch_replay", "analyse_game"}:
selected["run_mode"] = mode
if session:
selected["replay_session"] = session
if mode in {"watch_replay", "analyse_game"}:
selected["tts_provider"] = "off"
return selected
def _infer_session_player_names(session_dir: Path) -> List[str]:
"""Infer locked player names for a replay/resume source session."""
names = infer_players_from_session(session_dir)
if names:
return names[:4]
try:
decisions = load_replay_decision_chain(session_dir)
except Exception:
return []
return infer_players_from_decisions(decisions)[:4]
def collect_settings(port: int = 5000, key_mode: str = "env") -> Dict[str, Any]:
settings: Dict[str, Any] = {}
ready = threading.Event()
models = fetch_openrouter_models(os.environ.get("OPENROUTER_API_KEY", ""))
suitable_by_id = {model["id"]: model for model in models if is_suitable_openrouter_model(model)}
valid_model_ids = set(suitable_by_id)
valid_providers = {model["provider"] for model in models}
use_env_keys = key_mode == "env"
class ReusableThreadingHTTPServer(ThreadingHTTPServer):
allow_reuse_address = True
class SettingsHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
return
def _send_html(self, body: Any, status: int = 200) -> None:
body_bytes = body if isinstance(body, bytes) else body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body_bytes)))
self.end_headers()
self.wfile.write(body_bytes)
def _send_json(self, payload: Dict[str, Any], status: int = 200) -> None:
body_bytes = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body_bytes)))
self.end_headers()
self.wfile.write(body_bytes)
def do_GET(self):
parsed_url = urlparse(self.path)
if parsed_url.path == "/healthz":
self._send_json({"ok": True})
return
if parsed_url.path == "/replay-markers":
query = parse_qs(parsed_url.query)
session_ref = query.get("session", [""])[0].strip()
if not session_ref:
self._send_json({"markers": []})
return
try:
session_dir = resolve_session_path(session_ref)
markers = list_replay_marker_options(session_dir)
except Exception as exc:
self._send_json({"error": str(exc), "markers": []}, status=400)
return
self._send_json({"markers": markers})
return
if parsed_url.path == "/session-players":
query = parse_qs(parsed_url.query)
session_ref = query.get("session", [""])[0].strip()
if not session_ref:
self._send_json({"players": []})
return
try:
session_dir = resolve_session_path(session_ref)
players = _infer_session_player_names(session_dir)
except Exception as exc:
self._send_json({"error": str(exc), "players": []}, status=400)
return
self._send_json({"players": players})
return
if parsed_url.path == "/":
self._send_html(render_landing_page())
return
if parsed_url.path != "/settings":
self.send_response(302)
self.send_header("Location", "/")
self.end_headers()
return
selected = _settings_selection_from_query(parse_qs(parsed_url.query))
self._send_html(render_settings_page(models, key_mode, selected=selected))
def do_POST(self):
if self.path != "/start":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
raw_fields = parse_qs(self.rfile.read(length).decode("utf-8"), keep_blank_values=True)
fields = {key: values[0].strip() for key, values in raw_fields.items()}
errors: List[str] = []
run_mode = fields.get("run_mode", "new_game")
valid_run_modes = {"new_game", "resume_session", "watch_replay", "analyse_game"}
if run_mode not in valid_run_modes:
errors.append("Choose a valid run mode.")
live_mode = run_mode in {"new_game", "resume_session"}
no_llm = _as_bool_field(fields, "no_llm")
replay_speak = _as_bool_field(fields, "replay_speak")
api_key = fields.get("openrouter_api_key") or (os.environ.get("OPENROUTER_API_KEY", "") if use_env_keys else "")
if live_mode and not no_llm and not api_key:
errors.append("Enter an OpenRouter API key or set OPENROUTER_API_KEY.")
replay_session = fields.get("replay_session", "")
if run_mode != "new_game" and not replay_session:
errors.append("Choose a recorded session for replay/resume/analyse mode.")
if fields.get("replay_through") and fields.get("replay_stop_before"):
errors.append("Use either replay-through or replay-stop-before, not both.")
replay_session_path_for_validation = None
locked_player_names: List[str] = []
if run_mode != "new_game" and replay_session:
try:
replay_session_path_for_validation = resolve_session_path(replay_session)
locked_player_names = _infer_session_player_names(replay_session_path_for_validation)
except FileNotFoundError as exc:
errors.append(str(exc))
if replay_session_path_for_validation and (fields.get("replay_through") or fields.get("replay_stop_before")):
try:
load_replay_decision_chain(
replay_session_path_for_validation,
replay_through=fields.get("replay_through") or None,
replay_stop_before=fields.get("replay_stop_before") or None,
)
except (TypeError, ValueError) as exc:
errors.append(
f"{exc}. Choose one of the suggested action markers; reaction-only table talk is not replayable as a marker."
)
if fields.get("config_path") and not Path(fields["config_path"]).exists():
errors.append("Config file was not found.")
player_count = _parse_optional_int(fields.get("player_count", "4"), errors, "Player count") or 4
if run_mode != "new_game" and locked_player_names:
player_count = min(4, max(2, len(locked_player_names)))
for index, name in enumerate(locked_player_names[:player_count]):
fields[f"player_{index + 1}"] = name
if player_count not in (2, 3, 4):
errors.append("Choose 2, 3, or 4 players.")
player_count = 4
fields["player_count"] = str(player_count)
replay_max_decisions = _parse_optional_int(fields.get("replay_max_decisions", ""), errors, "Replay max decisions")
replay_delay = _parse_float(fields.get("replay_delay", "2.5"), 2.5, errors, "Replay delay")
replay_text_lead = _parse_float(fields.get("replay_text_lead", "0.25"), 0.25, errors, "Replay text lead")
reaction_mode = fields.get("reaction_mode", "default")
if reaction_mode not in {"default", "off", "sync", "async"}:
errors.append("Choose a valid reaction mode.")
reaction_batch_size = _parse_optional_int(fields.get("reaction_batch_size", ""), errors, "Reaction batch size")
victory_points = _parse_optional_int(fields.get("victory_points", "5"), errors, "Victory points") or 5
fields["victory_points"] = str(victory_points)
game_context = fields.get("game_context", "").strip()
if len(game_context) > 4000:
errors.append("Additional game context must be 4000 characters or less.")
random_seed = 0
if fields.get("random_seed"):
try:
random_seed = int(fields["random_seed"])
except ValueError:
errors.append("Random seed must be a whole number.")
tts_provider = fields.get("tts_provider", "gemini")
if tts_provider not in {"off", "gemini", "elevenlabs"}:
errors.append("Choose a valid voice provider.")
gemini_api_key = fields.get("gemini_api_key") or (os.environ.get("GEMINI_API_KEY", "") if use_env_keys else "")
elevenlabs_api_key = fields.get("elevenlabs_api_key") or (os.environ.get("ELEVENLABS_API_KEY", "") if use_env_keys else "")
elevenlabs_voice_id = fields.get("elevenlabs_default_voice_id") or (os.environ.get("ELEVENLABS_DEFAULT_VOICE_ID", "") if use_env_keys else "")
needs_voice = live_mode or replay_speak
if tts_provider == "gemini" and needs_voice and not gemini_api_key:
errors.append("Enter a Gemini API key for Gemini TTS or set GEMINI_API_KEY.")
if tts_provider == "elevenlabs" and needs_voice:
if not elevenlabs_api_key:
errors.append("Enter an ElevenLabs API key or set ELEVENLABS_API_KEY.")
if not elevenlabs_voice_id:
errors.append("Enter an ElevenLabs default voice ID or set ELEVENLABS_DEFAULT_VOICE_ID.")
slot_llms: List[Dict[str, str]] = []
player_configs: List[Dict[str, Any]] = []
seen_names = set()
for index in range(player_count):
slot = index + 1
name = fields.get(f"player_{slot}") or DEFAULT_PLAYER_NAMES[index]
model_id = fields.get(f"manual_model_{slot}") or fields.get(f"model_{slot}") or ""
provider = fields.get(f"provider_{slot}") or provider_from_model_id(model_id)
if run_mode == "new_game":
if not name:
errors.append(f"P{slot} needs a name.")
if name.lower() in seen_names:
errors.append("Player names must be unique.")
seen_names.add(name.lower())
if provider and provider not in valid_providers:
errors.append(f"Unknown provider for P{slot}: {provider}")
if live_mode and not no_llm:
if not model_id:
errors.append(f"P{slot} needs a model.")
elif model_id not in valid_model_ids:
errors.append(
f"Model for P{slot} is not suitable. Choose a listed model with tools, "
f"response_format, text output, >= {MIN_CONTEXT_LENGTH} context and "
f">= {MIN_COMPLETION_TOKENS} output tokens."
)
llm = {
"provider": "openrouter",
"model_provider": provider_from_model_id(model_id),
"model_name": model_id,
"api_key_env_var": "OPENROUTER_API_KEY",
}
slot_llms.append(llm)
player_configs.append({"name": name, "is_ai": True, "color": PLAYER_COLORS[index], "llm": llm})
if errors:
self._send_html(render_settings_page(models, key_mode, errors=errors, selected=fields), status=400)
return
if api_key:
os.environ["OPENROUTER_API_KEY"] = api_key
if gemini_api_key:
os.environ["GEMINI_API_KEY"] = gemini_api_key
if tts_provider == "gemini":
os.environ["TTS_PROVIDER"] = "gemini"
os.environ["GEMINI_TTS_ENABLED"] = "true"
os.environ["GEMINI_TTS_MODEL_ID"] = fields.get("gemini_tts_model", GEMINI_TTS_MODELS[0])
os.environ["GEMINI_TTS_VOICE_NAME"] = fields.get("gemini_tts_voice", "Kore")
os.environ.setdefault("GEMINI_TTS_PLAY_AUDIO", "true")
elif tts_provider == "elevenlabs":
os.environ["TTS_PROVIDER"] = "elevenlabs"
os.environ["ELEVENLABS_TTS_ENABLED"] = "true"
os.environ["ELEVENLABS_API_KEY"] = elevenlabs_api_key
os.environ["ELEVENLABS_DEFAULT_VOICE_ID"] = elevenlabs_voice_id
os.environ["ELEVENLABS_TTS_MODEL_ID"] = fields.get("elevenlabs_tts_model", "eleven_v3")
os.environ.setdefault("ELEVENLABS_TTS_OUTPUT_FORMAT", "pcm_16000")
os.environ.setdefault("ELEVENLABS_TTS_PLAY_AUDIO", "true")
else:
os.environ["TTS_PROVIDER"] = "off"
settings.update({
"run_mode": run_mode,
"player_configs": player_configs if run_mode == "new_game" else [],
"slot_llms": slot_llms,
"chat_language": normalize_chat_language(fields.get("chat_language") or "hebrew"),
"no_llm": no_llm,
"reaction_mode": reaction_mode,
"reaction_batch_size": reaction_batch_size,
"victory_points": victory_points,
"game_context": game_context,
"random_seed": random_seed,
"config_path": fields.get("config_path") or None,
"replay_session": replay_session or None,
"replay_max_decisions": replay_max_decisions,
"replay_through": fields.get("replay_through") or None,
"replay_stop_before": fields.get("replay_stop_before") or None,
"replay_skip_chat": _as_bool_field(fields, "replay_skip_chat"),
"replay_delay": replay_delay,
"replay_text_lead": replay_text_lead,
"replay_speak": replay_speak,
})
starting_players = player_configs if run_mode == "new_game" else [
{"name": replay_session or "recorded session", "llm": {"model_name": run_mode}}
]
self._send_html(render_starting_page(starting_players, run_mode))
ready.set()
bind_host = os.environ.get("PYCATAN_BIND_HOST", "127.0.0.1")
public_host = os.environ.get("PYCATAN_PUBLIC_HOST", "localhost")
server = ReusableThreadingHTTPServer((bind_host, port), SettingsHandler)
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()
url = f"http://{public_host}:{port}/settings"
print(f"[SETUP] OpenRouter settings page: {url}")
if os.environ.get("PYCATAN_NO_BROWSER", "").lower() not in {"1", "true", "yes", "on"}:
try:
webbrowser.open(url)
except Exception as exc:
print(f"[SETUP] Could not open browser automatically: {exc}")
print("[SETUP] Waiting for OpenRouter settings...")
try:
while not ready.wait(timeout=0.25):
pass
finally:
server.shutdown()
server.server_close()
return settings
def _player_configs_for_replay(player_names: List[str], slot_llms: List[Dict[str, str]]) -> List[Dict[str, Any]]:
configs = []
for index, name in enumerate(player_names[:4]):
llm = slot_llms[index] if index < len(slot_llms) else slot_llms[-1]
configs.append({"name": name, "is_ai": True, "color": PLAYER_COLORS[index], "llm": llm})
return configs
def _apply_reaction_settings(ai_config, settings: Dict[str, Any]) -> None:
reaction_mode = settings["reaction_mode"]
if reaction_mode == "off":
ai_config.agent.enable_reactions = False
elif reaction_mode == "async":
ai_config.agent.enable_reactions = True
ai_config.agent.async_reactions = True
elif reaction_mode == "sync":
ai_config.agent.enable_reactions = True
ai_config.agent.async_reactions = False
if settings["reaction_batch_size"] is not None:
ai_config.agent.reaction_max_batch_messages = settings["reaction_batch_size"]
def main() -> None:
import argparse
parser = argparse.ArgumentParser(description="Play Catan with per-agent OpenRouter models")
parser.add_argument("--config", type=str, help="Optional AI config YAML")
parser.add_argument("--port", type=int, default=5000, help="Setup and game web port")
parser.add_argument("--use-env-keys", action="store_true", help="Use ENV/.env keys when browser fields are blank")
parser.add_argument("--ask-api-keys", "--ask-keys", action="store_true", help="Require keys typed into the browser form")
args = parser.parse_args()
if args.use_env_keys and args.ask_api_keys:
parser.error("--use-env-keys and --ask-api-keys cannot be used together")
load_env_file()
settings = collect_settings(port=args.port, key_mode=("ask" if args.ask_api_keys else "env"))
ai_config = load_ai_config(settings.get("config_path") or args.config)
ai_config.llm.provider = "openrouter"
ai_config.llm.api_key_env_var = "OPENROUTER_API_KEY"
ai_config.llm.enable_streaming = True
ai_config.agent.chat_language = settings["chat_language"]
_apply_reaction_settings(ai_config, settings)
replay_session_path = resolve_session_path(settings["replay_session"]) if settings["replay_session"] else None
watch_mode = settings["run_mode"] in {"watch_replay", "analyse_game"}
analyse_mode = settings["run_mode"] == "analyse_game"
if watch_mode and not replay_session_path:
parser.error("watch/analyse mode requires a replay session")
if (
watch_mode
and replay_session_path
and not os.environ.get("AI_TTS_CACHE_DIR")
and not os.environ.get("PYCATAN_TTS_CACHE_DIR")
):
os.environ["AI_TTS_CACHE_DIR"] = str(replay_session_path / "tts_cache")
os.environ["AI_TTS_CACHE_DIR_AUTO"] = "replay_session_default"
print(f"[REPLAY] TTS cache: {os.environ['AI_TTS_CACHE_DIR']}")
elif not watch_mode and not os.environ.get("AI_TTS_CACHE_DIR") and not os.environ.get("PYCATAN_TTS_CACHE_DIR"):
print("[TTS] Voice cache: per-session tts_cache/")
replay_decision_list: List[Dict[str, Any]] = []
replay_decisions_by_player: Dict[str, List[Dict[str, Any]]] = {}
if replay_session_path:
replay_decision_list = load_replay_decision_chain(
replay_session_path,
max_decisions=settings["replay_max_decisions"],
replay_through=settings["replay_through"],
replay_stop_before=settings["replay_stop_before"],
)
replay_decisions_by_player = group_replay_decisions(replay_decision_list)
player_names = infer_players_from_session(replay_session_path) or infer_players_from_decisions(replay_decision_list)
player_configs = _player_configs_for_replay(player_names, settings["slot_llms"])
print(f"[REPLAY] Source: {replay_session_path}")
print(f"[REPLAY] Loaded {len(replay_decision_list)} parsed decisions")
else:
player_configs = settings["player_configs"]
if not player_configs:
parser.error("No players could be inferred for this run")
if not settings["no_llm"]:
ai_config.llm.model_name = player_configs[0]["llm"]["model_name"]
send_to_llm = (not settings["no_llm"]) and not watch_mode
manual_actions = False
print(f"[MODE] LLM: {'ON' if send_to_llm else 'OFF'} | Actions: Auto")
print(f"[CONFIG] Victory points to win: {settings['victory_points']}")
if settings.get("game_context"):
print("[CONFIG] Additional game context enabled")
print("[CONFIG] OpenRouter per-agent models")
game_manager, ai_manager, web_viz = create_game(
player_configs,
send_to_llm=send_to_llm,
manual_actions=manual_actions,
config=ai_config,
replay_decisions=replay_decisions_by_player,
replay_chat=not settings["replay_skip_chat"],
replay_speak=(settings["replay_speak"] and not watch_mode),
replay_only=watch_mode,
web_port=args.port,
random_seed=settings["random_seed"],
game_config={
"victory_points": settings["victory_points"],
"game_context": settings.get("game_context", ""),
},
)
if send_to_llm:
for player in player_configs:
ai_manager.set_agent_llm_config(
player_name=player["name"],
provider=player["llm"]["provider"],
model_name=player["llm"]["model_name"],
api_key_env_var=player["llm"]["api_key_env_var"],
)
print(f"[OPENROUTER] {player['name']}: {player['llm']['model_name']}")
if replay_session_path:
annotate_replay_session(
ai_manager,
replay_session_path,
replay_decision_list,
settings["replay_through"],
settings["replay_stop_before"],
mode=(
"analyse_game_visual_playback"
if analyse_mode
else "watch_replay_visual_playback"
if watch_mode
else "fast_action_replay_then_live_ai"
),
)
print(f"[REPLAY] New derived session: {ai_manager.get_session_path()}")
if watch_mode:
run_replay_viewer(
game_manager,
ai_manager,
web_viz,
delay_seconds=max(0.0, settings["replay_delay"]),
source_session=replay_session_path,
text_lead_seconds=max(0.0, settings["replay_text_lead"]),
)
else:
run_game(game_manager, ai_manager, web_viz)
if __name__ == "__main__":
main()