feat(phase2): OpenAI-compatible provider client (P5.1)

This commit is contained in:
2026-09-25 22:22:03 +02:00
parent 790df17623
commit 42802f7bf5
4 changed files with 597 additions and 2 deletions
+6
View File
@@ -58,6 +58,12 @@ allowed-confusables = ["σ"]
"src/pf1e_simulator/runner.py" = ["PLR0913"] "src/pf1e_simulator/runner.py" = ["PLR0913"]
# client.py: LLMClient/FakeLLMClient need the full safeguard knobs (cache, budget, pricing, retry, breaker) # client.py: LLMClient/FakeLLMClient need the full safeguard knobs (cache, budget, pricing, retry, breaker)
"src/pf1e_simulator/client.py" = ["PLR0913"] "src/pf1e_simulator/client.py" = ["PLR0913"]
# provider.py: OpenAILLMClient.__init__ needs the full knob set (model, key,
# base url, timeout, transport, schema, cache, budget, pricing, retry, breaker)
# and the chat-completions URL is built from an env-gated base URL defaulting
# to HTTPS (section 15), so the S310 audit and the 14-param constructor are
# deliberate
"src/pf1e_simulator/provider.py" = ["PLR0913", "S310"]
# robustness.py: the cartography API takes the full knob set (scenario, specs, # robustness.py: the cartography API takes the full knob set (scenario, specs,
# side, runs, seed, fractions, target, precision, budget) as keyword parameters # side, runs, seed, fractions, target, precision, budget) as keyword parameters
"src/pf1e_simulator/robustness.py" = ["PLR0913"] "src/pf1e_simulator/robustness.py" = ["PLR0913"]
+28 -2
View File
@@ -1,4 +1,4 @@
"""Environment-driven configuration for the phase-2 LLM layer (P1). """Environment-driven configuration for the phase-2 LLM layer (P1/P5.1).
Architecture doc section 15: the phase-2 code never hard-codes data or Architecture doc section 15: the phase-2 code never hard-codes data or
configuration paths -- every externally supplied value is resolved here from configuration paths -- every externally supplied value is resolved here from
@@ -14,9 +14,19 @@ from pathlib import Path
# Environment variable names (documented in the architecture doc, sections # Environment variable names (documented in the architecture doc, sections
# 14-15). ``PF1E_PROMPT_PACK_DIR`` points at the external, versioned prompt # 14-15). ``PF1E_PROMPT_PACK_DIR`` points at the external, versioned prompt
# pack; ``PF1E_LLM_API_KEY`` feeds the remote provider client (P5.1). # pack; ``PF1E_LLM_API_KEY`` feeds the remote provider client (P5.1);
# ``PF1E_LLM_BASE_URL`` and ``PF1E_LLM_MODEL`` select the provider endpoint
# and model, so a self-hosted OpenAI-compatible gateway (vLLM, llama.cpp...)
# is configured without touching the code.
ENV_PROMPT_PACK_DIR = "PF1E_PROMPT_PACK_DIR" ENV_PROMPT_PACK_DIR = "PF1E_PROMPT_PACK_DIR"
ENV_LLM_API_KEY = "PF1E_LLM_API_KEY" ENV_LLM_API_KEY = "PF1E_LLM_API_KEY"
ENV_LLM_BASE_URL = "PF1E_LLM_BASE_URL"
ENV_LLM_MODEL = "PF1E_LLM_MODEL"
# Sensible offline defaults: the OpenAI-compatible chat endpoint and a small,
# cheap model. Nothing here is a secret -- the API key stays environment-only.
DEFAULT_LLM_BASE_URL = "https://api.openai.com/v1"
DEFAULT_LLM_MODEL = "gpt-4o-mini"
def env_str(name: str, *, default: str | None = None) -> str | None: def env_str(name: str, *, default: str | None = None) -> str | None:
@@ -34,3 +44,19 @@ def llm_api_key() -> str | None:
"""The provider API key, or None when the caller runs offline.""" """The provider API key, or None when the caller runs offline."""
return env_str(ENV_LLM_API_KEY) return env_str(ENV_LLM_API_KEY)
def llm_base_url() -> str:
"""The OpenAI-compatible base URL (``PF1E_LLM_BASE_URL``, else default).
The returned value has no trailing slash; ``OpenAILLMClient`` appends
``/chat/completions`` to it (P5.1).
"""
raw = env_str(ENV_LLM_BASE_URL)
return raw.rstrip("/") if raw else DEFAULT_LLM_BASE_URL
def llm_model() -> str:
"""The provider model name (``PF1E_LLM_MODEL``, else default)."""
raw = env_str(ENV_LLM_MODEL)
return raw or DEFAULT_LLM_MODEL
+227
View File
@@ -0,0 +1,227 @@
"""Real OpenAI-compatible chat client for the phase-2 LLM layer (P5.1).
``OpenAILLMClient`` speaks the OpenAI chat-completions wire format over HTTPS
using only the standard library (``urllib.request``), so the repository ships
no HTTP dependency. The base URL, model and API key are resolved from the
environment (:mod:`pf1e_simulator.config`, section 15); the key is never
embedded in code.
Error taxonomy (shared with :mod:`pf1e_simulator.client`):
- ``ProviderError`` for transient failures (transport errors, HTTP 429, any
5xx) -- retried by ``LLMClient``'s backoff loop and counted by the breaker;
- ``ProviderRejectedError`` for permanent 4xx rejections (bad key, unknown
model, quota) -- raised immediately, never retried;
- ``ProviderResponseError`` for a 2xx body that is not a valid
chat-completions payload -- raised immediately, never retried.
"""
from __future__ import annotations
import json
import time
import urllib.request
from typing import TYPE_CHECKING, Protocol, Self, TypeGuard
from urllib.error import HTTPError
from urllib.request import Request
from pf1e_simulator.client import (
CircuitBreaker,
ClientError,
ClientParams,
Completion,
CostBudget,
LLMClient,
Pricing,
ProviderError,
ResponseCache,
TokenUsage,
estimate_tokens,
)
from pf1e_simulator.combat import OBSERVATION_SCHEMA_VERSION
from pf1e_simulator.config import (
ENV_LLM_API_KEY,
llm_api_key,
llm_base_url,
llm_model,
)
if TYPE_CHECKING:
from collections.abc import Callable
# Status-code buckets of the chat-completions wire (RFC 9110): 2xx is a
# success, 429 and every 5xx mean "try again later" (the only statuses safe
# to retry), and any other 4xx is a permanent rejection.
_OK_MIN = 200
_OK_EXCLUSIVE = 300
_RATE_LIMITED = 429
_SERVER_ERROR_MIN = 500
class ProviderRejectedError(ClientError):
"""A permanent 4xx provider rejection - never retried."""
class ProviderResponseError(ClientError):
"""A 2xx body that is not a valid chat-completions payload."""
class HttpResponse(Protocol):
"""The minimal ``urllib.response`` surface the wire call uses."""
status: int
def read(self) -> bytes: ...
def __enter__(self) -> Self: ...
def __exit__(self, _exc_type: object, _exc_value: object, _traceback: object) -> None: ...
def _is_object_dict(value: object) -> TypeGuard[dict[str, object]]:
return isinstance(value, dict)
def _is_object_list(value: object) -> TypeGuard[list[object]]:
return isinstance(value, list)
def _parse_completion(payload: dict[str, object]) -> str:
"""Extract the completion text from a chat-completions payload."""
choices_raw = payload.get("choices")
if not _is_object_list(choices_raw) or not choices_raw:
msg = "provider response has no choices"
raise ProviderResponseError(msg)
choice = choices_raw[0]
if not _is_object_dict(choice):
msg = "provider response choice is not a JSON object"
raise ProviderResponseError(msg)
message_raw = choice.get("message")
if not _is_object_dict(message_raw):
msg = "provider response message is not a JSON object"
raise ProviderResponseError(msg)
content = message_raw.get("content")
if not isinstance(content, str):
msg = "provider response content is not a string"
raise ProviderResponseError(msg)
return content
def _parse_usage(payload: dict[str, object], prompt: str, text: str) -> TokenUsage:
"""Read the usage block, or estimate tokens when the provider omits it."""
usage_raw = payload.get("usage")
if not _is_object_dict(usage_raw):
return TokenUsage(
prompt_tokens=estimate_tokens(prompt),
completion_tokens=estimate_tokens(text),
)
prompt_tokens = usage_raw.get("prompt_tokens")
completion_tokens = usage_raw.get("completion_tokens")
if not isinstance(prompt_tokens, int) or not isinstance(completion_tokens, int):
msg = "provider usage block is malformed"
raise ProviderResponseError(msg)
return TokenUsage(prompt_tokens=prompt_tokens, completion_tokens=completion_tokens)
class OpenAILLMClient(LLMClient):
"""Real chat-completions client (OpenAI or any compatible gateway).
Endpoint, model and key default to the environment (section 15); all may
be overridden explicitly. ``urlopen`` and ``sleep`` are injectable so
tests exercise the full retry/breaker machinery offline.
"""
def __init__(
self,
*,
prompt_version: str,
model: str | None = None,
api_key: str | None = None,
base_url: str | None = None,
timeout_s: float = 30.0,
urlopen: Callable[..., HttpResponse] = urllib.request.urlopen,
schema_version: int = OBSERVATION_SCHEMA_VERSION,
cache: ResponseCache | None = None,
budget: CostBudget | None = None,
pricing: Pricing | None = None,
retries: int = 2,
backoff_base_s: float = 0.25,
breaker: CircuitBreaker | None = None,
sleep: Callable[[float], None] | None = None,
) -> None:
key = api_key if api_key is not None else llm_api_key()
if key is None or key == "":
msg = (
f"no LLM API key: set the {ENV_LLM_API_KEY} environment "
"variable or pass an explicit api_key"
)
raise ValueError(msg)
if timeout_s <= 0:
msg = f"timeout_s must be > 0, got {timeout_s}"
raise ValueError(msg)
super().__init__(
model=model if model is not None else llm_model(),
prompt_version=prompt_version,
schema_version=schema_version,
cache=cache,
budget=budget,
pricing=pricing,
retries=retries,
backoff_base_s=backoff_base_s,
sleep=sleep if sleep is not None else time.sleep,
breaker=breaker,
)
resolved_base_url = base_url if base_url is not None else llm_base_url()
self._api_key = key
self._base_url = resolved_base_url.rstrip("/")
self._timeout_s = timeout_s
self._urlopen = urlopen
def _call_once(self, prompt: str, *, params: ClientParams) -> Completion:
"""One chat-completions attempt; raise ``ProviderError`` when retryable."""
body = {
"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"temperature": params.temperature,
"max_tokens": params.max_tokens,
"top_p": params.top_p,
"response_format": {"type": "json_object"},
}
request = Request(
f"{self._base_url}/chat/completions",
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with self._urlopen(request, timeout=self._timeout_s) as response:
status = response.status
data = response.read()
except HTTPError as exc:
status = exc.code
data = exc.read()
except OSError as exc:
msg = f"provider unreachable: {exc}"
raise ProviderError(msg) from exc
if status == _RATE_LIMITED or status >= _SERVER_ERROR_MIN:
msg = f"provider transient failure: HTTP {status}"
raise ProviderError(msg)
if not _OK_MIN <= status < _OK_EXCLUSIVE:
msg = f"provider rejected request: HTTP {status}"
raise ProviderRejectedError(msg)
try:
payload = json.loads(data.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
msg = f"provider response is not valid JSON: {exc}"
raise ProviderResponseError(msg) from exc
if not _is_object_dict(payload):
msg = "provider payload is not a JSON object"
raise ProviderResponseError(msg)
text = _parse_completion(payload)
usage = _parse_usage(payload, prompt, text)
return Completion(text=text, usage=usage)
+336
View File
@@ -0,0 +1,336 @@
"""Phase-2 real provider client (docs/phase-2 P5.1, sections 15/16).
``OpenAILLMClient`` (``provider.py``) speaks the OpenAI chat-completions
wire format over ``urllib.request`` with zero HTTP dependency. These tests
pin the wire format, the error taxonomy (transient 429/5xx retried, 4xx
rejected, invalid payload rejected), the env-key resolution, the token
ledger and the cache behaviour - all through an injectable fake transport,
never the network. Every key used below is a fake test credential.
"""
from __future__ import annotations
import io
import json
from typing import TYPE_CHECKING, Self
from urllib.error import HTTPError
import pytest
from pf1e_simulator.client import (
RetryExhaustedError,
estimate_tokens,
)
from pf1e_simulator.config import (
ENV_LLM_API_KEY,
ENV_LLM_BASE_URL,
ENV_LLM_MODEL,
)
from pf1e_simulator.provider import (
OpenAILLMClient,
ProviderRejectedError,
ProviderResponseError,
)
if TYPE_CHECKING:
from collections.abc import Sequence
from urllib.request import Request
_KEY = "sk-test"
_ENV_KEY = "sk-env-key"
_PROMPT_VERSION = "fixture:provider:0"
_BASE_URL = "https://api.openai.com/v1"
_MODEL = "gpt-4o-mini"
class _FakeResponse:
"""Minimal ``urllib.response`` double: status + body, context manager."""
def __init__(self, status: int, body: bytes) -> None:
self.status = status
self._body = body
def read(self) -> bytes:
return self._body
def __enter__(self) -> Self:
return self
def __exit__(self, _exc_type: object, _exc_value: object, _traceback: object) -> None:
return None
class _FakeTransport:
"""Injectable ``urlopen``: replays a script, keeping the last entry.
An ``Exception`` entry is raised; provider code sees it exactly as it
would see a real transport failure.
"""
def __init__(self, script: Sequence[_FakeResponse | Exception]) -> None:
self._script = tuple(script)
self._calls: list[tuple[Request, float]] = []
def __call__(self, request: Request, *, timeout: float) -> _FakeResponse:
self._calls.append((request, timeout))
index = min(len(self._calls) - 1, len(self._script) - 1)
entry = self._script[index]
if isinstance(entry, Exception):
raise entry
return entry
@property
def calls(self) -> tuple[tuple[Request, float], ...]:
return tuple(self._calls)
def _http_error(status: int) -> HTTPError:
return HTTPError(
f"{_BASE_URL}/chat/completions",
status,
"simulated HTTP error",
hdrs={},
fp=io.BytesIO(b"{}"),
)
def _ok_response(*, content: str = "action json", include_usage: bool = True) -> _FakeResponse:
body: dict[str, object] = {
"choices": [{"message": {"content": content}}],
}
if include_usage:
body["usage"] = {"prompt_tokens": 12, "completion_tokens": 7}
return _FakeResponse(200, json.dumps(body).encode("utf-8"))
def _fake_transport(script: Sequence[_FakeResponse | Exception]) -> _FakeTransport:
return _FakeTransport(script)
def _client(
transport: _FakeTransport,
*,
api_key: str = _KEY,
retries: int = 2,
timeout_s: float = 30.0,
base_url: str = _BASE_URL,
model: str = _MODEL,
) -> OpenAILLMClient:
return OpenAILLMClient(
prompt_version=_PROMPT_VERSION,
model=model,
api_key=api_key,
base_url=base_url,
timeout_s=timeout_s,
urlopen=transport,
retries=retries,
sleep=lambda _: None,
)
# ── wire format ─────────────────────────────────────────────────────────────
def test_wire_format_matches_openai_chat_completions() -> None:
"""The request is a POST to /v1/chat/completions with the raw body."""
transport = _fake_transport((_ok_response(),))
client = _client(transport)
assert client.complete("hello") == "action json"
assert len(transport.calls) == 1
request, timeout = transport.calls[0]
assert timeout == 30.0
assert request.full_url == f"{_BASE_URL}/chat/completions"
assert request.method == "POST"
assert request.get_header("Authorization") == f"Bearer {_KEY}"
# urllib capitalizes header keys via str.capitalize(): "Content-Type" is
# stored as "Content-type".
assert request.get_header("Content-type") == "application/json"
assert request.data is not None
body = json.loads(request.data)
assert body["model"] == _MODEL
assert body["messages"] == [{"role": "user", "content": "hello"}]
assert body["temperature"] == 0.0
assert body["max_tokens"] == 1024
assert body["top_p"] == 1.0
assert body["response_format"] == {"type": "json_object"}
def test_ledger_records_provider_usage() -> None:
"""Provider-reported token counts land in the client ledger."""
transport = _fake_transport((_ok_response(),))
client = _client(transport)
client.complete("hello")
assert client.stats.calls == 1
assert client.stats.prompt_tokens == 12
assert client.stats.completion_tokens == 7
assert client.stats.cost_usd == 0.0
assert client.stats.cache_hits == 0
assert client.stats.cache_misses == 1
def test_usage_estimated_when_provider_omits_it() -> None:
"""Without a usage block, tokens are estimated from character counts."""
transport = _fake_transport((_ok_response(include_usage=False),))
client = _client(transport)
client.complete("hello")
assert client.stats.prompt_tokens == estimate_tokens("hello")
assert client.stats.completion_tokens == estimate_tokens("action json")
# ── key resolution ──────────────────────────────────────────────────────────
def test_missing_or_empty_api_key_raises(monkeypatch: pytest.MonkeyPatch) -> None:
"""A missing or empty key is rejected with the env-var name in the error."""
monkeypatch.delenv(ENV_LLM_API_KEY, raising=False)
with pytest.raises(ValueError, match="PF1E_LLM_API_KEY"):
OpenAILLMClient(
prompt_version=_PROMPT_VERSION,
api_key=None,
base_url=_BASE_URL,
urlopen=_fake_transport((_ok_response(),)),
)
with pytest.raises(ValueError, match="PF1E_LLM_API_KEY"):
OpenAILLMClient(
prompt_version=_PROMPT_VERSION,
api_key="",
base_url=_BASE_URL,
urlopen=_fake_transport((_ok_response(),)),
)
def test_api_key_read_from_environment(monkeypatch: pytest.MonkeyPatch) -> None:
"""With no explicit key, PF1E_LLM_API_KEY is used for the bearer token."""
monkeypatch.setenv(ENV_LLM_API_KEY, _ENV_KEY)
transport = _fake_transport((_ok_response(),))
client = OpenAILLMClient(
prompt_version=_PROMPT_VERSION,
api_key=None,
base_url=_BASE_URL,
urlopen=transport,
sleep=lambda _: None,
)
client.complete("hello")
request, _timeout = transport.calls[0]
assert request.get_header("Authorization") == f"Bearer {_ENV_KEY}"
# ── error taxonomy and retries ──────────────────────────────────────────────
def test_rate_limit_retries_then_succeeds() -> None:
"""HTTP 429 is transient: with retries=2 the third attempt succeeds."""
transport = _fake_transport(
(_http_error(429), _http_error(429), _ok_response()),
)
client = _client(transport, retries=2)
assert client.complete("hello") == "action json"
assert client.calls == 3
def test_transient_oserror_retries_then_exhausts() -> None:
"""A transport error is retried, then RetryExhaustedError names it."""
transport = _fake_transport((TimeoutError("simulated timeout"),))
client = _client(transport, retries=1)
with pytest.raises(RetryExhaustedError, match="unreachable"):
client.complete("hello")
assert client.calls == 2
def test_permanent_4xx_is_not_retried() -> None:
"""HTTP 401 is a permanent rejection: one attempt, then raise."""
transport = _fake_transport((_http_error(401),))
client = _client(transport, retries=3)
with pytest.raises(ProviderRejectedError, match="HTTP 401"):
client.complete("hello")
assert client.calls == 1
def test_server_error_retries_then_exhausts() -> None:
"""HTTP 500 is transient: retries exhausted raises with the status."""
transport = _fake_transport((_http_error(500),))
client = _client(transport, retries=1)
with pytest.raises(RetryExhaustedError, match="HTTP 500"):
client.complete("hello")
assert client.calls == 2
# ── payload validation ──────────────────────────────────────────────────────
def test_invalid_json_body_raises_provider_response_error() -> None:
"""A 2xx body that is not JSON is a permanent response error."""
transport = _fake_transport((_FakeResponse(200, b"<html>oops</html>"),))
client = _client(transport)
with pytest.raises(ProviderResponseError, match="not valid JSON"):
client.complete("hello")
@pytest.mark.parametrize(
("payload", "message"),
[
({"choices": []}, "no choices"),
({"choices": [{}]}, "message"),
({"choices": [{"message": {}}]}, "content"),
([], "not a JSON object"),
],
)
def test_malformed_payload_raises_provider_response_error(
payload: object,
message: str,
) -> None:
"""Non-chat-completions payloads are rejected with a stable message."""
transport = _fake_transport(
(_FakeResponse(200, json.dumps(payload).encode("utf-8")),),
)
client = _client(transport)
with pytest.raises(ProviderResponseError, match=message):
client.complete("hello")
# ── caching and environment defaults ────────────────────────────────────────
def test_cache_serves_second_call_without_provider() -> None:
"""An identical second call is served from the cache, no wire traffic."""
transport = _fake_transport((_ok_response(),))
client = _client(transport)
assert client.complete("hello") == "action json"
assert client.complete("hello") == "action json"
assert len(transport.calls) == 1
assert client.calls == 1
assert client.cache_hits == 1
assert client.cache_misses == 1
def test_base_url_and_model_read_from_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""PF1E_LLM_BASE_URL/PF1E_LLM_MODEL drive the endpoint and the body."""
monkeypatch.setenv(ENV_LLM_BASE_URL, "http://localhost:8080/v1/")
monkeypatch.setenv(ENV_LLM_MODEL, "local-model")
transport = _fake_transport((_ok_response(),))
client = OpenAILLMClient(
prompt_version=_PROMPT_VERSION,
api_key=_KEY,
base_url=None,
model=None,
urlopen=transport,
sleep=lambda _: None,
)
client.complete("hello")
request, _timeout = transport.calls[0]
assert request.full_url == "http://localhost:8080/v1/chat/completions"
assert request.data is not None
assert json.loads(request.data)["model"] == "local-model"
def test_invalid_timeout_rejected() -> None:
"""A non-positive timeout_s is a construction error, not a wire call."""
with pytest.raises(ValueError, match="timeout_s"):
OpenAILLMClient(
prompt_version=_PROMPT_VERSION,
api_key=_KEY,
base_url=_BASE_URL,
timeout_s=0,
)