# ADR-0082 — Frontier Provider Adapters

**Status:** Ratified
**Date:** 2026-05-20
**Author:** Shay
**Renumbered from:** ADR-0081 (collided with a parallel `docs/decisions/` track; this `docs/adr/` track is renumbered to 0082 to keep both sequences monotonic).

---

## Context

Wave 1 of the frontier comparison benchmark family (`evals/frontier_compare/`) was deliberately local and CORE-native — it required no API keys and made no external calls.  The README called out `feat/frontier-compare-provider-adapters` as the intended next wave.

We now have API credentials for OpenAI, Anthropic, and a local Ollama install.  To run the Wave 1 suites (and future suites) against real frontier models, we need:

1. A consistent interface so any benchmark can call any provider with the same `(prompt: str) -> str` shape.
2. A `.env.example` that documents every env var the codebase consumes so a new operator knows exactly what to set.
3. A model registry that enforces documented, pinned model identity in every benchmark report — floating aliases like `gpt-4o` are banned in report metadata because the underlying weights can change silently.
4. `.gitignore` coverage for `.env` files so secrets are never committed.

---

## Decision

### Provider adapter module: `evals/frontier_compare/providers.py`

A single module exposes:

- **`ProviderConfig`** — immutable, hashable dataclass carrying `(provider, model, temperature, max_tokens, extra)`.  Built from env vars via `ProviderConfig.from_env(provider_name)`.  Serialises to a stable dict for embedding in report JSON.
- **`build_adapter(cfg: ProviderConfig) -> Callable[[str], str]`** — the single entry point for all benchmark code.  Returns a stateless `(prompt) -> surface` callable.  Registered builders: `core`, `openai`, `anthropic`, `ollama`.
- **`load_dotenv_if_present(path)`** — minimal `.env` reader with no external dependency.  Shell exports always win over `.env` values.

Adapter behaviour per provider:

| Provider | Package required | Auth env var | Notes |
|---|---|---|---|
| `core` | none | none | Fresh `ChatRuntime` per call; deterministic |
| `openai` | `openai` | `OPENAI_API_KEY` | Uses `client.chat.completions.create` |
| `anthropic` | `anthropic` | `ANTHROPIC_API_KEY` | Uses `client.messages.create` |
| `ollama` | `httpx` | `OLLAMA_API_KEY` (optional) | POSTs to `{OLLAMA_URL}/api/chat` |

### Model registry: `evals/frontier_compare/model_registry.py`

Every model ever used in a CORE benchmark must have a `ModelCard` entry before it can be used via `require_model_card()`.  Fields:

- `provider`, `model_id` (exact API slug)
- `display_name`, `knowledge_cutoff`, `context_window`, `output_tokens`
- `architecture` — plain-English description
- `sampling` — note on determinism/stochasticity
- `notes` — known quirks, version history, benchmark-specific observations
- `tags` — searchable taxonomy

Initially registered models:

| Key | Display name |
|---|---|
| `core/core-native` | CORE (native) |
| `openai/gpt-4o` | GPT-4o (floating alias) |
| `openai/gpt-4o-2024-08-06` | GPT-4o (2024-08-06 snapshot) |
| `openai/gpt-4o-mini` | GPT-4o mini |
| `openai/o3` | OpenAI o3 |
| `anthropic/claude-opus-4-5` | Claude Opus 4.5 |
| `anthropic/claude-sonnet-4-5` | Claude Sonnet 4.5 |
| `anthropic/claude-haiku-3-5` | Claude Haiku 3.5 |
| `ollama/llama3.2` | Llama 3.2 (latest tag) |
| `ollama/llama3.2:3b-instruct-q8_0` | Llama 3.2 3B Instruct Q8_0 |
| `ollama/mistral` | Mistral 7B |
| `ollama/gemma3:12b` | Gemma 3 12B |

### `.env.example`

Documents all env vars in one place:

```
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o
OPENAI_BASE_URL=          # optional proxy/Azure override

ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-opus-4-5

OLLAMA_URL=http://localhost:11434
OLLAMA_API_KEY=           # empty for local installs
OLLAMA_MODEL=llama3.2

BENCHMARK_PROVIDERS=core  # comma-separated: core,openai,anthropic,ollama
BENCHMARK_RUNS=3
BENCHMARK_TEMPERATURE=0
BENCHMARK_REPORT_DIR=reports
```

### `.gitignore` additions

```gitignore
.env
.env.local
.env.*.local
reports/
```

---

## How to run a multi-provider benchmark

```python
from evals.frontier_compare.providers import load_dotenv_if_present, ProviderConfig, build_adapter
from evals.frontier_compare.model_registry import require_model_card
from evals.frontier_compare.runner import run_all
from benchmarks.replay_vs_llm import compare_to_llm, DEFAULT_LONGFORM_PROMPTS

# 1. Load .env
load_dotenv_if_present()

# 2. Build an adapter (will raise if API key is missing)
cfg = ProviderConfig.from_env("openai")
card = require_model_card(cfg.provider, cfg.model)  # enforces registry entry
adapter = build_adapter(cfg)

# 3. Run the replay determinism comparison
report = compare_to_llm(
    list(DEFAULT_LONGFORM_PROMPTS),
    llm_callable=adapter,
    runs=5,
)
print(report.summary())

# 4. The frontier_compare runner can also accept a provider adapter
# directly in future suite extensions — the ProviderConfig.as_dict()
# embeds cleanly into BenchmarkReport metadata.
```

---

## Consequences

- **Good:** Any benchmark file can call `build_adapter(cfg)` and receive a uniform callable.  No provider SDK leaks into benchmark logic.
- **Good:** `require_model_card()` enforces that unregistered models cannot produce undocumented benchmark results.  This is the key discipline addition.
- **Good:** `load_dotenv_if_present()` requires no new dependency (`python-dotenv` is not added).  Shell env always wins.
- **Good:** `.env.example` is the single source of truth for all secrets and tuning knobs; it lives at the repo root so any operator finds it immediately.
- **Neutral:** Provider SDK packages (`openai`, `anthropic`, `httpx`) are not added to `pyproject.toml` as hard dependencies — they are soft requirements that the adapter import will surface with a clear error message.  This keeps the base install lean and avoids forcing all CI runs to hold API keys.
- **Watch:** Floating model aliases (e.g. bare `gpt-4o`) are registered with a warning note but not banned at the registry level.  The discipline is enforced by convention: benchmark operators should pin to dated snapshots.

---

## Non-goals

- This ADR does not add async adapter support (synchronous calls are sufficient for current benchmark volumes).
- It does not add streaming.
- It does not add retry/backoff logic (callers can wrap `build_adapter` output with tenacity or similar).
- It does not modify `ChatRuntime` or any pack/lens behaviour.
- It does not add provider SDK packages to `pyproject.toml` hard dependencies.
