feat(llm): structured per-attempt provider request log with provider request ids

Every model-provider attempt (LiteLLM callback + native OpenAI/Codex wrapper)
emits an LlmRequestEvent: provider, model, host, outcome, HTTP status,
provider request id (response header first, Anthropic error body fallback),
usage/cost, duration, agent + retry attempt. Prompts, completions, headers
and credentials are never recorded; error text is redacted and truncated.
Agent failure reasons now go through failure_text() so a blocked Anthropic
call's request id lands in the operator-visible failure text.
This commit is contained in:
Ahmed Allam 2026-09-22 18:51:20 +00:00
parent 56e9ae982c
commit 432edf0df6
8 changed files with 1651 additions and 10 deletions

View file

@ -308,6 +308,9 @@ ignore = [
"strix/report/dedupe.py" = ["PLC0415"]
"strix/telemetry/logging.py" = ["PLC0415"]
"strix/config/models.py" = ["PLC0415"]
# LiteLLM is imported lazily: the request log is wired at startup on every
# route, including the native OpenAI ones that never load LiteLLM.
"strix/llm/request_log.py" = ["PLC0415"]
# Heavy inference deps (httpx, openai) imported lazily so auth-status checks
# don't pull them in.
"strix/config/codex.py" = ["PLC0415"]

View file

@ -40,6 +40,7 @@ from strix.config import codex
from strix.config.loader import load_settings
from strix.config.tool_call_ids import TurnCallIdRewriter, dedupe_input
from strix.config.tool_call_limits import TurnToolCallLimiter
from strix.llm import request_log
if TYPE_CHECKING:
@ -525,13 +526,25 @@ class StrixProvider(MultiProvider):
# The ChatGPT subscription backend is always streamed; it has no
# non-streaming mode to fall back to, so LLM_DISABLE_STREAMING
# does not apply here.
model: Model = _CodexResponsesModel(
slug,
codex.get_subscription_client(),
reasoning_effort=llm.reasoning_effort,
model: Model = request_log.RequestLoggingModel(
_CodexResponsesModel(
slug,
codex.get_subscription_client(),
reasoning_effort=llm.reasoning_effort,
),
model_name=slug,
provider="openai-codex",
base_url=None,
)
else:
model = super().get_model(model_name)
if not _routes_via_litellm(model):
model = request_log.RequestLoggingModel(
model,
model_name=model_name or llm.model or "unknown",
provider="openai",
base_url=self._override_base_url or llm.api_base,
)
if llm.disable_streaming:
model = _NonStreamingModel(model)
# The wrapper emits its single event only once the whole request
@ -545,6 +558,13 @@ class StrixProvider(MultiProvider):
)
def _routes_via_litellm(model: Model) -> bool:
"""LiteLLM-backed models are captured by the LiteLLM callback, not the wrapper."""
from agents.extensions.models.litellm_model import LitellmModel
return isinstance(model, LitellmModel)
DEFAULT_MODEL_RETRY = ModelRetrySettings(
max_retries=5,
backoff=ModelRetryBackoffSettings(
@ -621,6 +641,7 @@ def configure_sdk_model_defaults(settings: Settings) -> None:
"""Apply Strix config to SDK-native defaults."""
llm = settings.llm
set_tracing_disabled(True)
request_log.install()
if codex.subscription_model(llm.model):
return
_configure_litellm_compatibility()

View file

@ -33,6 +33,7 @@ from strix.core.sessions import (
seed_initial_input,
strip_all_images_from_session,
)
from strix.llm import request_log
from strix.llm.compaction import is_context_overflow, maybe_compact
@ -197,6 +198,44 @@ async def run_agent_loop(
start_parked: bool = False,
event_sink: StreamEventSink | None = None,
hooks: RunHooks[dict[str, Any]] | None = None,
) -> RunResultBase | None:
agent_name = getattr(agent, "name", None)
token = request_log.bind_call_context(
agent_id, agent_name if isinstance(agent_name, str) else None
)
try:
return await _run_agent_loop(
agent=agent,
initial_input=initial_input,
run_config=run_config,
context=context,
max_turns=max_turns,
coordinator=coordinator,
agent_id=agent_id,
interactive=interactive,
session=session,
start_parked=start_parked,
event_sink=event_sink,
hooks=hooks,
)
finally:
request_log.reset_call_context(token)
async def _run_agent_loop(
*,
agent: Any,
initial_input: Any,
run_config: RunConfig,
context: dict[str, Any],
max_turns: int,
coordinator: AgentCoordinator,
agent_id: str,
interactive: bool,
session: Session | None,
start_parked: bool,
event_sink: StreamEventSink | None,
hooks: RunHooks[dict[str, Any]] | None,
) -> RunResultBase | None:
await coordinator.attach_runtime(
agent_id,
@ -636,7 +675,7 @@ async def _run_cycle_parked(
raise
except Exception as exc:
logger.exception("error escaped the run cycle for %s; parking as failed", agent_id)
await coordinator.set_status(agent_id, "failed", error=str(exc) or type(exc).__name__)
await coordinator.set_status(agent_id, "failed", error=request_log.failure_text(exc))
await notify_parent_on_terminal(coordinator, agent_id, "failed")
return None
@ -658,6 +697,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
image_strips = 0
compactions = 0
model_retries = 0
request_log.set_retry_attempt(0)
while True:
stream: Any = None
pre_run_items: list[Any] = []
@ -785,6 +825,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
exc,
)
await asyncio.sleep(delay)
request_log.set_retry_attempt(model_retries)
if session is not None:
input_data = []
continue
@ -792,7 +833,9 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
await _salvage_stream_to_session(session, pre_run_items, stream, agent_id)
if isinstance(exc, ProviderRefusalError):
logger.warning("agent %s refused by the model provider: %s", agent_id, exc)
await coordinator.set_status(agent_id, "failed", error=str(exc))
await coordinator.set_status(
agent_id, "failed", error=request_log.failure_text(exc)
)
await notify_parent_on_terminal(coordinator, agent_id, "failed")
return None
if isinstance(exc, MaxTurnsExceeded):
@ -806,7 +849,7 @@ async def _run_cycle( # noqa: PLR0912, PLR0915
# non-interactive agent's task: a child that dies still owes its parent a
# report, and the parent would otherwise wait out its timeout on a message
# the dead child can no longer send.
await coordinator.set_status(agent_id, status, error=str(exc) or type(exc).__name__)
await coordinator.set_status(agent_id, status, error=request_log.failure_text(exc))
await notify_parent_on_terminal(coordinator, agent_id, status)
if not interactive:
raise

784
strix/llm/request_log.py Normal file
View file

@ -0,0 +1,784 @@
"""Structured, secret-free record of every model provider call.
One :class:`LlmRequestEvent` per HTTP attempt against a provider, whatever the
route (LiteLLM or the native OpenAI client) and whatever the outcome. Retries
are separate events; a streamed call is one event once the stream settles.
Events carry the provider's own request identifier when the provider returns
one (Anthropic ``request-id``, OpenAI ``x-request-id``), which is what a
provider's support team asks for when a call was blocked or misbehaved.
The record deliberately holds no prompt, completion, header block, or
credential. Error text is redacted and truncated before it is stored.
Sinks are plain callables. The built-in sink writes one log line per event;
deployments register their own (a database, a queue) with
:func:`register_sink`.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import re
import time
import uuid
from collections.abc import AsyncIterator, Callable, Mapping
from contextvars import ContextVar, Token
from dataclasses import asdict, dataclass, replace
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Literal, cast
from urllib.parse import urlsplit
from agents.items import ModelResponse
from agents.models.interface import Model
from agents.usage import Usage
from openai import APIError, APIStatusError
from openai.types.responses import ResponseCompletedEvent
if TYPE_CHECKING:
from agents.agent_output import AgentOutputSchemaBase
from agents.handoffs import Handoff
from agents.items import TResponseInputItem, TResponseStreamEvent
from agents.model_settings import ModelSettings
from agents.models.interface import ModelTracing
from agents.retry import ModelRetryAdvice, ModelRetryAdviceRequest
from agents.tool import Tool
from openai.types.responses import ResponsePromptParam
logger = logging.getLogger(__name__)
Outcome = Literal["success", "error"]
Route = Literal["litellm", "openai"]
ERROR_MESSAGE_MAX_CHARS = 2000
@dataclass(frozen=True)
class LlmCallContext:
"""Who is making the call, and which replay of the turn this is."""
agent_id: str | None = None
agent_name: str | None = None
retry_attempt: int = 0
@dataclass(frozen=True)
class LlmRequestEvent:
"""One attempt against a model provider."""
call_id: str
route: Route
provider: str | None
model: str
api_host: str | None
streaming: bool
outcome: Outcome
status_code: int | None
provider_request_id: str | None
response_id: str | None
error_type: str | None
error_message: str | None
started_at: datetime
finished_at: datetime
duration_ms: int
input_tokens: int | None = None
output_tokens: int | None = None
cached_input_tokens: int | None = None
total_tokens: int | None = None
cost_usd: float | None = None
agent_id: str | None = None
agent_name: str | None = None
retry_attempt: int = 0
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["started_at"] = self.started_at.isoformat()
data["finished_at"] = self.finished_at.isoformat()
return data
LlmRequestSink = Callable[[LlmRequestEvent], None]
_sinks: list[LlmRequestSink] = []
_DEFAULT_CALL_CONTEXT = LlmCallContext()
_call_context: ContextVar[LlmCallContext] = ContextVar("strix_llm_call_context")
def bind_call_context(agent_id: str | None, agent_name: str | None) -> Token[LlmCallContext]:
"""Attribute every provider call made from this task tree to one agent.
Bind in the task that owns the agent's run loop, not in an SDK hook: the
SDK awaits hooks through ``asyncio.gather``, whose child tasks copy the
context and cannot write it back.
"""
return _call_context.set(LlmCallContext(agent_id=agent_id, agent_name=agent_name))
def reset_call_context(token: Token[LlmCallContext]) -> None:
_call_context.reset(token)
def set_retry_attempt(attempt: int) -> None:
"""Stamp subsequent calls with the turn-replay number (0 = first try)."""
_call_context.set(replace(current_call_context(), retry_attempt=attempt))
def current_call_context() -> LlmCallContext:
return _call_context.get(_DEFAULT_CALL_CONTEXT)
def register_sink(sink: LlmRequestSink) -> None:
"""Add a sink. Sinks must return quickly and never raise; a raising sink is logged and kept."""
if sink not in _sinks:
_sinks.append(sink)
install()
def unregister_sink(sink: LlmRequestSink) -> None:
with contextlib.suppress(ValueError):
_sinks.remove(sink)
def emit(event: LlmRequestEvent) -> None:
for sink in tuple(_sinks):
try:
sink(event)
except Exception:
logger.exception("LLM request log sink %r failed", sink)
# --------------------------------------------------------------------------- #
# Redaction #
# --------------------------------------------------------------------------- #
# Credential shapes a provider or gateway error may echo back. Broad by design:
# a redacted-but-useless message costs nothing, a leaked key costs a rotation.
_SECRET_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"(?i)\b(?:bearer|basic)\s+[a-z0-9._~+/=-]{8,}"),
re.compile(r"\bsk-(?:ant-|proj-|or-v1-)?[A-Za-z0-9_-]{8,}"),
re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"),
re.compile(r"\bAIza[0-9A-Za-z_-]{20,}\b"),
re.compile(r"\bgh[pousr]_[A-Za-z0-9]{20,}\b"),
re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b"),
re.compile(r"\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
re.compile(
r"(?i)([\"']?(?:api[_-]?key|x-api-key|authorization|api[_-]?token|secret|password)"
r"[\"']?\s*[:=]\s*[\"']?)(?:(?:bearer|basic)\s+)?[^\s\"',;&]{4,}"
),
re.compile(r"(?i)((?:api[_-]?key|token|secret|password)=)[^&\s\"']{4,}"),
)
def redact(text: str) -> str:
"""Replace anything credential-shaped in ``text``."""
for pattern in _SECRET_PATTERNS:
if pattern.groups:
text = pattern.sub(lambda m: f"{m.group(1)}[REDACTED]", text)
else:
text = pattern.sub("[REDACTED]", text)
return text
def clean_error_message(exc: BaseException) -> str:
message = str(exc).strip() or type(exc).__name__
message = redact(message)
if len(message) > ERROR_MESSAGE_MAX_CHARS:
message = message[: ERROR_MESSAGE_MAX_CHARS - 1] + "…"
return message
# --------------------------------------------------------------------------- #
# Provider request-id extraction #
# --------------------------------------------------------------------------- #
# Provider-side request identifiers, in the order they are trusted. Anthropic
# and most gateways: ``request-id``; OpenAI/Azure: ``x-request-id``; Bedrock:
# ``x-amzn-requestid``; Vertex/Gemini: ``x-goog-request-id``. LiteLLM prefixes
# stored provider headers with ``llm_provider-``; both spellings are accepted.
_REQUEST_ID_HEADERS: tuple[str, ...] = (
"request-id",
"x-request-id",
"x-amzn-requestid",
"x-amz-request-id",
"x-goog-request-id",
"cf-ray",
)
_LITELLM_HEADER_PREFIX = "llm_provider-"
# Anthropic error bodies carry the id even when a gateway strips the header.
_BODY_REQUEST_ID = re.compile(r"[\"']?request_id[\"']?\s*[:=]\s*[\"']?(req_[A-Za-z0-9_-]{6,})")
def request_id_from_headers(headers: Mapping[str, Any] | None) -> str | None:
if not headers:
return None
normalized: dict[str, str] = {}
for key, value in headers.items():
if not isinstance(value, str) or not value.strip():
continue
name = str(key).lower().removeprefix(_LITELLM_HEADER_PREFIX)
normalized.setdefault(name, value.strip())
for name in _REQUEST_ID_HEADERS:
if name in normalized:
return normalized[name]
return None
def request_id_from_text(text: str | None) -> str | None:
if not text:
return None
match = _BODY_REQUEST_ID.search(text)
return match.group(1) if match else None
def api_host(url: str | None) -> str | None:
"""Hostname of an endpoint URL. Never the path or query, which may hold keys."""
if not isinstance(url, str) or not url.strip():
return None
try:
host = urlsplit(url if "://" in url else f"//{url}").hostname
except ValueError:
return None
return host or None
def _int_or_none(value: object) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float) and value.is_integer():
return int(value)
return None
def _float_or_none(value: object) -> float | None:
if isinstance(value, bool):
return None
if isinstance(value, int | float):
return float(value)
return None
def _str_or_none(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _mapping(value: object) -> Mapping[str, Any] | None:
return cast("Mapping[str, Any]", value) if isinstance(value, Mapping) else None
def _to_datetime(value: object, fallback: datetime) -> datetime:
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=UTC)
if isinstance(value, int | float):
return datetime.fromtimestamp(value, tz=UTC)
return fallback
# --------------------------------------------------------------------------- #
# LiteLLM route #
# --------------------------------------------------------------------------- #
def _litellm_usage(response: object) -> dict[str, int | None]:
usage = getattr(response, "usage", None)
if usage is None:
return {}
prompt = _int_or_none(getattr(usage, "prompt_tokens", None))
completion = _int_or_none(getattr(usage, "completion_tokens", None))
total = _int_or_none(getattr(usage, "total_tokens", None))
details = getattr(usage, "prompt_tokens_details", None)
cached = _int_or_none(getattr(details, "cached_tokens", None)) if details else None
if cached is None:
cached = _int_or_none(getattr(usage, "cache_read_input_tokens", None))
return {
"input_tokens": prompt,
"output_tokens": completion,
"cached_input_tokens": cached,
"total_tokens": total,
}
def _exception_headers(exc: BaseException | None) -> Mapping[str, Any] | None:
"""Provider response headers LiteLLM keeps on a mapped exception.
Where they live depends on the provider adapter: ``litellm_response_headers``
for the HTTP-handler adapters (Anthropic), ``headers`` for the OpenAI SDK
adapters, and the wrapped ``response`` otherwise.
"""
if exc is None:
return None
candidates = (
getattr(exc, "litellm_response_headers", None),
getattr(exc, "headers", None),
getattr(getattr(exc, "response", None), "headers", None),
)
for raw in candidates:
headers = _headers_mapping(raw)
if headers and request_id_from_headers(headers):
return headers
return None
def _headers_mapping(headers: object) -> Mapping[str, Any] | None:
return cast("Mapping[str, Any]", headers) if isinstance(headers, Mapping) else None
def event_from_litellm(
kwargs: Mapping[str, Any],
response: object,
start_time: object,
end_time: object,
*,
outcome: Outcome,
) -> LlmRequestEvent:
"""Build the event from a LiteLLM success/failure callback payload."""
now = datetime.now(UTC)
started = _to_datetime(start_time, now)
finished = _to_datetime(end_time, now)
slo = _mapping(kwargs.get("standard_logging_object")) or {}
hidden = _mapping(slo.get("hidden_params")) or {}
litellm_params = _mapping(kwargs.get("litellm_params")) or {}
error_info = _mapping(slo.get("error_information")) or {}
exc = kwargs.get("exception")
exc = exc if isinstance(exc, BaseException) else None
provider = (
_str_or_none(kwargs.get("custom_llm_provider"))
or _str_or_none(slo.get("custom_llm_provider"))
or _str_or_none(error_info.get("llm_provider"))
or _str_or_none(litellm_params.get("custom_llm_provider"))
)
model = _str_or_none(kwargs.get("model")) or _str_or_none(slo.get("model")) or "unknown"
host = api_host(
_str_or_none(hidden.get("api_base"))
or _str_or_none(slo.get("api_base"))
or _str_or_none(litellm_params.get("api_base"))
)
streaming = bool(kwargs.get("stream")) or bool(slo.get("stream"))
call_id = (
_str_or_none(kwargs.get("litellm_call_id"))
or _str_or_none(slo.get("litellm_call_id"))
or str(uuid.uuid4())
)
response_headers = _mapping(hidden.get("additional_headers"))
response_hidden = _mapping(getattr(response, "_hidden_params", None)) or {}
request_id = request_id_from_headers(response_headers) or request_id_from_headers(
_mapping(response_hidden.get("additional_headers"))
)
status_code: int | None = None
error_type: str | None = None
error_message: str | None = None
response_id: str | None = None
usage: dict[str, int | None] = {}
cost: float | None = None
if outcome == "success":
status_code = 200
response_id = _str_or_none(getattr(response, "id", None))
usage = _litellm_usage(response)
cost = _float_or_none(kwargs.get("response_cost"))
if cost is None:
cost = _float_or_none(hidden.get("response_cost"))
else:
if exc is not None:
status_code = _int_or_none(getattr(exc, "status_code", None))
error_type = type(exc).__name__
error_message = clean_error_message(exc)
request_id = request_id or request_id_from_headers(_exception_headers(exc))
if status_code is None:
code = error_info.get("error_code")
status_code = _int_or_none(code)
if status_code is None and isinstance(code, str) and code.isdigit():
status_code = int(code)
error_type = error_type or _str_or_none(error_info.get("error_class")) or "Exception"
if error_message is None:
raw = _str_or_none(error_info.get("error_message")) or _str_or_none(
slo.get("error_str")
)
error_message = redact(raw)[:ERROR_MESSAGE_MAX_CHARS] if raw else error_type
request_id = request_id or request_id_from_text(error_message)
context = current_call_context()
return LlmRequestEvent(
call_id=call_id,
route="litellm",
provider=provider,
model=model,
api_host=host,
streaming=streaming,
outcome=outcome,
status_code=status_code,
provider_request_id=request_id,
response_id=response_id,
error_type=error_type,
error_message=error_message,
started_at=started,
finished_at=finished,
duration_ms=max(0, int((finished - started).total_seconds() * 1000)),
input_tokens=usage.get("input_tokens"),
output_tokens=usage.get("output_tokens"),
cached_input_tokens=usage.get("cached_input_tokens"),
total_tokens=usage.get("total_tokens"),
cost_usd=cost,
agent_id=context.agent_id,
agent_name=context.agent_name,
retry_attempt=context.retry_attempt,
)
def _build_litellm_logger() -> Any:
from litellm.integrations.custom_logger import CustomLogger
class _StrixRequestLogger(CustomLogger):
"""Forwards each LiteLLM attempt to the registered sinks.
Only the async handlers are implemented: the engine calls
``acompletion`` exclusively, and LiteLLM schedules these on the calling
task, so the agent context bound there still applies. Sync fallbacks
run in a thread pool and would lose it.
"""
async def async_log_success_event(
self, kwargs: dict[str, Any], response_obj: Any, start_time: Any, end_time: Any
) -> None:
_dispatch(kwargs, response_obj, start_time, end_time, outcome="success")
async def async_log_failure_event(
self, kwargs: dict[str, Any], response_obj: Any, start_time: Any, end_time: Any
) -> None:
_dispatch(kwargs, response_obj, start_time, end_time, outcome="error")
return _StrixRequestLogger()
def _dispatch(
kwargs: Mapping[str, Any], response: object, start: object, end: object, *, outcome: Outcome
) -> None:
try:
event = event_from_litellm(kwargs, response, start, end, outcome=outcome)
except Exception:
logger.exception("could not build LLM request event from LiteLLM callback")
return
emit(event)
_litellm_logger: Any | None = None
def install() -> None:
"""Attach the LiteLLM capture (idempotent) and the default log-line sink."""
global _litellm_logger # noqa: PLW0603
if _log_line_sink not in _sinks:
_sinks.insert(0, _log_line_sink)
if _litellm_logger is not None:
return
import litellm
capture = _build_litellm_logger()
_litellm_logger = capture
# litellm types this list with a bare Callable, which strict pyright cannot resolve.
callbacks = litellm.callbacks # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
if capture not in callbacks:
callbacks.append(capture) # pyright: ignore[reportUnknownMemberType]
def _log_line_sink(event: LlmRequestEvent) -> None:
level = logging.DEBUG if event.outcome == "success" else logging.WARNING
logger.log(
level,
"llm_request route=%s provider=%s model=%s host=%s outcome=%s status=%s "
"request_id=%s response_id=%s stream=%s duration_ms=%d "
"in=%s out=%s cached=%s cost=%s agent=%s attempt=%d%s",
event.route,
event.provider or "-",
event.model,
event.api_host or "-",
event.outcome,
event.status_code if event.status_code is not None else "-",
event.provider_request_id or "-",
event.response_id or "-",
"1" if event.streaming else "0",
event.duration_ms,
event.input_tokens if event.input_tokens is not None else "-",
event.output_tokens if event.output_tokens is not None else "-",
event.cached_input_tokens if event.cached_input_tokens is not None else "-",
f"{event.cost_usd:.6f}" if event.cost_usd is not None else "-",
event.agent_id or "-",
event.retry_attempt,
f" error={event.error_type}: {event.error_message}" if event.outcome == "error" else "",
)
# --------------------------------------------------------------------------- #
# Native OpenAI route #
# --------------------------------------------------------------------------- #
def _openai_error_fields(exc: BaseException) -> tuple[int | None, str | None]:
"""(status_code, provider_request_id) from an OpenAI SDK exception."""
status: int | None = None
request_id: str | None = None
if isinstance(exc, APIStatusError):
status = _int_or_none(exc.status_code)
request_id = _str_or_none(exc.request_id)
if request_id is None:
request_id = request_id_from_headers(_headers_mapping(exc.response.headers))
if request_id is None:
request_id = request_id_from_text(str(exc))
return status, request_id
def _openai_usage(response: ModelResponse) -> dict[str, int | None]:
usage = response.usage
details = usage.input_tokens_details
return {
"input_tokens": _int_or_none(usage.input_tokens),
"output_tokens": _int_or_none(usage.output_tokens),
"cached_input_tokens": _int_or_none(details.cached_tokens) if details else None,
"total_tokens": _int_or_none(usage.total_tokens),
}
class RequestLoggingModel(Model):
"""Wrap a Model whose calls do not pass through LiteLLM.
The native OpenAI Responses / Chat Completions routes (``openai/…``, the
ChatGPT subscription backend) never hit ``litellm.acompletion``, so the
LiteLLM capture does not see them. This wrapper records the same event
around each ``get_response`` and ``stream_response``.
"""
def __init__(self, inner: Model, *, model_name: str, provider: str, base_url: str | None):
self._inner = inner
self._model_name = model_name
self._provider = provider
self._host = api_host(base_url) or ("api.openai.com" if provider == "openai" else None)
@property
def model(self) -> str:
return self._model_name
async def close(self) -> None:
await self._inner.close()
def get_retry_advice(self, request: ModelRetryAdviceRequest) -> ModelRetryAdvice | None:
return self._inner.get_retry_advice(request)
def _event(
self,
*,
started: datetime,
started_mono: float,
streaming: bool,
response: ModelResponse | None,
exc: BaseException | None,
) -> LlmRequestEvent:
finished = datetime.now(UTC)
duration_ms = max(0, int((time.monotonic() - started_mono) * 1000))
context = current_call_context()
base = LlmRequestEvent(
call_id=str(uuid.uuid4()),
route="openai",
provider=self._provider,
model=self._model_name,
api_host=self._host,
streaming=streaming,
outcome="success",
status_code=200,
provider_request_id=None,
response_id=None,
error_type=None,
error_message=None,
started_at=started,
finished_at=finished,
duration_ms=duration_ms,
agent_id=context.agent_id,
agent_name=context.agent_name,
retry_attempt=context.retry_attempt,
)
if exc is None:
usage = _openai_usage(response) if response is not None else {}
response_id = response.response_id if response is not None else None
return replace(
base,
response_id=response_id,
input_tokens=usage.get("input_tokens"),
output_tokens=usage.get("output_tokens"),
cached_input_tokens=usage.get("cached_input_tokens"),
total_tokens=usage.get("total_tokens"),
)
status, request_id = _openai_error_fields(exc)
return replace(
base,
outcome="error",
status_code=status,
provider_request_id=request_id,
error_type=type(exc).__name__,
error_message=clean_error_message(exc),
)
async def get_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem], # noqa: A002
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
*,
previous_response_id: str | None,
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> ModelResponse:
started, started_mono = datetime.now(UTC), time.monotonic()
try:
response = await self._inner.get_response(
system_instructions,
input,
model_settings,
tools,
output_schema,
handoffs,
tracing,
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=prompt,
)
except asyncio.CancelledError:
raise
except BaseException as exc:
emit(
self._event(
started=started,
started_mono=started_mono,
streaming=False,
response=None,
exc=exc,
)
)
raise
emit(
self._event(
started=started,
started_mono=started_mono,
streaming=False,
response=response,
exc=None,
)
)
return response
async def stream_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem], # noqa: A002
model_settings: ModelSettings,
tools: list[Tool],
output_schema: AgentOutputSchemaBase | None,
handoffs: list[Handoff],
tracing: ModelTracing,
*,
previous_response_id: str | None,
conversation_id: str | None,
prompt: ResponsePromptParam | None,
) -> AsyncIterator[TResponseStreamEvent]:
started, started_mono = datetime.now(UTC), time.monotonic()
completed: ModelResponse | None = None
try:
async for event in self._inner.stream_response(
system_instructions,
input,
model_settings,
tools,
output_schema,
handoffs,
tracing,
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=prompt,
):
if isinstance(event, ResponseCompletedEvent):
raw_usage = event.response.usage
usage = Usage()
if raw_usage is not None:
usage = Usage(
requests=1,
input_tokens=raw_usage.input_tokens,
output_tokens=raw_usage.output_tokens,
total_tokens=raw_usage.total_tokens,
input_tokens_details=raw_usage.input_tokens_details,
output_tokens_details=raw_usage.output_tokens_details,
)
completed = ModelResponse(output=[], usage=usage, response_id=event.response.id)
yield event
except asyncio.CancelledError:
raise
except BaseException as exc:
emit(
self._event(
started=started,
started_mono=started_mono,
streaming=True,
response=None,
exc=exc,
)
)
raise
emit(
self._event(
started=started,
started_mono=started_mono,
streaming=True,
response=completed,
exc=None,
)
)
def failure_text(exc: BaseException) -> str:
"""The message stored as an agent's failure reason, with the provider request id appended.
LiteLLM's Anthropic mapping keeps the error body (which carries
``request_id``) but drops the ``request-id`` header; OpenAI errors carry
only the header. Either way the id lands in the text an operator reads.
"""
text = redact(str(exc)) or type(exc).__name__
request_id: str | None = None
if isinstance(exc, APIError):
_, request_id = _openai_error_fields(exc)
request_id = request_id or request_id_from_headers(_exception_headers(exc))
if request_id and request_id not in text:
text = f"{text} [provider request id: {request_id}]"
return text
__all__ = [
"ERROR_MESSAGE_MAX_CHARS",
"LlmCallContext",
"LlmRequestEvent",
"LlmRequestSink",
"RequestLoggingModel",
"api_host",
"bind_call_context",
"clean_error_message",
"current_call_context",
"emit",
"event_from_litellm",
"failure_text",
"install",
"redact",
"register_sink",
"request_id_from_headers",
"request_id_from_text",
"reset_call_context",
"set_retry_attempt",
"unregister_sink",
]

View file

@ -32,6 +32,7 @@ from openai.types.responses import (
from strix.config import codex, loader
from strix.config.loader import load_settings
from strix.config.models import StrixProvider, _NonStreamingModel, _TurnGuardModel
from strix.llm.request_log import RequestLoggingModel
if TYPE_CHECKING:
@ -312,7 +313,8 @@ def test_get_model_keeps_streaming_by_default(
model = StrixProvider().get_model("openai/gpt-4o-mini")
assert isinstance(model, _TurnGuardModel)
assert model._inner is inner
assert isinstance(model._inner, RequestLoggingModel)
assert model._inner._inner is inner
def test_get_model_guards_subscription_model_but_keeps_it_streaming(

View file

@ -0,0 +1,134 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
import httpx
import pytest
from agents import RunConfig, Runner
from openai import APIError, PermissionDeniedError
from strix.core import execution
from strix.core.agents import AgentCoordinator
from strix.llm import request_log
if TYPE_CHECKING:
from collections.abc import AsyncIterator
def _request() -> httpx.Request:
return httpx.Request("POST", "https://api.openai.com/v1/responses")
def _midstream_api_error() -> APIError:
return APIError("An error occurred while processing the request.", _request(), body=None)
def _blocked_error() -> PermissionDeniedError:
response = httpx.Response(
403, request=_request(), headers={"x-request-id": "req_blocked_hdr"}, text="blocked"
)
return PermissionDeniedError("Output blocked by policy", response=response, body=None)
class _FakeStream:
def __init__(self, exc: BaseException | None = None) -> None:
self._exc = exc
self.run_loop_exception: BaseException | None = None
self.seen_context: request_log.LlmCallContext | None = None
async def stream_events(self) -> AsyncIterator[Any]:
self.seen_context = request_log.current_call_context()
if self._exc is not None:
raise self._exc
items: tuple[Any, ...] = ()
for item in items:
yield item
async def _run(
monkeypatch: pytest.MonkeyPatch,
streams: list[_FakeStream],
coordinator: AgentCoordinator | None = None,
) -> Any:
monkeypatch.setattr(execution, "_TRANSIENT_MODEL_RETRY_BASE_DELAY_S", 0.0)
monkeypatch.setattr(execution, "_TRANSIENT_MODEL_RETRY_MAX_DELAY_S", 0.0)
calls = {"n": 0}
def _fake_run_streamed(*_args: Any, **_kwargs: Any) -> _FakeStream:
stream = streams[calls["n"]]
calls["n"] += 1
return stream
monkeypatch.setattr(Runner, "run_streamed", _fake_run_streamed)
coordinator = coordinator or AgentCoordinator()
await coordinator.register("root", "strix", parent_id=None)
return await execution._run_cycle(
object(),
coordinator,
"root",
input_data="task",
run_config=cast("RunConfig", object()),
context={},
max_turns=5,
session=None,
interactive=False,
event_sink=None,
hooks=None,
)
@pytest.mark.asyncio
async def test_each_transient_replay_is_stamped_with_its_attempt_number(
monkeypatch: pytest.MonkeyPatch,
) -> None:
streams = [
_FakeStream(exc=_midstream_api_error()),
_FakeStream(exc=_midstream_api_error()),
_FakeStream(),
]
await _run(monkeypatch, streams)
assert [s.seen_context.retry_attempt for s in streams if s.seen_context] == [0, 1, 2]
@pytest.mark.asyncio
async def test_blocked_provider_failure_text_carries_request_id(
monkeypatch: pytest.MonkeyPatch,
) -> None:
coordinator = AgentCoordinator()
with pytest.raises(PermissionDeniedError):
await _run(monkeypatch, [_FakeStream(exc=_blocked_error())], coordinator)
assert coordinator.statuses["root"] == "failed"
error = coordinator.errors["root"]
assert "Output blocked by policy" in error
assert error.endswith("[provider request id: req_blocked_hdr]")
@pytest.mark.asyncio
async def test_run_agent_loop_binds_agent_context_and_resets(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: dict[str, request_log.LlmCallContext] = {}
async def _fake_loop(**_kwargs: Any) -> None:
seen["ctx"] = request_log.current_call_context()
monkeypatch.setattr(execution, "_run_agent_loop", _fake_loop)
class _Agent:
name = "Recon Agent"
coordinator = AgentCoordinator()
await execution.run_agent_loop(
agent=_Agent(),
initial_input="task",
run_config=cast("RunConfig", object()),
context={},
max_turns=1,
coordinator=coordinator,
agent_id="agent-42",
interactive=False,
)
assert seen["ctx"].agent_id == "agent-42"
assert seen["ctx"].agent_name == "Recon Agent"
assert request_log.current_call_context().agent_id is None

View file

@ -0,0 +1,653 @@
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, ClassVar, cast
import httpx
import litellm
import pytest
from agents.items import ModelResponse
from agents.models.interface import Model
from agents.usage import Usage
from litellm.llms.anthropic.common_utils import AnthropicError
from openai import APIStatusError, APITimeoutError, PermissionDeniedError
from openai.types.responses import Response, ResponseCompletedEvent, ResponseCreatedEvent
from strix.llm import request_log
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator
from strix.llm.request_log import LlmRequestEvent
ANTHROPIC_BLOCK_BODY = (
'{"type":"error","error":{"type":"invalid_request_error","message":"Output blocked by '
'content filtering policy"},"request_id":"req_011CVBodyOnly00"}'
)
@pytest.fixture
def captured() -> Iterator[list[LlmRequestEvent]]:
events: list[LlmRequestEvent] = []
request_log.register_sink(events.append)
try:
yield events
finally:
request_log.unregister_sink(events.append)
@pytest.fixture(autouse=True)
def _reset_context() -> Iterator[None]:
token = request_log.bind_call_context(None, None)
try:
yield
finally:
request_log.reset_call_context(token)
def _anthropic_kwargs(
exc: BaseException | None,
*,
headers: dict[str, str] | None = None,
stream: bool = False,
error_message: str | None = None,
) -> dict[str, Any]:
slo: dict[str, Any] = {
"model": "claude-sonnet-4-5",
"custom_llm_provider": "anthropic",
"api_base": "https://api.anthropic.com",
"stream": stream,
"litellm_call_id": "call-1",
"hidden_params": {
"additional_headers": {f"llm_provider-{k}": v for k, v in (headers or {}).items()},
"response_cost": 0.0123,
},
}
if exc is not None:
slo["error_information"] = {
"error_code": str(getattr(exc, "status_code", "")),
"error_class": type(exc).__name__,
"llm_provider": "anthropic",
"error_message": error_message or str(exc),
}
kwargs: dict[str, Any] = {
"model": "anthropic/claude-sonnet-4-5",
"custom_llm_provider": "anthropic",
"litellm_call_id": "call-1",
"stream": stream,
"standard_logging_object": slo,
"litellm_params": {"api_base": "https://api.anthropic.com", "api_key": "sk-ant-secret"},
"messages": [{"role": "user", "content": "SECRET PROMPT"}],
}
if exc is not None:
kwargs["exception"] = exc
return kwargs
class _FakeUsage:
prompt_tokens = 120
completion_tokens = 30
total_tokens = 150
prompt_tokens_details = None
cache_read_input_tokens = 100
class _FakeResponse:
id = "msg_01abc"
usage = _FakeUsage()
_hidden_params: ClassVar[dict[str, Any]] = {}
choices: ClassVar[list[Any]] = [{"message": {"content": "SECRET COMPLETION"}}]
def _anthropic_error(
status: int, body: str, headers: dict[str, str] | None = None
) -> AnthropicError:
return AnthropicError(status, body, headers=httpx.Headers(headers or {}))
# --------------------------------------------------------------------------- #
# request-id extraction #
# --------------------------------------------------------------------------- #
def test_header_request_id_accepts_raw_and_litellm_prefixed_names() -> None:
assert request_log.request_id_from_headers({"Request-Id": "req_a"}) == "req_a"
assert request_log.request_id_from_headers({"llm_provider-request-id": "req_b"}) == "req_b"
assert request_log.request_id_from_headers({"x-request-id": "req_c"}) == "req_c"
assert request_log.request_id_from_headers({"x-amzn-requestid": "abc-123"}) == "abc-123"
assert request_log.request_id_from_headers({"content-type": "json"}) is None
assert request_log.request_id_from_headers({"request-id": " "}) is None
assert request_log.request_id_from_headers(None) is None
def test_header_request_id_prefers_provider_id_over_cdn_ray() -> None:
headers = {"cf-ray": "8f0-FRA", "request-id": "req_real"}
assert request_log.request_id_from_headers(headers) == "req_real"
def test_body_request_id_extraction() -> None:
assert request_log.request_id_from_text(ANTHROPIC_BLOCK_BODY) == "req_011CVBodyOnly00"
assert request_log.request_id_from_text("request_id=req_abcdef") == "req_abcdef"
assert request_log.request_id_from_text("no id here") is None
assert request_log.request_id_from_text(None) is None
def test_api_host_never_leaks_path_or_query() -> None:
assert request_log.api_host("https://gw.corp.example/v1?key=abc") == "gw.corp.example"
assert request_log.api_host("gw.corp.example:8443/v1") == "gw.corp.example"
assert request_log.api_host("") is None
assert request_log.api_host(None) is None
# --------------------------------------------------------------------------- #
# redaction #
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"secret",
[
"sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789",
"sk-proj-abcdefghijklmnopqrstuvwxyz",
"Bearer abcdefghijklmnopqrstuvwxyz0123456789",
"AKIAABCDEFGHIJKLMNOP",
"AIzaSyABCDEFGHIJKLMNOPQRSTUVWXYZ01234",
"ghp_abcdefghijklmnopqrstuvwxyz0123456789",
"xoxb-1234567890-abcdefghij",
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abcdefghijklmnopqrstuvwxyz",
],
)
def test_redact_removes_credential_shapes(secret: str) -> None:
redacted = request_log.redact(f"upstream said: {secret} (401)")
assert secret not in redacted
assert "[REDACTED]" in redacted
assert "(401)" in redacted
def test_redact_key_value_forms_keep_the_key_name() -> None:
text = 'invalid "api_key": "abcd1234", authorization: Basic Zm9vOmJhcg== token=abcdef99'
redacted = request_log.redact(text)
assert "abcd1234" not in redacted
assert "Zm9vOmJhcg==" not in redacted
assert "abcdef99" not in redacted
assert '"api_key": "[REDACTED]"' in redacted
def test_clean_error_message_truncates_and_redacts() -> None:
exc = RuntimeError("x" * 5000 + " sk-ant-api03-abcdefghijklmnop")
message = request_log.clean_error_message(exc)
assert len(message) <= request_log.ERROR_MESSAGE_MAX_CHARS
assert "sk-ant" not in message
assert request_log.clean_error_message(RuntimeError("")) == "RuntimeError"
# --------------------------------------------------------------------------- #
# LiteLLM route #
# --------------------------------------------------------------------------- #
def test_litellm_success_event_carries_header_request_id_usage_and_no_content() -> None:
kwargs = _anthropic_kwargs(None, headers={"request-id": "req_ok_123456"})
kwargs["response_cost"] = 0.0123
start = datetime(2026, 9, 19, 12, 0, tzinfo=UTC)
end = start + timedelta(milliseconds=850)
event = request_log.event_from_litellm(kwargs, _FakeResponse(), start, end, outcome="success")
assert event.route == "litellm"
assert event.outcome == "success"
assert event.status_code == 200
assert event.provider == "anthropic"
assert event.model == "anthropic/claude-sonnet-4-5"
assert event.api_host == "api.anthropic.com"
assert event.provider_request_id == "req_ok_123456"
assert event.response_id == "msg_01abc"
assert event.call_id == "call-1"
assert event.duration_ms == 850
assert (event.input_tokens, event.output_tokens, event.total_tokens) == (120, 30, 150)
assert event.cached_input_tokens == 100
assert event.cost_usd == pytest.approx(0.0123)
assert event.error_type is None and event.error_message is None
serialized = str(event.to_dict())
assert "SECRET PROMPT" not in serialized
assert "SECRET COMPLETION" not in serialized
assert "sk-ant-secret" not in serialized
def test_litellm_success_falls_back_to_response_hidden_headers() -> None:
kwargs = _anthropic_kwargs(None)
class _Response(_FakeResponse):
_hidden_params: ClassVar[dict[str, Any]] = {
"additional_headers": {"llm_provider-request-id": "req_hidden_1"}
}
event = request_log.event_from_litellm(kwargs, _Response(), None, None, outcome="success")
assert event.provider_request_id == "req_hidden_1"
def test_litellm_anthropic_block_prefers_header_id_over_body_id() -> None:
exc = _anthropic_error(400, ANTHROPIC_BLOCK_BODY, {"request-id": "req_HeaderWins01"})
kwargs = _anthropic_kwargs(exc, headers={"request-id": "req_HeaderWins01"})
event = request_log.event_from_litellm(kwargs, None, None, None, outcome="error")
assert event.outcome == "error"
assert event.status_code == 400
assert event.provider_request_id == "req_HeaderWins01"
assert event.error_type == "AnthropicError"
assert event.error_message is not None
assert "content filtering policy" in event.error_message
assert "req_011CVBodyOnly00" in event.error_message
def test_litellm_anthropic_block_falls_back_to_body_id() -> None:
exc = _anthropic_error(400, ANTHROPIC_BLOCK_BODY)
kwargs = _anthropic_kwargs(exc)
event = request_log.event_from_litellm(kwargs, None, None, None, outcome="error")
assert event.provider_request_id == "req_011CVBodyOnly00"
assert event.status_code == 400
def test_litellm_failure_uses_exception_headers_when_slo_has_none() -> None:
exc = _anthropic_error(529, "overloaded", {"request-id": "req_from_exc_hdr"})
kwargs = _anthropic_kwargs(exc)
event = request_log.event_from_litellm(kwargs, None, None, None, outcome="error")
assert event.provider_request_id == "req_from_exc_hdr"
assert event.status_code == 529
def test_litellm_failure_without_any_request_id_is_none_not_fabricated() -> None:
exc = _anthropic_error(500, "internal error")
event = request_log.event_from_litellm(
_anthropic_kwargs(exc), None, None, None, outcome="error"
)
assert event.provider_request_id is None
assert event.error_message == "internal error"
def test_litellm_failure_without_exception_object_uses_error_information() -> None:
kwargs = _anthropic_kwargs(None)
kwargs["standard_logging_object"]["error_information"] = {
"error_code": "429",
"error_class": "RateLimitError",
"llm_provider": "anthropic",
"error_message": 'rate limited "request_id": "req_slo_only01"',
}
event = request_log.event_from_litellm(kwargs, None, None, None, outcome="error")
assert event.status_code == 429
assert event.error_type == "RateLimitError"
assert event.provider_request_id == "req_slo_only01"
def test_litellm_timeout_is_an_error_event_with_no_status() -> None:
exc = APITimeoutError(httpx.Request("POST", "https://api.anthropic.com/v1/messages"))
kwargs = _anthropic_kwargs(exc)
kwargs["standard_logging_object"]["error_information"]["error_code"] = ""
event = request_log.event_from_litellm(kwargs, None, None, None, outcome="error")
assert event.outcome == "error"
assert event.status_code is None
assert event.error_type == "APITimeoutError"
assert event.provider_request_id is None
def test_litellm_failure_message_is_redacted() -> None:
exc = _anthropic_error(401, "invalid x-api-key: sk-ant-api03-abcdefghijklmnopqrstu")
event = request_log.event_from_litellm(
_anthropic_kwargs(exc), None, None, None, outcome="error"
)
assert event.error_message is not None
assert "sk-ant-api03" not in event.error_message
assert "[REDACTED]" in event.error_message
def test_litellm_streaming_flag_and_call_id() -> None:
kwargs = _anthropic_kwargs(None, headers={"request-id": "req_stream1"}, stream=True)
event = request_log.event_from_litellm(kwargs, _FakeResponse(), None, None, outcome="success")
assert event.streaming is True
assert event.provider_request_id == "req_stream1"
exc = _anthropic_error(400, ANTHROPIC_BLOCK_BODY)
failed = request_log.event_from_litellm(
_anthropic_kwargs(exc, stream=True), None, None, None, outcome="error"
)
assert failed.streaming is True
assert failed.provider_request_id == "req_011CVBodyOnly00"
def test_litellm_event_carries_bound_agent_context_and_retry_attempt() -> None:
token = request_log.bind_call_context("agent-7", "Recon")
try:
request_log.set_retry_attempt(2)
event = request_log.event_from_litellm(
_anthropic_kwargs(None), _FakeResponse(), None, None, outcome="success"
)
finally:
request_log.reset_call_context(token)
assert (event.agent_id, event.agent_name, event.retry_attempt) == ("agent-7", "Recon", 2)
assert request_log.current_call_context().agent_id is None
@pytest.mark.asyncio
async def test_call_context_is_isolated_between_tasks() -> None:
seen: dict[str, str | None] = {}
async def run(agent_id: str) -> None:
token = request_log.bind_call_context(agent_id, None)
try:
await asyncio.sleep(0)
seen[agent_id] = request_log.current_call_context().agent_id
finally:
request_log.reset_call_context(token)
await asyncio.gather(run("a"), run("b"))
assert seen == {"a": "a", "b": "b"}
assert request_log.current_call_context().agent_id is None
@pytest.mark.asyncio
async def test_litellm_logger_dispatches_and_isolates_sink_failures(
captured: list[LlmRequestEvent], caplog: pytest.LogCaptureFixture
) -> None:
def boom(_event: LlmRequestEvent) -> None:
raise RuntimeError("sink down")
request_log.register_sink(boom)
try:
logger = request_log._build_litellm_logger()
with caplog.at_level(logging.ERROR, logger="strix.llm.request_log"):
await logger.async_log_success_event(
_anthropic_kwargs(None, headers={"request-id": "req_dispatch"}),
_FakeResponse(),
None,
None,
)
await logger.async_log_failure_event(
_anthropic_kwargs(_anthropic_error(400, ANTHROPIC_BLOCK_BODY)), None, None, None
)
finally:
request_log.unregister_sink(boom)
assert [e.outcome for e in captured] == ["success", "error"]
assert captured[0].provider_request_id == "req_dispatch"
assert captured[1].provider_request_id == "req_011CVBodyOnly00"
assert sum("sink down" in r.getMessage() or "failed" in r.getMessage() for r in caplog.records)
def test_dispatch_swallows_malformed_callback_payloads(captured: list[LlmRequestEvent]) -> None:
request_log._dispatch(cast("Any", None), None, None, None, outcome="success")
assert captured == []
def test_install_is_idempotent_and_registers_one_litellm_callback() -> None:
request_log.install()
request_log.install()
ours = [cb for cb in litellm.callbacks if type(cb).__name__ == "_StrixRequestLogger"]
assert len(ours) == 1
def test_log_line_sink_formats_without_content(caplog: pytest.LogCaptureFixture) -> None:
exc = _anthropic_error(400, ANTHROPIC_BLOCK_BODY, {"request-id": "req_line01"})
event = request_log.event_from_litellm(
_anthropic_kwargs(exc, headers={"request-id": "req_line01"}),
None,
None,
None,
outcome="error",
)
with caplog.at_level(logging.DEBUG, logger="strix.llm.request_log"):
request_log._log_line_sink(event)
line = caplog.records[-1].getMessage()
assert "request_id=req_line01" in line
assert "status=400" in line
assert "provider=anthropic" in line
assert "SECRET PROMPT" not in line
# --------------------------------------------------------------------------- #
# Native OpenAI route #
# --------------------------------------------------------------------------- #
def _openai_response(response_id: str, *, usage: Usage | None = None) -> ModelResponse:
return ModelResponse(
output=[],
usage=usage or Usage(requests=1, input_tokens=10, output_tokens=5, total_tokens=15),
response_id=response_id,
)
def _completed_event(response_id: str) -> ResponseCompletedEvent:
response = Response(
id=response_id,
created_at=0,
model="gpt-5",
object="response",
output=[],
parallel_tool_calls=False,
tool_choice="auto",
tools=[],
)
return ResponseCompletedEvent(response=response, sequence_number=1, type="response.completed")
class _Inner(Model):
def __init__(
self,
*,
response: ModelResponse | None = None,
exc: BaseException | None = None,
stream_events: list[Any] | None = None,
fail_after: int | None = None,
) -> None:
self._response = response
self._exc = exc
self._stream_events = stream_events or []
self._fail_after = fail_after
self.closed = False
async def get_response(self, *_args: Any, **_kwargs: Any) -> ModelResponse:
if self._exc is not None:
raise self._exc
assert self._response is not None
return self._response
async def stream_response(self, *_args: Any, **_kwargs: Any) -> AsyncIterator[Any]:
for index, event in enumerate(self._stream_events):
if self._fail_after is not None and index == self._fail_after:
assert self._exc is not None
raise self._exc
yield event
async def close(self) -> None:
self.closed = True
_CALL_ARGS: tuple[Any, ...] = (None, "hi", None, [], None, [], None)
_CALL_KWARGS: dict[str, Any] = {
"previous_response_id": None,
"conversation_id": None,
"prompt": None,
}
def _openai_status_error(status: int, request_id: str | None, body: str = "") -> APIStatusError:
request = httpx.Request("POST", "https://api.openai.com/v1/responses")
headers = {"x-request-id": request_id} if request_id else {}
response = httpx.Response(status, request=request, headers=headers, text=body)
if status == 403:
return PermissionDeniedError(body or "denied", response=response, body=None)
return APIStatusError(body or f"status {status}", response=response, body=None)
@pytest.mark.asyncio
async def test_openai_route_success_event(captured: list[LlmRequestEvent]) -> None:
model = request_log.RequestLoggingModel(
_Inner(response=_openai_response("resp_123")),
model_name="gpt-5",
provider="openai",
base_url=None,
)
result = await model.get_response(*_CALL_ARGS, **_CALL_KWARGS)
assert result.response_id == "resp_123"
assert len(captured) == 1
event = captured[0]
assert event.route == "openai"
assert event.provider == "openai"
assert event.api_host == "api.openai.com"
assert event.outcome == "success"
assert event.status_code == 200
assert event.response_id == "resp_123"
assert event.provider_request_id is None
assert (event.input_tokens, event.output_tokens, event.total_tokens) == (10, 5, 15)
assert event.streaming is False
assert model.model == "gpt-5"
@pytest.mark.asyncio
async def test_openai_route_blocked_request_keeps_header_request_id(
captured: list[LlmRequestEvent],
) -> None:
exc = _openai_status_error(403, "req_openai_blocked", "content policy violation")
model = request_log.RequestLoggingModel(
_Inner(exc=exc),
model_name="gpt-5",
provider="openai",
base_url="https://gateway.corp.example/v1?token=abc",
)
with pytest.raises(PermissionDeniedError):
await model.get_response(*_CALL_ARGS, **_CALL_KWARGS)
event = captured[0]
assert event.outcome == "error"
assert event.status_code == 403
assert event.provider_request_id == "req_openai_blocked"
assert event.error_type == "PermissionDeniedError"
assert event.api_host == "gateway.corp.example"
assert "token=abc" not in str(event.to_dict())
@pytest.mark.asyncio
async def test_openai_route_timeout_event(captured: list[LlmRequestEvent]) -> None:
exc = APITimeoutError(httpx.Request("POST", "https://api.openai.com/v1/responses"))
model = request_log.RequestLoggingModel(
_Inner(exc=exc), model_name="gpt-5", provider="openai", base_url=None
)
with pytest.raises(APITimeoutError):
await model.get_response(*_CALL_ARGS, **_CALL_KWARGS)
assert captured[0].outcome == "error"
assert captured[0].status_code is None
assert captured[0].error_type == "APITimeoutError"
assert captured[0].provider_request_id is None
@pytest.mark.asyncio
async def test_openai_route_cancellation_is_not_logged(captured: list[LlmRequestEvent]) -> None:
model = request_log.RequestLoggingModel(
_Inner(exc=asyncio.CancelledError()), model_name="gpt-5", provider="openai", base_url=None
)
with pytest.raises(asyncio.CancelledError):
await model.get_response(*_CALL_ARGS, **_CALL_KWARGS)
assert captured == []
@pytest.mark.asyncio
async def test_openai_route_streaming_success(captured: list[LlmRequestEvent]) -> None:
created = ResponseCreatedEvent(
response=_completed_event("resp_stream").response,
sequence_number=0,
type="response.created",
)
model = request_log.RequestLoggingModel(
_Inner(stream_events=[created, _completed_event("resp_stream")]),
model_name="gpt-5",
provider="openai-codex",
base_url="https://chatgpt.com/backend-api",
)
events = [e async for e in model.stream_response(*_CALL_ARGS, **_CALL_KWARGS)]
assert len(events) == 2
assert len(captured) == 1
event = captured[0]
assert event.streaming is True
assert event.outcome == "success"
assert event.response_id == "resp_stream"
assert event.provider == "openai-codex"
assert event.api_host == "chatgpt.com"
@pytest.mark.asyncio
async def test_openai_route_streaming_failure_midstream(captured: list[LlmRequestEvent]) -> None:
exc = _openai_status_error(500, "req_mid_stream", "upstream reset")
created = ResponseCreatedEvent(
response=_completed_event("resp_x").response, sequence_number=0, type="response.created"
)
model = request_log.RequestLoggingModel(
_Inner(stream_events=[created, created], exc=exc, fail_after=1),
model_name="gpt-5",
provider="openai",
base_url=None,
)
received = 0
with pytest.raises(APIStatusError):
async for _ in model.stream_response(*_CALL_ARGS, **_CALL_KWARGS):
received += 1
assert received == 1
assert len(captured) == 1
assert captured[0].streaming is True
assert captured[0].outcome == "error"
assert captured[0].status_code == 500
assert captured[0].provider_request_id == "req_mid_stream"
@pytest.mark.asyncio
async def test_openai_route_delegates_close() -> None:
inner = _Inner(response=_openai_response("r"))
model = request_log.RequestLoggingModel(
inner, model_name="gpt-5", provider="openai", base_url=None
)
await model.close()
assert inner.closed is True
# --------------------------------------------------------------------------- #
# failure_text #
# --------------------------------------------------------------------------- #
def test_failure_text_appends_header_id_for_openai_errors() -> None:
exc = _openai_status_error(403, "req_hdr_only", "blocked")
text = request_log.failure_text(exc)
assert text.endswith("[provider request id: req_hdr_only]")
assert "blocked" in text
def test_failure_text_does_not_duplicate_body_id() -> None:
exc = _anthropic_error(400, ANTHROPIC_BLOCK_BODY)
text = request_log.failure_text(exc)
assert text.count("req_011CVBodyOnly00") == 1
assert "[provider request id" not in text
def test_failure_text_uses_litellm_exception_headers_and_redacts() -> None:
exc = _anthropic_error(
401, "bad key sk-ant-api03-abcdefghijklmnopqrstu", {"request-id": "req_exc_hdr"}
)
text = request_log.failure_text(exc)
assert "sk-ant-api03" not in text
assert text.endswith("[provider request id: req_exc_hdr]")
def test_failure_text_plain_exception_unchanged() -> None:
assert request_log.failure_text(RuntimeError("boom")) == "boom"
assert request_log.failure_text(RuntimeError("")) == "RuntimeError"

View file

@ -23,6 +23,7 @@ from strix.config.models import (
uses_chat_completions_tool_schema,
)
from strix.config.settings import Settings
from strix.llm.request_log import RequestLoggingModel
@pytest.mark.parametrize("model_name", RECOMMENDED_MODEL_NAMES)
@ -172,7 +173,7 @@ def test_routes_through_litellm_matches_the_provider(
# proves the route is not LiteLLM's.
assert not litellm
return
while isinstance(model, _NonStreamingModel | _TurnGuardModel):
while isinstance(model, _NonStreamingModel | _TurnGuardModel | RequestLoggingModel):
model = model._inner
assert isinstance(model, LitellmModel) is litellm
@ -215,6 +216,6 @@ def test_api_type_overrides_the_api_base_route(
monkeypatch.setenv("STRIX_API_TYPE", api_type)
configure_sdk_model_defaults(Settings())
model = StrixProvider().get_model("gpt-5")
while isinstance(model, _NonStreamingModel | _TurnGuardModel):
while isinstance(model, _NonStreamingModel | _TurnGuardModel | RequestLoggingModel):
model = model._inner
assert isinstance(model, expected)