mirror of
https://github.com/usestrix/strix.git
synced 2026-10-13 03:57:59 +00:00
fix(llm): request-log fixes from the live provider matrix
- Attribute LiteLLM calls to the configured api_base before LiteLLM's hidden api_base: a non-streamed call through a gateway (OpenRouter) otherwise lands on the provider default (api.openai.com). - Size a mapped LiteLLM error by the body it kept, not the empty stand-in httpx.Response every litellm exception carries. - Accept any non-empty req_ id in an error body (drop the arbitrary 6-char minimum). - Prefer the configured litellm_params.api_base; ignore empty APIStatusError.response.content; regression tests for both.
This commit is contained in:
parent
b4a774ace8
commit
13c66cbb9e
2 changed files with 289 additions and 59 deletions
|
|
@ -39,6 +39,7 @@ from typing import TYPE_CHECKING, Any, Literal, cast
|
|||
from urllib.parse import urlsplit
|
||||
|
||||
from agents.items import ModelResponse
|
||||
from agents.models.fake_id import FAKE_RESPONSES_ID
|
||||
from agents.models.interface import Model
|
||||
from agents.tool import FunctionTool
|
||||
from agents.usage import Usage
|
||||
|
|
@ -258,23 +259,27 @@ def clean_error_message(exc: BaseException) -> str:
|
|||
|
||||
# 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.
|
||||
# ``x-amzn-requestid``; Vertex/Gemini: ``x-goog-request-id``; OpenRouter:
|
||||
# ``x-generation-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",
|
||||
"x-generation-id",
|
||||
)
|
||||
# The Cloudflare edge id: the only handle a reply carries when the provider
|
||||
# sends none of its own, in the header or the body.
|
||||
_EDGE_ID_HEADERS: tuple[str, ...] = ("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,})")
|
||||
_BODY_REQUEST_ID = re.compile(r"[\"']?request_id[\"']?\s*[:=]\s*[\"']?(req_[A-Za-z0-9_-]+)")
|
||||
|
||||
|
||||
def request_id_from_headers(headers: Mapping[str, Any] | None) -> str | None:
|
||||
def _header_value(headers: Mapping[str, Any] | None, names: tuple[str, ...]) -> str | None:
|
||||
if not headers:
|
||||
return None
|
||||
normalized: dict[str, str] = {}
|
||||
|
|
@ -283,12 +288,22 @@ def request_id_from_headers(headers: Mapping[str, Any] | None) -> str | None:
|
|||
continue
|
||||
name = str(key).lower().removeprefix(_LITELLM_HEADER_PREFIX)
|
||||
normalized.setdefault(name, value.strip())
|
||||
for name in _REQUEST_ID_HEADERS:
|
||||
for name in names:
|
||||
if name in normalized:
|
||||
return normalized[name]
|
||||
return None
|
||||
|
||||
|
||||
def request_id_from_headers(headers: Mapping[str, Any] | None) -> str | None:
|
||||
"""The provider's own request id from a reply's headers, if it sent one."""
|
||||
return _header_value(headers, _REQUEST_ID_HEADERS)
|
||||
|
||||
|
||||
def edge_request_id(headers: Mapping[str, Any] | None) -> str | None:
|
||||
"""The edge network's id, for replies where nothing better exists."""
|
||||
return _header_value(headers, _EDGE_ID_HEADERS)
|
||||
|
||||
|
||||
def request_id_from_text(text: str | None) -> str | None:
|
||||
if not text:
|
||||
return None
|
||||
|
|
@ -296,6 +311,13 @@ def request_id_from_text(text: str | None) -> str | None:
|
|||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def request_id_from_reply(headers: Mapping[str, Any] | None, text: str | None = None) -> str | None:
|
||||
"""Provider header id, then the id in the error body, then the edge id."""
|
||||
return (
|
||||
request_id_from_headers(headers) or request_id_from_text(text) or edge_request_id(headers)
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Free-form telemetry: response headers and details #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
@ -537,6 +559,13 @@ def _str_or_none(value: object) -> str | None:
|
|||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _real_response_id(response: ModelResponse | None) -> str | None:
|
||||
"""The provider's response id; the SDK's chat-completions placeholder is not one."""
|
||||
if response is None or response.response_id == FAKE_RESPONSES_ID:
|
||||
return None
|
||||
return _str_or_none(response.response_id)
|
||||
|
||||
|
||||
def _mapping(value: object) -> Mapping[str, Any] | None:
|
||||
return cast("Mapping[str, Any]", value) if isinstance(value, Mapping) else None
|
||||
|
||||
|
|
@ -594,8 +623,10 @@ def _litellm_request_body(kwargs: Mapping[str, Any]) -> object:
|
|||
params, which is the same information before translation.
|
||||
"""
|
||||
additional = _mapping(kwargs.get("additional_args"))
|
||||
payload = _mapping(additional.get("complete_input_dict")) if additional else None
|
||||
if payload:
|
||||
payload = additional.get("complete_input_dict") if additional else None
|
||||
if isinstance(payload, str | bytes) and payload:
|
||||
return payload
|
||||
if _mapping(payload):
|
||||
return payload
|
||||
body: dict[str, Any] = {"model": kwargs.get("model")}
|
||||
messages = kwargs.get("messages")
|
||||
|
|
@ -627,9 +658,9 @@ def _exception_details(exc: BaseException | None) -> dict[str, Any] | None:
|
|||
if exc is None:
|
||||
return None
|
||||
error: dict[str, Any] = {}
|
||||
body = getattr(exc, "body", None)
|
||||
if isinstance(body, Mapping | str):
|
||||
error["body"] = body if isinstance(body, Mapping) else _mapping(_parse_json(body)) or body
|
||||
body = _error_body(exc)
|
||||
if body is not None:
|
||||
error["body"] = body
|
||||
for name in ("code", "param", "type", "llm_provider", "max_retries", "num_retries"):
|
||||
value = getattr(exc, name, None)
|
||||
if isinstance(value, str | int) and not isinstance(value, bool):
|
||||
|
|
@ -637,9 +668,26 @@ def _exception_details(exc: BaseException | None) -> dict[str, Any] | None:
|
|||
return error or None
|
||||
|
||||
|
||||
def _parse_json(text: str) -> object:
|
||||
def _error_body(exc: BaseException) -> object:
|
||||
"""The provider's error reply as the SDK kept it.
|
||||
|
||||
The OpenAI SDK parses it onto ``body``. LiteLLM's mapped exceptions keep
|
||||
only their message, which ends in the reply text (``AnthropicException -
|
||||
{...}``); the JSON object in that text is the body.
|
||||
"""
|
||||
body = getattr(exc, "body", None)
|
||||
if body is not None:
|
||||
return body
|
||||
text = getattr(exc, "message", None)
|
||||
return _json_object_in(text if isinstance(text, str) else str(exc))
|
||||
|
||||
|
||||
def _json_object_in(text: str) -> Mapping[str, Any] | None:
|
||||
start, end = text.find("{"), text.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
return None
|
||||
try:
|
||||
return json.loads(text)
|
||||
return _mapping(json.loads(text[start : end + 1]))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
|
@ -670,17 +718,32 @@ def _exception_headers(exc: BaseException | None) -> Mapping[str, Any] | None:
|
|||
return first
|
||||
|
||||
|
||||
def _exception_body(exc: BaseException) -> object:
|
||||
"""The response body an SDK exception carries, else its message text."""
|
||||
body = getattr(exc, "body", None)
|
||||
if body is not None:
|
||||
return body
|
||||
text = getattr(exc, "message", None)
|
||||
return text if isinstance(text, str) and text else str(exc)
|
||||
def _exception_body_size(exc: BaseException, status_code: int | None) -> int | None:
|
||||
"""Size of the error reply's body: the bytes the OpenAI SDK read, else the
|
||||
body LiteLLM kept. Unknown when no reply arrived or the SDK kept none of it.
|
||||
"""
|
||||
if status_code is None:
|
||||
return None
|
||||
if isinstance(exc, APIStatusError):
|
||||
# LiteLLM's mapped exceptions subclass this with an empty stand-in response.
|
||||
with contextlib.suppress(Exception):
|
||||
content = exc.response.content
|
||||
if content:
|
||||
return len(content)
|
||||
return json_size(_error_body(exc))
|
||||
|
||||
|
||||
def _exception_body_size(exc: BaseException) -> int | None:
|
||||
return json_size(_exception_body(exc))
|
||||
def _litellm_reply_status(exc: BaseException, headers: Mapping[str, Any] | None) -> int | None:
|
||||
"""The status of the provider's reply, or None when no reply arrived.
|
||||
|
||||
LiteLLM stamps a status on every exception, including 408 on timeouts and
|
||||
500 on connection failures that never reached the provider. Those are the
|
||||
non-status ``APIError`` family and carry no response headers; a reply that
|
||||
was received always does.
|
||||
"""
|
||||
if isinstance(exc, APIError) and not isinstance(exc, APIStatusError) and not headers:
|
||||
return None
|
||||
return _int_or_none(getattr(exc, "status_code", None))
|
||||
|
||||
|
||||
def _headers_mapping(headers: object) -> Mapping[str, Any] | None:
|
||||
|
|
@ -713,10 +776,13 @@ def event_from_litellm(
|
|||
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"
|
||||
# The endpoint the call was configured with. LiteLLM's hidden ``api_base``
|
||||
# can be the provider default (``api.openai.com``) on a non-streamed call
|
||||
# that actually went to a gateway.
|
||||
host = api_host(
|
||||
_str_or_none(hidden.get("api_base"))
|
||||
_str_or_none(litellm_params.get("api_base"))
|
||||
or _str_or_none(slo.get("api_base"))
|
||||
or _str_or_none(litellm_params.get("api_base"))
|
||||
or _str_or_none(hidden.get("api_base"))
|
||||
)
|
||||
streaming = bool(kwargs.get("stream")) or bool(slo.get("stream"))
|
||||
call_id = (
|
||||
|
|
@ -725,13 +791,12 @@ def event_from_litellm(
|
|||
or str(uuid.uuid4())
|
||||
)
|
||||
|
||||
response_headers = _mapping(hidden.get("additional_headers"))
|
||||
# LiteLLM stores the reply's headers on the logging object or on the
|
||||
# response, depending on the adapter; the first non-empty block is the reply.
|
||||
response_hidden = _mapping(getattr(response, "_hidden_params", None)) or {}
|
||||
hidden_headers = _mapping(response_hidden.get("additional_headers"))
|
||||
request_id = request_id_from_headers(response_headers) or request_id_from_headers(
|
||||
hidden_headers
|
||||
reply_headers = _mapping(hidden.get("additional_headers")) or _mapping(
|
||||
response_hidden.get("additional_headers")
|
||||
)
|
||||
headers = headers_from_response(response_headers) or headers_from_response(hidden_headers)
|
||||
|
||||
ttft_ms: int | None = None
|
||||
if streaming:
|
||||
|
|
@ -750,7 +815,7 @@ def event_from_litellm(
|
|||
streaming=streaming,
|
||||
outcome=outcome,
|
||||
status_code=None,
|
||||
provider_request_id=request_id,
|
||||
provider_request_id=request_id_from_reply(reply_headers),
|
||||
response_id=None,
|
||||
error_type=None,
|
||||
error_message=None,
|
||||
|
|
@ -762,11 +827,11 @@ def event_from_litellm(
|
|||
retry_attempt=context.retry_attempt,
|
||||
request_bytes=json_size(_litellm_request_body(kwargs)),
|
||||
time_to_first_token_ms=ttft_ms,
|
||||
response_headers=headers,
|
||||
response_headers=headers_from_response(reply_headers),
|
||||
)
|
||||
if outcome == "success":
|
||||
return _litellm_success(base, kwargs, response, hidden, slo)
|
||||
return _litellm_failure(base, kwargs, exc, error_info, slo)
|
||||
return _litellm_failure(base, kwargs, exc, error_info, slo, reply_headers)
|
||||
|
||||
|
||||
def _litellm_response_details(response: object) -> dict[str, Any] | None:
|
||||
|
|
@ -841,22 +906,21 @@ def _litellm_failure(
|
|||
exc: BaseException | None,
|
||||
error_info: Mapping[str, Any],
|
||||
slo: Mapping[str, Any],
|
||||
reply_headers: Mapping[str, Any] | None,
|
||||
) -> LlmRequestEvent:
|
||||
status_code: int | None = None
|
||||
error_type: str | None = None
|
||||
error_message: str | None = None
|
||||
request_id = base.provider_request_id
|
||||
headers = base.response_headers
|
||||
response_bytes: int | None = None
|
||||
error: dict[str, Any] = {}
|
||||
if exc is not None:
|
||||
status_code = _int_or_none(getattr(exc, "status_code", None))
|
||||
reply_headers = reply_headers or _exception_headers(exc)
|
||||
status_code = _litellm_reply_status(exc, reply_headers)
|
||||
error_type = type(exc).__name__
|
||||
error_message = clean_error_message(exc)
|
||||
exc_headers = _exception_headers(exc)
|
||||
request_id = request_id or request_id_from_headers(exc_headers)
|
||||
headers = headers or headers_from_response(exc_headers)
|
||||
response_bytes = _exception_body_size(exc)
|
||||
if status_code is None:
|
||||
response_bytes = _exception_body_size(exc, status_code)
|
||||
error = _exception_details(exc) or {}
|
||||
else:
|
||||
code = error_info.get("error_code")
|
||||
status_code = _int_or_none(code)
|
||||
if status_code is None and isinstance(code, str) and code.isdigit():
|
||||
|
|
@ -865,19 +929,21 @@ def _litellm_failure(
|
|||
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)
|
||||
provider = _str_or_none(error_info.get("llm_provider"))
|
||||
if provider:
|
||||
error.setdefault("llm_provider", provider)
|
||||
hidden = _mapping(slo.get("hidden_params")) or {}
|
||||
return replace(
|
||||
base,
|
||||
status_code=status_code,
|
||||
provider_request_id=request_id,
|
||||
provider_request_id=request_id_from_reply(reply_headers, error_message),
|
||||
error_type=error_type,
|
||||
error_message=error_message,
|
||||
response_bytes=response_bytes,
|
||||
response_headers=headers,
|
||||
response_headers=headers_from_response(reply_headers),
|
||||
details=merge_details(
|
||||
("request", _litellm_request_details(kwargs)),
|
||||
("error", _exception_details(exc) or dict(error_info) or None),
|
||||
("error", error or None),
|
||||
("litellm", _litellm_hidden_details(hidden, slo)),
|
||||
),
|
||||
)
|
||||
|
|
@ -995,11 +1061,7 @@ def _openai_error_fields(exc: BaseException) -> tuple[int | None, str | 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(_openai_error_headers(exc))
|
||||
if request_id is None:
|
||||
request_id = request_id_from_text(str(exc))
|
||||
return status, request_id
|
||||
return status, request_id or request_id_from_reply(_openai_error_headers(exc), str(exc))
|
||||
|
||||
|
||||
def _openai_error_headers(exc: BaseException) -> Mapping[str, Any] | None:
|
||||
|
|
@ -1098,7 +1160,7 @@ class RequestLoggingModel(Model):
|
|||
streaming=streaming,
|
||||
outcome="success",
|
||||
status_code=reply.status_code or 200,
|
||||
provider_request_id=request_id_from_headers(reply.headers),
|
||||
provider_request_id=request_id_from_reply(reply.headers),
|
||||
response_id=None,
|
||||
error_type=None,
|
||||
error_message=None,
|
||||
|
|
@ -1115,7 +1177,7 @@ class RequestLoggingModel(Model):
|
|||
)
|
||||
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
|
||||
response_id = _real_response_id(response)
|
||||
body_source = (
|
||||
raw_response
|
||||
if raw_response is not None
|
||||
|
|
@ -1136,16 +1198,17 @@ class RequestLoggingModel(Model):
|
|||
)
|
||||
status, request_id = _openai_error_fields(exc)
|
||||
error_headers = _openai_error_headers(exc)
|
||||
# A reply the hook saw but the SDK turned into an exception (a
|
||||
# cancel mid-stream, an unparsable 200) keeps its wire status.
|
||||
status = status if status is not None else reply.status_code
|
||||
return replace(
|
||||
base,
|
||||
outcome="error",
|
||||
# A reply the hook saw but the SDK turned into an exception (a
|
||||
# cancel mid-stream, an unparsable 200) keeps its wire status.
|
||||
status_code=status if status is not None else reply.status_code,
|
||||
status_code=status,
|
||||
provider_request_id=request_id or base.provider_request_id,
|
||||
error_type=type(exc).__name__,
|
||||
error_message=_abandonment_message(exc) or clean_error_message(exc),
|
||||
response_bytes=_exception_body_size(exc),
|
||||
response_bytes=_exception_body_size(exc, status),
|
||||
response_headers=headers_from_response(error_headers) or base.response_headers,
|
||||
details=merge_details(
|
||||
("request", request.details),
|
||||
|
|
@ -1407,9 +1470,12 @@ def _openai_response_details(
|
|||
) -> dict[str, Any] | None:
|
||||
"""The Responses API object minus its output, or the SDK usage when that is all there is."""
|
||||
if isinstance(raw_response, BaseModel):
|
||||
return raw_response.model_dump(
|
||||
dumped = raw_response.model_dump(
|
||||
exclude={"output", "instructions", "tools", "text"}, exclude_none=True
|
||||
)
|
||||
if dumped.get("id") == FAKE_RESPONSES_ID:
|
||||
del dumped["id"]
|
||||
return dumped
|
||||
if response is None:
|
||||
return None
|
||||
return {"usage": response.usage, "output_items": len(response.output)}
|
||||
|
|
@ -1436,7 +1502,7 @@ def failure_text(exc: BaseException) -> str:
|
|||
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))
|
||||
request_id = request_id or request_id_from_reply(_exception_headers(exc), text)
|
||||
if request_id and request_id not in text:
|
||||
text = f"{text} [provider request id: {request_id}]"
|
||||
return text
|
||||
|
|
@ -1452,6 +1518,7 @@ __all__ = [
|
|||
"bind_call_context",
|
||||
"clean_error_message",
|
||||
"current_call_context",
|
||||
"edge_request_id",
|
||||
"emit",
|
||||
"event_from_litellm",
|
||||
"failure_text",
|
||||
|
|
@ -1463,6 +1530,7 @@ __all__ = [
|
|||
"redact",
|
||||
"register_sink",
|
||||
"request_id_from_headers",
|
||||
"request_id_from_reply",
|
||||
"request_id_from_text",
|
||||
"reset_call_context",
|
||||
"sanitize_details",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
|
@ -12,10 +13,15 @@ import litellm
|
|||
import pytest
|
||||
from agents.items import ModelResponse
|
||||
from agents.models import _openai_shared
|
||||
from agents.models.fake_id import FAKE_RESPONSES_ID
|
||||
from agents.models.interface import Model
|
||||
from agents.models.openai_provider import shared_http_client
|
||||
from agents.tool import FunctionTool
|
||||
from agents.usage import Usage
|
||||
from litellm.exceptions import APIConnectionError as LiteLlmConnectionError
|
||||
from litellm.exceptions import APIError as LiteLlmApiError
|
||||
from litellm.exceptions import AuthenticationError as LiteLlmAuthenticationError
|
||||
from litellm.exceptions import Timeout as LiteLlmTimeout
|
||||
from litellm.llms.anthropic.common_utils import AnthropicError
|
||||
from openai import APIStatusError, APITimeoutError, PermissionDeniedError
|
||||
from openai.types.responses import Response, ResponseCompletedEvent, ResponseCreatedEvent
|
||||
|
|
@ -636,6 +642,33 @@ def test_litellm_request_size_prefers_the_provider_payload_litellm_built() -> No
|
|||
assert "sk-ant-secret" not in str(event.to_dict())
|
||||
|
||||
|
||||
def test_litellm_request_size_accepts_the_serialized_payload_streaming_adapters_record() -> None:
|
||||
kwargs = _anthropic_kwargs(None, stream=True)
|
||||
payload = '{"model":"claude-sonnet-4-5","messages":[],"max_tokens":1,"stream":true}'
|
||||
kwargs["additional_args"] = {"complete_input_dict": payload}
|
||||
event = request_log.event_from_litellm(kwargs, _FakeResponse(), None, None, outcome="success")
|
||||
assert event.request_bytes == len(payload.encode())
|
||||
|
||||
|
||||
def test_openrouter_generation_id_beats_the_cloudflare_ray() -> None:
|
||||
headers = {"cf-ray": "a3f5d8f23fde88dc-PDX", "x-generation-id": "gen-1790127690-M8vfqPTAr"}
|
||||
assert request_log.request_id_from_reply(headers) == "gen-1790127690-M8vfqPTAr"
|
||||
assert request_log.request_id_from_headers({"cf-ray": "a3f5-PDX"}) is None
|
||||
assert request_log.request_id_from_reply({"cf-ray": "a3f5-PDX"}) == "a3f5-PDX"
|
||||
|
||||
|
||||
def test_request_id_in_the_error_body_beats_the_cloudflare_ray() -> None:
|
||||
"""A gateway that strips ``request-id`` still forwards Anthropic's body."""
|
||||
body = '{"type":"error","error":{"type":"not_found_error"},"request_id":"req_body1"}'
|
||||
exc = _anthropic_error(404, body, {"cf-ray": "a3f5-PDX", "content-type": "application/json"})
|
||||
event = request_log.event_from_litellm(
|
||||
_anthropic_kwargs(exc), None, None, None, outcome="error"
|
||||
)
|
||||
assert event.provider_request_id == "req_body1"
|
||||
assert request_log.request_id_from_reply({"cf-ray": "a3f5-PDX"}, body) == "req_body1"
|
||||
assert request_log.request_id_from_reply({"cf-ray": "a3f5-PDX"}, "no id here") == "a3f5-PDX"
|
||||
|
||||
|
||||
def test_litellm_streaming_time_to_first_token_from_completion_start() -> None:
|
||||
kwargs = _anthropic_kwargs(None, stream=True)
|
||||
start = datetime(2026, 9, 19, 12, 0, tzinfo=UTC)
|
||||
|
|
@ -689,13 +722,106 @@ def test_litellm_failure_carries_status_body_size_headers_and_error_details() ->
|
|||
assert "sk-ant-secret" not in str(event.to_dict())
|
||||
|
||||
|
||||
def test_litellm_failure_without_headers_has_no_headers() -> None:
|
||||
def test_litellm_failure_without_a_reply_has_no_status_headers_or_size() -> 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.status_code is None
|
||||
assert event.response_headers is None
|
||||
assert event.response_bytes is not None # size of the timeout message text
|
||||
assert event.response_bytes is None
|
||||
assert event.error_type == "APITimeoutError"
|
||||
|
||||
|
||||
def test_litellm_connection_failure_drops_the_synthetic_500() -> None:
|
||||
exc = LiteLlmConnectionError(
|
||||
message="Connection refused", llm_provider="openrouter", model="openrouter/x"
|
||||
)
|
||||
kwargs = _anthropic_kwargs(exc)
|
||||
event = request_log.event_from_litellm(kwargs, None, None, None, outcome="error")
|
||||
assert exc.status_code == 500
|
||||
assert event.status_code is None
|
||||
assert event.response_bytes is None
|
||||
assert event.provider_request_id is None
|
||||
|
||||
|
||||
def test_litellm_timeout_drops_the_synthetic_408() -> None:
|
||||
exc = LiteLlmTimeout(message="Request timed out.", model="x", llm_provider="openrouter")
|
||||
kwargs = _anthropic_kwargs(exc)
|
||||
event = request_log.event_from_litellm(kwargs, None, None, None, outcome="error")
|
||||
assert exc.status_code == 408
|
||||
assert event.status_code is None
|
||||
assert event.response_bytes is None
|
||||
|
||||
|
||||
def test_litellm_api_error_with_headers_keeps_its_status() -> None:
|
||||
body = '{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}'
|
||||
exc = LiteLlmApiError(
|
||||
status_code=529,
|
||||
message=f"AnthropicException - b'{body}'",
|
||||
llm_provider="anthropic",
|
||||
model="x",
|
||||
)
|
||||
# LiteLLM's exception mapping attaches the reply's headers this way.
|
||||
exc.litellm_response_headers = {"request-id": "req_529"} # type: ignore[attr-defined]
|
||||
kwargs = _anthropic_kwargs(exc)
|
||||
event = request_log.event_from_litellm(kwargs, None, None, None, outcome="error")
|
||||
assert event.status_code == 529
|
||||
assert event.provider_request_id == "req_529"
|
||||
assert event.response_bytes == len(body.encode())
|
||||
assert event.details is not None
|
||||
assert event.details["error"]["body"] == {
|
||||
"type": "error",
|
||||
"error": {"type": "overloaded_error"},
|
||||
}
|
||||
|
||||
|
||||
def test_litellm_mapped_error_without_a_json_body_has_no_size_but_keeps_its_status() -> None:
|
||||
exc = LiteLlmApiError(
|
||||
status_code=502,
|
||||
message="OpenrouterException - <html>bad gateway</html>",
|
||||
llm_provider="openrouter",
|
||||
model="x",
|
||||
)
|
||||
exc.litellm_response_headers = {"cf-ray": "a3f5-PDX"} # type: ignore[attr-defined]
|
||||
event = request_log.event_from_litellm(
|
||||
_anthropic_kwargs(exc), None, None, None, outcome="error"
|
||||
)
|
||||
assert event.status_code == 502
|
||||
assert event.response_bytes is None
|
||||
assert event.provider_request_id == "a3f5-PDX"
|
||||
assert event.details is not None
|
||||
assert "body" not in event.details["error"]
|
||||
|
||||
|
||||
def test_litellm_mapped_error_sizes_the_body_it_kept_not_its_empty_stand_in_response() -> None:
|
||||
# LiteLLM's mapped exceptions subclass openai.APIStatusError and carry an
|
||||
# httpx.Response with no content; the reply body lives on ``body``/``message``.
|
||||
body = {"error": {"message": "User not found.", "code": 401}}
|
||||
exc = LiteLlmAuthenticationError(
|
||||
message="OpenrouterException - " + json.dumps(body, separators=(",", ":")),
|
||||
llm_provider="openrouter",
|
||||
model="x",
|
||||
)
|
||||
assert isinstance(exc, APIStatusError)
|
||||
assert exc.response.content == b""
|
||||
exc.litellm_response_headers = {"cf-ray": "a3f5-PDX"} # type: ignore[attr-defined]
|
||||
event = request_log.event_from_litellm(
|
||||
_anthropic_kwargs(exc), None, None, None, outcome="error"
|
||||
)
|
||||
assert event.status_code == 401
|
||||
assert event.response_bytes == len(json.dumps(body, separators=(",", ":")).encode())
|
||||
|
||||
|
||||
def test_litellm_host_is_the_configured_endpoint_not_the_provider_default() -> None:
|
||||
# On a non-streamed call through a gateway, LiteLLM's hidden api_base can be
|
||||
# the provider default (api.openai.com) while the call went to the gateway.
|
||||
kwargs = _anthropic_kwargs(None)
|
||||
kwargs["litellm_params"] = {"api_base": "https://openrouter.ai/api/v1/"}
|
||||
kwargs["standard_logging_object"]["api_base"] = "https://openrouter.ai/api/v1"
|
||||
kwargs["standard_logging_object"]["hidden_params"]["api_base"] = "https://api.openai.com"
|
||||
event = request_log.event_from_litellm(kwargs, _FakeResponse(), None, None, outcome="success")
|
||||
assert event.api_host == "openrouter.ai"
|
||||
|
||||
|
||||
def test_to_dict_and_log_line_include_new_fields() -> None:
|
||||
|
|
@ -820,6 +946,42 @@ async def test_openai_route_success_event(captured: list[LlmRequestEvent]) -> No
|
|||
assert model.model == "gpt-5"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_route_drops_the_sdk_placeholder_response_id(
|
||||
captured: list[LlmRequestEvent],
|
||||
) -> None:
|
||||
"""Chat-completions backends get ``__fake_id__`` from the SDK, not a provider id."""
|
||||
model = request_log.RequestLoggingModel(
|
||||
_Inner(response=_openai_response(FAKE_RESPONSES_ID)),
|
||||
model_name="anthropic/claude-sonnet-4-5",
|
||||
provider="openai",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
)
|
||||
await model.get_response(*_CALL_ARGS, **_CALL_KWARGS)
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].response_id is None
|
||||
assert FAKE_RESPONSES_ID not in str(captured[0].to_dict())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_route_streaming_drops_the_sdk_placeholder_response_id(
|
||||
captured: list[LlmRequestEvent],
|
||||
) -> None:
|
||||
model = request_log.RequestLoggingModel(
|
||||
_Inner(stream_events=[_completed_event(FAKE_RESPONSES_ID)]),
|
||||
model_name="anthropic/claude-sonnet-4-5",
|
||||
provider="openai",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
)
|
||||
async for _ in model.stream_response(*_CALL_ARGS, **_CALL_KWARGS):
|
||||
pass
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].response_id is None
|
||||
assert FAKE_RESPONSES_ID not in str(captured[0].to_dict())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_route_blocked_request_keeps_header_request_id(
|
||||
captured: list[LlmRequestEvent],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue