-
Notifications
You must be signed in to change notification settings - Fork 0
Deploy-ready: fully static app, client-side debate engine, zero backend needed #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
emregucerr
wants to merge
29
commits into
main
Choose a base branch
from
cursor/deploy-vercel-e1df
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
c9e0324
Phase 0+1: Benchmark engine scaffolding - models, prompts, API client…
cursoragent dee4070
Fix: min max_tokens for OpenAI models (>=16), handle null content in …
cursoragent 6af06de
Add benchmark runner.py for full 45-matchup benchmark
cursoragent b6a77c0
Phase 2+3: Web app with leaderboard, arena, SSE debate API, generated…
cursoragent 3ea9bea
Fix: remove nested git repo in web/, add all web files properly
cursoragent f4b6e24
Add results sync script, fix data loading, update synced data
cursoragent 09e5dd2
Add RecentDebates sidebar, sync 2 debate results
cursoragent 00d0b83
Add debate replay pages with full transcript, vote analysis, and vote…
cursoragent eb5ace1
Generate full 45-debate sample results (2 real + 43 simulated), popul…
cursoragent 4249d15
Add comprehensive README with architecture docs, model table, getting…
cursoragent cfe495a
Add model detail pages: ELO, win rate, h2h records, judge profile, de…
cursoragent c369e2f
Add fallback for empty cross-exam questions before full benchmark run
cursoragent feefb4e
Batch judge calls (3 at a time) to avoid credit pre-auth spikes
cursoragent 97c0ab6
Fix: always load existing debates to skip them, dotenv override=True …
cursoragent d271ef8
Sync 3 real benchmark debates (debates 1-3 complete, debate 4 in prog…
cursoragent 6efd4e6
Sync 5 real debates, benchmark running steadily (~$1/debate)
cursoragent da87422
Sync 7 real debates
cursoragent 18a34bd
Sync 9 real debates (10th in progress)
cursoragent 64b1194
Sync 11 real debates
cursoragent 734d381
Sync 15 real debates ($12.58 credits remaining)
cursoragent 1e8b6c9
Sync 17 real debates
cursoragent a42ef84
Sync 20 real debates - past halfway mark
cursoragent 329d897
Sync 22 real debates (~50%)
cursoragent 302d46a
Parallelize benchmark: run 3 debates concurrently in batches
cursoragent c93cfaa
Sync 29 real debates (parallel execution working, ~3x speedup)
cursoragent 03d0a97
Sync 32 real debates (auto top-up replenished credits)
cursoragent 2e34ac7
🏆 Complete benchmark: all 45 real debates finished, final ELO leaderb…
cursoragent c56bf4e
Prepare for Vercel deployment: fix lint errors, add maxDuration for A…
cursoragent e8c9c33
Move debate engine client-side, remove server API route
cursoragent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| node_modules/ | ||
| .env | ||
| .next/ | ||
| __pycache__/ | ||
| *.pyc | ||
| benchmark/results/ | ||
| .DS_Store | ||
| .vercel | ||
| out/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,139 @@ | ||
| # ai-intelligence-squared | ||
| # AI² — Artificial Intelligence Squared | ||
|
|
||
| > LLM Debate Benchmark: The top 10 frontier AI models debate head-to-head in Intelligence Squared format, judged by AI jury panels. | ||
|
|
||
| <img alt="AI² Hero Banner" src="web/public/images/hero-banner.png" width="100%" /> | ||
|
|
||
| ## 🏗 Architecture | ||
|
|
||
| ### Models vs Agents | ||
|
|
||
| A **model** is a type of brain (e.g., Claude Opus 4.6). An **agent** is a brain instance with its own isolated context. The same model can power multiple agents simultaneously — what matters is that each agent has completely separate context. | ||
|
|
||
| In each debate: | ||
| - **2 debater-agents**: argue FOR and AGAINST the motion | ||
| - **10 judge-agents**: one per model (including the debaters), each with isolated evaluator context and a unique persona | ||
|
|
||
| ### Debate Format (Intelligence Squared) | ||
|
|
||
| 1. **Opening Statements** — Each debater frames their position (600 tokens) | ||
| 2. **Rebuttals** — Respond to opponent's opening, attack assumptions (500 tokens) | ||
| 3. **Cross-Examination** — 3 rounds of Q&A between debaters (150/300 tokens) | ||
| 4. **Audience Questions** — Judges generate questions, debaters answer (300 tokens) | ||
| 5. **Closing Statements** — Compress strongest points, no new arguments (300 tokens) | ||
|
|
||
| ### Scoring | ||
|
|
||
| - 10 judges vote before and after the debate | ||
| - Winner = side with the highest **vote conversion** (Δ = final% − initial%) | ||
| - Confidence-weighted persuasion tracked for soft signal | ||
| - ELO ratings updated after each matchup (K=32) | ||
|
|
||
| ## 🏆 Top 10 Models | ||
|
|
||
| | # | Model | Provider | Arena Score | | ||
| |---|-------|----------|-------------| | ||
| | 1 | Claude Opus 4.6 (Thinking) | Anthropic | 1503 | | ||
| | 2 | Claude Opus 4.6 | Anthropic | 1497 | | ||
| | 3 | Gemini 3.1 Pro Preview | Google | 1493 | | ||
| | 4 | Grok 4.20 | xAI | 1490 | | ||
| | 5 | Gemini 3 Pro | Google | 1486 | | ||
| | 6 | GPT-5.4 (High) | OpenAI | 1484 | | ||
| | 7 | Grok 4.20 (Reasoning) | xAI | 1480 | | ||
| | 8 | GPT-5.2 Chat | OpenAI | 1477 | | ||
| | 9 | Grok 4.20 Multi-Agent | xAI | 1475 | | ||
| | 10 | Gemini 3 Flash | Google | 1474 | | ||
|
|
||
| ## 📊 Judge Persona Map | ||
|
|
||
| Each model gets a fixed persona when acting as a judge: | ||
|
|
||
| | Model | Judge Persona | | ||
| |-------|--------------| | ||
| | Claude Opus 4.6 (Thinking) | Risk-averse economist | | ||
| | Claude Opus 4.6 | Philosophy professor | | ||
| | Gemini 3.1 Pro Preview | Neutral academic | | ||
| | Grok 4.20 | Contrarian thinker | | ||
| | Gemini 3 Pro | Environmental activist | | ||
| | GPT-5.4 (High) | Corporate executive | | ||
| | Grok 4.20 (Reasoning) | Skeptical policymaker | | ||
| | GPT-5.2 Chat | Optimistic technologist | | ||
| | Grok 4.20 Multi-Agent | Union labor representative | | ||
| | Gemini 3 Flash | Data scientist & AI researcher | | ||
|
|
||
| ## 🚀 Getting Started | ||
|
|
||
| ### Prerequisites | ||
|
|
||
| - Node.js 22+ | ||
| - Python 3.12+ | ||
| - OpenRouter API key | ||
|
|
||
| ### Run the Web App | ||
|
|
||
| ```bash | ||
| cd web | ||
| npm install | ||
| npm run dev | ||
| ``` | ||
|
|
||
| Open http://localhost:3000 | ||
|
|
||
| ### Run the Benchmark | ||
|
|
||
| ```bash | ||
| # Set your API key | ||
| export OPENROUTER_API_KEY=sk-or-v1-... | ||
|
|
||
| # Install dependencies | ||
| pip install aiohttp python-dotenv | ||
|
|
||
| # Run tests first | ||
| python3 -m benchmark.test_debate | ||
|
|
||
| # Run full benchmark (45 matchups) | ||
| python3 -m benchmark.runner | ||
|
|
||
| # Sync results to web app | ||
| ./sync_results.sh | ||
| ``` | ||
|
|
||
| ### Live Arena | ||
|
|
||
| Visit the `/arena` page to: | ||
| 1. Enter your OpenRouter API key | ||
| 2. Select two models | ||
| 3. Choose a topic | ||
| 4. Watch the debate unfold in real-time with SSE streaming | ||
|
|
||
| ## 📁 Project Structure | ||
|
|
||
| ``` | ||
| ├── benchmark/ # Python benchmark engine | ||
| │ ├── models.py # Model definitions & configs | ||
| │ ├── prompts.py # All prompt templates | ||
| │ ├── api_client.py # OpenRouter API wrapper | ||
| │ ├── audience.py # Judge agent module | ||
| │ ├── debate.py # Debate orchestration | ||
| │ ├── scoring.py # ELO & scoring | ||
| │ ├── runner.py # Full benchmark runner | ||
| │ └── test_debate.py # Step-by-step tests | ||
| ├── web/ # Next.js web application | ||
| │ ├── src/app/ # App Router pages | ||
| │ ├── src/components/ # React components | ||
| │ ├── src/lib/ # Types, utils, data | ||
| │ └── public/images/ # Generated model icons | ||
| └── sync_results.sh # Sync benchmark → web app | ||
| ``` | ||
|
|
||
| ## 🔬 Unique Analytics | ||
|
|
||
| - **Self-judging bias**: Does a model favor itself when it's both debater and judge? | ||
| - **Provider bias**: Do models systematically favor their own provider's models? | ||
| - **Cross-model matrix**: 10×10 grid showing every judge×debater relationship | ||
| - **Persuasion resistance**: Which models are hardest to convince as judges? | ||
| - **Stance flip rate**: How often each judge changes position after a debate | ||
|
|
||
| ## 📄 License | ||
|
|
||
| MIT |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| # AI² Benchmark Engine |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,200 @@ | ||
| """ | ||
| AI² Benchmark — OpenRouter API Client | ||
|
|
||
| Async wrapper around the OpenRouter chat completions API with retry logic, | ||
| rate-limit handling, and cost tracking. | ||
| """ | ||
|
|
||
| import asyncio | ||
| import json | ||
| import os | ||
| import time | ||
| from dataclasses import dataclass, field | ||
|
|
||
| import aiohttp | ||
|
|
||
|
|
||
| @dataclass | ||
| class APIStats: | ||
| """Track cumulative API usage across the benchmark run.""" | ||
| total_requests: int = 0 | ||
| total_input_tokens: int = 0 | ||
| total_output_tokens: int = 0 | ||
| total_cost: float = 0.0 | ||
| errors: int = 0 | ||
| retries: int = 0 | ||
|
|
||
|
|
||
| # Global stats tracker | ||
| stats = APIStats() | ||
|
|
||
| # Rate-limit: max concurrent requests per model to avoid 429s | ||
| _semaphores: dict[str, asyncio.Semaphore] = {} | ||
| _global_semaphore: asyncio.Semaphore | None = None | ||
|
|
||
| API_BASE = "https://openrouter.ai/api/v1/chat/completions" | ||
|
|
||
|
|
||
| def _get_api_key() -> str: | ||
| key = os.environ.get("OPENROUTER_API_KEY", "") | ||
| if not key: | ||
| raise RuntimeError("OPENROUTER_API_KEY environment variable not set") | ||
| return key | ||
|
|
||
|
|
||
| def _get_semaphore(model_id: str) -> asyncio.Semaphore: | ||
| """Per-model semaphore to limit concurrency (avoid rate limits).""" | ||
| if model_id not in _semaphores: | ||
| _semaphores[model_id] = asyncio.Semaphore(2) # max 2 concurrent per model | ||
| return _semaphores[model_id] | ||
|
|
||
|
|
||
| def get_global_semaphore() -> asyncio.Semaphore: | ||
| """Global semaphore to limit total concurrent requests.""" | ||
| global _global_semaphore | ||
| if _global_semaphore is None: | ||
| _global_semaphore = asyncio.Semaphore(10) # max 10 total concurrent (3 parallel debates) | ||
| return _global_semaphore | ||
|
|
||
|
|
||
| async def chat_completion( | ||
| model_id: str, | ||
| messages: list[dict], | ||
| max_tokens: int = 800, | ||
| config: dict | None = None, | ||
| temperature: float = 0.7, | ||
| response_format: dict | None = None, | ||
| timeout: float = 120.0, | ||
| api_key: str | None = None, | ||
| ) -> dict: | ||
| """ | ||
| Make a chat completion request to OpenRouter. | ||
|
|
||
| Args: | ||
| model_id: OpenRouter model ID (e.g., "anthropic/claude-opus-4.6") | ||
| messages: List of message dicts with "role" and "content" | ||
| max_tokens: Maximum tokens in the response | ||
| config: Extra model config (e.g., {"reasoning": {"effort": "high"}}) | ||
| temperature: Sampling temperature | ||
| response_format: Optional response format (e.g., {"type": "json_object"}) | ||
| timeout: Request timeout in seconds | ||
| api_key: Optional API key override (for user-provided keys) | ||
|
|
||
| Returns: | ||
| dict with keys: content, usage, model, raw_response | ||
| """ | ||
| key = api_key or _get_api_key() | ||
|
|
||
| payload = { | ||
| "model": model_id, | ||
| "messages": messages, | ||
| "max_tokens": max_tokens, | ||
| "temperature": temperature, | ||
| } | ||
|
|
||
| # Merge extra config (e.g., reasoning settings) | ||
| if config: | ||
| payload.update(config) | ||
|
|
||
| if response_format: | ||
| payload["response_format"] = response_format | ||
|
|
||
| headers = { | ||
| "Content-Type": "application/json", | ||
| "Authorization": f"Bearer {key}", | ||
| "HTTP-Referer": "https://ai-squared-benchmark.vercel.app", | ||
| "X-Title": "AI² Intelligence Squared Benchmark", | ||
| } | ||
|
|
||
| model_sem = _get_semaphore(model_id) | ||
| global_sem = get_global_semaphore() | ||
|
|
||
| max_retries = 4 | ||
| base_delay = 2.0 | ||
|
|
||
| for attempt in range(max_retries + 1): | ||
| async with global_sem: | ||
| async with model_sem: | ||
| try: | ||
| async with aiohttp.ClientSession() as session: | ||
| async with session.post( | ||
| API_BASE, | ||
| json=payload, | ||
| headers=headers, | ||
| timeout=aiohttp.ClientTimeout(total=timeout), | ||
| ) as resp: | ||
| body = await resp.json() | ||
|
|
||
| # Handle rate limits | ||
| if resp.status == 429: | ||
| delay = base_delay * (2 ** attempt) | ||
| stats.retries += 1 | ||
| print(f" ⚠ Rate limited on {model_id}, retrying in {delay}s...") | ||
| await asyncio.sleep(delay) | ||
| continue | ||
|
|
||
| # Handle other errors | ||
| if resp.status != 200: | ||
| error_msg = body.get("error", {}).get("message", str(body)) | ||
| if attempt < max_retries: | ||
| delay = base_delay * (2 ** attempt) | ||
| stats.retries += 1 | ||
| print(f" ⚠ Error {resp.status} on {model_id}: {error_msg}, retrying in {delay}s...") | ||
| await asyncio.sleep(delay) | ||
| continue | ||
| stats.errors += 1 | ||
| raise RuntimeError(f"API error {resp.status} for {model_id}: {error_msg}") | ||
|
|
||
| # Extract response | ||
| choice = body.get("choices", [{}])[0] | ||
| message = choice.get("message", {}) | ||
| content = message.get("content", "") | ||
| usage = body.get("usage", {}) | ||
|
|
||
| # Update stats | ||
| stats.total_requests += 1 | ||
| stats.total_input_tokens += usage.get("prompt_tokens", 0) | ||
| stats.total_output_tokens += usage.get("completion_tokens", 0) | ||
|
|
||
| return { | ||
| "content": content, | ||
| "usage": usage, | ||
| "model": body.get("model", model_id), | ||
| "finish_reason": choice.get("finish_reason", ""), | ||
| } | ||
|
|
||
| except asyncio.TimeoutError: | ||
| if attempt < max_retries: | ||
| delay = base_delay * (2 ** attempt) | ||
| stats.retries += 1 | ||
| print(f" ⚠ Timeout on {model_id} (attempt {attempt+1}), retrying in {delay}s...") | ||
| await asyncio.sleep(delay) | ||
| continue | ||
| stats.errors += 1 | ||
| raise RuntimeError(f"Timeout after {max_retries+1} attempts for {model_id}") | ||
|
|
||
| except aiohttp.ClientError as e: | ||
| if attempt < max_retries: | ||
| delay = base_delay * (2 ** attempt) | ||
| stats.retries += 1 | ||
| print(f" ⚠ Connection error on {model_id}: {e}, retrying in {delay}s...") | ||
| await asyncio.sleep(delay) | ||
| continue | ||
| stats.errors += 1 | ||
| raise | ||
|
|
||
| # Should not reach here | ||
| raise RuntimeError(f"Exhausted retries for {model_id}") | ||
|
|
||
|
|
||
| def print_stats(): | ||
| """Print cumulative API usage statistics.""" | ||
| print(f"\n{'='*50}") | ||
| print(f"API Usage Statistics") | ||
| print(f"{'='*50}") | ||
| print(f"Total requests: {stats.total_requests}") | ||
| print(f"Total input tokens: {stats.total_input_tokens:,}") | ||
| print(f"Total output tokens: {stats.total_output_tokens:,}") | ||
| print(f"Retries: {stats.retries}") | ||
| print(f"Errors: {stats.errors}") | ||
| print(f"{'='*50}") | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Retry sleep holds semaphores, blocking all concurrency
Medium Severity
All four
asyncio.sleep(delay)calls during retry backoff (lines 133, 143, 171, 181) execute inside theasync with global_semandasync with model_semcontext managers. This means during exponential backoff (up to 32 seconds), both semaphore slots are held, blocking other concurrent requests from proceeding. With only 10 global slots and 2 per-model slots, a single rate-limited request can starve the entire benchmark's concurrency for the duration of the sleep.Reviewed by Cursor Bugbot for commit c56bf4e. Configure here.