mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(otel): map /v1/messages provider errors before failure logging
This commit is contained in:
parent
3e2927de9a
commit
666648d58c
5 changed files with 163 additions and 7 deletions
|
|
@ -2486,6 +2486,18 @@ def exception_type(
|
||||||
exception_provider=exception_provider,
|
exception_provider=exception_provider,
|
||||||
extra_information=extra_information,
|
extra_information=extra_information,
|
||||||
)
|
)
|
||||||
|
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||||
|
|
||||||
|
if custom_llm_provider and isinstance(original_exception, BaseLLMException):
|
||||||
|
_map_openai_like_exception(
|
||||||
|
model=model,
|
||||||
|
original_exception=mappable_exception,
|
||||||
|
custom_llm_provider=custom_llm_provider,
|
||||||
|
error_str=error_str,
|
||||||
|
exception_type=exception_type,
|
||||||
|
exception_provider=exception_provider,
|
||||||
|
extra_information=extra_information,
|
||||||
|
)
|
||||||
if "BadRequestError.__init__() missing 1 required positional argument: 'param'" in str(
|
if "BadRequestError.__init__() missing 1 required positional argument: 'param'" in str(
|
||||||
original_exception
|
original_exception
|
||||||
): # deal with edge-case invalid request error bug in openai-python sdk
|
): # deal with edge-case invalid request error bug in openai-python sdk
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from functools import partial
|
||||||
from typing import Any, Final, cast
|
from typing import Any, Final, cast
|
||||||
|
|
||||||
import litellm
|
import litellm
|
||||||
|
from litellm.litellm_core_utils.exception_mapping_utils import exception_type
|
||||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||||
from litellm.llms.anthropic.common_utils import (
|
from litellm.llms.anthropic.common_utils import (
|
||||||
flatten_unencrypted_web_search_results_in_anthropic_messages,
|
flatten_unencrypted_web_search_results_in_anthropic_messages,
|
||||||
|
|
@ -382,13 +383,18 @@ async def anthropic_messages(
|
||||||
)
|
)
|
||||||
ctx: Final = contextvars.copy_context()
|
ctx: Final = contextvars.copy_context()
|
||||||
func_with_context: Final = partial(ctx.run, func)
|
func_with_context: Final = partial(ctx.run, func)
|
||||||
init_response: Final = await loop.run_in_executor(None, func_with_context)
|
try:
|
||||||
|
init_response: Final = await loop.run_in_executor(None, func_with_context)
|
||||||
if asyncio.iscoroutine(init_response):
|
if asyncio.iscoroutine(init_response):
|
||||||
response = await init_response
|
return await init_response
|
||||||
else:
|
return init_response
|
||||||
response = init_response
|
except Exception as e: # noqa: BLE001 # the mapping boundary must see every provider-layer failure, like acompletion
|
||||||
return response
|
raise exception_type(
|
||||||
|
model=model,
|
||||||
|
custom_llm_provider=custom_llm_provider,
|
||||||
|
original_exception=e,
|
||||||
|
extra_kwargs=kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_anthropic_api_metadata(metadata: dict | None = None) -> dict | None:
|
def validate_anthropic_api_metadata(metadata: dict | None = None) -> dict | None:
|
||||||
|
|
|
||||||
|
|
@ -743,3 +743,61 @@ class TestOtelTraceCompleteness:
|
||||||
)
|
)
|
||||||
genai = next(span for span in hits[0].spans if span.operation_name == genai_span)
|
genai = next(span for span in hits[0].spans if span.operation_name == genai_span)
|
||||||
_assert_error_span_contract(genai)
|
_assert_error_span_contract(genai)
|
||||||
|
|
||||||
|
@pytest.mark.covers("logging.otel.failure.exports_metric", exercised_on=["messages"])
|
||||||
|
def test_failed_messages_error_span_attributes(
|
||||||
|
self, client: LoggingClient, otel_reader: OtelReader, resources: ResourceManager
|
||||||
|
) -> None:
|
||||||
|
"""A failed `/v1/messages` request must carry the same error-span
|
||||||
|
contract as a failed `/chat/completions` request (LIT-6164). The
|
||||||
|
async messages entrypoint used to surface the provider handler's raw
|
||||||
|
BaseLLMException to the failure logger, so the model-call span came
|
||||||
|
out with error.type=BaseLLMException and no
|
||||||
|
litellm.provider.error.llm_provider attribute.
|
||||||
|
|
||||||
|
Same setup as the chat sibling: a deployment with an invalid upstream
|
||||||
|
API key passes proxy auth and fails at the provider with a real 401,
|
||||||
|
and failed requests are not billed, so no cost-write span."""
|
||||||
|
route = "/v1/messages"
|
||||||
|
_assert_otel_destination_configured(client)
|
||||||
|
|
||||||
|
model_name = f"otel-err-{unique_marker()}"
|
||||||
|
model_id = client.create_model(
|
||||||
|
model_name,
|
||||||
|
LiteLLMParamsBody(model="anthropic/claude-haiku-4-5", api_key=INVALID_UPSTREAM_API_KEY),
|
||||||
|
)
|
||||||
|
resources.defer(lambda: client.delete_model(model_id))
|
||||||
|
key = client.key_with_alias(f"otel-err-{unique_marker()}", models=[model_name])
|
||||||
|
resources.defer(lambda: client.delete_key(key))
|
||||||
|
|
||||||
|
deadline = time.monotonic() + client.proxy.poll_timeout
|
||||||
|
while True:
|
||||||
|
outcome = client.messages_raw(key, model_name, "trigger an upstream auth failure", max_tokens=16)
|
||||||
|
assert not outcome.ok, "the call must fail; the deployment's upstream key is invalid"
|
||||||
|
if "AnthropicException" in outcome.body or time.monotonic() >= deadline:
|
||||||
|
break
|
||||||
|
time.sleep(client.proxy.poll_interval)
|
||||||
|
assert "AnthropicException" in outcome.body, (
|
||||||
|
"never saw the mapped upstream provider failure before the deadline; either the key is "
|
||||||
|
"still propagating or the messages route surfaced the raw unmapped provider error - "
|
||||||
|
f"last outcome {outcome.status_code}: {outcome.body[:200]}"
|
||||||
|
)
|
||||||
|
assert outcome.status_code == 401, (
|
||||||
|
f"an upstream auth failure must map to 401, got {outcome.status_code}: {outcome.body[:200]}"
|
||||||
|
)
|
||||||
|
assert outcome.call_id is not None, "failed responses must still carry x-litellm-call-id"
|
||||||
|
|
||||||
|
genai_span = f"chat {model_name}"
|
||||||
|
hits = otel_reader.poll_traces_for_call(
|
||||||
|
call_id=outcome.call_id,
|
||||||
|
settled_names=_settled_names(route=route, genai_span=genai_span, require_cost_span=False),
|
||||||
|
settled_prefixes={DB_SPAN_PREFIX},
|
||||||
|
)
|
||||||
|
_assert_complete_trace(hits, route=route, genai_span=genai_span, require_cost_span=False)
|
||||||
|
|
||||||
|
root = next(span for span in hits[0].spans if not span.references)
|
||||||
|
assert str(_tag(root, "http.status_code")) == "401", (
|
||||||
|
f"the SERVER span must record the 401 the client received, got {_tag(root, 'http.status_code')!r}"
|
||||||
|
)
|
||||||
|
genai = next(span for span in hits[0].spans if span.operation_name == genai_span)
|
||||||
|
_assert_error_span_contract(genai)
|
||||||
|
|
|
||||||
|
|
@ -1092,3 +1092,30 @@ def test_bedrock_mantle_context_overflow_maps_to_context_window_exceeded():
|
||||||
|
|
||||||
assert excinfo.value.status_code == 400
|
assert excinfo.value.status_code == 400
|
||||||
assert "prompt is too long: 1055489 tokens > 1050000 maximum" in excinfo.value.message
|
assert "prompt is too long: 1055489 tokens > 1050000 maximum" in excinfo.value.message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"status_code, expected_class",
|
||||||
|
[(401, litellm.AuthenticationError), (429, litellm.RateLimitError)],
|
||||||
|
)
|
||||||
|
def test_a_base_llm_exception_without_a_provider_branch_maps_by_status_code(
|
||||||
|
status_code, expected_class, quiet_exception_mapping
|
||||||
|
):
|
||||||
|
"""Regression test for LIT-6164. Native /v1/messages handlers raise raw
|
||||||
|
BaseLLMException, and providers without an exception_type branch (e.g.
|
||||||
|
minimax) must keep the upstream status instead of collapsing every failure
|
||||||
|
into a 500 APIConnectionError once that route maps its exceptions."""
|
||||||
|
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||||
|
|
||||||
|
original_exception = BaseLLMException(status_code=status_code, message="upstream rejected the call")
|
||||||
|
|
||||||
|
with pytest.raises(expected_class) as excinfo:
|
||||||
|
exception_type(
|
||||||
|
model="MiniMax-M2.5",
|
||||||
|
original_exception=original_exception,
|
||||||
|
custom_llm_provider="minimax",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert excinfo.value.status_code == status_code
|
||||||
|
assert excinfo.value.llm_provider == "minimax"
|
||||||
|
assert "MinimaxException" in excinfo.value.message
|
||||||
|
|
|
||||||
|
|
@ -1286,3 +1286,56 @@ class TestMessagesStreamingSuccessLogging:
|
||||||
assert payload["call_type"] == "acompletion"
|
assert payload["call_type"] == "acompletion"
|
||||||
assert payload["total_tokens"] > 0
|
assert payload["total_tokens"] > 0
|
||||||
assert payload["response_cost"] > 0
|
assert payload["response_cost"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
class _FailureCapture(CustomLogger):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.error_information: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
|
||||||
|
payload = kwargs.get("standard_logging_object") or {}
|
||||||
|
self.error_information.append(payload.get("error_information") or {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_anthropic_messages_maps_provider_exception_before_failure_logging(monkeypatch):
|
||||||
|
"""Regression test for LIT-6164. The async /v1/messages entrypoint awaited the
|
||||||
|
provider handler without exception_type mapping, so the @client failure
|
||||||
|
handler (and every logger behind it, e.g. OTel error spans) saw the raw
|
||||||
|
BaseLLMException: error.type=BaseLLMException and no llm_provider."""
|
||||||
|
from litellm.llms.anthropic.experimental_pass_through.messages import handler
|
||||||
|
|
||||||
|
capture = _FailureCapture()
|
||||||
|
monkeypatch.setattr(litellm, "callbacks", [capture])
|
||||||
|
|
||||||
|
def upstream_rejects_the_key(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
401,
|
||||||
|
json={"type": "error", "error": {"type": "authentication_error", "message": "invalid x-api-key"}},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
upstream = AsyncHTTPHandler()
|
||||||
|
upstream.client = httpx.AsyncClient(transport=httpx.MockTransport(upstream_rejects_the_key))
|
||||||
|
|
||||||
|
with pytest.raises(litellm.AuthenticationError) as excinfo:
|
||||||
|
await handler.anthropic_messages(
|
||||||
|
max_tokens=16,
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
model="anthropic/claude-haiku-4-5",
|
||||||
|
custom_llm_provider="anthropic",
|
||||||
|
api_key="sk-invalid",
|
||||||
|
client=upstream,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert excinfo.value.status_code == 401
|
||||||
|
assert excinfo.value.llm_provider == "anthropic"
|
||||||
|
assert "AnthropicException" in excinfo.value.message
|
||||||
|
assert '"authentication_error"' in excinfo.value.message
|
||||||
|
|
||||||
|
assert capture.error_information, "the failure handler must have logged the mapped exception"
|
||||||
|
error_information = capture.error_information[0]
|
||||||
|
assert error_information.get("error_class") == "AuthenticationError"
|
||||||
|
assert error_information.get("llm_provider") == "anthropic"
|
||||||
|
assert error_information.get("error_code") == "401"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue