Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# API Provider Configuration
# Choose "openrouter" or "unbound"
API_PROVIDER=openrouter

# OpenRouter API Key (required if API_PROVIDER=openrouter)
# Get yours at https://openrouter.ai/
OPENROUTER_API_KEY=sk-or-v1-...

# Unbound API Key (required if API_PROVIDER=unbound)
# Get yours at https://getunbound.ai/
UNBOUND_API_KEY=your-unbound-api-key
19 changes: 16 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,24 @@ LLM Council is a 3-stage deliberation system where multiple LLMs collaboratively
### Backend Structure (`backend/`)

**`config.py`**
- Contains `COUNCIL_MODELS` (list of OpenRouter model identifiers)
- Contains `API_PROVIDER` setting ("openrouter" or "unbound")
- Contains `COUNCIL_MODELS` (list of model identifiers)
- Contains `CHAIRMAN_MODEL` (model that synthesizes final answer)
- Uses environment variable `OPENROUTER_API_KEY` from `.env`
- Uses environment variables `OPENROUTER_API_KEY` or `UNBOUND_API_KEY` from `.env`
- Backend runs on **port 8001** (NOT 8000 - user had another app on 8000)

**`openrouter.py`**
- `query_model()`: Single async model query
- `query_model()`: Single async model query via OpenRouter
- `query_models_parallel()`: Parallel queries using `asyncio.gather()`
- Returns dict with 'content' and optional 'reasoning_details'
- Graceful degradation: returns None on failure, continues with successful responses

**`unbound.py`**
- `query_model()`: Single async model query via Unbound (OpenAI-compatible API)
- `query_models_parallel()`: Parallel queries using `asyncio.gather()`
- Same interface as openrouter.py for seamless provider switching
- Uses `https://api.getunbound.ai/v1/chat/completions` endpoint

**`council.py`** - The Core Logic
- `stage1_collect_responses()`: Parallel queries to all council models
- `stage2_collect_rankings()`:
Expand Down Expand Up @@ -119,6 +126,12 @@ All backend modules use relative imports (e.g., `from .config import ...`) not a
- Frontend: 5173 (Vite default)
- Update both `backend/main.py` and `frontend/src/api.js` if changing

### API Provider Configuration
- Set `API_PROVIDER=openrouter` or `API_PROVIDER=unbound` in `.env`
- OpenRouter is the default if not specified
- Unbound uses OpenAI-compatible API with same model naming convention
- Both providers support the same model identifiers (e.g., "openai/gpt-5.1")

### Markdown Rendering
All ReactMarkdown components must be wrapped in `<div className="markdown-content">` for proper spacing. This class is defined globally in `index.css`.

Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,24 @@ npm install
cd ..
```

### 2. Configure API Key
### 2. Configure API Provider

Create a `.env` file in the project root:
Create a `.env` file in the project root. You can use either **OpenRouter** or **Unbound** as your API provider.

**Option A: OpenRouter (default)**
```bash
API_PROVIDER=openrouter
OPENROUTER_API_KEY=sk-or-v1-...
```

Get your API key at [openrouter.ai](https://openrouter.ai/). Make sure to purchase the credits you need, or sign up for automatic top up.

**Option B: Unbound**
```bash
API_PROVIDER=unbound
UNBOUND_API_KEY=your-unbound-api-key
```
Get your API key at [getunbound.ai](https://getunbound.ai/). Unbound provides a secure AI gateway with cost controls and compliance features.

### 3. Configure Models (Optional)

Edit `backend/config.py` to customize the council:
Expand All @@ -57,6 +65,8 @@ COUNCIL_MODELS = [
CHAIRMAN_MODEL = "google/gemini-3-pro-preview"
```

Model identifiers are automatically mapped between providers, so you can use the same names regardless of which API provider you choose.

## Running the Application

**Option 1: Use the start script**
Expand Down
23 changes: 18 additions & 5 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,26 @@

load_dotenv()

# OpenRouter API key
# API Provider configuration
# Supports both OpenRouter and Unbound backends
API_PROVIDER = os.getenv("API_PROVIDER", "openrouter") # "openrouter" or "unbound"

# OpenRouter configuration
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY")
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"

# Unbound configuration
UNBOUND_API_KEY = os.getenv("UNBOUND_API_KEY")
UNBOUND_API_URL = "https://api.getunbound.ai/v1/chat/completions"

# Council members - list of OpenRouter model identifiers
# Active API configuration (based on provider)
def get_api_config():
"""Returns the active API key and URL based on configured provider."""
if API_PROVIDER == "unbound":
return UNBOUND_API_KEY, UNBOUND_API_URL
return OPENROUTER_API_KEY, OPENROUTER_API_URL

