Files

1 line
111 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "aihubmix/model-showdown", "files": {"showdown.config.json": {"65d4b5a87927aef2b7116e97f02aa3dde934fb19": {"t": 1785768970.4413335, "n": 425, "h": "e795539de08345a5", "c": "{\n \"gateway\": \"https://aihubmix.com/v1/chat/completions\",\n \"api_key_env\": \"AIHUBMIX_API_KEY\",\n \"brand\": {\n \"tagline\": \"AIHubMix: Unified API, 800+ models\",\n \"watermark\": \"Made with model-showdown \\u00b7 aihubmix.com\",\n \"gallery\": \"https://aihubmix.github.io/model-showdown/\"\n },\n \"web\": {\n \"server_domain\": \"https://aihubmix.com\",\n \"caption_model\": \"qwen3-vl-flash\"\n },\n \"models\": {},\n \"pricing\": {}\n}\n"}}, "run_showdown.py": {"65d4b5a87927aef2b7116e97f02aa3dde934fb19": {"t": 1785768995.1336634, "n": 41694, "h": "fed8c2c3a7b6afda", "c": "#!/usr/bin/env python3\n\"\"\"One-shot model showdown: same prompt -> N models via aihubmix chat/completions.\n\nUsage: python3 run_showdown.py <episode_dir> [--models m1,m2,...] [--record SECONDS]\nReads <episode_dir>/task.md, writes work_<model>/index.html, raw_<model>.json, metrics.json\n\n- Streams (SSE) with a 30s heartbeat log — long-thinking models (Kimi K3) get their\n connection killed by upstream proxies if no bytes flow for ~10-15 min.\n- metrics.json is written incrementally after EACH model finishes (a killed run\n loses nothing).\n- --record N pipelines recording: each model's artifact is recorded for N seconds\n as soon as its generation finishes, overlapping the slower models' generation.\n\"\"\"\nimport argparse\nimport base64\nimport concurrent.futures as cf\nimport glob as globmod\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nimport threading\nimport time\nimport urllib.request\nfrom datetime import datetime, timezone\n\nSCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))\n\n# showdown.config.json externalizes gateway/brand/model overrides so outside\n# users can self-deploy BYOK; when absent, fall back to built-in defaults so\n# the old workflow is unaffected.\nCONFIG_PATH = os.path.join(SCRIPT_DIR, \"showdown.config.json\")\nCONFIG = {}\nif os.path.exists(CONFIG_PATH):\n with open(CONFIG_PATH) as _f:\n CONFIG = json.load(_f)\n\nGATEWAY = os.environ.get(\"SHOWDOWN_GATEWAY\") \\\n or CONFIG.get(\"gateway\", \"https://aihubmix.com/v1/chat/completions\")\nAPI_KEY_ENV = CONFIG.get(\"api_key_env\", \"AIHUBMIX_API_KEY\")\n# Key validation lives in main(), not at import time: callers like the webapp\n# must be able to import MODELS/PRICING without a key (BYOK keys are passed to\n# the subprocess per-job via environment variables).\nAPI_KEY = os.environ.get(API_KEY_ENV)\n\n\ndef auth_headers(kind):\n \"\"\"kind: openai / anthropic / google — send the key header matching the protocol.\"\"\"\n if kind == \"anthropic\":\n return {\"x-api-key\": API_KEY}\n if kind == \"google\":\n return {\"x-goog-api-key\": API_KEY}\n return {\"Authorization\": f\"Bearer {API_KEY}\"}\n\n# USD per 1M tokens (input, output)\nPRICING = {\n \"kimi-k3\": (3.0, 15.0),\n \"claude-opus-4-8\": (5.0, 25.0),\n \"claude-opus-4-8-think\": (5.0, 25.0),\n \"claude-fable-5\": (10.0, 50.0),\n \"gpt-5.6-sol\": (5.0, 30.0),\n # qwen3.8-max-preview has no official per-token list price (subscription\n # preview); estimated from the previous-gen qwen3.7-max list price for now\n \"qwen3.8-max-preview\": (1.25, 3.75),\n \"gemini-3.6-flash\": (1.50, 7.50),\n}\n\n# display name, max_tokens override (K3 always thinks at max effort — reasoning\n# eats the budget). No fallback channels: the badge must reflect the real model\n# (coding-* channels bill differently and would misrepresent identity/cost).\n# Default lineup = every model at its MAX reasoning mode:\n# kimi-k3 always reasons at max effort (not configurable)\n# claude-opus-4-8-think is the thinking variant of Opus 4.8\n# gpt-5.6-sol gets reasoning_effort=max, its top tier (verified effective on chat/completions)\n# \"params\" is merged verbatim into the request payload.\nMODELS = {\n \"kimi-k3\": {\"display\": \"Kimi K3\", \"max_tokens\": 160000, \"retries\": 12},\n \"claude-opus-4-8-think\": {\"display\": \"Claude Opus 4.8\", \"max_tokens\": 100000},\n # effort is ONLY effective via /v1/responses — chat/completions silently\n # drops reasoning_effort for this model (verified by 3x3 A/B test)\n \"gpt-5.6-sol\": {\"display\": \"GPT-5.6 Sol\", \"max_tokens\": 100000,\n \"endpoint\": \"responses\",\n \"params\": {\"reasoning\": {\"effort\": \"max\"}}},\n # non-thinking / substitute / guest contestants — NOT in the default lineup,\n # pick explicitly via --models\n \"claude-opus-4-8\": {\"display\": \"Claude Opus 4.8\", \"lineup\": False},\n # Fable 5: adaptive reasoning via native /v1/messages (NOT chat/completions).\n # 128000 is claude-fable-5's hard ceiling on the gateway (verified via\n # direct curl 400). max_tokens must always be set explicitly — omitting it\n # does NOT fall back to that ceiling, it silently drops to ~4096, which the\n # thinking budget blows through instantly (finish_reason=length, empty\n # content).\n # endpoint=messages: switched from chat/completions hoping incremental\n # content_block_delta events would keep the wire hot — this did NOT fix\n # anything, both endpoints behave identically for this model/gateway, and\n # neither is worse than the other. Kept endpoint=messages since it's the\n # architecturally correct protocol.\n # ROOT CAUSE (confirmed via AIHubMix backend tid export, ep04): for a\n # real task (ref image + long prompt) the model needs ~12-13min before\n # its *first* content byte reaches the gateway (all of it is silent\n # server-side \"thinking\" — no partial frames, on either endpoint). Some\n # intermediary (proxy/LB — ours or AIHubMix's) kills idle client\n # connections at ~10-11min, i.e. 1-2min *before* that first byte would\n # arrive. This is NOT proportional to budget_tokens — 100k/40k/18k budgets\n # all died at the same ~10-11min mark, and the real run still used 47k\n # thinking_tokens despite budget_tokens=18000 (budget_tokens is a soft\n # hint here, not a hard cap). So lowering the budget doesn't help; the\n # generation itself still needs the same wall-clock time regardless.\n # reasoning:{effort:\"max\",display:\"summarized\"} was also tried as a\n # workaround: 400 invalid field on /v1/messages; on /v1/chat/completions\n # the request never even registered on the AIHubMix dashboard — dead end.\n # WORKAROUND when this happens: the generation still completes\n # successfully server-side even though the client connection dies —\n # ask the user to pull the AIHubMix dashboard record for the request's\n # tid (shown in error bodies, or found via the dashboard's request log)\n # and export it; the complete response (incl. the ```html block) can be\n # recovered from there and written directly to work_<model>/index.html,\n # no need to re-run generation. See episodes/ep04's recovery for tid\n # 2026072014131970707025835020540.\n \"claude-fable-5\": {\"display\": \"Claude Fable 5\", \"max_tokens\": 128000, \"lineup\": False,\n \"endpoint\": \"messages\",\n \"params\": {\"thinking\": {\"type\": \"enabled\", \"budget_tokens\": 40000}}},\n # Qwen3.8 Max Preview: generous output budget for large HTML artifacts;\n # opt in explicitly via --models\n \"qwen3.8-max-preview\": {\"display\": \"Qwen3.8 Max\", \"max_tokens\": 120000, \"lineup\": False},\n # Gemini 3.6 Flash (released 2026-07-21): native Google generateContent\n # protocol via the gateway's /gemini passthrough (x-goog-api-key auth,\n # SSE candidates[].content.parts[].text deltas). 65536 is its hard output\n # ceiling per the official API spec.\n \"gemini-3.6-flash\": {\"display\": \"Gemini 3.6 Flash\", \"max_tokens\": 65536, \"lineup\": False,\n \"endpoint\": \"gemini\",\n \"params\": {\"generationConfig\": {\"maxOutputTokens\": 65536,\n \"thinkingConfig\": {\"thinkingLevel\": \"high\"}}}},\n}\n# models/pricing in the config override/extend the built-in tables (a matching\n# key replaces the whole entry)\nPRICING.update({k: tuple(v) for k, v in CONFIG.get(\"pricing\", {}).items()})\nMODELS.update(CONFIG.get(\"models\", {}))\n# Ad-hoc models the webapp picks dynamically from the model catalog endpoint\n# (/api/v1/models) are injected via env and only apply to this process\nMODELS.update(json.loads(os.environ.get(\"SHOWDOWN_EXTRA_MODELS\") or \"{}\"))\nPRICING.update({k: tuple(v) for k, v in\n json.loads(os.environ.get(\"SHOWDOWN_EXTRA_PRICING\") or \"{}\").items()})\nDEFAULT_MAX_TOKENS = 60000\n\nREQUEST_TIMEOUT_S = 3600 # per socket read while streaming\n# Used to be 1200s: with reasoning=max + large multimodal input (images), real\n# generation time reliably exceeds 30 minutes (TTFT alone is 11-12 min), so at\n# 1200s the CLIENT would preemptively cut the connection while the server was\n# still streaming fine — not a gateway/upstream problem, the value was too low.\nRETRIES = 3\nRETRY_WAIT_S = 60\nHEARTBEAT_S = 30\n\n\ndef sniff_image_mime(data, ext):\n \"\"\"Determine mime from the actual bytes first, not the file extension.\n Anthropic (claude) strictly checks that the declared media type matches\n the real bytes — a png/jpeg mismatch is an immediate 400; a .png file\n holding jpeg bytes once wasted a whole generation round and forced a\n restart. The extension is only a last-resort fallback.\"\"\"\n if data[:3] == b\"\\xff\\xd8\\xff\":\n return \"jpeg\"\n if data[:8] == b\"\\x89PNG\\r\\n\\x1a\\n\":\n return \"png\"\n if data[:4] == b\"RIFF\" and data[8:12] == b\"WEBP\":\n return \"webp\"\n if data[:6] in (b\"GIF87a\", b\"GIF89a\"):\n return \"gif\"\n return {\"jpg\": \"jpeg\", \"jpeg\": \"jpeg\", \"png\": \"png\", \"webp\": \"webp\",\n \"gif\": \"gif\"}.get(ext, \"png\")\n\n\ndef build_user_content(prompt, ep_dir):\n \"\"\"Text-only content, or multimodal [text + image_url] when the episode\n ships a reference image (ref.png / ref.jpg).\"\"\"\n refs = sorted(r for r in globmod.glob(f\"{ep_dir}/ref*\")\n if r.rsplit(\".\", 1)[-1].lower() in (\"png\", \"jpg\", \"jpeg\", \"webp\", \"gif\"))\n if not refs:\n return prompt\n parts = [{\"type\": \"text\", \"text\": prompt}]\n for ref in refs:\n ext = ref.rsplit(\".\", 1)[-1].lower()\n with open(ref, \"rb\") as f:\n raw = f.read()\n mime = sniff_image_mime(raw, ext)\n b64 = base64.b64encode(raw).decode()\n parts.append({\"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:image/{mime};base64,{b64}\"}})\n return parts\n\n\ndef to_responses_input(content):\n \"\"\"Convert chat-style user content into Responses-API input parts.\"\"\"\n if isinstance(content, str):\n return content\n parts = []\n for p in content:\n if p[\"type\"] == \"text\":\n parts.append({\"type\": \"input_text\", \"text\": p[\"text\"]})\n elif p[\"type\"] == \"image_url\":\n parts.append({\"type\": \"input_image\", \"image_url\": p[\"image_url\"][\"url\"]})\n return [{\"role\": \"user\", \"content\": parts}]\n\n\ndef to_messages_content(content):\n \"\"\"Convert chat-style user content (text + image_url) into Anthropic\n Messages API native content blocks (text + image/base64).\"\"\"\n if isinstance(content, str):\n return content\n parts = []\n for p in content:\n if p[\"type\"] == \"text\":\n parts.append({\"type\": \"text\", \"text\": p[\"text\"]})\n elif p[\"type\"] == \"image_url\":\n header, b64 = p[\"image_url\"][\"url\"].split(\",\", 1)\n media_type = header.split(\":\")[1].split(\";\")[0]\n parts.append({\"type\": \"image\",\n \"source\": {\"type\": \"base64\", \"media_type\": media_type, \"data\": b64}})\n return parts\n\n\ndef to_gemini_parts(content):\n \"\"\"Convert chat-style user content (text + image_url) into Gemini native\n generateContent `parts` (text / inline_data base64).\"\"\"\n if isinstance(content, str):\n return [{\"text\": content}]\n parts = []\n for p in content:\n if p[\"type\"] == \"text\":\n parts.append({\"text\": p[\"text\"]})\n elif p[\"type\"] == \"image_url\":\n header, b64 = p[\"image_url\"][\"url\"].split(\",\", 1)\n mime_type = header.split(\":\")[1].split(\";\")[0]\n parts.append({\"inline_data\": {\"mime_type\": mime_type, \"data\": b64}})\n return parts\n\n\ndef _iso_now():\n return datetime.now(timezone.utc).isoformat()\n\n\nclass Timeline:\n \"\"\"Per-request client-side event timeline (UTC timestamps), written\n incrementally to ep_dir/timeline_<label>.json. Purpose: when a\n long-thinking model's connection dies mid-stream, this file lets you\n line up OUR observed timestamps (first_byte, first_content_delta,\n connection_dropped) directly against the AIHubMix backend's tid export\n timestamps for the same request — proving (not just inferring) whether\n the client disconnected before real content was sent, instead of relying\n on statistical correlation across separate runs. Survives a mid-stream\n exception since every mark() flushes immediately.\"\"\"\n def __init__(self, ep_dir, label, model, endpoint):\n self.ep_dir = ep_dir\n self.label = label\n self.t0 = time.time()\n self.data = {\n \"requested\": model, \"label\": label, \"endpoint\": endpoint,\n \"request_sent_utc\": _iso_now(), \"events\": [], \"outcome\": None,\n }\n # Flush immediately: during a long silent TTFT (max-effort thinking can sit\n # 10+ min before the first byte) this file is the only evidence the request\n # is actually in flight — without it the run looks like the model was skipped.\n self.flush()\n\n def mark(self, kind, **extra):\n self.data[\"events\"].append({\n \"t_rel_s\": round(time.time() - self.t0, 2),\n \"utc\": _iso_now(),\n \"kind\": kind,\n **extra,\n })\n self.flush()\n\n def finish(self, outcome):\n self.data[\"outcome\"] = outcome\n self.flush()\n\n def flush(self):\n if not self.ep_dir:\n return\n path = os.path.join(self.ep_dir, f\"timeline_{self.label}.json\")\n tmp = path + \".tmp\"\n with open(tmp, \"w\") as f:\n json.dump(self.data, f, ensure_ascii=False, indent=1)\n os.replace(tmp, path)\n\n\ndef call_model_messages(model, prompt, label, ep_dir=None):\n \"\"\"Same contract as call_model, but via the native Anthropic /v1/messages\n protocol (x-api-key auth, content_block_delta events). Needed for\n claude-fable-5: on /v1/chat/completions its thinking phase transmits as\n ONE opaque non-incremental marker — the whole thinking duration is a\n silent wire, which the upstream proxy idle-kills after ~10-15min on long\n thinking (verified: 3/3 failures at ~10.3min avg with a 100k budget).\n On /v1/messages, content streams as incremental content_block_delta\n events once generation starts, keeping the connection hot.\"\"\"\n cfg = MODELS.get(model, {})\n payload = {\n \"model\": model,\n \"max_tokens\": cfg.get(\"max_tokens\", DEFAULT_MAX_TOKENS),\n \"messages\": [{\"role\": \"user\", \"content\": to_messages_content(prompt)}],\n \"stream\": True,\n }\n payload.update(cfg.get(\"params\", {}))\n req = urllib.request.Request(\n GATEWAY.replace(\"/chat/completions\", \"/messages\"),\n data=json.dumps(payload).encode(),\n headers={\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\",\n **auth_headers(\"anthropic\"),\n },\n )\n content, usage, finish, err = [], {}, None, None\n chunks = 0\n t0 = time.time()\n next_beat = t0 + HEARTBEAT_S\n conn_dropped = None\n tl = Timeline(ep_dir, label, model, \"messages\")\n first_byte = first_content = False\n try:\n with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_S) as resp:\n for raw in resp:\n if not first_byte:\n first_byte = True\n tl.mark(\"first_byte\")\n now = time.time()\n if now >= next_beat:\n tl.mark(\"heartbeat\", events=chunks,\n content_chars=sum(len(c) for c in content))\n print(f\"[{label}] streaming(messages)… {chunks} events, \"\n f\"{sum(len(c) for c in content)} content chars, \"\n f\"{now - t0:.0f}s elapsed\", flush=True)\n next_beat = now + HEARTBEAT_S\n line = raw.decode(\"utf-8\", \"replace\").strip()\n if not line.startswith(\"data:\"):\n continue\n try:\n ev = json.loads(line[5:].strip())\n except ValueError:\n continue\n chunks += 1\n etype = ev.get(\"type\", \"\")\n if etype == \"content_block_delta\":\n delta = ev.get(\"delta\") or {}\n if delta.get(\"type\") == \"text_delta\":\n if not first_content:\n first_content = True\n tl.mark(\"first_content_delta\", events=chunks)\n content.append(delta.get(\"text\", \"\"))\n elif etype == \"message_start\":\n u = (ev.get(\"message\") or {}).get(\"usage\") or {}\n if u:\n usage[\"prompt_tokens\"] = u.get(\"input_tokens\", 0)\n elif etype == \"message_delta\":\n d = ev.get(\"delta\") or {}\n if d.get(\"stop_reason\"):\n finish = d[\"stop_reason\"]\n u = ev.get(\"usage\") or {}\n if u.get(\"output_tokens\") is not None:\n usage[\"completion_tokens\"] = u[\"output_tokens\"]\n usage[\"total_tokens\"] = usage.get(\"prompt_tokens\", 0) + u[\"output_tokens\"]\n elif etype == \"error\":\n err = (ev.get(\"error\") or {}).get(\"message\", \"stream error\")\n except Exception as e: # noqa: BLE001\n # See call_model: salvage partial text on a mid-stream connection drop\n # instead of discarding a possibly-complete generation.\n conn_dropped = str(e)\n tl.mark(\"connection_dropped\", events=chunks,\n content_chars=sum(len(c) for c in content), error=conn_dropped)\n tl.finish(\"connection_dropped\")\n print(f\"[{label}] connection dropped after {chunks} events, \"\n f\"{sum(len(c) for c in content)} content chars: {conn_dropped}\", flush=True)\n if not content:\n raise\n if not conn_dropped:\n tl.mark(\"stream_end\", events=chunks, content_chars=sum(len(c) for c in content))\n tl.finish(\"error\" if err else \"ok\")\n if err:\n return {\"error\": {\"message\": err}}\n if conn_dropped:\n finish = finish or \"connection_dropped\"\n return {\n \"model\": model,\n \"usage\": usage,\n \"choices\": [{\n \"message\": {\"role\": \"assistant\", \"content\": \"\".join(content)},\n \"finish_reason\": finish,\n }],\n }\n\n\ndef call_model_responses(model, prompt, label, ep_dir=None):\n \"\"\"Same contract as call_model, but via /v1/responses (needed where\n reasoning effort is only honored on this endpoint).\"\"\"\n cfg = MODELS.get(model, {})\n payload = {\n \"model\": model,\n \"input\": to_responses_input(prompt),\n \"max_output_tokens\": cfg.get(\"max_tokens\", DEFAULT_MAX_TOKENS),\n \"stream\": True,\n }\n payload.update(cfg.get(\"params\", {}))\n req = urllib.request.Request(\n GATEWAY.replace(\"/chat/completions\", \"/responses\"),\n data=json.dumps(payload).encode(),\n headers={\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\",\n **auth_headers(\"openai\"),\n },\n )\n text, usage, status, err = [], {}, None, None\n chunks = 0\n t0 = time.time()\n next_beat = t0 + HEARTBEAT_S\n conn_dropped = None\n tl = Timeline(ep_dir, label, model, \"responses\")\n first_byte = first_content = False\n try:\n with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_S) as resp:\n for raw in resp:\n if not first_byte:\n first_byte = True\n tl.mark(\"first_byte\")\n now = time.time()\n if now >= next_beat:\n tl.mark(\"heartbeat\", events=chunks,\n content_chars=sum(len(c) for c in text))\n print(f\"[{label}] streaming(responses)… {chunks} events, \"\n f\"{sum(len(c) for c in text)} content chars, \"\n f\"{now - t0:.0f}s elapsed\", flush=True)\n next_beat = now + HEARTBEAT_S\n line = raw.decode(\"utf-8\", \"replace\").strip()\n if not line.startswith(\"data:\"):\n continue\n try:\n ev = json.loads(line[5:].strip())\n except ValueError:\n continue\n chunks += 1\n etype = ev.get(\"type\", \"\")\n if etype == \"response.output_text.delta\":\n if not first_content:\n first_content = True\n tl.mark(\"first_content_delta\", events=chunks)\n text.append(ev.get(\"delta\") or \"\")\n elif etype in (\"response.completed\", \"response.incomplete\", \"response.failed\"):\n r = ev.get(\"response\") or {}\n status = r.get(\"status\")\n u = r.get(\"usage\") or {}\n usage = {\n \"prompt_tokens\": u.get(\"input_tokens\", 0),\n \"completion_tokens\": u.get(\"output_tokens\", 0),\n \"total_tokens\": u.get(\"total_tokens\", 0),\n \"completion_tokens_details\": u.get(\"output_tokens_details\") or {},\n }\n if etype == \"response.failed\":\n err = (r.get(\"error\") or {}).get(\"message\", \"response.failed\")\n elif etype == \"error\":\n err = ev.get(\"message\", \"stream error\")\n except Exception as e: # noqa: BLE001\n # See call_model: salvage partial text on a mid-stream connection drop\n # instead of discarding a possibly-complete generation.\n conn_dropped = str(e)\n tl.mark(\"connection_dropped\", events=chunks,\n content_chars=sum(len(c) for c in text), error=conn_dropped)\n tl.finish(\"connection_dropped\")\n print(f\"[{label}] connection dropped after {chunks} events, \"\n f\"{sum(len(c) for c in text)} content chars: {conn_dropped}\", flush=True)\n if not text:\n raise\n if not conn_dropped:\n tl.mark(\"stream_end\", events=chunks, content_chars=sum(len(c) for c in text))\n tl.finish(\"error\" if err else \"ok\")\n if err:\n return {\"error\": {\"message\": err}}\n finish = {\"completed\": \"stop\", \"incomplete\": \"length\"}.get(status, status)\n if conn_dropped and not finish:\n finish = \"connection_dropped\"\n return {\n \"model\": model,\n \"usage\": usage,\n \"choices\": [{\n \"message\": {\"role\": \"assistant\", \"content\": \"\".join(text)},\n \"finish_reason\": finish,\n }],\n }\n\n\ndef call_model_gemini(model, prompt, label, ep_dir=None):\n \"\"\"Same contract as call_model, but via the native Google generateContent\n protocol (x-goog-api-key auth, SSE candidates[].content.parts[].text\n deltas) — see aihubmix-env skill §2.3.\"\"\"\n cfg = MODELS.get(model, {})\n payload = {\n \"contents\": [{\"role\": \"user\", \"parts\": to_gemini_parts(prompt)}],\n \"generationConfig\": {\"maxOutputTokens\": cfg.get(\"max_tokens\", DEFAULT_MAX_TOKENS)},\n }\n payload.update(cfg.get(\"params\", {}))\n base = GATEWAY.rsplit(\"/v1/\", 1)[0]\n req = urllib.request.Request(\n f\"{base}/gemini/v1beta/models/{model}:streamGenerateContent?alt=sse\",\n data=json.dumps(payload).encode(),\n headers={\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\",\n **auth_headers(\"google\"),\n },\n )\n content, usage, finish, err = [], {}, None, None\n chunks = 0\n t0 = time.time()\n next_beat = t0 + HEARTBEAT_S\n conn_dropped = None\n tl = Timeline(ep_dir, label, model, \"gemini\")\n first_byte = first_content = False\n try:\n with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_S) as resp:\n for raw in resp:\n if not first_byte:\n first_byte = True\n tl.mark(\"first_byte\")\n now = time.time()\n if now >= next_beat:\n tl.mark(\"heartbeat\", events=chunks,\n content_chars=sum(len(c) for c in content))\n print(f\"[{label}] streaming(gemini)… {chunks} events, \"\n f\"{sum(len(c) for c in content)} content chars, \"\n f\"{now - t0:.0f}s elapsed\", flush=True)\n next_beat = now + HEARTBEAT_S\n line = raw.decode(\"utf-8\", \"replace\").strip()\n if not line.startswith(\"data:\"):\n continue\n try:\n ev = json.loads(line[5:].strip())\n except ValueError:\n continue\n chunks += 1\n if \"error\" in ev:\n err = (ev.get(\"error\") or {}).get(\"message\", \"stream error\")\n continue\n for cand in ev.get(\"candidates\") or []:\n for part in (cand.get(\"content\") or {}).get(\"parts\") or []:\n text = part.get(\"text\")\n if text:\n if not first_content:\n first_content = True\n tl.mark(\"first_content_delta\", events=chunks)\n content.append(text)\n if cand.get(\"finishReason\"):\n finish = cand[\"finishReason\"]\n u = ev.get(\"usageMetadata\") or {}\n if u:\n usage = {\n \"prompt_tokens\": u.get(\"promptTokenCount\", 0),\n \"completion_tokens\": u.get(\"candidatesTokenCount\", 0),\n \"total_tokens\": u.get(\"totalTokenCount\", 0),\n \"thoughts_tokens\": u.get(\"thoughtsTokenCount\", 0),\n }\n except Exception as e: # noqa: BLE001\n # See call_model: salvage partial text on a mid-stream connection drop\n # instead of discarding a possibly-complete generation.\n conn_dropped = str(e)\n tl.mark(\"connection_dropped\", events=chunks,\n content_chars=sum(len(c) for c in content), error=conn_dropped)\n tl.finish(\"connection_dropped\")\n print(f\"[{label}] connection dropped after {chunks} events, \"\n f\"{sum(len(c) for c in content)} content chars: {conn_dropped}\", flush=True)\n if not content:\n raise\n if not conn_dropped:\n tl.mark(\"stream_end\", events=chunks, content_chars=sum(len(c) for c in content))\n tl.finish(\"error\" if err else \"ok\")\n if err:\n return {\"error\": {\"message\": err}}\n finish = {\"STOP\": \"stop\", \"MAX_TOKENS\": \"length\"}.get(finish, finish)\n if conn_dropped:\n finish = finish or \"connection_dropped\"\n return {\n \"model\": model,\n \"usage\": usage,\n \"choices\": [{\n \"message\": {\"role\": \"assistant\", \"content\": \"\".join(content)},\n \"finish_reason\": finish,\n }],\n }\n\n\ndef call_model(model, prompt, label, ep_dir=None):\n \"\"\"Stream the completion (SSE) and reassemble an OpenAI-style response dict.\"\"\"\n endpoint = MODELS.get(model, {}).get(\"endpoint\")\n if endpoint == \"responses\":\n return call_model_responses(model, prompt, label, ep_dir)\n if endpoint == \"messages\":\n return call_model_messages(model, prompt, label, ep_dir)\n if endpoint == \"gemini\":\n return call_model_gemini(model, prompt, label, ep_dir)\n payload = {\n \"model\": model,\n \"messages\": [{\"role\": \"user\", \"content\": prompt}],\n \"stream\": True,\n \"stream_options\": {\"include_usage\": True},\n }\n max_tokens = MODELS.get(model, {}).get(\"max_tokens\", DEFAULT_MAX_TOKENS)\n if max_tokens is not None: # None = omit entirely, let the gateway pick its own ceiling\n payload[\"max_tokens\"] = max_tokens\n payload.update(MODELS.get(model, {}).get(\"params\", {}))\n req = urllib.request.Request(\n GATEWAY,\n data=json.dumps(payload).encode(),\n headers={\n \"Content-Type\": \"application/json\",\n \"Accept\": \"text/event-stream\",\n **auth_headers(\"openai\"),\n },\n )\n content, usage, finish, model_echo, err = [], {}, None, None, None\n chunks = 0\n t0 = time.time()\n next_beat = t0 + HEARTBEAT_S\n conn_dropped = None\n tl = Timeline(ep_dir, label, model, \"chat/completions\")\n first_byte = first_content = False\n try:\n with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_S) as resp:\n for raw in resp:\n if not first_byte:\n first_byte = True\n tl.mark(\"first_byte\")\n now = time.time()\n if now >= next_beat:\n tl.mark(\"heartbeat\", chunks=chunks,\n content_chars=sum(len(c) for c in content))\n print(f\"[{label}] streaming… {chunks} chunks, \"\n f\"{sum(len(c) for c in content)} content chars, \"\n f\"{now - t0:.0f}s elapsed\", flush=True)\n next_beat = now + HEARTBEAT_S\n line = raw.decode(\"utf-8\", \"replace\").strip()\n if not line.startswith(\"data:\"):\n continue\n data = line[5:].strip()\n if data == \"[DONE]\":\n break\n try:\n chunk = json.loads(data)\n except ValueError:\n continue\n chunks += 1\n if \"error\" in chunk:\n err = chunk[\"error\"]\n break\n model_echo = chunk.get(\"model\") or model_echo\n if chunk.get(\"usage\"):\n usage = chunk[\"usage\"]\n for ch in chunk.get(\"choices\") or []:\n delta = ch.get(\"delta\") or {}\n if delta.get(\"content\"):\n if not first_content:\n first_content = True\n tl.mark(\"first_content_delta\", chunks=chunks)\n content.append(delta[\"content\"])\n if ch.get(\"finish_reason\"):\n finish = ch[\"finish_reason\"]\n except Exception as e: # noqa: BLE001\n # Connection can die mid-stream (upstream idle-kill on a long silent\n # thinking phase — seen with claude-fable-5). Don't throw away\n # whatever content already arrived: the model may have finished (or\n # nearly finished) generating server-side even though the socket\n # never delivered the tail. Salvage it and let extract_html decide.\n conn_dropped = str(e)\n tl.mark(\"connection_dropped\", chunks=chunks,\n content_chars=sum(len(c) for c in content), error=conn_dropped)\n tl.finish(\"connection_dropped\")\n print(f\"[{label}] connection dropped after {chunks} chunks, \"\n f\"{sum(len(c) for c in content)} content chars: {conn_dropped}\", flush=True)\n if not content:\n raise\n if not conn_dropped:\n tl.mark(\"stream_end\", chunks=chunks, content_chars=sum(len(c) for c in content))\n tl.finish(\"error\" if err else \"ok\")\n if err:\n return {\"error\": err}\n if conn_dropped:\n finish = finish or \"connection_dropped\"\n return {\n \"model\": model_echo,\n \"usage\": usage,\n \"choices\": [{\n \"message\": {\"role\": \"assistant\", \"content\": \"\".join(content)},\n \"finish_reason\": finish,\n }],\n }\n\n\ndef extract_html(content):\n blocks = re.findall(r\"```(?:html)?\\s*\\n(.*?)```\", content, re.DOTALL)\n html_blocks = [b for b in blocks if \"<html\" in b.lower() or \"<!doctype\" in b.lower()]\n if html_blocks:\n return max(html_blocks, key=len).strip()\n stripped = content.strip()\n if stripped.lower().startswith(\"<!doctype\") or stripped.lower().startswith(\"<html\"):\n return stripped\n if blocks:\n return max(blocks, key=len).strip()\n return None\n\n\nFFMPEG = [\"npx\", \"--no-install\", \"--prefix\", os.path.join(SCRIPT_DIR, \"video\"),\n \"remotion\", \"ffmpeg\"] # Remotion bundles ffmpeg — no system install needed\n\n\ndef _luma(webm, t):\n \"\"\"Mean luma (0-255) of the webm at t seconds; -1 if unavailable.\"\"\"\n try:\n from PIL import Image, ImageStat\n except ImportError:\n return -1\n p = os.path.join(SCRIPT_DIR, \"_luma_probe.png\")\n subprocess.run(FFMPEG + [\"-y\", \"-loglevel\", \"error\", \"-ss\", str(t), \"-i\", webm,\n \"-frames:v\", \"1\", p], capture_output=True)\n if not os.path.exists(p):\n return -1\n v = ImageStat.Stat(Image.open(p).convert(\"L\")).mean[0]\n os.remove(p)\n return v\n\n\ndef trim_leading_black(webm, floor=45, step=0.5, upto=18):\n \"\"\"Trim black frames at the start of a recording (models often make the\n start screen a black title card) so the file plays content from frame one →\n compositing can always use startFrom=0 and play from the top, no manual\n frame-picking. Silently skips when PIL is unavailable.\n Why trim rather than skip ahead: auto-demo triggers on \"no input\", so the\n recorder cannot inject a keypress to start the run (that would stall on the\n title screen) — those black title-screen seconds inevitably get recorded\n and can only be cut afterwards.\"\"\"\n if _luma(webm, 0) < 0: # no PIL/ffmpeg, skip\n return\n t = 0.0\n while t < upto and _luma(webm, t) < floor:\n t += step\n if t <= 0.01 or t >= upto: # first frame already has content / all black: leave it\n return\n tmp = webm + \".trim.webm\"\n r = subprocess.run(FFMPEG + [\"-y\", \"-loglevel\", \"error\", \"-i\", webm, \"-ss\", str(round(t, 2)),\n \"-c:v\", \"libvpx\", \"-deadline\", \"realtime\", \"-cpu-used\", \"6\",\n \"-b:v\", \"6M\", \"-an\", tmp], capture_output=True)\n if r.returncode == 0 and os.path.exists(tmp) and os.path.getsize(tmp) > 0:\n os.replace(tmp, webm)\n print(f\"[trim] cut {t:.1f}s black title screen → {os.path.basename(webm)} \"\n f\"now starts on a content frame\", flush=True)\n elif os.path.exists(tmp):\n os.remove(tmp)\n\n\ndef record_artifact(ep_dir, model, seconds, rec_w=None, rec_h=None):\n \"\"\"Record one model's artifact (called as soon as its generation finishes).\n rec_w/rec_h set the recording viewport: landscape content (e.g. racing)\n MUST pass 1280x720, or the default 720x960 portrait squashes the widescreen\n picture — this once forced 3 rounds of re-recording.\"\"\"\n cmd = [\"node\", os.path.join(SCRIPT_DIR, \"record.mjs\"), ep_dir,\n \"--seconds\", str(seconds), \"--model\", model]\n if rec_w:\n cmd += [\"--width\", str(rec_w)]\n if rec_h:\n cmd += [\"--height\", str(rec_h)]\n t0 = time.time()\n proc = subprocess.run(cmd, capture_output=True, text=True, timeout=seconds + 120)\n ok = proc.returncode == 0\n print(f\"[{model}] recording {'done' if ok else 'FAILED'} \"\n f\"in {time.time() - t0:.0f}s\", flush=True)\n if not ok:\n print(f\"[{model}] record stderr: {proc.stderr[-500:]}\", flush=True)\n return ok\n # Ensure a non-black first frame: auto-trim the leading black title screen\n # so startFrom=0 shows content immediately\n trim_leading_black(os.path.join(ep_dir, \"recordings\", f\"{model}.webm\"))\n return ok\n\n\ndef run_one(requested, prompt, ep_dir, record_seconds, rec_w=None, rec_h=None):\n meta = MODELS.get(requested, {\"display\": requested})\n retries = meta.get(\"retries\", RETRIES)\n last_err = None\n for attempt in range(1, retries + 1):\n t0 = time.time()\n try:\n data = call_model(requested, prompt, requested, ep_dir)\n except Exception as e: # noqa: BLE001\n last_err = f\"attempt {attempt}: {e}\"\n print(f\"[{requested}] FAIL {last_err}\", flush=True)\n time.sleep(RETRY_WAIT_S)\n continue\n if \"error\" in data:\n last_err = f\"attempt {attempt}: {data['error'].get('message', data['error'])}\"\n print(f\"[{requested}] API ERROR {last_err}\", flush=True)\n time.sleep(RETRY_WAIT_S)\n continue\n latency = time.time() - t0\n with open(f\"{ep_dir}/raw_{requested}.json\", \"w\") as f:\n json.dump(data, f, ensure_ascii=False, indent=1)\n content = data[\"choices\"][0][\"message\"][\"content\"] or \"\"\n html = extract_html(content)\n if not html and attempt < retries:\n last_err = f\"attempt {attempt}: 200 OK but no code in response\"\n print(f\"[{requested}] EMPTY {last_err}\", flush=True)\n time.sleep(RETRY_WAIT_S)\n continue\n work = f\"{ep_dir}/work_{requested}\"\n os.makedirs(work, exist_ok=True)\n if html:\n with open(f\"{work}/index.html\", \"w\") as f:\n f.write(html)\n usage = data.get(\"usage\", {})\n pin, pout = PRICING.get(requested, (0, 0))\n cost = (usage.get(\"prompt_tokens\", 0) * pin + usage.get(\"completion_tokens\", 0) * pout) / 1e6\n print(f\"[{requested}] OK in {latency:.0f}s, cost ${cost:.2f}, \"\n f\"html={'yes' if html else 'NO'}\", flush=True)\n result = {\n \"requested\": requested,\n \"display\": meta[\"display\"],\n \"served_by\": requested,\n \"model_echo\": data.get(\"model\"),\n \"latency_s\": round(latency, 1),\n \"usage\": usage,\n \"cost_usd\": round(cost, 4),\n \"finish_reason\": data[\"choices\"][0].get(\"finish_reason\"),\n \"code_extracted\": bool(html),\n \"code_lines\": html.count(\"\\n\") + 1 if html else 0,\n \"error\": None,\n }\n if html and record_seconds:\n result[\"recorded\"] = record_artifact(ep_dir, requested, record_seconds,\n rec_w, rec_h)\n return result\n return {\"requested\": requested, \"display\": meta[\"display\"], \"error\": last_err,\n \"code_extracted\": False}\n\n\ndef next_episode_dir():\n \"\"\"Auto-increment: episodes/ep01, ep02, … under the script's directory.\"\"\"\n base = os.path.join(SCRIPT_DIR, \"episodes\")\n nums = [int(m.group(1)) for d in globmod.glob(f\"{base}/ep*\")\n if (m := re.match(r\"ep(\\d+)$\", os.path.basename(d)))]\n return os.path.join(base, f\"ep{(max(nums) if nums else 0) + 1:02d}\")\n\n\ndef main():\n ap = argparse.ArgumentParser()\n ap.add_argument(\"episode_dir\", nargs=\"?\", default=None,\n help=\"omit to auto-create the next episodes/epNN\")\n ap.add_argument(\"--task\", metavar=\"FILE\",\n help=\"prompt file to copy into the episode as task.md\")\n ap.add_argument(\"--ref\", metavar=\"IMAGE\", action=\"append\", default=[],\n help=\"reference image(s) to send along with the prompt\")\n ap.add_argument(\"--models\",\n default=\",\".join(m for m, c in MODELS.items() if c.get(\"lineup\", True)))\n ap.add_argument(\"--record\", type=int, default=0, metavar=\"SECONDS\",\n help=\"record each artifact for N seconds as soon as it lands\")\n ap.add_argument(\"--rec-size\", metavar=\"WxH\", default=\"720x960\",\n help=\"recording viewport; use 1280x720 for landscape content \"\n \"(racing/wide scenes), default 720x960 portrait\")\n args = ap.parse_args()\n if not API_KEY:\n sys.exit(f\"missing API key: export {API_KEY_ENV}=sk-... \"\n f\"(any OpenAI-compatible gateway works; default is aihubmix.com)\")\n try:\n rec_w, rec_h = (int(x) for x in args.rec_size.lower().split(\"x\"))\n except ValueError:\n sys.exit(f\"--rec-size must look like 1280x720, got: {args.rec_size}\")\n\n if args.episode_dir:\n ep_dir = args.episode_dir.rstrip(\"/\")\n if not os.path.isabs(ep_dir):\n ep_dir = os.path.join(os.getcwd(), ep_dir)\n else:\n ep_dir = next_episode_dir()\n os.makedirs(ep_dir, exist_ok=True)\n print(f\"episode: {ep_dir}\", flush=True)\n\n if args.task:\n with open(args.task) as f:\n task_text = f.read()\n with open(f\"{ep_dir}/task.md\", \"w\") as f:\n f.write(task_text)\n for i, ref in enumerate(args.ref):\n ext = ref.rsplit(\".\", 1)[-1].lower()\n with open(ref, \"rb\") as src, open(f\"{ep_dir}/ref{i or ''}.{ext}\", \"wb\") as dst:\n dst.write(src.read())\n\n if not os.path.exists(f\"{ep_dir}/task.md\"):\n sys.exit(f\"no task.md in {ep_dir} — pass --task FILE or create it first\")\n with open(f\"{ep_dir}/task.md\") as f:\n prompt = build_user_content(f.read(), ep_dir)\n models = args.models.split(\",\")\n\n t0 = time.time()\n results = []\n lock = threading.Lock()\n metrics_path = f\"{ep_dir}/metrics.json\"\n\n def flush_metrics(done):\n payload = {\"task\": f\"{ep_dir}/task.md\", \"wall_s\": round(time.time() - t0, 1),\n \"done\": done, \"total\": len(models), \"results\": results}\n tmp = metrics_path + \".tmp\"\n with open(tmp, \"w\") as f:\n json.dump(payload, f, ensure_ascii=False, indent=1)\n os.replace(tmp, metrics_path)\n\n with cf.ThreadPoolExecutor(max_workers=len(models)) as ex:\n futs = {ex.submit(run_one, m, prompt, ep_dir, args.record, rec_w, rec_h): m\n for m in models}\n for fut in cf.as_completed(futs):\n with lock:\n results.append(fut.result())\n flush_metrics(len(results))\n print(json.dumps({\"wall_s\": round(time.time() - t0, 1), \"results\": results},\n ensure_ascii=False, indent=1))\n if not all(r[\"code_extracted\"] for r in results):\n sys.exit(1)\n\n\nif __name__ == \"__main__\":\n main()\n"}}, "webapp.py": {"65d4b5a87927aef2b7116e97f02aa3dde934fb19": {"t": 1785768995.1642225, "n": 65313, "h": "325e3981cd4088be", "c": "#!/usr/bin/env python3\n\"\"\"Self-hosted web UI for showdown — create, browse, and inspect comparison runs.\n\n python3 webapp.py [--port 7788] [--host 127.0.0.1]\n\nLayout: first screen is the create form; below it, a feed of every generated\nepisode (episodes/epNN + episodes/web/*). Clicking a card opens the detail\nview — left side shows every model's recording auto-playing, right side pins\nthe prompt + shared run info, and clicking a work reveals its per-model\nparams, response stats, and wire protocol.\n\nKey acquisition — paste an sk-key (BYOK); memory-only, never stored or logged.\n\nVisibility: every episode with a recorded artifact shows in the \"Runs\" feed by\ndefault. Manual takedown: add the ep-id to episodes/removed.json (takedown.py)\nand its feed entry, /watch/<id> and /media/<id>/* all 404 immediately; the\nsubmitter's private /job/<id> page keeps working.\n\nMVP for trusted networks — binds 127.0.0.1 by default; put real auth in front\nbefore exposing it anywhere public.\n\"\"\"\nimport argparse\nimport glob\nimport html\nimport json\nimport os\nimport re\nimport secrets\nimport subprocess\nimport sys\nimport threading\nimport time\nimport urllib.parse\nimport urllib.request\nfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer\n\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\nimport run_showdown # noqa: E402 (for MODELS + API_KEY_ENV; import needs no key)\nimport showdown_db as db # noqa: E402 (MySQL via SHOWDOWN_DB_URL, else SQLite)\n\nSCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))\nEPISODES_DIR = os.path.join(SCRIPT_DIR, \"episodes\")\nWEB_EP_DIR = os.path.join(EPISODES_DIR, \"web\")\nREMOVED_PATH = os.path.join(EPISODES_DIR, \"removed.json\")\nKEY_ENV = run_showdown.API_KEY_ENV\n\n_web_cfg = run_showdown.CONFIG.get(\"web\", {})\n# env sentinel \"none\": explicitly disables the capability (deploy hosts don't edit the\n# repo's config file)\ndef _cfg(env_key, cfg_key, default=\"\"):\n v = os.environ.get(env_key) or _web_cfg.get(cfg_key, default)\n return \"\" if v == \"none\" else v\n\n\nSERVER_DOMAIN = _cfg(\"SHOWDOWN_SERVER_DOMAIN\", \"server_domain\",\n \"https://aihubmix.com\").rstrip(\"/\")\n# Reverse-proxy prefix (e.g. SHOWDOWN_BASE_PATH=/showdown mounted at\n# example.com/showdown/); all generated in-site URLs carry B, and incoming\n# requests strip it before routing\nB = os.environ.get(\"SHOWDOWN_BASE_PATH\", \"\").rstrip(\"/\")\n# Behind the reverse proxy, the LB rewrites the Host header to the origin FQDN when\n# forwarding (so Caddy can pick the site / issue certs), while the browser's Referer\n# carries the public domain — same-site checks must count the public domain as \"this site\"\nPUBLIC_HOST = os.environ.get(\"SHOWDOWN_PUBLIC_HOST\", \"\")\nCAPTION_MODEL = os.environ.get(\"SHOWDOWN_CAPTION_MODEL\") or _web_cfg.get(\"caption_model\", \"gpt-4o-mini\")\n\n# Instruction for reference image -> build prompt: the core goal is faithful reproduction —\n# pin down layout / palette / materials / motion item by item\nCAPTION_INSTRUCTION = \"\"\"You are a meticulous visual analyst helping write a build brief for a coding exercise. First, study the attached image and describe exactly what it shows. Then write that up as a prompt asking a model to build a similar scene/interface as ONE self-contained HTML file.\n\nThe prompt you write must:\n1) Lay out the structure: every visible element/object, its position and proportions in the frame.\n2) Pin down the visual system in implementation-ready detail — color palette (hex codes), typography, spacing, materials, lighting, shadows, glow. The goal is that the result closely matches the image, so explicitly call out framing, proportions, and details that are easy to get wrong.\n3) Specify motion: what should animate and how; require an auto-demo that starts by itself ~1s after load with no user input.\n4) Technical constraints: single self-contained HTML file, no external assets.\n5) End with exactly: Return ONLY the complete HTML file in a single ```html code block.\n\nOutput just the prompt text, no preamble, no commentary.\"\"\"\n\n# Job metadata lives in the DB (survives restarts); pasted sk-keys stay in memory only —\n# a deliberate security design. A restart loses keys of queued manual jobs\n# (recover_on_boot marks them failed with a resubmit hint)\nSECRETS = {} # job_id -> pasted key\nSECRETS_LOCK = threading.Lock()\n\nPROTO = {\"responses\": \"OpenAI /v1/responses\", \"messages\": \"Anthropic /v1/messages\",\n \"gemini\": \"Google generateContent\"}\n\n# ---------- AIHubmix Design System (tokens.css subset, light theme) ----------\nCSS = \"\"\"\n@import url('https://fonts.googleapis.com/css2?family=Old+Standard+TT:wght@400;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap');\n:root {\n --ah-primary:#2563EB; --ah-primary-hover:#6180F9; --ah-primary-pressed:#0049DF;\n --ah-bg:#FCFCFC; --ah-card:#FFFFFF;\n --ah-success:#A4E522; --ah-success-text:#5C991F; --ah-warning-text:#E59100;\n --ah-danger-text:#F2320C; --ah-disabled:rgba(53,57,65,0.10);\n --ah-fg-1:#111111; --ah-fg-2:#353941; --ah-fg-3:#626773;\n --ah-border:rgba(17,17,17,0.08); --ah-border-strong:rgba(17,17,17,0.16);\n --ah-font-display:'Old Standard TT','Noto Serif SC',serif;\n --ah-font-body:'Inter','Noto Sans SC','PingFang SC',-apple-system,system-ui,sans-serif;\n --ah-font-mono:'JetBrains Mono','SF Mono',Menlo,monospace;\n --ah-shadow-card:0px 2px 8px rgba(0,0,0,0.08);\n --ah-shadow-lift:0px 8px 24px rgba(0,0,0,0.10);\n --ah-ease:cubic-bezier(0.2,0.8,0.2,1);\n}\n* { box-sizing:border-box; }\nbody { margin:0; background:var(--ah-bg); color:var(--ah-fg-2);\n font-family:var(--ah-font-body); font-size:16px; line-height:1.55;\n touch-action:manipulation; }\n/* Keyboard accessibility: unified focus ring on all interactive elements */\n:is(a,button,input,select,textarea,[tabindex]):focus-visible {\n outline:none; box-shadow:0 0 0 3px rgba(37,99,235,0.3); }\nbutton { cursor:pointer; }\n.spinner { display:inline-block; width:11px; height:11px; border-radius:50%;\n border:2px solid rgba(37,99,235,0.25); border-top-color:var(--ah-primary);\n animation:spin 0.8s linear infinite; vertical-align:-1px; }\n@keyframes spin { to { transform:rotate(360deg); } }\ntextarea.drafting { color:var(--ah-fg-3); background:rgba(17,17,17,0.02); }\n@media (prefers-reduced-motion: reduce) {\n *, *::before, *::after { transition-duration:0.01ms !important;\n animation-duration:0.01ms !important; }\n}\n.wrap { max-width:1080px; margin:0 auto; padding:40px 24px 80px; }\nh1 { font-family:var(--ah-font-display); font-size:32px; color:var(--ah-fg-1);\n font-weight:700; margin:0 0 4px; }\nh2 { font-family:var(--ah-font-display); font-size:24px; color:var(--ah-fg-1);\n font-weight:700; margin:40px 0 16px; }\n.sub { color:var(--ah-fg-3); font-size:14px; margin-bottom:28px; }\n.card { background:var(--ah-card); border:1px solid var(--ah-border);\n border-radius:16px; box-shadow:var(--ah-shadow-card); padding:24px; margin-bottom:24px; }\nlabel { display:block; font-size:14px; font-weight:600; color:var(--ah-fg-1); margin:16px 0 6px; }\nlabel:first-child { margin-top:0; }\n.hint { font-weight:400; color:var(--ah-fg-3); font-size:12px; margin-left:6px; }\ntextarea, input[type=text], input[type=password], select {\n width:100%; border:1px solid var(--ah-border-strong); border-radius:8px;\n padding:10px 12px; font-family:var(--ah-font-body); font-size:14px; color:var(--ah-fg-2);\n background:var(--ah-card); outline:none; transition:border-color 120ms var(--ah-ease), box-shadow 120ms var(--ah-ease); }\ntextarea:focus, input:focus, select:focus { border-color:var(--ah-primary);\n box-shadow:0 0 0 3px rgba(37,99,235,0.15); }\ntextarea { min-height:110px; font-family:var(--ah-font-mono); font-size:13px; resize:vertical; }\n.models { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:8px; }\n.model-opt { display:flex; align-items:center; gap:8px; margin:0;\n border:1px solid var(--ah-border);\n border-radius:8px; padding:7px 12px; font-size:13.5px; cursor:pointer;\n transition:background 120ms var(--ah-ease); }\n.model-opt:hover { background:rgba(17,17,17,0.03); }\n.model-opt code { font-family:var(--ah-font-mono); font-size:10.5px; color:var(--ah-fg-3); }\n.mo-txt { display:flex; flex-direction:column; line-height:1.35; min-width:0; }\n.mo-txt > span { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }\n.row { display:flex; gap:16px; }\n.row > div { flex:1; }\n.btn { display:inline-flex; align-items:center; gap:8px; background:var(--ah-primary);\n color:#fff; border:0; border-radius:8px; padding:12px 24px; font-size:15px;\n font-weight:600; font-family:var(--ah-font-body); cursor:pointer; margin-top:20px;\n transition:background 120ms var(--ah-ease); }\n.btn:hover { background:var(--ah-primary-hover); }\n.btn:active { background:var(--ah-primary-pressed); transform:scale(0.98); }\n.btn:disabled { opacity:0.5; cursor:default; transform:none; }\n.btn2 { display:inline-flex; align-items:center; background:var(--ah-card);\n color:var(--ah-primary); border:1px solid var(--ah-primary); border-radius:8px;\n padding:8px 16px; font-size:14px; font-weight:600; cursor:pointer;\n transition:background 120ms var(--ah-ease); }\n.btn2:hover { background:rgba(37,99,235,0.06); }\n.tag { display:inline-block; border-radius:8px; padding:2px 10px; font-size:12px; font-weight:600; }\n.tag.queued { background:var(--ah-disabled); color:var(--ah-fg-3); }\n.tag.running { background:rgba(245,182,39,0.14); color:var(--ah-warning-text); }\n.tag.done { background:rgba(164,229,34,0.18); color:var(--ah-success-text); }\n.tag.failed { background:rgba(242,80,48,0.12); color:var(--ah-danger-text); }\n.tag.arena { background:rgba(38,204,235,0.14); color:#0e7490; }\n.tag.web { background:rgba(102,38,235,0.10); color:#6626EB; }\npre.log { background:#1e1e1e; color:#e0e0e0; border-radius:8px; padding:16px;\n font-family:var(--ah-font-mono); font-size:12px; line-height:1.5; overflow-x:auto;\n max-height:420px; overflow-y:auto; }\ntable { width:100%; border-collapse:collapse; font-size:14px; }\ntd, th { text-align:left; padding:8px 10px; border-bottom:1px solid var(--ah-border); }\nth { color:var(--ah-fg-3); font-size:12px; font-weight:600; }\na { color:var(--ah-primary); text-decoration:none; }\na:hover { color:var(--ah-primary-hover); }\n.files a { display:inline-block; margin:4px 12px 4px 0; font-family:var(--ah-font-mono); font-size:13px; }\n.opts { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:0 16px; }\n/* watch page comparison table: one column per model, works on top, metrics below */\n.cmp-wrap { overflow-x:auto; }\n.cmp { width:100%; border-collapse:collapse; table-layout:fixed; min-width:560px; }\n.cmp th, .cmp td { text-align:left; vertical-align:top; padding:6px 12px;\n border-top:1px solid var(--ah-border); }\n.cmp tr.cmp-vid th, .cmp tr.cmp-vid td { border-top:0; padding:0 12px 10px 0; }\n/* fixed layout sizes columns from the FIRST row — the video row's blank th must\n carry the label-column width or every column splits evenly */\n.cmp tr.cmp-vid th { width:84px; }\n.cmp th[scope=row] { width:84px; padding-left:0; padding-right:8px; font-size:11px;\n font-weight:500; color:var(--ah-fg-3); white-space:nowrap; }\n.cmp td { font-family:var(--ah-font-mono); font-size:12.5px; color:var(--ah-fg-2);\n overflow-wrap:break-word; }\n.cmp video { width:100%; border-radius:10px; background:#000; display:block; }\n.cmp .mname { display:flex; align-items:center; gap:7px; font-family:var(--ah-font-body);\n font-weight:600; font-size:14px; color:var(--ah-fg-1); margin:0 0 8px; }\n.dropzone { min-height:150px; display:flex; flex-direction:column; align-items:center;\n justify-content:center; gap:6px; text-align:center;\n border:1.5px dashed var(--ah-border-strong); border-radius:8px; padding:14px;\n cursor:pointer; transition:border-color 120ms var(--ah-ease), background 120ms var(--ah-ease); }\n.dropzone.over { border-color:var(--ah-primary); background:rgba(37,99,235,0.05); }\n.ref-previews { display:flex; gap:10px; flex-wrap:wrap; }\n.ref-previews:not(:empty) { margin-top:10px; }\n.ref-item { position:relative; }\n.ref-item img { width:88px; height:88px; object-fit:cover; border-radius:8px;\n border:1px solid var(--ah-border); display:block; }\n.ref-item button { position:absolute; top:-7px; right:-7px; width:20px; height:20px;\n border-radius:50%; border:0; background:var(--ah-fg-2); color:#fff; font-size:12px;\n line-height:1; cursor:pointer; }\n.model-opt.novision { opacity:0.38; pointer-events:none; }\n\n/* ---------- feed ---------- */\n.feed { display:grid; grid-template-columns:repeat(auto-fill,minmax(240px,1fr)); gap:16px; }\na.feed-card { display:block; background:var(--ah-card); border:1px solid var(--ah-border);\n border-radius:16px; overflow:hidden; color:var(--ah-fg-2); box-shadow:var(--ah-shadow-card);\n transition:transform 120ms var(--ah-ease), box-shadow 120ms var(--ah-ease); }\na.feed-card:hover { transform:translateY(-2px); box-shadow:var(--ah-shadow-lift); color:var(--ah-fg-2); }\n.feed-card .thumb { aspect-ratio:4/3; background:#0b0e14; overflow:hidden; }\n.feed-card .thumb img { width:100%; height:100%; object-fit:cover; display:block; }\n.feed-card .body { padding:12px 14px 14px; }\n.feed-card .t { font-weight:600; font-size:14.5px; color:var(--ah-fg-1);\n overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }\n.feed-card .m { color:var(--ah-fg-3); font-size:12px; margin-top:4px;\n font-family:var(--ah-font-mono); display:flex; gap:8px; align-items:center; flex-wrap:wrap; }\n\n/* ---------- watch (detail) ---------- */\n.watch { display:grid; grid-template-columns:minmax(0,1fr) 300px; gap:24px; }\n@media (max-width:960px) { .watch { grid-template-columns:1fr; } }\n.works { display:grid; gap:16px; }\n.work { background:var(--ah-card); border:2px solid var(--ah-border); border-radius:16px;\n overflow:hidden; cursor:pointer; box-shadow:var(--ah-shadow-card);\n transition:border-color 120ms var(--ah-ease); }\n.work.sel { border-color:var(--ah-primary); }\n.work video { width:100%; display:block; background:#000; }\n.work .wh { display:flex; align-items:center; justify-content:space-between;\n padding:10px 14px; font-size:14px; }\n.work .wh b { color:var(--ah-fg-1); display:flex; align-items:center; gap:8px; }\n.dot { width:10px; height:10px; border-radius:50%; display:inline-block; }\n.cost { font-family:var(--ah-font-mono); color:var(--ah-success-text); font-weight:700; font-size:13px; }\n.side { display:grid; gap:16px; }\n.side .card { margin:0; height:100%; }\npre.prompt { background:rgba(17,17,17,0.03); border:1px solid var(--ah-border);\n border-radius:8px; padding:14px 16px; font-family:var(--ah-font-mono); font-size:12.5px;\n line-height:1.6; max-height:300px; overflow:auto; white-space:pre-wrap; }\n.kv { font-size:13px; }\n.kv div { display:flex; justify-content:space-between; gap:12px; padding:5px 0;\n border-bottom:1px solid var(--ah-border); }\n.kv div:last-child { border-bottom:0; }\n.kv span:first-child { color:var(--ah-fg-3); flex:none; }\n.kv span:last-child { font-family:var(--ah-font-mono); font-size:12px; text-align:right;\n overflow-wrap:anywhere; }\n\"\"\"\n\ndef page(title, body, refresh=None, head=\"\", body_attrs=\"\"):\n meta = f'<meta http-equiv=\"refresh\" content=\"{refresh}\">' if refresh else \"\"\n return f\"\"\"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">{meta}\n<script>window.BASE={json.dumps(B)};</script>{head}\n<title>{html.escape(title)}</title><style>{CSS}</style></head><body{body_attrs}><div class=\"wrap\">\n{body}</div></body></html>\"\"\"\n\n\ndef lineup_models():\n return [(mid, cfg.get(\"display\", mid), cfg.get(\"lineup\", True) is not False)\n for mid, cfg in run_showdown.MODELS.items()]\n\n\n# ---------- model catalog (live gateway list: /api/v1/models?type=llm) ----------\n\n_CATALOG = {\"ts\": 0.0, \"rows\": []}\n_CATALOG_LOCK = threading.Lock()\nMODEL_ID_RE = re.compile(r\"^[A-Za-z0-9._:/-]{1,80}$\")\n\n\ndef infer_endpoint(row):\n \"\"\"Infer the native protocol from catalog row hints: claude_api -> messages,\n responses -> /v1/responses, everything else uses chat/completions\n (returns None = default protocol).\"\"\"\n eps = [t.strip().lower() for t in str(row.get(\"endpoints\") or \"\").split(\",\")]\n mid = str(row.get(\"model_id\") or \"\").lower()\n if \"claude_api\" in eps or \"claude\" in mid:\n return \"messages\"\n if \"responses\" in eps or \"responses_api\" in eps:\n return \"responses\"\n return None\n\n\ndef get_catalog():\n \"\"\"LLM catalog, cached 10 minutes. If the API is down, returns the last result /\n empty list (picker degrades to the built-in models).\"\"\"\n with _CATALOG_LOCK:\n if time.time() - _CATALOG[\"ts\"] < 600 and _CATALOG[\"rows\"]:\n return _CATALOG[\"rows\"]\n try:\n req = urllib.request.Request(f\"{SERVER_DOMAIN}/api/v1/models?type=llm&sort_by=order\")\n with urllib.request.urlopen(req, timeout=15) as r:\n rows = (json.load(r).get(\"data\")) or []\n except Exception: # noqa: BLE001\n return _CATALOG[\"rows\"]\n out = []\n for row in rows:\n mid = row.get(\"model_id\") or row.get(\"id\")\n if not mid or mid == \"auto\" or not MODEL_ID_RE.match(mid):\n continue\n pricing = row.get(\"pricing\") or {}\n modalities = str(row.get(\"input_modalities\") or row.get(\"modalities\") or \"\")\n out.append({\"id\": mid,\n \"name\": row.get(\"model_name\") or row.get(\"name\") or mid,\n \"endpoint\": infer_endpoint(row),\n \"max_output\": int(row.get(\"max_output\") or 0),\n \"image\": \"image\" in modalities.lower(),\n \"input\": float(pricing.get(\"input\") or row.get(\"input_price\") or 0),\n \"output\": float(pricing.get(\"output\") or row.get(\"output_price\") or 0)})\n with _CATALOG_LOCK:\n if out:\n _CATALOG.update(ts=time.time(), rows=out)\n return _CATALOG[\"rows\"]\n\n\ndef picker_models():\n \"\"\"Configured models pinned on top (with tuned params, default lineup pre-checked);\n the rest of the catalog follows. Deduped by display name — ids are no longer\n shown in the picker, so same-named channel variants (e.g. coding-kimi-k3, the\n non-think opus) would look like inexplicable duplicates; keep the first\n (lineup entries first, then catalog order).\"\"\"\n out, seen_ids, seen_names = [], set(), set()\n for mid, cfg in run_showdown.MODELS.items():\n name = cfg.get(\"display\", mid)\n if name in seen_names:\n continue\n pin, pout = run_showdown.PRICING.get(mid, (0, 0))\n out.append({\"id\": mid, \"name\": name, \"configured\": True,\n \"default\": cfg.get(\"lineup\", True) is not False,\n \"image\": True, # built-in lineup all verified for multimodal ref images\n \"input\": pin, \"output\": pout})\n seen_ids.add(mid)\n seen_names.add(name)\n for m in get_catalog():\n if m[\"id\"] in seen_ids or m[\"name\"] in seen_names:\n continue\n out.append(m)\n seen_names.add(m[\"name\"])\n return out\n\n\ndef catalog_model_cfg(mid):\n \"\"\"Build a temporary run_showdown config + list price for a non-built-in model\n picked from the catalog.\"\"\"\n m = next((x for x in get_catalog() if x[\"id\"] == mid), None)\n if not m:\n return None, None\n cfg = {\"display\": m[\"name\"],\n \"max_tokens\": min(m[\"max_output\"], 128000) or 60000}\n if m[\"endpoint\"]:\n cfg[\"endpoint\"] = m[\"endpoint\"]\n return cfg, (m[\"input\"], m[\"output\"])\n\n\n# ---------- episode index ----------\n\ndef load_results(ep_dir):\n results = []\n for path in sorted(glob.glob(f\"{ep_dir}/metrics*.json\")):\n try:\n with open(path) as f:\n for r in json.load(f)[\"results\"]:\n if r.get(\"code_extracted\"):\n results.append(r)\n except (ValueError, KeyError):\n continue\n return results\n\n\ndef arena_episodes():\n \"\"\"episodes already featured in the public gallery (docs/rounds.js).\"\"\"\n try:\n with open(os.path.join(SCRIPT_DIR, \"docs\", \"rounds.js\")) as f:\n return set(re.findall(r'episode:\\s*\"([^\"]+)\"', f.read()))\n except OSError:\n return set()\n\n\ndef ep_title(ep_dir, results):\n meta_path = os.path.join(ep_dir, \"meta.json\")\n if os.path.exists(meta_path):\n try:\n with open(meta_path) as f:\n t = json.load(f).get(\"title\")\n if t:\n return t\n except ValueError:\n pass\n task = os.path.join(ep_dir, \"task.md\")\n if os.path.exists(task):\n with open(task) as f:\n first = \" \".join(f.read().split()).lstrip(\"# \").strip()\n if first:\n return first[:80] + (\"…\" if len(first) > 80 else \"\")\n return os.path.basename(ep_dir)\n\n\ndef load_removed():\n \"\"\"Takedown blocklist: ep-id list in episodes/removed.json (relative to\n EPISODES_DIR, e.g. \"ep13\"/\"web/0724-...\"). Everything is visible by default;\n listed ids disappear from listing/watch/media. Read from disk every time with\n no caching, so hand-editing the file takes effect immediately.\"\"\"\n try:\n with open(REMOVED_PATH) as f:\n return set(json.load(f))\n except (OSError, ValueError):\n return set()\n\n\ndef ep_resolve(ep_id):\n \"\"\"ep-id -> absolute episode dir, or None if it escapes its root.\n Bundled \"examples/...\" ids resolve under the repo's examples/ dir\n (always public, shipped in git); everything else under episodes/.\"\"\"\n example = ep_id.startswith(\"examples/\")\n base = SCRIPT_DIR if example else EPISODES_DIR\n guard = os.path.realpath(os.path.join(SCRIPT_DIR, \"examples\") if example\n else EPISODES_DIR)\n d = os.path.realpath(os.path.join(base, ep_id))\n return d if d.startswith(guard + os.sep) else None\n\n\ndef is_removed(ep_dir_or_id):\n ep_id = (os.path.relpath(ep_dir_or_id, EPISODES_DIR)\n if os.path.isabs(ep_dir_or_id) else ep_dir_or_id)\n return ep_id in load_removed()\n\n\ndef scan_episodes():\n \"\"\"Every episode with at least one recorded artifact, newest first — minus\n manual takedowns. Bundled examples/ runs are listed too.\"\"\"\n arena = arena_episodes()\n removed = load_removed()\n eps = []\n dirs = ([(d, os.path.relpath(d, EPISODES_DIR)) for d in\n sorted(glob.glob(f\"{EPISODES_DIR}/ep*\") + glob.glob(f\"{WEB_EP_DIR}/*\"))]\n + [(d, os.path.relpath(d, SCRIPT_DIR)) for d in\n sorted(glob.glob(os.path.join(SCRIPT_DIR, \"examples\", \"*\")))])\n for d, ep_id in dirs:\n if not os.path.isdir(d):\n continue\n example = ep_id.startswith(\"examples/\")\n if ep_id in removed:\n continue\n results = load_results(d)\n models = []\n for r in results:\n m = r[\"requested\"]\n if os.path.exists(os.path.join(d, \"recordings\", f\"{m}.webm\")):\n models.append(r)\n if not models:\n continue\n poster = next((f\"poster_{r['requested']}.png\" for r in models\n if os.path.exists(os.path.join(d, \"recordings\",\n f\"poster_{r['requested']}.png\"))), None)\n eps.append({\n \"id\": ep_id, \"dir\": d, \"title\": ep_title(d, results), \"models\": models,\n \"poster\": poster, \"cost\": sum(r.get(\"cost_usd\", 0) for r in models),\n \"mtime\": os.path.getmtime(d), \"arena\": os.path.basename(d) in arena,\n \"web\": ep_id.startswith(\"web/\"), \"example\": example,\n })\n eps.sort(key=lambda e: (-e[\"arena\"], -e[\"mtime\"]))\n return eps\n\n\ndef find_episode(ep_id):\n d = ep_resolve(ep_id)\n if not (d and os.path.isdir(d)):\n return None\n for e in scan_episodes():\n if e[\"id\"] == ep_id:\n return e\n return None\n\n\n# ---------- pages ----------\n\nKEY_SECTION_JS = \"\"\"\n<script type=\"module\">\n const $ = (id) => document.getElementById(id);\n\n // Keep the Runs scroll position when coming back from a watch/job page —\n // \"← all runs\" is a normal navigation, which would otherwise reset to top\n addEventListener('scroll', () => sessionStorage.setItem('feedScroll', scrollY),\n { passive: true });\n const sy = +sessionStorage.getItem('feedScroll');\n if (sy && /\\\\/(watch|job)\\\\//.test(document.referrer)) scrollTo(0, sy);\n\n // Search filtering removes checked-but-not-currently-listed models from the DOM —\n // before submit, inject hidden inputs from the checked set, and disable all visible\n // checkboxes to prevent duplicates\n function syncModels(form) {\n form.querySelectorAll('input.m-hidden').forEach(n => n.remove());\n form.querySelectorAll('#model-list input[type=checkbox]').forEach(cb => { cb.disabled = true; });\n checked.forEach(id => {\n const inp = document.createElement('input');\n inp.type = 'hidden'; inp.name = 'models'; inp.value = id; inp.className = 'm-hidden';\n form.appendChild(inp);\n });\n }\n function submitLoading(form) { // loading-buttons: disable + give feedback while async\n const btn = form.querySelector('button[type=submit]');\n btn.disabled = true; btn.textContent = 'Queuing…';\n }\n document.getElementById('showdown-form').addEventListener('submit', (e) => {\n syncModels(e.target);\n submitLoading(e.target);\n });\n\n // ---- model picker: live catalog + search, tuned lineup pinned & pre-checked ----\n const MAX_PICK = 4;\n const list = $('model-list');\n let MODELS = [];\n const checked = new Set();\n function renderModels(q) {\n q = (q || '').toLowerCase();\n const vis = MODELS.filter(m => !q || m.id.toLowerCase().includes(q) ||\n m.name.toLowerCase().includes(q)).slice(0, 60);\n list.innerHTML = vis.map(m => `\n <label class=\"model-opt\"><input type=\"checkbox\" name=\"models\" value=\"${m.id}\"\n ${checked.has(m.id) ? 'checked' : ''}>\n <span class=\"mo-txt\"><span>${m.name}</span>\n <code>$${m.input || 0} / $${m.output || 0}</code></span></label>`\n ).join('') || '<span class=\"hint\" style=\"margin:0\">no match</span>';\n }\n // Reference image section only shows when ALL selected models support image input\n // (pick models first, then attach images)\n function updateRefSection() {\n const ms = [...checked].map(id => MODELS.find(x => x.id === id));\n document.getElementById('ref-section').hidden =\n !(checked.size > 0 && ms.every(m => m && m.image));\n }\n list.addEventListener('change', (ev) => {\n const cb = ev.target;\n if (cb.checked && checked.size >= MAX_PICK) {\n cb.checked = false;\n alert(`pick at most ${MAX_PICK} models per run`);\n return;\n }\n cb.checked ? checked.add(cb.value) : checked.delete(cb.value);\n updateRefSection();\n });\n fetch(window.BASE + '/api/models').then(r => r.json()).then(ms => {\n MODELS = ms;\n ms.filter(m => m.default).forEach(m => checked.add(m.id));\n renderModels('');\n updateRefSection();\n }).catch(() => { list.innerHTML = '<span class=\"hint\" style=\"margin:0\">catalog unavailable</span>'; });\n\n // ---- reference images: drag & drop / paste / browse -> base64 hidden inputs ----\n // (client-side FileReader to base64 dataURL, sent in the request body)\n const MAX_REFS = 3, MAX_REF_MB = 6;\n const refs = []; // dataURL strings\n const dz = $('dropzone'), previews = $('ref-previews'), fileInput = $('ref-file');\n function renderRefs() {\n previews.innerHTML = refs.map((d, i) => `\n <span class=\"ref-item\"><img src=\"${d}\" alt=\"reference image ${i + 1}\">\n <button type=\"button\" data-i=\"${i}\" title=\"remove\" aria-label=\"remove reference image ${i + 1}\">×</button></span>`).join('');\n $('drop-hint').textContent = refs.length\n ? `${refs.length}/${MAX_REFS} attached — drop / paste / click to add more`\n : 'Drag & drop images here';\n $('vision-note').hidden = !refs.length;\n renderModels(''); // gray out non-vision models when refs present\n }\n const origRender = renderModels;\n renderModels = function(q) {\n origRender(q);\n if (!refs.length) return;\n list.querySelectorAll('.model-opt').forEach(el => {\n const cb = el.querySelector('input');\n const m = MODELS.find(x => x.id === cb.value);\n if (m && !m.image) {\n el.classList.add('novision');\n if (cb.checked) { cb.checked = false; checked.delete(cb.value); }\n }\n });\n };\n // Dropping an image drafts a prompt immediately: a cheap vision model writes a\n // reproduction-oriented build brief from the image, freely hand-editable;\n // never overwritten once the user has manually edited it and it's non-empty\n const ta = document.querySelector('textarea[name=prompt]');\n let manualEdited = !!ta.value.trim();\n ta.addEventListener('input', () => { manualEdited = true; });\n async function draftPrompt() {\n if (manualEdited && ta.value.trim()) return;\n const prev = ta.value;\n // loading state: spinner on label, textarea locked & grayed, submit button disabled\n const submitBtn = document.querySelector('#showdown-form button[type=submit]');\n ta.value = 'Drafting a build prompt from your reference image…';\n ta.readOnly = true;\n ta.classList.add('drafting');\n $('draft-status').hidden = false;\n submitBtn.disabled = true;\n const body = { image: refs[0] };\n const k = document.querySelector('input[name=api_key]').value.trim();\n if (k) body.key = k;\n try {\n const r = await fetch(window.BASE + '/api/caption', { method: 'POST',\n headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });\n const d = await r.json();\n if (!r.ok || !d.prompt) throw new Error(d.error || ('HTTP ' + r.status));\n ta.value = d.prompt;\n manualEdited = false;\n } catch (e) {\n ta.value = prev;\n alert('prompt drafting failed: ' + e.message + ' — write it manually');\n } finally {\n ta.readOnly = false;\n ta.classList.remove('drafting');\n $('draft-status').hidden = true;\n submitBtn.disabled = false;\n }\n }\n function addFiles(fileList) {\n for (const f of fileList) {\n if (!/^image\\\\/(png|jpeg|webp)$/.test(f.type)) continue;\n if (refs.length >= MAX_REFS) { alert(`up to ${MAX_REFS} reference images`); break; }\n if (f.size > MAX_REF_MB * 1024 * 1024) { alert(`${f.name}: over ${MAX_REF_MB}MB`); continue; }\n const rd = new FileReader();\n rd.onload = () => {\n refs.push(rd.result); renderRefs();\n if (refs.length === 1) draftPrompt();\n };\n rd.readAsDataURL(f);\n }\n }\n dz.addEventListener('click', (ev) => { if (ev.target.tagName !== 'BUTTON') fileInput.click(); });\n dz.addEventListener('keydown', (ev) => {\n if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); fileInput.click(); }\n });\n fileInput.addEventListener('change', () => { addFiles(fileInput.files); fileInput.value = ''; });\n previews.addEventListener('click', (ev) => {\n if (ev.target.tagName === 'BUTTON') { refs.splice(+ev.target.dataset.i, 1); renderRefs(); }\n });\n ['dragover', 'dragenter'].forEach(t => dz.addEventListener(t, (ev) => {\n ev.preventDefault(); dz.classList.add('over'); }));\n ['dragleave', 'drop'].forEach(t => dz.addEventListener(t, (ev) => {\n ev.preventDefault(); dz.classList.remove('over'); }));\n dz.addEventListener('drop', (ev) => addFiles(ev.dataTransfer.files));\n document.querySelector('textarea[name=prompt]').addEventListener('paste', (ev) => {\n const files = [...(ev.clipboardData?.items || [])]\n .filter(it => it.kind === 'file').map(it => it.getAsFile()).filter(Boolean);\n if (files.length) { ev.preventDefault(); addFiles(files); }\n });\n const origSync = syncModels;\n syncModels = function(form) {\n origSync(form);\n form.querySelectorAll('input.r-hidden').forEach(n => n.remove());\n refs.forEach(d => {\n const inp = document.createElement('input');\n inp.type = 'hidden'; inp.name = 'refs'; inp.value = d; inp.className = 'r-hidden';\n form.appendChild(inp);\n });\n };\n</script>\n\"\"\"\n\n\ndef create_form_html(prefill=\"\"):\n server_key_hint = (\" · server key configured — may stay empty\"\n if os.environ.get(KEY_ENV) else \"\")\n return f\"\"\"\n<div class=\"card\" id=\"create\"><form method=\"post\" action=\"{B}/submit\" id=\"showdown-form\">\n <label>API key <span class=\"hint\">stays in memory for this job only, never stored\n or logged · <a href=\"https://console.aihubmix.com/token\" target=\"_blank\"\n rel=\"noopener\">get a key ↗</a>{server_key_hint}</span></label>\n <input type=\"password\" name=\"api_key\" autocomplete=\"off\" placeholder=\"sk-...\">\n <label>Models <span class=\"hint\">live catalog (/api/v1/models) · tuned lineup pinned\n first · pick up to 4<span id=\"vision-note\" hidden> · <b>ref images attached — models\n without image input are disabled</b></span></span></label>\n <div class=\"models\" id=\"model-list\" style=\"max-height:264px;overflow:auto;margin-top:8px\">\n <span class=\"hint\" style=\"margin:0\">loading catalog…</span>\n </div>\n <div id=\"ref-section\" hidden>\n <label>Reference images <span class=\"hint\">every selected model supports image\n input · up to 3 · dropping one auto-drafts the prompt below</span></label>\n <div class=\"dropzone\" id=\"dropzone\" role=\"button\" tabindex=\"0\"\n aria-label=\"Upload reference images: drop, paste, or press Enter to browse\">\n <svg width=\"34\" height=\"34\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"\n style=\"color:var(--ah-fg-3)\"><rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"2\"/>\n <circle cx=\"8.5\" cy=\"8.5\" r=\"1.5\"/><path d=\"m21 15-5-5L5 21\"/></svg>\n <span id=\"drop-hint\" style=\"font-size:14px;color:var(--ah-fg-2)\">Drag &amp; drop\n images here</span>\n <span class=\"hint\" style=\"margin:0\">or paste (⌘V) · or click to browse ·\n PNG / JPEG / WebP</span>\n <div class=\"ref-previews\" id=\"ref-previews\"></div>\n <input type=\"file\" id=\"ref-file\" accept=\"image/png,image/jpeg,image/webp\" multiple hidden>\n </div>\n </div>\n <label>Prompt <span class=\"hint\">what should every model build? single-file HTML\n with an auto-demo works best — auto-drafted from your reference image, edit freely</span>\n <span id=\"draft-status\" hidden><span class=\"spinner\" aria-hidden=\"true\"></span>\n <span class=\"hint\" style=\"margin-left:4px\">drafting from your image…</span></span></label>\n <textarea name=\"prompt\" required placeholder=\"Build a playable ... as one self-contained HTML file ...\">{html.escape(prefill)}</textarea>\n <div class=\"opts\">\n <div><label>Record</label>\n <select name=\"seconds\"><option value=\"18\">18 s</option>\n <option value=\"26\" selected>26 s</option><option value=\"40\">40 s</option></select></div>\n <div><label>Orientation <span class=\"hint\" style=\"margin:0\">of each recording</span></label>\n <select name=\"orientation\"><option value=\"portrait\" selected>Portrait 720×960</option>\n <option value=\"landscape\">Landscape 1280×720</option></select></div>\n <div><label>Video layout</label>\n <select name=\"layout\"><option value=\"classic\" selected>Classic panels</option>\n <option value=\"fullbleed\">Fullbleed (no gaps)</option></select></div>\n <div><label>Audio</label>\n <select name=\"style\"><option value=\"\" selected>Random style</option>\n <option value=\"zen\">Zen</option><option value=\"arcade\">Arcade</option>\n <option value=\"cinematic\">Cinematic</option><option value=\"ethereal\">Ethereal</option></select></div>\n <div><label>Output</label>\n <select name=\"formats\"><option value=\"wide\" selected>Wide 16:9</option>\n <option value=\"square\">Square 1:1</option>\n <option value=\"wide,square\">Both</option></select></div>\n </div>\n <button class=\"btn\" type=\"submit\">Run the showdown</button>\n</form></div>\"\"\"\n\n\ndef home_html(prefill=\"\"):\n active = [j for j in db.list_recent(20) if j[\"status\"] in (\"queued\", \"running\")]\n active_html = \"\"\n if active:\n rows = \"\".join(\n f'<tr><td><a href=\"{B}/job/{j[\"id\"]}\">{j[\"id\"]}</a></td>'\n f'<td>{html.escape(j[\"title\"])}</td>'\n f'<td><span class=\"tag {j[\"status\"]}\">{j[\"status\"].upper()}</span></td></tr>'\n for j in active)\n active_html = (f'<div class=\"card\" style=\"padding:16px 24px\"><label>In progress</label>'\n f\"<table>{rows}</table></div>\")\n cards = []\n for e in scan_episodes():\n thumb = (f'<img src=\"{B}/media/{e[\"id\"]}/{e[\"poster\"]}\" loading=\"lazy\" '\n f'alt=\"{html.escape(e[\"title\"])} poster\">' if e[\"poster\"] else \"\")\n badges = \"\"\n if e[\"arena\"]:\n badges += '<span class=\"tag arena\">ARENA ROUND</span>'\n if e[\"web\"]:\n badges += '<span class=\"tag web\">WEB</span>'\n if e.get(\"example\"):\n badges += '<span class=\"tag web\">EXAMPLE</span>'\n names = \" · \".join(r[\"display\"] for r in e[\"models\"][:4])\n cards.append(f\"\"\"\n<a class=\"feed-card\" href=\"{B}/watch/{e[\"id\"]}\">\n <div class=\"thumb\">{thumb}</div>\n <div class=\"body\">\n <div class=\"t\">{html.escape(e[\"title\"])}</div>\n <div class=\"m\"><span>{len(e[\"models\"])} models</span><span>${e[\"cost\"]:.2f}</span>{badges}</div>\n <div class=\"m\" style=\"margin-top:2px\">{html.escape(names)}</div>\n </div>\n</a>\"\"\")\n return page(\"Model Showdown\", f\"\"\"\n<h1>Model Showdown</h1>\n<div class=\"sub\">Same prompt · one shot each · real costs — generate a publish-ready\nside-by-side comparison, then browse every run below. All models served via AIHubMix.</div>\n{create_form_html(prefill)}\n{active_html}\n<h2>Runs</h2>\n<div class=\"feed\">{\"\".join(cards) or '<div class=\"sub\">nothing yet — create the first one above</div>'}</div>\n{KEY_SECTION_JS}\"\"\")\n\n\ndef watch_html(e):\n \"\"\"Detail view: left = auto-playing works, right = pinned prompt/common info\n + per-work params revealed on click.\"\"\"\n task_path = os.path.join(e[\"dir\"], \"task.md\")\n prompt_text = \"\"\n if os.path.exists(task_path):\n with open(task_path) as f:\n prompt_text = f.read()\n wall = None\n mpath = os.path.join(e[\"dir\"], \"metrics.json\")\n if os.path.exists(mpath):\n try:\n with open(mpath) as f:\n wall = json.load(f).get(\"wall_s\")\n except ValueError:\n pass\n accents = [\"#38bdf8\", \"#f97316\", \"#a78bfa\", \"#22c55e\", \"#34d399\", \"#f472b6\"]\n per = [] # one column per model, works on top + metric rows below\n for i, r in enumerate(e[\"models\"]):\n m = r[\"requested\"]\n cfg = run_showdown.MODELS.get(m, {})\n rep_path = os.path.join(e[\"dir\"], \"recordings\", f\"report_{m}.json\")\n verdict = \"SHIPPED\"\n if os.path.exists(rep_path):\n try:\n with open(rep_path) as f:\n rep = json.load(f)\n verdict = (\"FROZE\" if rep.get(\"frozen\")\n else \"RAN W/ ERRORS\" if rep.get(\"consoleErrors\") else \"SHIPPED\")\n except ValueError:\n pass\n u = r.get(\"usage\", {}) or {}\n rt = (u.get(\"completion_tokens_details\") or {}).get(\"reasoning_tokens\", 0)\n ct = u.get(\"completion_tokens\", 0)\n per.append({\n \"display\": html.escape(r[\"display\"]), \"model\": m,\n \"accent\": accents[i % len(accents)],\n \"protocol\": PROTO.get(cfg.get(\"endpoint\"), \"OpenAI /v1/chat/completions\"),\n \"params\": cfg.get(\"params\") or {}, \"max_tokens\": cfg.get(\"max_tokens\"),\n \"latency_s\": r.get(\"latency_s\"), \"cost\": r.get(\"cost_usd\"),\n \"prompt_tokens\": u.get(\"prompt_tokens\"), \"completion_tokens\": ct,\n \"reasoning_tokens\": rt,\n \"reasoning_share\": f\"{rt / ct * 100:.0f}%\" if ct and rt else \"—\",\n \"finish_reason\": r.get(\"finish_reason\"), \"code_lines\": r.get(\"code_lines\"),\n \"verdict\": verdict,\n \"raw\": f\"{B}/media/{e['id']}/raw_{m}.json\"\n if os.path.exists(os.path.join(e[\"dir\"], f\"raw_{m}.json\")) else None,\n \"artifact\": f\"{B}/media/{e['id']}/work_{m}.html\"\n if os.path.exists(os.path.join(e[\"dir\"], f\"work_{m}\", \"index.html\")) else None,\n })\n\n def cmp_row(label, cell):\n return (f'<tr><th scope=\"row\">{label}</th>'\n + \"\".join(f\"<td>{cell(d)}</td>\" for d in per) + \"</tr>\")\n\n def fmt(v, suffix=\"\", nd=None):\n if v is None:\n return \"—\"\n return f\"{v:.{nd}f}{suffix}\" if nd is not None else f\"{v}{suffix}\"\n\n vid_cells = \"\".join(f\"\"\"\n<td><div class=\"mname\"><span class=\"dot\" style=\"background:{d['accent']}\"></span>{d['display']}</div>\n <video src=\"{B}/media/{e['id']}/{d['model']}.webm\" autoplay muted loop playsinline\n aria-label=\"{d['display']} artifact recording\"></video></td>\"\"\"\n for d in per)\n params_cell = lambda d: (html.escape(json.dumps(d[\"params\"])) # noqa: E731\n if d[\"params\"] else \"—\")\n links_cell = lambda d: \" · \".join( # noqa: E731\n [f'<a href=\"{d[\"artifact\"]}\" target=\"_blank\" rel=\"noopener\">artifact ↗</a>'\n if d[\"artifact\"] else \"\"] +\n [f'<a href=\"{d[\"raw\"]}\" target=\"_blank\" rel=\"noopener\">raw ↗</a>'\n if d[\"raw\"] else \"\"]).strip(\" ·\") or \"—\"\n cmp_table = f\"\"\"\n<div class=\"cmp-wrap\"><table class=\"cmp\">\n <tr class=\"cmp-vid\"><th></th>{vid_cells}</tr>\n {cmp_row(\"open\", links_cell)}\n {cmp_row(\"cost\", lambda d: f\"${d['cost']:.2f}\" if d[\"cost\"] is not None else \"—\")}\n {cmp_row(\"time\", lambda d: fmt(d[\"latency_s\"], \"s\", nd=0))}\n {cmp_row(\"verdict\", lambda d: d[\"verdict\"])}\n {cmp_row(\"code lines\", lambda d: fmt(d[\"code_lines\"]))}\n {cmp_row(\"output tokens\", lambda d: fmt(d[\"completion_tokens\"]))}\n {cmp_row(\"reasoning\", lambda d: f'{d[\"reasoning_tokens\"]} ({d[\"reasoning_share\"]})' if d[\"reasoning_tokens\"] else \"—\")}\n {cmp_row(\"prompt tokens\", lambda d: fmt(d[\"prompt_tokens\"]))}\n {cmp_row(\"finish\", lambda d: d[\"finish_reason\"] or \"—\")}\n {cmp_row(\"protocol\", lambda d: d[\"protocol\"])}\n {cmp_row(\"max_tokens\", lambda d: fmt(d[\"max_tokens\"]))}\n {cmp_row(\"params\", params_cell)}\n</table></div>\"\"\"\n # Share section: mp4s download only from this site (no public direct link, guards\n # against hotlinking/abuse); share buttons carry copy + gallery link, the user\n # downloads the video and attaches it on the posting page themselves\n # Share always offers X/Reddit; mp4 downloads appear when finished videos exist\n # (they live in dist/ for pipeline runs, or at the episode root for older cuts)\n vids = sorted({os.path.basename(p)\n for pat in (\"dist/*.mp4\", \"*.mp4\")\n for p in glob.glob(os.path.join(e[\"dir\"], pat))})\n gallery = run_showdown.CONFIG.get(\"brand\", {}).get(\n \"gallery\", \"https://aihubmix.github.io/model-showdown/\")\n share_text = urllib.parse.quote(\n f\"{e['title']} — same prompt, one shot each. The bill is real. {gallery}\"\n \"\\n\\n(attach the downloaded mp4 here — the video isn't included automatically)\")\n dl = \"\".join(\n f'<a class=\"btn2\" style=\"padding:5px 12px;font-size:12.5px\" download '\n f'title=\"download the mp4 and attach it when you post — videos are never '\n f'exposed as public URLs\" href=\"{B}/media/{e[\"id\"]}/{urllib.parse.quote(nm)}\">'\n f\"⬇ {html.escape(nm[:-4])}</a>\" for nm in vids)\n actions_html = (\n f'<a class=\"btn2\" style=\"padding:5px 12px;font-size:12.5px\" target=\"_blank\" '\n f'rel=\"noopener\" href=\"https://twitter.com/intent/tweet?text={share_text}\">Share on X</a>'\n f'<a class=\"btn2\" style=\"padding:5px 12px;font-size:12.5px\" target=\"_blank\" '\n f'rel=\"noopener\" href=\"https://www.reddit.com/submit?url={urllib.parse.quote(gallery)}'\n f'&title={urllib.parse.quote(e[\"title\"])}\">Post to Reddit</a>' + dl)\n ref_html = \"\"\n ref_files = sorted(os.path.basename(p) for p in glob.glob(os.path.join(e[\"dir\"], \"ref*\"))\n if os.path.splitext(p)[1].lower() in (\".png\", \".jpg\", \".jpeg\", \".webp\"))\n if ref_files:\n ref_html = ('<label>Reference images</label><div class=\"ref-previews\">'\n + \"\".join(f'<span class=\"ref-item\"><a href=\"{B}/media/{e[\"id\"]}/{nm}\" target=\"_blank\">'\n f'<img src=\"{B}/media/{e[\"id\"]}/{nm}\" alt=\"{nm}\"></a></span>'\n for nm in ref_files) + \"</div>\")\n # Derived highlights double as a mini scoreboard and keep the narrow Run card\n # roughly level with the tall prompt card on its left\n out_tok = sum(d[\"completion_tokens\"] or 0 for d in per)\n shipped = sum(1 for d in per if d[\"verdict\"] == \"SHIPPED\")\n common_rows = \"\".join(\n f\"<div><span>{k}</span><span>{v}</span></div>\"\n for k, v in [\n (\"models\", len(per)), (\"shipped\", f\"{shipped}/{len(per)}\"),\n (\"total cost\", f\"${e['cost']:.2f}\"),\n (\"wall time\", f\"{wall:.0f}s\" if wall else \"—\"),\n (\"output tokens\", f\"{out_tok:,}\" if out_tok else \"—\"),\n (\"created\", time.strftime(\"%Y-%m-%d %H:%M\", time.localtime(e[\"mtime\"]))),\n (\"gateway\", \"AIHubMix\"),\n ])\n badges = ('<span class=\"tag arena\">ARENA ROUND</span> ' if e[\"arena\"] else \"\")\n return page(e[\"title\"], f\"\"\"\n<div class=\"sub\" style=\"margin-bottom:12px\"><a href=\"{B}/\">← all runs</a></div>\n<h1 style=\"font-size:26px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\" title=\"{html.escape(e[\"title\"])}\">{html.escape(e[\"title\"])}</h1>\n<div class=\"sub\">{badges}same prompt · one shot each — the works face off below</div>\n<div class=\"watch\">\n <div class=\"card\" style=\"margin:0\">\n <label style=\"display:flex;align-items:center;justify-content:space-between\">Prompt\n <span style=\"display:flex;gap:8px\">\n <button type=\"button\" class=\"btn2\" id=\"copy-prompt\" style=\"padding:5px 12px;font-size:12.5px\">Copy</button>\n <a class=\"btn2\" href=\"{B}/?from={urllib.parse.quote(e[\"id\"])}#create\"\n style=\"padding:5px 12px;font-size:12.5px\">Remix ↗</a>\n </span>\n </label>\n <pre class=\"prompt\" id=\"prompt-text\">{html.escape(prompt_text) or \"—\"}</pre>\n {ref_html}\n </div>\n <div class=\"side\">\n <div class=\"card\">\n <label>Run</label>\n <div class=\"kv\">{common_rows}</div>\n <label>Share</label>\n <div style=\"display:flex;gap:8px;flex-wrap:wrap\">{actions_html}</div>\n <span class=\"hint\" style=\"margin:6px 0 0;display:block\">posts don't auto-attach\n the video — download an mp4 above and upload it manually when posting</span>\n </div>\n </div>\n</div>\n<div class=\"card\" style=\"margin-top:24px;padding:16px 20px\">{cmp_table}</div>\n<div class=\"sub\" style=\"text-align:center;margin-top:8px\">all costs on this page are\nlist-price estimates — your actual bill is at\n<a href=\"https://console.aihubmix.com/logs\" target=\"_blank\"\n rel=\"noopener\">console.aihubmix.com/logs</a></div>\n<script>\n document.getElementById('copy-prompt').addEventListener('click', async (ev) => {{\n await navigator.clipboard.writeText(document.getElementById('prompt-text').textContent);\n ev.target.textContent = 'Copied ✓';\n setTimeout(() => {{ ev.target.textContent = 'Copy'; }}, 1500);\n }});\n</script>\"\"\")\n\n\ndef job_html(job):\n st = job[\"status\"]\n body = [f'<h1>Job {job[\"id\"]}</h1>',\n f'<div class=\"sub\">{html.escape(job[\"title\"])} · '\n f'<span class=\"tag {st}\">{st.upper()}</span></div>']\n if st == \"done\":\n body.append(f'<div class=\"card\">Done — <a href=\"{B}/watch/web/{job[\"id\"]}\">watch it ↗</a> '\n f\"or grab the share pack below.</div>\")\n if st == \"failed\" and job.get(\"fail_reason\"):\n body.append(f'<div class=\"card\" style=\"padding:14px 24px\"><span class=\"hint\" '\n f'style=\"margin:0;color:var(--ah-danger-text)\">'\n f'{html.escape(job[\"fail_reason\"])}</span></div>')\n if st == \"queued\":\n ahead = db.queued_ahead(job[\"created\"])\n body.append(f'<div class=\"card\">Position in queue: {ahead + 1} '\n f\"(jobs run one at a time — recording needs the GPU to itself)</div>\")\n log_path = os.path.join(job[\"ep_dir\"], \"run.log\")\n if st in (\"running\", \"done\", \"failed\") and os.path.exists(log_path):\n with open(log_path, errors=\"replace\") as f:\n f.seek(0, 2)\n f.seek(max(0, f.tell() - 6000))\n tail = f.read()\n body.append(f'<div class=\"card\"><label>Log</label>'\n f'<pre class=\"log\">{html.escape(tail)}</pre></div>')\n if st == \"done\":\n dist = os.path.join(job[\"ep_dir\"], \"dist\")\n files = sorted(os.listdir(dist)) if os.path.isdir(dist) else []\n links = \"\".join(f'<a href=\"{B}/job/{job[\"id\"]}/dl/{urllib.parse.quote(n)}\">{html.escape(n)}</a>'\n for n in files)\n body.append(f'<div class=\"card\"><label>Share pack</label>'\n f'<div class=\"files\">{links}</div></div>')\n body.append(f'<div class=\"sub\"><a href=\"{B}/\">← home</a></div>')\n refresh = 5 if st in (\"queued\", \"running\") else None\n return page(f\"Job {job['id']}\", \"\\n\".join(body), refresh=refresh)\n\n\ndef decode_ref_image(data_url, max_bytes=6 * 1024 * 1024):\n \"\"\"dataURL -> (ext, bytes). Type is detected from real magic bytes (Anthropic\n strictly validates that the declared mime matches the bytes; extension/prefix\n can't be trusted). Returns None if oversized or not an image.\"\"\"\n m = re.match(r\"data:image/(?:png|jpeg|webp);base64,(.+)$\", data_url, re.S)\n if not m:\n return None\n import base64\n try:\n data = base64.b64decode(m.group(1), validate=False)\n except Exception: # noqa: BLE001\n return None\n if not data or len(data) > max_bytes:\n return None\n if data[:8] == b\"\\x89PNG\\r\\n\\x1a\\n\":\n return (\"png\", data)\n if data[:3] == b\"\\xff\\xd8\\xff\":\n return (\"jpg\", data)\n if data[:4] == b\"RIFF\" and data[8:12] == b\"WEBP\":\n return (\"webp\", data)\n return None\n\n\n# ---------- job execution ----------\n\ndef worker():\n while True:\n job = db.claim_next()\n if not job:\n time.sleep(3)\n continue\n with SECRETS_LOCK:\n key = SECRETS.get(job[\"id\"])\n env = os.environ.copy()\n if key:\n env[KEY_ENV] = key\n if job.get(\"extra_models\"):\n env[\"SHOWDOWN_EXTRA_MODELS\"] = json.dumps(job[\"extra_models\"])\n env[\"SHOWDOWN_EXTRA_PRICING\"] = json.dumps(job[\"extra_pricing\"])\n cmd = [sys.executable, os.path.join(SCRIPT_DIR, \"showdown.py\"), job[\"ep_dir\"],\n \"--task\", os.path.join(job[\"ep_dir\"], \"task.md\"),\n \"--models\", job[\"models\"], \"--seconds\", str(job[\"seconds\"]),\n \"--title\", job[\"title\"],\n \"--formats\", job.get(\"formats\") or \"wide,square\",\n \"--rec-size\", job.get(\"rec_size\") or \"720x960\",\n \"--layout\", job.get(\"layout\") or \"classic\",\n \"--no-brand\"] # web jobs never carry the logo/watermark\n if job.get(\"style\"):\n cmd += [\"--style\", job[\"style\"]]\n # Whole-block safety net: no exception at any step (including the log write\n # itself) may kill the worker thread, and the job must always reach a terminal\n # state — otherwise it would show as running forever until a restart\n ok, reason = False, \"run failed — see log\"\n try:\n with open(os.path.join(job[\"ep_dir\"], \"run.log\"), \"w\") as log:\n r = subprocess.run(cmd, stdout=log, stderr=subprocess.STDOUT,\n env=env, timeout=3 * 3600)\n ok = r.returncode == 0\n except Exception as e: # noqa: BLE001\n reason = f\"worker error: {str(e)[:180]}\"\n try:\n with open(os.path.join(job[\"ep_dir\"], \"run.log\"), \"a\") as log:\n log.write(f\"\\n{reason}\\n\")\n except OSError:\n pass\n finally:\n with SECRETS_LOCK:\n SECRETS.pop(job[\"id\"], None) # discard key once used\n db.set_status(job[\"id\"], \"done\" if ok else \"failed\", None if ok else reason)\n\n\n# ---------- http ----------\n\nMEDIA_TYPES = {\".webm\": \"video/webm\", \".mp4\": \"video/mp4\", \".png\": \"image/png\",\n \".jpg\": \"image/jpeg\", \".jpeg\": \"image/jpeg\", \".webp\": \"image/webp\",\n \".json\": \"application/json\", \".md\": \"text/plain; charset=utf-8\",\n \".html\": \"text/html; charset=utf-8\", \".wav\": \"audio/wav\"}\n\n\nclass Handler(BaseHTTPRequestHandler):\n server_version = \"showdown-web\"\n\n def _send(self, code, body, ctype=\"text/html; charset=utf-8\", extra=None):\n data = body.encode() if isinstance(body, str) else body\n self.send_response(code)\n self.send_header(\"Content-Type\", ctype)\n self.send_header(\"Content-Length\", str(len(data)))\n for k, v in (extra or {}).items():\n self.send_header(k, v)\n self.end_headers()\n self.wfile.write(data)\n\n def log_message(self, fmt, *a): # no request logging (bodies contain pasted user keys)\n pass\n\n def _same_site(self):\n \"\"\"Video hotlink protection (MVP grade): Referer must be a page of this site —\n pasting the URL directly or embedding in an external <video> both fail.\n For production, switch to signed URLs / signed cookies.\"\"\"\n ref_host = urllib.parse.urlparse(self.headers.get(\"Referer\", \"\")).netloc\n if not ref_host:\n return False\n host = self.headers.get(\"Host\", \"\")\n return ref_host == host or (bool(PUBLIC_HOST) and ref_host == PUBLIC_HOST)\n\n def _media(self, rest):\n \"\"\"/media/<ep-id>/<file> — recordings first, then episode root/dist; whitelisted\n types only; realpath must stay inside its root (no traversal).\"\"\"\n name = os.path.basename(urllib.parse.unquote(rest[-1]))\n ep_id = \"/\".join(rest[:-1])\n ep_dir = ep_resolve(ep_id)\n if not ep_dir:\n return self._send(404, \"not found\", \"text/plain\")\n if is_removed(ep_id):\n return self._send(404, \"not found\", \"text/plain\")\n ext = os.path.splitext(name)[1]\n if ext not in MEDIA_TYPES or name.startswith(\".\"):\n return self._send(404, \"not found\", \"text/plain\")\n if ext in (\".mp4\", \".webm\") and not self._same_site():\n return self._send(403, \"videos are only served to this site's own pages\",\n \"text/plain\")\n m = re.fullmatch(r\"work_(.+)\\.html\", name)\n candidates = ([os.path.join(ep_dir, f\"work_{m.group(1)}\", \"index.html\")] if m else\n [os.path.join(ep_dir, \"recordings\", name), os.path.join(ep_dir, name),\n os.path.join(ep_dir, \"dist\", name)])\n for path in candidates:\n if os.path.exists(path) and os.path.realpath(path).startswith(ep_dir + os.sep):\n with open(path, \"rb\") as f:\n # artifact html gets a sandbox header — don't grant it same-origin power\n extra = ({\"Content-Security-Policy\": \"sandbox allow-scripts allow-pointer-lock\"}\n if m else {\"Cache-Control\": \"max-age=3600\"})\n return self._send(200, f.read(), MEDIA_TYPES[ext], extra)\n return self._send(404, \"not found\", \"text/plain\")\n\n def _strip_base(self):\n if B and self.path.startswith(B):\n self.path = self.path[len(B):] or \"/\"\n\n def do_GET(self):\n self._strip_base()\n url = urllib.parse.urlparse(self.path)\n parts = url.path.strip(\"/\").split(\"/\")\n if url.path in (\"/\", \"\"):\n # Remix: /?from=<ep-id> prefills that episode's prompt into the create form\n prefill = \"\"\n src = (urllib.parse.parse_qs(url.query).get(\"from\") or [\"\"])[0]\n if src:\n e = find_episode(src)\n task = os.path.join(e[\"dir\"], \"task.md\") if e else None\n if task and os.path.exists(task):\n with open(task) as f:\n prefill = f.read().strip()\n return self._send(200, home_html(prefill))\n if parts[0] == \"healthz\":\n return self._send(200, \"ok\", \"text/plain\")\n if parts[0] == \"api\" and len(parts) == 2 and parts[1] == \"models\":\n return self._send(200, json.dumps(picker_models()), \"application/json\",\n {\"Cache-Control\": \"max-age=300\"})\n if parts[0] == \"media\" and len(parts) >= 3:\n return self._media(parts[1:])\n if parts[0] == \"watch\" and len(parts) >= 2:\n e = find_episode(\"/\".join(parts[1:]))\n if not e:\n return self._send(404, page(\"Not found\", \"<h1>run not found</h1>\"))\n return self._send(200, watch_html(e))\n if parts[0] == \"job\" and len(parts) >= 2:\n job = db.get(parts[1])\n if not job:\n return self._send(404, page(\"Not found\", \"<h1>job not found</h1>\"))\n if len(parts) == 2:\n return self._send(200, job_html(job))\n if len(parts) == 4 and parts[2] == \"dl\":\n name = os.path.basename(urllib.parse.unquote(parts[3]))\n path = os.path.join(job[\"ep_dir\"], \"dist\", name)\n if not os.path.exists(path):\n return self._send(404, page(\"Not found\", \"<h1>file not found</h1>\"))\n ext = os.path.splitext(name)[1]\n if ext in (\".mp4\", \".webm\") and not self._same_site():\n return self._send(403, \"videos are only served to this site's own pages\",\n \"text/plain\")\n with open(path, \"rb\") as f:\n return self._send(200, f.read(), MEDIA_TYPES.get(ext, \"application/octet-stream\"),\n {\"Content-Disposition\": f'attachment; filename=\"{name}\"'}\n if ext == \".mp4\" else None)\n return self._send(404, page(\"Not found\", \"<h1>404</h1>\"))\n\n def do_POST(self):\n self._strip_base()\n parts = self.path.strip(\"/\").split(\"/\")\n length = int(self.headers.get(\"Content-Length\", 0))\n raw = self.rfile.read(length)\n\n # reference image -> build prompt (cheap vision model drafts from the image,\n # faithful reproduction first)\n if len(parts) == 2 and parts[0] == \"api\" and parts[1] == \"caption\":\n try:\n body = json.loads(raw)\n except ValueError:\n return self._send(400, '{\"error\":\"bad json\"}', \"application/json\")\n img = decode_ref_image(str(body.get(\"image\", \"\")))\n if not img:\n return self._send(400, '{\"error\":\"invalid image\"}', \"application/json\")\n ext, data = img\n import base64\n data_url = f\"data:image/{'jpeg' if ext == 'jpg' else ext};base64,\" \\\n f\"{base64.b64encode(data).decode()}\"\n key = str(body.get(\"key\", \"\"))\n if key or os.environ.get(KEY_ENV):\n auth = {\"Authorization\": f\"Bearer {key or os.environ[KEY_ENV]}\"}\n else:\n return self._send(401, '{\"error\":\"no key available\"}', \"application/json\")\n payload = {\"model\": CAPTION_MODEL, \"max_tokens\": 1200,\n \"messages\": [{\"role\": \"user\", \"content\": [\n {\"type\": \"text\", \"text\": CAPTION_INSTRUCTION},\n {\"type\": \"image_url\", \"image_url\": {\"url\": data_url}}]}]}\n req = urllib.request.Request(\n f\"{SERVER_DOMAIN}/v1/chat/completions\",\n data=json.dumps(payload).encode(),\n headers={\"Content-Type\": \"application/json\", **auth})\n try:\n with urllib.request.urlopen(req, timeout=90) as r:\n resp = json.load(r)\n text = (resp.get(\"choices\") or [{}])[0].get(\"message\", {}).get(\"content\") or \"\"\n except urllib.error.HTTPError as e:\n return self._send(e.code, json.dumps({\"error\": f\"upstream {e.code}\"}),\n \"application/json\")\n except Exception as e: # noqa: BLE001\n return self._send(502, json.dumps({\"error\": str(e)[:200]}), \"application/json\")\n if not text.strip():\n return self._send(502, '{\"error\":\"empty caption\"}', \"application/json\")\n return self._send(200, json.dumps({\"prompt\": text.strip()}, ensure_ascii=False),\n \"application/json\")\n\n if self.path != \"/submit\":\n return self._send(404, page(\"Not found\", \"<h1>404</h1>\"))\n form = urllib.parse.parse_qs(raw.decode())\n get = lambda k: (form.get(k) or [\"\"])[0].strip() # noqa: E731\n prompt = get(\"prompt\")\n extra_models, extra_pricing, models = {}, {}, []\n for m in dict.fromkeys(form.get(\"models\", [])): # dedupe, preserve order\n if m in run_showdown.MODELS:\n models.append(m)\n elif MODEL_ID_RE.match(m):\n cfg, price = catalog_model_cfg(m)\n if cfg: # only accept ids found in the catalog, blocks arbitrary id injection\n models.append(m)\n extra_models[m] = cfg\n extra_pricing[m] = list(price)\n key = get(\"api_key\")\n # Title is derived from the prompt's first line — one less field to fill\n first = \" \".join(prompt.split(\"\\n\", 1)[0].split()).lstrip(\"# \").strip()\n title = (first[:60] + (\"…\" if len(first) > 60 else \"\")) if first else \"Model Showdown\"\n try:\n seconds = max(8, min(60, int(get(\"seconds\") or \"26\")))\n except ValueError:\n seconds = 26\n # Pre-run choices, whitelisted (form values can't be trusted)\n rec_size = {\"portrait\": \"720x960\", \"landscape\": \"1280x720\"}.get(\n get(\"orientation\"), \"720x960\")\n layout = get(\"layout\") if get(\"layout\") in (\"classic\", \"fullbleed\") else \"classic\"\n style = get(\"style\") if get(\"style\") in (\"zen\", \"arcade\", \"cinematic\",\n \"ethereal\") else \"\"\n formats = get(\"formats\") if get(\"formats\") in (\"wide\", \"square\",\n \"wide,square\") else \"wide\"\n if not prompt or not models:\n return self._send(400, page(\"Invalid\", \"<h1>prompt and at least one model required</h1>\"))\n refs = []\n for d in form.get(\"refs\", [])[:3]:\n img = decode_ref_image(d)\n if img:\n refs.append(img)\n if not key and not os.environ.get(KEY_ENV):\n return self._send(400, page(\"Invalid\",\n f\"<h1>no key: paste one, or set {KEY_ENV} on the server</h1>\"))\n job_id = time.strftime(\"%m%d-%H%M%S\") + \"-\" + secrets.token_hex(3)\n ep_dir = os.path.join(WEB_EP_DIR, job_id)\n os.makedirs(ep_dir, exist_ok=True)\n with open(os.path.join(ep_dir, \"task.md\"), \"w\") as f:\n f.write(prompt + \"\\n\")\n # ref{,1,2}.<ext>: run_showdown build_user_content auto-scans ep_dir/ref* and\n # sends them as multimodal image blocks with the prompt to every model\n for i, (ext, data) in enumerate(refs):\n with open(os.path.join(ep_dir, f\"ref{i or ''}.{ext}\"), \"wb\") as f:\n f.write(data)\n with open(os.path.join(ep_dir, \"meta.json\"), \"w\") as f:\n json.dump({\"title\": title}, f, ensure_ascii=False)\n # auth_mode/token_id columns still exist in the DB table (prod DB schema left\n # untouched); always filled with manual/None. brand is always False for web\n # jobs — the logo/watermark toggle was removed (open-source deployments must\n # never let UGC carry the brand); CLI keeps the --no-brand flag.\n job = {\"id\": job_id, \"ep_dir\": ep_dir, \"models\": \",\".join(models),\n \"seconds\": seconds, \"title\": title, \"status\": \"queued\",\n \"created\": time.time(), \"auth_mode\": \"manual\",\n \"token_id\": None, \"brand\": False,\n \"rec_size\": rec_size, \"layout\": layout, \"style\": style,\n \"formats\": formats,\n \"extra_models\": extra_models, \"extra_pricing\": extra_pricing}\n if key:\n with SECRETS_LOCK:\n SECRETS[job_id] = key\n db.insert(job)\n self.send_response(303)\n self.send_header(\"Location\", f\"{B}/job/{job_id}\")\n self.end_headers()\n\n\ndef main():\n ap = argparse.ArgumentParser()\n ap.add_argument(\"--port\", type=int, default=7788)\n ap.add_argument(\"--host\", default=\"127.0.0.1\",\n help=\"bind address; keep 127.0.0.1 unless you have auth in front\")\n args = ap.parse_args()\n os.makedirs(WEB_EP_DIR, exist_ok=True)\n db.init()\n db.recover_on_boot(bool(os.environ.get(KEY_ENV)), SECRETS)\n threading.Thread(target=worker, daemon=True).start()\n srv = ThreadingHTTPServer((args.host, args.port), Handler)\n print(f\"showdown web: http://{args.host}:{args.port} [manual keys only] \"\n f\"(jobs land in episodes/web/)\")\n srv.serve_forever()\n\n\nif __name__ == \"__main__\":\n main()\n"}}}}