Agent-Logs-Url: https://github.com/AssetOverflow/core/sessions/f88b48fa-0c2a-4f9d-a42b-d275596e43b8 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: AssetOverflow <109810776+AssetOverflow@users.noreply.github.com>
406 lines
14 KiB
Python
406 lines
14 KiB
Python
"""Frontier provider adapters for CORE benchmark comparisons.
|
|
|
|
Design contract
|
|
---------------
|
|
Every adapter exposes a single callable::
|
|
|
|
adapter(prompt: str) -> str
|
|
|
|
The return value is a raw surface string — no metadata, no JSON envelope.
|
|
Callers in the benchmark runner treat this identically to a CORE surface;
|
|
the comparison layer handles diffing.
|
|
|
|
All provider configuration is read from environment variables (loaded via
|
|
``_env()``). No provider credentials appear in benchmark source code.
|
|
|
|
Model identity is *always* resolved to a pinned slug (the exact version
|
|
string the provider returns or that is configured). Adapters refuse to
|
|
run if the slug is absent — callers must set ``OPENAI_MODEL``,
|
|
``ANTHROPIC_MODEL``, or ``OLLAMA_MODEL`` explicitly. This ensures every
|
|
benchmark report carries an exact model identifier in its metadata rather
|
|
than a floating alias like ``gpt-4o`` whose underlying weights may change.
|
|
|
|
Usage
|
|
-----
|
|
::
|
|
|
|
from evals.frontier_compare.providers import build_adapter, ProviderConfig
|
|
from evals.frontier_compare.model_registry import resolve_model_card
|
|
|
|
cfg = ProviderConfig.from_env("openai")
|
|
adapter = build_adapter(cfg)
|
|
card = resolve_model_card(cfg.provider, cfg.model)
|
|
|
|
# Warm up (optional — some providers have cold-start overhead)
|
|
_ = adapter("What is truth?")
|
|
|
|
# Use in a benchmark
|
|
surface = adapter("What is knowledge?")
|
|
|
|
Provider keys
|
|
-------------
|
|
* ``openai`` — OpenAI REST API (requires ``openai`` package)
|
|
* ``anthropic`` — Anthropic REST API (requires ``anthropic`` package)
|
|
* ``ollama`` — Local Ollama server (requires ``httpx`` or ``requests``)
|
|
* ``core`` — CORE's own ChatRuntime (no external dependency)
|
|
|
|
ADR
|
|
---
|
|
ADR-0082 — Frontier provider adapters
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Callable
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _env(key: str, default: str = "") -> str:
|
|
"""Read an env var, stripping surrounding whitespace."""
|
|
return os.environ.get(key, default).strip()
|
|
|
|
|
|
def _require_env(key: str) -> str:
|
|
val = _env(key)
|
|
if not val:
|
|
raise EnvironmentError(
|
|
f"Required environment variable {key!r} is not set or empty. "
|
|
f"Copy .env.example to .env and fill in the value."
|
|
)
|
|
return val
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ProviderConfig — the resolved identity of one adapter instance
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ProviderConfig:
|
|
"""Fully-resolved provider + model identity used for one benchmark run.
|
|
|
|
All fields are immutable after construction so a config can be hashed,
|
|
stored in a report, and compared across runs.
|
|
"""
|
|
|
|
provider: str
|
|
"""One of: openai, anthropic, ollama, core."""
|
|
|
|
model: str
|
|
"""Exact model slug as it will appear in every benchmark report.
|
|
|
|
For OpenAI this should be a dated snapshot like ``gpt-4o-2024-08-06``
|
|
rather than a floating alias. For Ollama this is the tag as returned
|
|
by ``ollama list``.
|
|
"""
|
|
|
|
temperature: float = 0.0
|
|
"""Sampling temperature passed to the provider. CORE ignores this."""
|
|
|
|
max_tokens: int = 512
|
|
"""Maximum completion tokens requested."""
|
|
|
|
extra: dict = None # type: ignore[assignment]
|
|
"""Provider-specific overrides (e.g. base_url for OpenAI)."""
|
|
|
|
def __post_init__(self) -> None:
|
|
object.__setattr__(self, "extra", self.extra or {})
|
|
|
|
@classmethod
|
|
def from_env(cls, provider: str) -> "ProviderConfig":
|
|
"""Build a ProviderConfig by reading the standard env vars for
|
|
*provider*. Raises ``EnvironmentError`` if required vars are absent.
|
|
"""
|
|
provider = provider.lower().strip()
|
|
temperature = float(_env("BENCHMARK_TEMPERATURE", "0"))
|
|
if provider == "openai":
|
|
model = _env("OPENAI_MODEL", "gpt-4o")
|
|
if not model:
|
|
raise EnvironmentError("OPENAI_MODEL must be set for openai provider.")
|
|
extra: dict = {}
|
|
base_url = _env("OPENAI_BASE_URL")
|
|
if base_url:
|
|
extra["base_url"] = base_url
|
|
return cls(provider=provider, model=model, temperature=temperature, extra=extra)
|
|
|
|
if provider == "anthropic":
|
|
model = _env("ANTHROPIC_MODEL", "claude-opus-4-5")
|
|
if not model:
|
|
raise EnvironmentError("ANTHROPIC_MODEL must be set for anthropic provider.")
|
|
return cls(provider=provider, model=model, temperature=temperature)
|
|
|
|
if provider == "ollama":
|
|
model = _env("OLLAMA_MODEL", "llama3.2")
|
|
if not model:
|
|
raise EnvironmentError("OLLAMA_MODEL must be set for ollama provider.")
|
|
return cls(
|
|
provider=provider,
|
|
model=model,
|
|
temperature=temperature,
|
|
extra={
|
|
"url": _env("OLLAMA_URL", "http://localhost:11434"),
|
|
"api_key": _env("OLLAMA_API_KEY", ""),
|
|
},
|
|
)
|
|
|
|
if provider == "core":
|
|
return cls(provider=provider, model="core-native")
|
|
|
|
raise ValueError(
|
|
f"Unknown provider {provider!r}. "
|
|
f"Valid values: openai, anthropic, ollama, core."
|
|
)
|
|
|
|
def as_dict(self) -> dict:
|
|
return {
|
|
"provider": self.provider,
|
|
"model": self.model,
|
|
"temperature": self.temperature,
|
|
"max_tokens": self.max_tokens,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AdapterResponse:
|
|
"""Unified provider response payload for benchmark telemetry."""
|
|
|
|
surface: str
|
|
input_tokens: int | None = None
|
|
output_tokens: int | None = None
|
|
total_tokens: int | None = None
|
|
|
|
def as_dict(self) -> dict:
|
|
return {
|
|
"surface": self.surface,
|
|
"input_tokens": self.input_tokens,
|
|
"output_tokens": self.output_tokens,
|
|
"total_tokens": self.total_tokens,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Adapter builders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_core_adapter(cfg: ProviderConfig) -> Callable[[str], AdapterResponse]:
|
|
"""CORE ChatRuntime adapter. Fresh runtime per call — no session bleed."""
|
|
from chat.runtime import ChatRuntime
|
|
|
|
def adapter(prompt: str) -> AdapterResponse:
|
|
rt = ChatRuntime()
|
|
resp = rt.chat(prompt, max_tokens=cfg.max_tokens)
|
|
return AdapterResponse(surface=resp.surface or "")
|
|
|
|
return adapter
|
|
|
|
|
|
def _build_openai_adapter(cfg: ProviderConfig) -> Callable[[str], AdapterResponse]:
|
|
"""OpenAI Chat Completions adapter.
|
|
|
|
Requires: ``pip install openai``
|
|
Env vars: OPENAI_API_KEY, OPENAI_MODEL (see .env.example)
|
|
"""
|
|
try:
|
|
import openai # noqa: PLC0415
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"openai package is required for the OpenAI adapter. "
|
|
"Install it with: pip install openai"
|
|
) from exc
|
|
|
|
api_key = _require_env("OPENAI_API_KEY")
|
|
client_kwargs: dict = {"api_key": api_key}
|
|
if "base_url" in cfg.extra:
|
|
client_kwargs["base_url"] = cfg.extra["base_url"]
|
|
client = openai.OpenAI(**client_kwargs)
|
|
|
|
def adapter(prompt: str) -> AdapterResponse:
|
|
response = client.chat.completions.create(
|
|
model=cfg.model,
|
|
messages=[{"role": "user", "content": prompt}],
|
|
temperature=cfg.temperature,
|
|
max_tokens=cfg.max_tokens,
|
|
)
|
|
usage = getattr(response, "usage", None)
|
|
input_tokens = int(getattr(usage, "prompt_tokens", 0)) if usage else None
|
|
output_tokens = int(getattr(usage, "completion_tokens", 0)) if usage else None
|
|
total_tokens = int(getattr(usage, "total_tokens", 0)) if usage else None
|
|
return AdapterResponse(
|
|
surface=(response.choices[0].message.content or "").strip(),
|
|
input_tokens=input_tokens,
|
|
output_tokens=output_tokens,
|
|
total_tokens=total_tokens,
|
|
)
|
|
|
|
return adapter
|
|
|
|
|
|
def _build_anthropic_adapter(cfg: ProviderConfig) -> Callable[[str], AdapterResponse]:
|
|
"""Anthropic Messages adapter.
|
|
|
|
Requires: ``pip install anthropic``
|
|
Env vars: ANTHROPIC_API_KEY, ANTHROPIC_MODEL (see .env.example)
|
|
"""
|
|
try:
|
|
import anthropic as ant # noqa: PLC0415
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"anthropic package is required for the Anthropic adapter. "
|
|
"Install it with: pip install anthropic"
|
|
) from exc
|
|
|
|
api_key = _require_env("ANTHROPIC_API_KEY")
|
|
client = ant.Anthropic(api_key=api_key)
|
|
|
|
def adapter(prompt: str) -> AdapterResponse:
|
|
message = client.messages.create(
|
|
model=cfg.model,
|
|
max_tokens=cfg.max_tokens,
|
|
messages=[{"role": "user", "content": prompt}],
|
|
# Anthropic uses a top_p/temperature combo; temperature is supported
|
|
# on most models as of 2025. Clip to valid range [0, 1].
|
|
temperature=max(0.0, min(1.0, cfg.temperature)),
|
|
)
|
|
block = message.content[0] if message.content else None
|
|
usage = getattr(message, "usage", None)
|
|
input_tokens = int(getattr(usage, "input_tokens", 0)) if usage else None
|
|
output_tokens = int(getattr(usage, "output_tokens", 0)) if usage else None
|
|
total_tokens = (
|
|
(input_tokens or 0) + (output_tokens or 0)
|
|
if (input_tokens is not None or output_tokens is not None)
|
|
else None
|
|
)
|
|
return AdapterResponse(
|
|
surface=(getattr(block, "text", "") or "").strip(),
|
|
input_tokens=input_tokens,
|
|
output_tokens=output_tokens,
|
|
total_tokens=total_tokens,
|
|
)
|
|
|
|
return adapter
|
|
|
|
|
|
def _build_ollama_adapter(cfg: ProviderConfig) -> Callable[[str], AdapterResponse]:
|
|
"""Ollama local model adapter (HTTP /api/chat endpoint).
|
|
|
|
Requires: ``pip install httpx`` (already present in most Python envs)
|
|
Env vars: OLLAMA_URL, OLLAMA_API_KEY (empty for local), OLLAMA_MODEL
|
|
|
|
The adapter posts to ``{OLLAMA_URL}/api/chat`` using the OpenAI-compatible
|
|
messages format that Ollama >= 0.1.14 supports.
|
|
"""
|
|
try:
|
|
import httpx # noqa: PLC0415
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"httpx is required for the Ollama adapter. "
|
|
"Install it with: pip install httpx"
|
|
) from exc
|
|
|
|
base_url = (cfg.extra.get("url") or "http://localhost:11434").rstrip("/")
|
|
api_key = cfg.extra.get("api_key") or ""
|
|
headers = {"Content-Type": "application/json"}
|
|
if api_key:
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
|
|
def adapter(prompt: str) -> AdapterResponse:
|
|
payload = {
|
|
"model": cfg.model,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"stream": False,
|
|
"options": {"temperature": cfg.temperature},
|
|
}
|
|
resp = httpx.post(
|
|
f"{base_url}/api/chat",
|
|
json=payload,
|
|
headers=headers,
|
|
timeout=120.0,
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
input_tokens = data.get("prompt_eval_count")
|
|
output_tokens = data.get("eval_count")
|
|
total_tokens = (
|
|
int(input_tokens or 0) + int(output_tokens or 0)
|
|
if input_tokens is not None or output_tokens is not None
|
|
else None
|
|
)
|
|
return AdapterResponse(
|
|
surface=(data.get("message", {}).get("content") or "").strip(),
|
|
input_tokens=int(input_tokens) if input_tokens is not None else None,
|
|
output_tokens=int(output_tokens) if output_tokens is not None else None,
|
|
total_tokens=total_tokens,
|
|
)
|
|
|
|
return adapter
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public factory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_BUILDERS = {
|
|
"core": _build_core_adapter,
|
|
"openai": _build_openai_adapter,
|
|
"anthropic": _build_anthropic_adapter,
|
|
"ollama": _build_ollama_adapter,
|
|
}
|
|
|
|
|
|
def build_adapter(cfg: ProviderConfig) -> Callable[[str], str]:
|
|
"""Return a ``(prompt: str) -> str`` callable for the given config.
|
|
|
|
This is the single entry point for all benchmark runner code. The
|
|
callable is stateless per-call (each invocation is independent) so
|
|
it is safe to pass to ``compare_to_llm`` and the frontier_compare
|
|
runner suites.
|
|
"""
|
|
observing = build_observing_adapter(cfg)
|
|
|
|
def _surface_only(prompt: str) -> str:
|
|
return observing(prompt).surface
|
|
|
|
return _surface_only
|
|
|
|
|
|
def build_observing_adapter(cfg: ProviderConfig) -> Callable[[str], AdapterResponse]:
|
|
"""Return a provider adapter that includes usage telemetry when available."""
|
|
builder = _BUILDERS.get(cfg.provider)
|
|
if builder is None:
|
|
raise ValueError(
|
|
f"No adapter builder registered for provider {cfg.provider!r}. "
|
|
f"Registered: {', '.join(sorted(_BUILDERS))}."
|
|
)
|
|
return builder(cfg)
|
|
|
|
|
|
def load_dotenv_if_present(path: str = ".env") -> None:
|
|
"""Minimal .env loader — no external dependency on ``python-dotenv``.
|
|
|
|
Reads KEY=VALUE lines from *path* and sets them in ``os.environ`` only
|
|
if the key is not already set (so shell exports always win). Comments
|
|
and blank lines are skipped. Quoted values have the quotes stripped.
|
|
|
|
Call this once at the top of any benchmark entrypoint script::
|
|
|
|
from evals.frontier_compare.providers import load_dotenv_if_present
|
|
load_dotenv_if_present() # reads .env from repo root
|
|
"""
|
|
import pathlib
|
|
|
|
p = pathlib.Path(path)
|
|
if not p.exists():
|
|
return
|
|
for raw in p.read_text(encoding="utf-8").splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, _, val = line.partition("=")
|
|
key = key.strip()
|
|
val = val.strip().strip('"').strip("'")
|
|
if key and key not in os.environ:
|
|
os.environ[key] = val
|