# Council members - model identifiers
COUNCIL_MODELS = [
"openai/gpt-5.1",
"google/gemini-3-pro-preview",
Expand All @@ -19,8 +35,5 @@
# Chairman model - synthesizes final response
CHAIRMAN_MODEL = "google/gemini-3-pro-preview"

# OpenRouter API endpoint
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"

# Data directory for conversation storage
DATA_DIR = "data/conversations"
8 changes: 7 additions & 1 deletion backend/council.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
"""3-stage LLM Council orchestration."""

from typing import List, Dict, Any, Tuple
from .openrouter import query_models_parallel, query_model
from .config import API_PROVIDER

# Import API client based on configured provider
if API_PROVIDER == "unbound":
from .unbound import query_models_parallel, query_model
else:
from .openrouter import query_models_parallel, query_model
from .config import COUNCIL_MODELS, CHAIRMAN_MODEL


Expand Down
100 changes: 100 additions & 0 deletions backend/unbound.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Unbound API client for making LLM requests."""

import httpx
from typing import List, Dict, Any, Optional
from .config import UNBOUND_API_KEY, UNBOUND_API_URL

# Model name mappings from OpenRouter format to Unbound format
MODEL_MAPPINGS = {
"anthropic/claude-sonnet-4.5": "anthropic/claude-sonnet-4-5",
"anthropic/claude-opus-4.5": "anthropic/claude-opus-4-5",
"anthropic/claude-haiku-4.5": "anthropic/claude-haiku-4-5",
}


def _map_model_name(model: str) -> str:
"""Map OpenRouter-style model names to Unbound format."""
return MODEL_MAPPINGS.get(model, model)


async def query_model(
model: str,
messages: List[Dict[str, str]],
timeout: float = 120.0
) -> Optional[Dict[str, Any]]:
"""
Query a single model via Unbound API.

Args:
model: Model identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4")
messages: List of message dicts with 'role' and 'content'
timeout: Request timeout in seconds

Returns:
Response dict with 'content' and optional 'reasoning_details', or None if failed
"""
headers = {
"Authorization": f"Bearer {UNBOUND_API_KEY}",
"Content-Type": "application/json",
}

payload = {
"model": _map_model_name(model),
"messages": messages,
}

try:
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(
UNBOUND_API_URL,
headers=headers,
json=payload
)
response.raise_for_status()

data = response.json()
message = data['choices'][0]['message']
content = message.get('content')

# Handle Claude's content block format (array of objects)
if isinstance(content, list):
# Extract text from content blocks
content = ' '.join(
block.get('text', '') for block in content
if isinstance(block, dict) and block.get('type') == 'text'
)

return {
'content': content,
'reasoning_details': message.get('reasoning_details')
}

except Exception as e:
print(f"Error querying model {model}: {e}")
return None


async def query_models_parallel(
models: List[str],
messages: List[Dict[str, str]]
) -> Dict[str, Optional[Dict[str, Any]]]:
"""
Query multiple models in parallel via Unbound.

Args:
models: List of model identifiers
messages: List of message dicts to send to each model

Returns:
Dict mapping model identifier to response dict (or None if failed)
"""
import asyncio

# Create tasks for all models
tasks = [query_model(model, messages) for model in models]

# Wait for all to complete
responses = await asyncio.gather(*tasks)

# Map models to their responses
return {model: response for model, response in zip(models, responses)}
64 changes: 64 additions & 0 deletions test_unbound.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Test script for Unbound API integration."""

import asyncio
import os
from dotenv import load_dotenv

load_dotenv()

# Council models to test
COUNCIL_MODELS = [
"openai/gpt-5.1",
"google/gemini-3-pro-preview",
"anthropic/claude-sonnet-4-5",
"x-ai/grok-4",
]

async def test_model(model: str):
"""Test a single model via Unbound API."""
import httpx

api_key = os.getenv("UNBOUND_API_KEY")
api_url = "https://api.getunbound.ai/v1/chat/completions"

headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}

payload = {
"model": model,
"messages": [{"role": "user", "content": "Say 'Hello!' and identify yourself in one short sentence."}],
}

try:
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(api_url, headers=headers, json=payload)
response.raise_for_status()

data = response.json()
content = data['choices'][0]['message']['content']
return True, content[:100] + "..." if len(content) > 100 else content

except Exception as e:
return False, str(e)

async def test_all_models():
"""Test all council models."""
print(f"API Provider: {os.getenv('API_PROVIDER', 'openrouter')}")
print(f"Testing {len(COUNCIL_MODELS)} council models via Unbound API...")
print("=" * 60)

for model in COUNCIL_MODELS:
print(f"\n{model}:")
success, result = await test_model(model)
if success:
print(f" ✓ {result}")
else:
print(f" ✗ Error: {result}")

print("\n" + "=" * 60)
print("Done!")

if __name__ == "__main__":
asyncio.run(test_all_models())