feat(phase2): OpenAI-compatible provider client (P5.1)
This commit is contained in:
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user