Metadata-Version: 2.4
Name: star-chamber
Version: 0.8.0
Summary: Multi-LLM council protocol SDK.
License: Apache-2.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: any-llm-sdk<2.0.0,>=1.23.0
Requires-Dist: click>=8.3.1
Provides-Extra: otari
Requires-Dist: any-llm-sdk[otari]<2.0.0,>=1.23.0; extra == "otari"
Requires-Dist: otari>=0.4.0; extra == "otari"
Dynamic: license-file

# star-chamber

Multi-LLM council protocol SDK. Fan out code reviews and design questions to multiple LLM providers, then classify findings by consensus.

## Installation

```bash
pip install star-chamber
```

Or with [uv](https://docs.astral.sh/uv/):

```bash
uv add star-chamber
```

### Otari extra

Routing through an [Otari](#otari-gateway) gateway needs the `otari` extra, because the gateway client ships outside the base install:

```bash
pip install 'star-chamber[otari]'
uv add 'star-chamber[otari]'
uvx --from 'star-chamber[otari]' star-chamber list-providers
```

Without the extra, an Otari-mode council still starts, but every non-local provider fails on its first call reporting that the Otari packages are missing. Direct-key councils never need the extra.

## Configuration

Create `~/.config/star-chamber/providers.json`:

```json
{
  "providers": [
    {"provider": "openai", "model": "gpt-4o", "api_key": "${OPENAI_API_KEY}"},
    {"provider": "anthropic", "model": "claude-sonnet-4-20250514", "api_key": "${ANTHROPIC_API_KEY}"}
  ],
  "timeout_seconds": 90,
  "consensus_threshold": 2
}
```

API keys can be literal values or `${ENV_VAR}` references that are resolved at runtime.

### Response token budget

`max_tokens` is optional. When it is omitted, a member may spend 16384 tokens on its reply.

The field is a budget for the response, not a property of the model. The value is sent to the provider as given, and no limit is ever read back from one, so there is nothing to look up before adding a member.

A budget that is too low makes the member fail rather than return half a review, and the error names the budget that was reached, so raise it from that number or send fewer files. A budget above what the model permits is rejected by the provider. Omit the field until a member reports a cut-off reply.

### Provider display names

When several council members share one provider — for example, multiple models behind an OpenAI-compatible gateway such as OpenRouter — give each entry a unique `display_name`. It becomes that member's identity in council output (consensus classification, `flagged_by`, quality ratings, individual issues) and is matched by `--provider` filters. Without it, same-provider members collapse into a single vote and unanimous consensus becomes unreachable:

```json
{
  "providers": [
    {"provider": "openrouter", "display_name": "gpt-5.2", "model": "openai/gpt-5.2", "api_key": "${OPENROUTER_API_KEY}"},
    {"provider": "openrouter", "display_name": "gemini-3.1-pro", "model": "google/gemini-3.1-pro-preview", "api_key": "${OPENROUTER_API_KEY}"},
    {"provider": "openrouter", "display_name": "grok-4.3", "model": "x-ai/grok-4.3", "api_key": "${OPENROUTER_API_KEY}"}
  ],
  "consensus_threshold": 2
}
```

In the JSON output, each provider response carries both the underlying `provider` (the routing target) and this `display_name`, and the display name is the key used throughout the aggregated result — `providers_used`, `providers_responded`, `quality_ratings`, `individual_issues`, `flagged_by`, and `failed_providers`. A member with no `display_name` falls back to the provider name.

`providers_used` is the roster that was asked, including members that failed. `providers_responded` is the roster that answered, and it is the denominator the consensus and majority classifications were computed over. A consensus across two members when three were asked is a weaker signal than the same word across three, so report the answering roster rather than the roster asked.

### Otari gateway

Instead of managing API keys per provider, you can route all non-local providers through [Otari](https://github.com/mozilla-ai/otari), Mozilla AI's OpenAI-compatible LLM gateway, by adding a top-level `otari` object. This mode requires the [`otari` extra](#otari-extra):

```json
{
  "providers": [
    {"provider": "openai", "model": "openai:gpt-4o"},
    {"provider": "anthropic", "model": "anthropic:claude-sonnet-4-20250514"}
  ],
  "otari": {
    "api_base": "https://your-gateway.example",
    "api_key": "${OTARI_API_KEY}"
  },
  "timeout_seconds": 90,
  "consensus_threshold": 2
}
```

In Otari mode, Otari — not the SDK — picks the upstream provider, so the per-provider `provider` field becomes a label. Otari expects the `model` field to use a `provider:model` prefix such as `"openai:gpt-4o"`; consult Otari's documentation for its model-naming convention.

`api_base` is the gateway origin with no path, such as `https://api.otari.ai`, because the client adds the API path itself. Every command rejects a base that ends in an API path, such as `/v1`, before it calls any provider. The error names the setting that supplied the base, whether the config or `OTARI_API_BASE`/`GATEWAY_API_BASE`, and the corrected value to set.

`api_base` and `api_key` may be omitted from the config; when omitted, the SDK's OtariProvider auto-detects credentials from its own env vars: `OTARI_API_KEY` for the hosted otari.ai platform (`Authorization: Bearer`, matching otari.ai's docs), or `GATEWAY_API_KEY` for a self-hosted gateway (`Otari-Key` header). `OTARI_AI_TOKEN` is also accepted as a platform-token alias. Both fields also support `${ENV_VAR}` references.

Providers marked `"local": true` always bypass Otari and continue to use their own `api_base`.

Override the config path with the `STAR_CHAMBER_CONFIG` environment variable.

## CLI

### Code review

```bash
star-chamber review src/auth.py src/db.py
```

### Design question

```bash
star-chamber ask "Should we use Redis or Memcached for session storage?"
```

### Options

```
--provider, -p    Provider to include (repeatable)
--config          Path to providers.json
--timeout         Per-provider timeout in seconds
--context-file    File containing project context to include in the prompt
--council-context File containing prior council round feedback (debate mode)
--format          Output format: text or json
--output          Write JSON result to file
```

### List providers

```bash
star-chamber list-providers
```

### Protocol schemas

The SDK ships the council protocol specification as package data.

```bash
# List available schemas.
star-chamber schema list

# Print a specific schema.
star-chamber schema code-review-result
```

## Python API

```python
from star_chamber import run_council_sync, CouncilConfig, ProviderConfig

config = CouncilConfig(
    providers=(
        ProviderConfig(provider="openai", model="gpt-4o"),
        ProviderConfig(provider="anthropic", model="claude-sonnet-4-20250514"),
    ),
    timeout_seconds=90,
    consensus_threshold=2,
)

# Code review.
result = run_council_sync(
    files={"auth.py": open("auth.py").read()},
    config=config,
    mode="code-review",
)

print(result.summary)
for issue in result.consensus_issues:
    print(f"  [{issue.severity}] {issue.location}: {issue.description}")

# Design question.
result = run_council_sync(
    prompt="Should we use a monorepo or polyrepo?",
    config=config,
    mode="design-question",
)

print(result.consensus_recommendation)
```

### Async

```python
import asyncio
from star_chamber import run_council

result = asyncio.run(run_council(
    files={"auth.py": open("auth.py").read()},
    mode="code-review",
))
```

### Schema access

```python
from star_chamber import get_schema, list_schemas

# List available schema names.
names = list_schemas()

# Get a specific schema as a JSON string.
schema_json = get_schema("code-review-result")
```

## Consensus classification

Issues from multiple providers are grouped by file, line proximity (within 5 lines), and category, then classified as:

- **Consensus** -- all providers agree.
- **Majority** -- two or more providers agree, but not all.
- **Individual** -- flagged by a single provider.

Results are sorted by severity within each bucket.

## Session summary

Every session ends with a short overall assessment in `summary.synthesis`. One member writes it, and it is not assembled from the members' own summaries. The author is the first member in config order that answered, so the same roster always asks the same member, and the material it reads carries no member name, so the assessment cannot attribute a point to a member.

`summary.synthesized_by` names the member asked, so you can check the author against the rule above. Set `"synthesis": false` in the config, or pass `--no-synthesis`, to skip it and spend no call on it. This costs one model call beyond the fan-out, so a council of three providers makes four calls per session. The call runs after the fan-out and uses the same `timeout_seconds`, so a session's worst-case wall time is two timeouts rather than one, and a debate pays the second one per round. When the author returns nothing usable, `summary.synthesis` is empty and `summary.synthesis_error` names the member asked and the reason.

## License

Apache-2.0
