mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge pull request #32948 from BerriAI/litellm_backport_1_91_x_bp-guard-otel-0711
chore(release): backport #32542, #32655 to stable/1.91.x and cut 1.91.3
This commit is contained in:
commit
7a4a68f022
14 changed files with 892 additions and 45 deletions
|
|
@ -223,6 +223,15 @@ lives in [`plumbing/`](./plumbing):
|
|||
readers/exporters receive them alongside the server metrics, and one is built
|
||||
and registered as the global only when none is set (mirroring how V2 owns trace
|
||||
export).
|
||||
- [`events.py`](./plumbing/events.py) — GenAI client events. Gated on
|
||||
`enable_events` (`LITELLM_OTEL_INTEGRATION_ENABLE_EVENTS`), a failed LLM call
|
||||
records the semconv `gen_ai.client.operation.exception` log event at severity
|
||||
WARN, carrying `exception.type` / `exception.message` / `exception.stacktrace`
|
||||
and correlated to the failed span through the trace and span ids. The
|
||||
`LoggerProvider` is resolved like the meter provider, except that an explicit
|
||||
`NoOpLoggerProvider` global is an operator opt-out that builds no recorder at
|
||||
all. The deprecated `error.*` span attributes and the `exception` span event
|
||||
are still stamped by the emitter for backwards compatibility.
|
||||
|
||||
### Adapter
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from litellm.integrations.otel.model.payloads import (
|
|||
ServiceSpanData,
|
||||
SpanError,
|
||||
)
|
||||
from litellm.integrations.otel.plumbing.events import GenAIEventRecorder
|
||||
from litellm.integrations.otel.plumbing.providers import to_otel_span_kind
|
||||
from litellm.integrations.otel.model.semconv import Error, ExceptionEvent, LiteLLMError
|
||||
from litellm.integrations.otel.model.spans import (
|
||||
|
|
@ -74,9 +75,11 @@ class SpanEmitter:
|
|||
tracer: Tracer,
|
||||
config: OpenTelemetryV2Config,
|
||||
mappers: Sequence[AttributeMapper] | None = None,
|
||||
event_recorder: GenAIEventRecorder | None = None,
|
||||
) -> None:
|
||||
self._tracer = tracer
|
||||
self._config = config
|
||||
self._event_recorder = event_recorder
|
||||
# The mapper chain is the sole source of span attributes. When not
|
||||
# passed in, resolve it from the config so there's one source of truth.
|
||||
self._mappers: list[AttributeMapper] = (
|
||||
|
|
@ -208,6 +211,14 @@ class SpanEmitter:
|
|||
ExceptionEvent.NAME,
|
||||
{ExceptionEvent.TYPE: error_type, ExceptionEvent.MESSAGE: message},
|
||||
)
|
||||
if self._event_recorder is not None and role is SpanRole.LLM_CALL:
|
||||
self._event_recorder.record_operation_exception(
|
||||
span_context=span.get_span_context(),
|
||||
error_type=error_type,
|
||||
message=message,
|
||||
stack_trace=error.stack_trace,
|
||||
timestamp_ns=end_time_ns,
|
||||
)
|
||||
# On success leave the status UNSET (the semconv default) rather than
|
||||
# forcing OK — that matches the FastAPI server span and avoids implying a
|
||||
# span-level health signal litellm doesn't actually evaluate. Only a
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from datetime import datetime
|
|||
from typing import TYPE_CHECKING, Any, Callable, Iterator, Mapping, Sequence, cast
|
||||
|
||||
from opentelemetry.context import attach, get_current
|
||||
from opentelemetry.sdk._logs import LoggerProvider
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.trace import Span, Tracer, get_current_span, use_span
|
||||
|
||||
|
|
@ -37,14 +38,17 @@ from litellm.integrations.otel.model.payloads import (
|
|||
SpanError,
|
||||
is_mcp_tool_call,
|
||||
)
|
||||
from litellm.integrations.otel.plumbing.events import GenAIEventRecorder
|
||||
from litellm.integrations.otel.plumbing.metrics import (
|
||||
GenAIMetricRecorder,
|
||||
create_genai_metrics,
|
||||
)
|
||||
from litellm.integrations.otel.plumbing.providers import (
|
||||
build_tracer_provider,
|
||||
get_event_logger,
|
||||
get_meter,
|
||||
get_tracer,
|
||||
resolve_logger_provider,
|
||||
resolve_meter_provider,
|
||||
)
|
||||
from litellm.integrations.otel.plumbing.routing import TenantTracerCache
|
||||
|
|
@ -101,7 +105,7 @@ class OpenTelemetryV2(CustomLogger):
|
|||
config: OpenTelemetryV2Config | None = None,
|
||||
callback_name: str | None = None,
|
||||
tracer_provider: TracerProvider | None = None,
|
||||
logger_provider: Any | None = None, # reserved for OTel logs
|
||||
logger_provider: LoggerProvider | None = None,
|
||||
meter_provider: Any | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
|
|
@ -114,7 +118,12 @@ class OpenTelemetryV2(CustomLogger):
|
|||
self.tracer: Tracer = get_tracer(self._tracer_provider, LITELLM_TRACER_NAME)
|
||||
self._metrics_recorder = self._init_metrics(meter_provider)
|
||||
self._metric_filter_error_logged = False
|
||||
self._emitter = SpanEmitter(self.tracer, self.config, mappers=resolve_mappers(self.config.mapper_names))
|
||||
self._emitter = SpanEmitter(
|
||||
self.tracer,
|
||||
self.config,
|
||||
mappers=resolve_mappers(self.config.mapper_names),
|
||||
event_recorder=self._init_events(logger_provider),
|
||||
)
|
||||
self._tenant_tracers = TenantTracerCache(self.config, callback_name, LITELLM_TRACER_NAME)
|
||||
self._open_llm_calls: "OrderedDict[str, _LLMCallSpan]" = OrderedDict()
|
||||
self._init_otel_logger_on_litellm_proxy()
|
||||
|
|
@ -133,6 +142,22 @@ class OpenTelemetryV2(CustomLogger):
|
|||
meter = get_meter(provider, LITELLM_TRACER_NAME)
|
||||
return GenAIMetricRecorder(create_genai_metrics(meter), self.callback_name)
|
||||
|
||||
def _init_events(self, logger_provider: LoggerProvider | None) -> "GenAIEventRecorder | None":
|
||||
"""Create the GenAI event recorder when events are enabled, else ``None``.
|
||||
|
||||
``logger_provider`` is an explicit override (tests inject one); otherwise the
|
||||
provider is resolved from the OTel global so an operator-configured logs
|
||||
pipeline receives the events, building and registering one only when no
|
||||
global provider is set. A ``None`` resolution means the operator opted out
|
||||
of the logs signal, so no recorder is built.
|
||||
"""
|
||||
if not self.config.enable_events:
|
||||
return None
|
||||
provider = resolve_logger_provider(self.config, logger_provider)
|
||||
if provider is None:
|
||||
return None
|
||||
return GenAIEventRecorder(get_event_logger(provider, LITELLM_TRACER_NAME))
|
||||
|
||||
# ====================================================================== #
|
||||
# Proxy global registration
|
||||
# ====================================================================== #
|
||||
|
|
|
|||
|
|
@ -179,6 +179,19 @@ class ExceptionEvent:
|
|||
NAME: Final = "exception"
|
||||
TYPE: Final = "exception.type"
|
||||
MESSAGE: Final = "exception.message"
|
||||
STACKTRACE: Final = "exception.stacktrace"
|
||||
|
||||
|
||||
class GenAIEvent:
|
||||
"""GenAI semconv event names, from the GenAI registry's *events* section.
|
||||
|
||||
``gen_ai.client.operation.exception`` is defined as a log-based event
|
||||
(severity WARN) carrying the ``exception.*`` trio, correlated to the failed
|
||||
span via the trace/span ids — the semconv-compliant home for GenAI failure
|
||||
details, unlike the deprecated ``error.message`` span attribute.
|
||||
"""
|
||||
|
||||
OPERATION_EXCEPTION: Final = "gen_ai.client.operation.exception"
|
||||
|
||||
|
||||
class Server:
|
||||
|
|
|
|||
52
litellm/integrations/otel/plumbing/events.py
Normal file
52
litellm/integrations/otel/plumbing/events.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"""GenAI client events: the ``gen_ai.client.operation.exception`` log event.
|
||||
|
||||
The GenAI semantic conventions define exception recording for client
|
||||
operations as a log-based event (severity WARN) carrying the ``exception.*``
|
||||
attribute trio, correlated to the failed span through the trace/span ids —
|
||||
not as a span attribute or span event. This module owns building and
|
||||
emitting that event; the exporter pipeline it rides is built in
|
||||
:mod:`litellm.integrations.otel.plumbing.providers`.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from opentelemetry._events import Event, EventLogger
|
||||
from opentelemetry._logs.severity import SeverityNumber
|
||||
from opentelemetry.trace import SpanContext
|
||||
|
||||
from litellm.integrations.otel.model.semconv import ExceptionEvent, GenAIEvent
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GenAIEventRecorder:
|
||||
event_logger: EventLogger
|
||||
|
||||
def record_operation_exception(
|
||||
self,
|
||||
span_context: SpanContext,
|
||||
error_type: str,
|
||||
message: str,
|
||||
stack_trace: str | None,
|
||||
timestamp_ns: int | None,
|
||||
) -> None:
|
||||
# ``exception.type`` and ``exception.message`` are the semconv-required
|
||||
# pair and always ride the event; only the recommended stacktrace is
|
||||
# conditional on the payload carrying one.
|
||||
stacktrace = ((ExceptionEvent.STACKTRACE, stack_trace),) if stack_trace else ()
|
||||
self.event_logger.emit(
|
||||
Event(
|
||||
name=GenAIEvent.OPERATION_EXCEPTION,
|
||||
timestamp=timestamp_ns,
|
||||
trace_id=span_context.trace_id,
|
||||
span_id=span_context.span_id,
|
||||
trace_flags=span_context.trace_flags,
|
||||
severity_number=SeverityNumber.WARN,
|
||||
attributes=dict(
|
||||
(
|
||||
(ExceptionEvent.TYPE, error_type),
|
||||
(ExceptionEvent.MESSAGE, message),
|
||||
*stacktrace,
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
|
@ -2,9 +2,20 @@
|
|||
|
||||
from typing import TYPE_CHECKING, Any, Callable, Iterable
|
||||
|
||||
from opentelemetry import baggage, metrics
|
||||
from opentelemetry import _logs, baggage, metrics
|
||||
from opentelemetry._events import EventLogger
|
||||
from opentelemetry._logs import LoggerProvider, NoOpLoggerProvider
|
||||
from opentelemetry.context import Context
|
||||
from opentelemetry.metrics import MeterProvider, NoOpMeterProvider
|
||||
from opentelemetry.sdk._events import EventLoggerProvider
|
||||
from opentelemetry.sdk._logs import LoggerProvider as SDKLoggerProvider
|
||||
from opentelemetry.sdk._logs.export import (
|
||||
BatchLogRecordProcessor,
|
||||
ConsoleLogExporter,
|
||||
InMemoryLogExporter,
|
||||
LogExporter,
|
||||
SimpleLogRecordProcessor,
|
||||
)
|
||||
from opentelemetry.sdk.metrics import MeterProvider as SDKMeterProvider
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, TracerProvider
|
||||
|
|
@ -224,6 +235,112 @@ def build_metric_reader(config: OpenTelemetryV2Config) -> "MetricReader":
|
|||
return PeriodicExportingMetricReader(exporter, export_interval_millis=5000)
|
||||
|
||||
|
||||
def _otlp_logs_endpoint(endpoint: str | None) -> str | None:
|
||||
"""Point an OTLP/HTTP base endpoint at the ``/v1/logs`` signal path.
|
||||
|
||||
The OTLP/HTTP exporter only appends ``/v1/logs`` when it reads
|
||||
``OTEL_EXPORTER_OTLP_ENDPOINT`` itself; an explicitly passed endpoint is used
|
||||
verbatim, so a base URL would POST to the root. Mirror ``_otlp_traces_endpoint``
|
||||
for the logs signal (rewriting a sibling signal path when present).
|
||||
"""
|
||||
if not endpoint:
|
||||
return endpoint
|
||||
endpoint = endpoint.rstrip("/")
|
||||
if endpoint.endswith("/v1/logs"):
|
||||
return endpoint
|
||||
for other_signal in ("/v1/traces", "/v1/metrics"):
|
||||
if endpoint.endswith(other_signal):
|
||||
return endpoint[: -len(other_signal)] + "/v1/logs"
|
||||
return endpoint + "/v1/logs"
|
||||
|
||||
|
||||
def build_log_exporter(config: OpenTelemetryV2Config) -> LogExporter:
|
||||
"""Build a log exporter mirroring the exporter selection of the other signals.
|
||||
|
||||
``console`` (and any unrecognized kind) exports to the console; ``otlp_http``
|
||||
and ``otlp_grpc`` export over OTLP with the configured endpoint/headers;
|
||||
``in_memory`` buffers for tests. Like GenAI metrics, events ride the
|
||||
single-destination shorthand fields, not the multi-exporter ``exporters`` list.
|
||||
"""
|
||||
kind = (config.exporter or "console").lower()
|
||||
if kind in ("in_memory", "inmemory", "memory"):
|
||||
return InMemoryLogExporter()
|
||||
if kind in ("otlp_http", "http", "http/protobuf", "http/json"):
|
||||
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
|
||||
OTLPLogExporter as HTTPLogExporter,
|
||||
)
|
||||
|
||||
return HTTPLogExporter(
|
||||
endpoint=_otlp_logs_endpoint(config.endpoint),
|
||||
headers=parse_headers(config.headers),
|
||||
)
|
||||
if kind in ("otlp_grpc", "grpc"):
|
||||
try:
|
||||
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
|
||||
OTLPLogExporter as GRPCLogExporter,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"OpenTelemetry OTLP gRPC log exporter is not available. Install "
|
||||
"`opentelemetry-exporter-otlp` and `grpcio` (or `litellm[grpc]`)."
|
||||
) from exc
|
||||
|
||||
return GRPCLogExporter(endpoint=config.endpoint, headers=parse_headers(config.headers))
|
||||
return ConsoleLogExporter()
|
||||
|
||||
|
||||
def build_logger_provider(
|
||||
config: OpenTelemetryV2Config,
|
||||
log_exporter: LogExporter | None = None,
|
||||
) -> SDKLoggerProvider:
|
||||
"""Build the :class:`LoggerProvider` GenAI events export through.
|
||||
|
||||
``log_exporter`` is an explicit override (tests inject an
|
||||
``InMemoryLogExporter``); otherwise the exporter is selected from the config's
|
||||
exporter kind via :func:`build_log_exporter`. Console and in-memory exporters
|
||||
get a Simple processor (synchronous export, which tests rely on), everything
|
||||
else a Batch processor — the same split as span processing.
|
||||
"""
|
||||
exporter = log_exporter if log_exporter is not None else build_log_exporter(config)
|
||||
provider = SDKLoggerProvider(resource=build_resource(config))
|
||||
use_simple = isinstance(exporter, (ConsoleLogExporter, InMemoryLogExporter))
|
||||
provider.add_log_record_processor(
|
||||
SimpleLogRecordProcessor(exporter) if use_simple else BatchLogRecordProcessor(exporter)
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
def resolve_logger_provider(
|
||||
config: OpenTelemetryV2Config,
|
||||
logger_provider: SDKLoggerProvider | None = None,
|
||||
) -> SDKLoggerProvider | None:
|
||||
"""Resolve the :class:`LoggerProvider` GenAI events record through, or ``None``
|
||||
when the operator has opted out of the logs signal.
|
||||
|
||||
Same resolution order as :func:`resolve_meter_provider`: an injected provider
|
||||
wins (DI/tests); an operator-configured SDK global is reused so events ride
|
||||
their pipeline; an explicit ``NoOpLoggerProvider`` global is an opt-out and
|
||||
yields ``None``, so no event is ever built. Only the default placeholder
|
||||
global makes V2 build a provider from the config and publish it as the global.
|
||||
"""
|
||||
if logger_provider is not None:
|
||||
return logger_provider
|
||||
|
||||
existing: LoggerProvider = _logs.get_logger_provider()
|
||||
if isinstance(existing, SDKLoggerProvider):
|
||||
return existing
|
||||
if isinstance(existing, NoOpLoggerProvider):
|
||||
return None
|
||||
|
||||
provider = build_logger_provider(config)
|
||||
_logs.set_logger_provider(provider)
|
||||
return provider
|
||||
|
||||
|
||||
def get_event_logger(provider: SDKLoggerProvider, name: str = "litellm") -> EventLogger:
|
||||
return EventLoggerProvider(logger_provider=provider).get_event_logger(name, litellm_version)
|
||||
|
||||
|
||||
def build_meter_provider(
|
||||
config: OpenTelemetryV2Config,
|
||||
metric_reader: "MetricReader | None" = None,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ def is_text_content_call_type(call_type: str) -> bool:
|
|||
return call_type in TEXT_CONTENT_CALL_TYPES
|
||||
|
||||
|
||||
TEXT_PART_TYPES: FrozenSet[str] = frozenset({"text", "input_text", "output_text"})
|
||||
|
||||
|
||||
def _iter_text_parts_in_content(content: Any) -> Iterator[str]:
|
||||
"""Yield text fragments from a ``message.content`` value (string or
|
||||
multimodal list). Non-text parts (images, audio, …) are skipped."""
|
||||
|
|
@ -48,7 +51,7 @@ def _iter_text_parts_in_content(content: Any) -> Iterator[str]:
|
|||
continue
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get("type") == "text":
|
||||
if part.get("type") in TEXT_PART_TYPES:
|
||||
text = part.get("text")
|
||||
if isinstance(text, str) and text:
|
||||
yield text
|
||||
|
|
@ -58,14 +61,20 @@ def _coerce_input_to_messages(input_value: Any) -> List[Dict[str, Any]]:
|
|||
"""Coerce a Responses-API ``data["input"]`` value into chat-style messages."""
|
||||
if isinstance(input_value, str):
|
||||
return [{"role": "user", "content": input_value}]
|
||||
if isinstance(input_value, list):
|
||||
if input_value and all(isinstance(item, dict) and "role" in item for item in input_value):
|
||||
return list(input_value)
|
||||
# Mixed lists (content-part dicts + bare strings) and pure
|
||||
# string/dict lists all become a single user message; the content
|
||||
# iterator below handles each element type uniformly.
|
||||
return [{"role": "user", "content": input_value}]
|
||||
return []
|
||||
if not isinstance(input_value, list):
|
||||
return []
|
||||
messages: List[Dict[str, Any]] = []
|
||||
for item in input_value:
|
||||
if isinstance(item, str):
|
||||
messages.append({"role": "user", "content": item})
|
||||
elif isinstance(item, dict):
|
||||
if item.get("type") in TEXT_PART_TYPES:
|
||||
messages.append({"role": item.get("role") or "user", "content": [item]})
|
||||
elif "content" in item:
|
||||
messages.append({"role": item.get("role") or "user", "content": item["content"]})
|
||||
elif item.get("type") == "function_call_output" and "output" in item:
|
||||
messages.append({"role": item.get("role") or "tool", "content": item["output"]})
|
||||
return messages
|
||||
|
||||
|
||||
def _iter_inspection_messages(data: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
|
||||
|
|
@ -112,7 +121,7 @@ def walk_user_text(data: Dict[str, Any], visit: Callable[[str], str]) -> int:
|
|||
new_parts.append(visit(part))
|
||||
elif (
|
||||
isinstance(part, dict)
|
||||
and part.get("type") == "text"
|
||||
and part.get("type") in TEXT_PART_TYPES
|
||||
and isinstance(part.get("text"), str)
|
||||
and part["text"]
|
||||
):
|
||||
|
|
@ -136,25 +145,20 @@ def walk_user_text(data: Dict[str, Any], visit: Callable[[str], str]) -> int:
|
|||
data["input"] = visit(input_value)
|
||||
return visited
|
||||
if isinstance(input_value, list):
|
||||
# List of full messages: rewrite each message's content.
|
||||
if input_value and all(isinstance(item, dict) and "role" in item for item in input_value):
|
||||
for item in input_value:
|
||||
if "content" in item:
|
||||
item["content"] = _rewrite_content(item["content"])
|
||||
return visited
|
||||
# List of content parts and/or bare strings: rewrite in place.
|
||||
for idx, item in enumerate(input_value):
|
||||
if isinstance(item, str) and item:
|
||||
visited += 1
|
||||
input_value[idx] = visit(item)
|
||||
elif (
|
||||
isinstance(item, dict)
|
||||
and item.get("type") == "text"
|
||||
and isinstance(item.get("text"), str)
|
||||
and item["text"]
|
||||
):
|
||||
visited += 1
|
||||
input_value[idx] = {**item, "text": visit(item["text"])}
|
||||
if isinstance(item, str):
|
||||
if item:
|
||||
visited += 1
|
||||
input_value[idx] = visit(item)
|
||||
elif isinstance(item, dict):
|
||||
if item.get("type") in TEXT_PART_TYPES:
|
||||
if isinstance(item.get("text"), str) and item["text"]:
|
||||
visited += 1
|
||||
input_value[idx] = {**item, "text": visit(item["text"])}
|
||||
elif "content" in item:
|
||||
item["content"] = _rewrite_content(item["content"])
|
||||
elif item.get("type") == "function_call_output" and "output" in item:
|
||||
item["output"] = _rewrite_content(item["output"])
|
||||
return visited
|
||||
|
||||
return visited
|
||||
|
|
|
|||
|
|
@ -93,11 +93,10 @@ class AimGuardrail(CustomGuardrail):
|
|||
user_email=user_email,
|
||||
litellm_call_id=call_id,
|
||||
)
|
||||
# Covers multimodal list content + Responses-API input.
|
||||
response = await self.async_handler.post(
|
||||
f"{self.api_base}/fw/v1/analyze",
|
||||
headers=headers,
|
||||
json={"messages": build_inspection_messages(data)},
|
||||
json={"messages": self._build_aim_inspection_messages(data)},
|
||||
)
|
||||
response.raise_for_status()
|
||||
res = response.json()
|
||||
|
|
@ -116,6 +115,15 @@ class AimGuardrail(CustomGuardrail):
|
|||
verbose_proxy_logger.error(f"Aim: {action_type} action")
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _build_aim_inspection_messages(data: dict) -> list[dict[str, str]]:
|
||||
"""AIM validates against the OpenAI chat schema. Bare ``role: "tool"``
|
||||
without ``tool_call_id`` and bare ``role: "function"`` without ``name``
|
||||
are rejected; the flatten drops those fields, so any role outside
|
||||
``{system, user, assistant}`` collapses to ``user`` for the AIM POST."""
|
||||
safe_roles = {"system", "user", "assistant"}
|
||||
return [{**m, "role": "user"} if m["role"] not in safe_roles else m for m in build_inspection_messages(data)]
|
||||
|
||||
@staticmethod
|
||||
def _rejection(message: str, *, openai_code: str | None = None) -> ProxyException:
|
||||
return ProxyException(
|
||||
|
|
@ -177,7 +185,10 @@ class AimGuardrail(CustomGuardrail):
|
|||
user_email=user_email,
|
||||
litellm_call_id=call_id,
|
||||
),
|
||||
json={"messages": build_inspection_messages(request_data) + [{"role": "assistant", "content": output}]},
|
||||
json={
|
||||
"messages": self._build_aim_inspection_messages(request_data)
|
||||
+ [{"role": "assistant", "content": output}]
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
res = response.json()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "litellm"
|
||||
version = "1.91.2"
|
||||
version = "1.91.3"
|
||||
description = "Library to easily interface with LLM API providers"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10, <3.14"
|
||||
|
|
@ -269,7 +269,7 @@ source-exclude = [
|
|||
profile = "black"
|
||||
|
||||
[tool.commitizen]
|
||||
version = "1.91.2"
|
||||
version = "1.91.3"
|
||||
version_files = [
|
||||
"pyproject.toml:^version",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -485,6 +485,74 @@ def test_build_span_exporter_variants():
|
|||
OpenTelemetryV2Config(exporter="otlp_http", endpoint="http://h:4318")
|
||||
)
|
||||
assert "OTLPSpanExporter" in type(http_exporter).__name__
|
||||
|
||||
|
||||
def test_otlp_logs_endpoint_normalization():
|
||||
norm = providers._otlp_logs_endpoint
|
||||
# A base endpoint gets the signal path appended (the common OTLP env shape).
|
||||
assert norm("http://collector:4318") == "http://collector:4318/v1/logs"
|
||||
assert norm("http://collector:4318/") == "http://collector:4318/v1/logs"
|
||||
# An already-correct path is left intact.
|
||||
assert norm("http://collector:4318/v1/logs") == "http://collector:4318/v1/logs"
|
||||
# A sibling signal's path is rewritten to logs, so one OTEL_ENDPOINT works
|
||||
# for every signal rather than POSTing events at the traces path.
|
||||
assert norm("http://collector:4318/v1/traces") == "http://collector:4318/v1/logs"
|
||||
assert norm("http://collector:4318/v1/metrics") == "http://collector:4318/v1/logs"
|
||||
assert norm(None) is None
|
||||
|
||||
|
||||
def test_build_log_exporter_variants():
|
||||
from opentelemetry.sdk._logs.export import ConsoleLogExporter, InMemoryLogExporter
|
||||
|
||||
assert isinstance(
|
||||
providers.build_log_exporter(OpenTelemetryV2Config(exporter="console")),
|
||||
ConsoleLogExporter,
|
||||
)
|
||||
assert isinstance(
|
||||
providers.build_log_exporter(OpenTelemetryV2Config(exporter="in_memory")),
|
||||
InMemoryLogExporter,
|
||||
)
|
||||
# An unrecognized kind falls back to console rather than dropping events.
|
||||
assert isinstance(
|
||||
providers.build_log_exporter(OpenTelemetryV2Config(exporter="unknown")),
|
||||
ConsoleLogExporter,
|
||||
)
|
||||
http_exporter = providers.build_log_exporter(
|
||||
OpenTelemetryV2Config(exporter="otlp_http", endpoint="http://h:4318")
|
||||
)
|
||||
assert "OTLPLogExporter" in type(http_exporter).__name__
|
||||
|
||||
|
||||
def test_build_logger_provider_picks_processor_by_exporter_kind():
|
||||
"""Console and in-memory exporters export synchronously (tests depend on it);
|
||||
every other destination gets the batch processor."""
|
||||
from opentelemetry.sdk._logs.export import (
|
||||
BatchLogRecordProcessor,
|
||||
ConsoleLogExporter,
|
||||
InMemoryLogExporter,
|
||||
SimpleLogRecordProcessor,
|
||||
)
|
||||
|
||||
cfg = OpenTelemetryV2Config(exporter="in_memory")
|
||||
|
||||
def processor_of(provider):
|
||||
return provider._multi_log_record_processor._log_record_processors[0]
|
||||
|
||||
assert isinstance(
|
||||
processor_of(providers.build_logger_provider(cfg, log_exporter=InMemoryLogExporter())),
|
||||
SimpleLogRecordProcessor,
|
||||
)
|
||||
assert isinstance(
|
||||
processor_of(providers.build_logger_provider(cfg, log_exporter=ConsoleLogExporter())),
|
||||
SimpleLogRecordProcessor,
|
||||
)
|
||||
http_exporter = providers.build_log_exporter(
|
||||
OpenTelemetryV2Config(exporter="otlp_http", endpoint="http://h:4318")
|
||||
)
|
||||
assert isinstance(
|
||||
processor_of(providers.build_logger_provider(cfg, log_exporter=http_exporter)),
|
||||
BatchLogRecordProcessor,
|
||||
)
|
||||
grpc_exporter = providers.build_span_exporter(
|
||||
OpenTelemetryV2Config(exporter="otlp_grpc", endpoint="http://h:4317")
|
||||
)
|
||||
|
|
@ -721,6 +789,177 @@ def test_success_span_records_no_exception_event():
|
|||
assert all(e.name != ExceptionEvent.NAME for e in span.events)
|
||||
|
||||
|
||||
def _engine_with_event_recorder():
|
||||
from opentelemetry.sdk._logs.export import InMemoryLogExporter
|
||||
|
||||
from litellm.integrations.otel.emitter import SpanEmitter
|
||||
from litellm.integrations.otel.plumbing.events import GenAIEventRecorder
|
||||
|
||||
cfg = OpenTelemetryV2Config(exporter="in_memory", enable_events=True)
|
||||
provider, span_exporter = providers.in_memory_provider(cfg)
|
||||
log_exporter = InMemoryLogExporter()
|
||||
logger_provider = providers.build_logger_provider(cfg, log_exporter=log_exporter)
|
||||
recorder = GenAIEventRecorder(providers.get_event_logger(logger_provider))
|
||||
engine = SpanEmitter(providers.get_tracer(provider, "t"), cfg, event_recorder=recorder)
|
||||
return engine, span_exporter, log_exporter
|
||||
|
||||
|
||||
def _llm_call_data(error):
|
||||
return LLMCallSpanData(
|
||||
operation=GenAIOperation.CHAT,
|
||||
provider="openai",
|
||||
request_model="gpt-4o",
|
||||
response_model=None,
|
||||
response_id=None,
|
||||
request_params=LLMRequestParams(),
|
||||
usage=LLMUsage(),
|
||||
finish_reasons=(),
|
||||
error=error,
|
||||
response_cost=None,
|
||||
server=None,
|
||||
identity=RequestIdentity(call_id=None),
|
||||
)
|
||||
|
||||
|
||||
def test_operation_exception_log_event_emitted_on_failed_llm_call():
|
||||
"""A failed LLM call records the GenAI semconv ``gen_ai.client.operation.exception``
|
||||
event on the logs signal: severity WARN, the full ``exception.*`` trio (including
|
||||
the stacktrace, which span-side only exists under a vendor key), correlated to
|
||||
the failed span via trace/span ids. The span-side error surface stays intact."""
|
||||
from opentelemetry._logs.severity import SeverityNumber
|
||||
|
||||
from litellm.integrations.otel.model.semconv import ExceptionEvent, GenAIEvent
|
||||
|
||||
engine, span_exporter, log_exporter = _engine_with_event_recorder()
|
||||
engine.emit(
|
||||
SpanRole.LLM_CALL,
|
||||
_llm_call_data(
|
||||
SpanError(
|
||||
error_type="RateLimitError",
|
||||
message="rate limited",
|
||||
code="429",
|
||||
stack_trace="Traceback (most recent call last) ...",
|
||||
llm_provider="openai",
|
||||
)
|
||||
),
|
||||
)
|
||||
(span,) = span_exporter.get_finished_spans()
|
||||
(log,) = log_exporter.get_finished_logs()
|
||||
record = log.log_record
|
||||
|
||||
assert record.attributes["event.name"] == GenAIEvent.OPERATION_EXCEPTION
|
||||
assert record.severity_number == SeverityNumber.WARN
|
||||
assert record.attributes[ExceptionEvent.TYPE] == "RateLimitError"
|
||||
assert record.attributes[ExceptionEvent.MESSAGE] == "rate limited"
|
||||
assert record.attributes[ExceptionEvent.STACKTRACE] == "Traceback (most recent call last) ..."
|
||||
assert record.trace_id == span.context.trace_id
|
||||
assert record.span_id == span.context.span_id
|
||||
|
||||
assert [e.name for e in span.events] == [ExceptionEvent.NAME]
|
||||
assert span.attributes["error.type"] == "RateLimitError"
|
||||
|
||||
|
||||
def test_operation_exception_log_event_omits_absent_stacktrace():
|
||||
from litellm.integrations.otel.model.semconv import ExceptionEvent
|
||||
|
||||
engine, _, log_exporter = _engine_with_event_recorder()
|
||||
engine.emit(SpanRole.LLM_CALL, _llm_call_data(SpanError(error_type="APIError", message="boom")))
|
||||
(log,) = log_exporter.get_finished_logs()
|
||||
|
||||
assert ExceptionEvent.STACKTRACE not in log.log_record.attributes
|
||||
assert log.log_record.attributes[ExceptionEvent.MESSAGE] == "boom"
|
||||
|
||||
|
||||
def test_operation_exception_log_event_always_carries_required_pair():
|
||||
"""``exception.type`` and ``exception.message`` are the semconv-required pair:
|
||||
they ride the event even when the recorder is handed empty strings, so an
|
||||
event is never emitted with no required field. Only the stacktrace is
|
||||
conditional."""
|
||||
from opentelemetry.sdk._logs.export import InMemoryLogExporter
|
||||
from opentelemetry.trace import INVALID_SPAN_CONTEXT
|
||||
|
||||
from litellm.integrations.otel.model.semconv import ExceptionEvent
|
||||
from litellm.integrations.otel.plumbing.events import GenAIEventRecorder
|
||||
|
||||
cfg = OpenTelemetryV2Config(exporter="in_memory", enable_events=True)
|
||||
log_exporter = InMemoryLogExporter()
|
||||
logger_provider = providers.build_logger_provider(cfg, log_exporter=log_exporter)
|
||||
recorder = GenAIEventRecorder(providers.get_event_logger(logger_provider))
|
||||
|
||||
recorder.record_operation_exception(
|
||||
span_context=INVALID_SPAN_CONTEXT,
|
||||
error_type="",
|
||||
message="",
|
||||
stack_trace="",
|
||||
timestamp_ns=None,
|
||||
)
|
||||
(log,) = log_exporter.get_finished_logs()
|
||||
attributes = log.log_record.attributes
|
||||
assert attributes[ExceptionEvent.TYPE] == ""
|
||||
assert attributes[ExceptionEvent.MESSAGE] == ""
|
||||
assert ExceptionEvent.STACKTRACE not in attributes
|
||||
|
||||
|
||||
def test_operation_exception_log_event_not_emitted_on_success():
|
||||
engine, span_exporter, log_exporter = _engine_with_event_recorder()
|
||||
engine.emit(SpanRole.LLM_CALL, _llm_call_data(None))
|
||||
|
||||
assert len(span_exporter.get_finished_spans()) == 1
|
||||
assert log_exporter.get_finished_logs() == ()
|
||||
|
||||
|
||||
def test_operation_exception_log_event_only_for_llm_call_role():
|
||||
"""The event is scoped to GenAI client operations; a failed guardrail span
|
||||
keeps its span-side error surface but records no GenAI exception event."""
|
||||
engine, span_exporter, log_exporter = _engine_with_event_recorder()
|
||||
engine.emit(
|
||||
SpanRole.GUARDRAIL,
|
||||
GuardrailSpanData("presidio", status="failure", error=SpanError(error_type="X", message="denied")),
|
||||
)
|
||||
(span,) = span_exporter.get_finished_spans()
|
||||
|
||||
assert span.attributes["error.type"] == "X"
|
||||
assert log_exporter.get_finished_logs() == ()
|
||||
|
||||
|
||||
def test_resolve_logger_provider_honors_explicit_noop_optout(monkeypatch):
|
||||
"""A ``NoOpLoggerProvider`` global is an explicit operator opt-out from the logs
|
||||
signal: resolve to ``None`` so no recorder (and so no event) is ever built,
|
||||
rather than emitting into a provider that drops everything."""
|
||||
from opentelemetry import _logs
|
||||
from opentelemetry._logs import NoOpLoggerProvider
|
||||
|
||||
from litellm.integrations.otel.logger import OpenTelemetryV2
|
||||
|
||||
cfg = OpenTelemetryV2Config(exporter="in_memory", enable_events=True)
|
||||
tracer_provider, _ = providers.in_memory_provider(cfg)
|
||||
monkeypatch.setattr(_logs, "get_logger_provider", lambda: NoOpLoggerProvider())
|
||||
|
||||
assert providers.resolve_logger_provider(cfg) is None
|
||||
logger = OpenTelemetryV2(config=cfg, tracer_provider=tracer_provider)
|
||||
assert logger._emitter._event_recorder is None
|
||||
|
||||
|
||||
def test_resolve_logger_provider_reuses_operator_sdk_global(monkeypatch):
|
||||
"""Events ride an operator-configured logs pipeline rather than a second one
|
||||
built by litellm, so they land wherever the operator's other logs land."""
|
||||
from opentelemetry import _logs
|
||||
from opentelemetry.sdk._logs.export import InMemoryLogExporter
|
||||
|
||||
cfg = OpenTelemetryV2Config(exporter="in_memory", enable_events=True)
|
||||
operator_provider = providers.build_logger_provider(cfg, log_exporter=InMemoryLogExporter())
|
||||
monkeypatch.setattr(_logs, "get_logger_provider", lambda: operator_provider)
|
||||
|
||||
assert providers.resolve_logger_provider(cfg) is operator_provider
|
||||
|
||||
|
||||
def test_operation_exception_event_keys_are_pinned():
|
||||
from litellm.integrations.otel.model.semconv import ExceptionEvent, GenAIEvent
|
||||
|
||||
assert GenAIEvent.OPERATION_EXCEPTION == "gen_ai.client.operation.exception"
|
||||
assert ExceptionEvent.STACKTRACE == "exception.stacktrace"
|
||||
|
||||
|
||||
# --- service taxonomy: which calls become spans, and of what kind ----------- #
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -176,6 +176,61 @@ def test_async_log_failure_event_marks_error_status():
|
|||
assert span.attributes["error.type"] == "RateLimitError"
|
||||
|
||||
|
||||
def _logger_with_events(enable_events):
|
||||
from opentelemetry.sdk._logs.export import InMemoryLogExporter
|
||||
|
||||
cfg = OpenTelemetryV2Config(exporter="in_memory", enable_events=enable_events)
|
||||
span_exporter = InMemorySpanExporter()
|
||||
tracer_provider = providers.build_tracer_provider(cfg, exporter=span_exporter)
|
||||
log_exporter = InMemoryLogExporter()
|
||||
logger_provider = providers.build_logger_provider(cfg, log_exporter=log_exporter)
|
||||
logger = OpenTelemetryV2(config=cfg, tracer_provider=tracer_provider, logger_provider=logger_provider)
|
||||
return logger, span_exporter, log_exporter
|
||||
|
||||
|
||||
def test_enable_events_records_operation_exception_through_failure_callback():
|
||||
"""With ``enable_events`` on, a real failure callback records the GenAI
|
||||
``gen_ai.client.operation.exception`` log event, carrying the traceback from
|
||||
the standard logging payload and correlated to the LLM-call span."""
|
||||
from litellm.integrations.otel.model.semconv import ExceptionEvent, GenAIEvent
|
||||
|
||||
logger, span_exporter, log_exporter = _logger_with_events(enable_events=True)
|
||||
payload = _payload(
|
||||
status="failure",
|
||||
error_information={
|
||||
"error_class": "RateLimitError",
|
||||
"error_message": "429 rate limited",
|
||||
"traceback": "Traceback (most recent call last) ...",
|
||||
},
|
||||
)
|
||||
_emit_llm(logger, _kwargs(payload=payload), fail=True)
|
||||
|
||||
(span,) = span_exporter.get_finished_spans()
|
||||
(log,) = log_exporter.get_finished_logs()
|
||||
record = log.log_record
|
||||
assert record.attributes["event.name"] == GenAIEvent.OPERATION_EXCEPTION
|
||||
assert record.attributes[ExceptionEvent.TYPE] == "RateLimitError"
|
||||
assert record.attributes[ExceptionEvent.MESSAGE] == "429 rate limited"
|
||||
assert record.attributes[ExceptionEvent.STACKTRACE] == "Traceback (most recent call last) ..."
|
||||
assert record.trace_id == span.context.trace_id
|
||||
assert record.span_id == span.context.span_id
|
||||
|
||||
|
||||
def test_events_off_by_default_records_no_log_event_on_failure():
|
||||
"""``enable_events`` defaults to off: even with a logs pipeline injected, a
|
||||
failure records only the span-side error surface, no log event."""
|
||||
logger, span_exporter, log_exporter = _logger_with_events(enable_events=False)
|
||||
payload = _payload(
|
||||
status="failure",
|
||||
error_information={"error_class": "RateLimitError", "error_message": "429"},
|
||||
)
|
||||
_emit_llm(logger, _kwargs(payload=payload), fail=True)
|
||||
|
||||
assert len(span_exporter.get_finished_spans()) == 1
|
||||
assert log_exporter.get_finished_logs() == ()
|
||||
assert OpenTelemetryV2Config(exporter="in_memory").enable_events is False
|
||||
|
||||
|
||||
def test_sync_log_event_is_noop():
|
||||
"""V2 closes the span async-only; the sync callback runs out-of-context, so
|
||||
it no-ops (the span stays open on the carrier until the async callback)."""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
"""Tests for the AIM guardrail's inspection-payload construction."""
|
||||
|
||||
from litellm.proxy.guardrails.guardrail_hooks.aim.aim import AimGuardrail
|
||||
|
||||
|
||||
def test_aim_inspection_messages_coerces_chat_completions_tool_role_to_user():
|
||||
"""LIT-4294: A valid chat-completions ``role: "tool"`` message carries a
|
||||
``tool_call_id``, but the inspection flatten drops every field except
|
||||
``role`` and ``content``. A bare ``tool`` message without ``tool_call_id``
|
||||
is schema-invalid per the OpenAI chat schema, and the customer's writeup
|
||||
reproduced AIM's ``/fw/v1/analyze`` returning 422 on exactly that shape.
|
||||
The AIM POST collapses the role to ``user``; the outbound request to the
|
||||
LLM is untouched."""
|
||||
data = {
|
||||
"messages": [
|
||||
{"role": "user", "content": "weather in SF"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "c1", "content": "sunny"},
|
||||
]
|
||||
}
|
||||
assert AimGuardrail._build_aim_inspection_messages(data) == [
|
||||
{"role": "user", "content": "weather in SF"},
|
||||
{"role": "user", "content": "sunny"},
|
||||
]
|
||||
|
||||
|
||||
def test_aim_inspection_messages_coerces_non_standard_caller_role_to_user():
|
||||
"""LIT-4294: A caller-supplied role outside {system, user, assistant}
|
||||
(e.g. ``developer``, ``function``) is coerced to ``user`` for the AIM
|
||||
POST, since AIM validates the payload against the OpenAI chat schema
|
||||
and rejects unknown roles the same way it rejects bare ``tool``."""
|
||||
data = {
|
||||
"messages": [
|
||||
{"role": "developer", "content": "system-ish instruction"},
|
||||
{"role": "user", "content": "normal user text"},
|
||||
]
|
||||
}
|
||||
assert AimGuardrail._build_aim_inspection_messages(data) == [
|
||||
{"role": "user", "content": "system-ish instruction"},
|
||||
{"role": "user", "content": "normal user text"},
|
||||
]
|
||||
|
||||
|
||||
def test_aim_inspection_messages_coerces_responses_function_call_output_role():
|
||||
"""LIT-4294: the shared helper synthesises ``role: "tool"`` for a
|
||||
Responses ``function_call_output`` item (semantic equivalent of
|
||||
chat-completions tool messages). AIM's schema-validating POST cannot
|
||||
carry ``tool_call_id`` in the flat inspection payload, so AIM collapses
|
||||
that ``tool`` role to ``user`` locally before POSTing."""
|
||||
data = {
|
||||
"input": [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "c1",
|
||||
"output": [{"type": "input_text", "text": "sunny"}],
|
||||
},
|
||||
]
|
||||
}
|
||||
assert AimGuardrail._build_aim_inspection_messages(data) == [
|
||||
{"role": "user", "content": "sunny"},
|
||||
]
|
||||
|
||||
|
||||
def test_aim_inspection_messages_preserves_safe_roles():
|
||||
"""Safe roles pass through untouched — the coercion only fires for
|
||||
roles the OpenAI chat schema flatten cannot represent standalone."""
|
||||
data = {
|
||||
"messages": [
|
||||
{"role": "system", "content": "be helpful"},
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
]
|
||||
}
|
||||
assert AimGuardrail._build_aim_inspection_messages(data) == [
|
||||
{"role": "system", "content": "be helpful"},
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "hello"},
|
||||
]
|
||||
|
|
@ -8,7 +8,6 @@ from litellm.proxy.guardrails._content_utils import (
|
|||
walk_user_text,
|
||||
)
|
||||
|
||||
|
||||
# ── iter_message_text ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -101,6 +100,55 @@ def test_iter_message_text_empty_data():
|
|||
assert list(iter_message_text({"input": ""})) == []
|
||||
|
||||
|
||||
def test_iter_message_text_responses_api_input_text_and_output_text_parts():
|
||||
"""LIT-4294: Responses-API content parts use ``input_text`` (request) and
|
||||
``output_text`` (assistant); reading only ``type == "text"`` skipped every
|
||||
``/v1/responses`` body and every text guardrail was a no-op on that path."""
|
||||
data = {
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "user text"}],
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "assistant text"}],
|
||||
},
|
||||
]
|
||||
}
|
||||
assert list(iter_message_text(data)) == ["user text", "assistant text"]
|
||||
|
||||
|
||||
def test_iter_message_text_responses_api_tool_call_taxonomy():
|
||||
"""LIT-4294: a Responses ``input`` list freely mixes message items,
|
||||
``function_call`` (no ``role``), and ``function_call_output`` items. The
|
||||
old ``all(item has 'role')`` gate wrapped the whole list as one blob and
|
||||
yielded nothing; every text fragment must be visited independently."""
|
||||
data = {
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello"}],
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "c1",
|
||||
"name": "get_weather",
|
||||
"arguments": "{}",
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "c1",
|
||||
"output": [{"type": "input_text", "text": "sunny"}],
|
||||
},
|
||||
]
|
||||
}
|
||||
assert list(iter_message_text(data)) == ["hello", "sunny"]
|
||||
|
||||
|
||||
# ── walk_user_text ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -160,6 +208,89 @@ def test_walk_user_text_redacts_responses_api_list_input():
|
|||
assert data["input"][1] == {"type": "image_url", "image_url": {"url": "..."}}
|
||||
|
||||
|
||||
def test_walk_user_text_redacts_responses_input_text_and_output_text_parts():
|
||||
"""LIT-4294: ``walk_user_text`` must recognise the Responses text-part
|
||||
variants so masking guardrails (secret detection, PII) actually redact
|
||||
``/v1/responses`` bodies instead of no-op'ing on them."""
|
||||
data = {
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "AKIAEXAMPLE"}],
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "AKIAEXAMPLE too"}],
|
||||
},
|
||||
]
|
||||
}
|
||||
visited = walk_user_text(data, lambda s: s.replace("AKIAEXAMPLE", "[REDACTED]"))
|
||||
assert visited == 2
|
||||
assert data["input"][0]["content"][0] == {
|
||||
"type": "input_text",
|
||||
"text": "[REDACTED]",
|
||||
}
|
||||
assert data["input"][1]["content"][0] == {
|
||||
"type": "output_text",
|
||||
"text": "[REDACTED] too",
|
||||
}
|
||||
|
||||
|
||||
def test_walk_user_text_redacts_function_call_output_text():
|
||||
"""LIT-4294: tool-call round-trips carry secrets in
|
||||
``function_call_output.output``; the redact walker must descend into it
|
||||
while leaving ``function_call`` items (call_id, arguments) untouched."""
|
||||
data = {
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "AKIAEXAMPLE user"}],
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "c1",
|
||||
"name": "get_weather",
|
||||
"arguments": '{"AKIAEXAMPLE": 1}',
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "c1",
|
||||
"output": [{"type": "input_text", "text": "AKIAEXAMPLE tool"}],
|
||||
},
|
||||
]
|
||||
}
|
||||
visited = walk_user_text(data, lambda s: s.replace("AKIAEXAMPLE", "[REDACTED]"))
|
||||
assert visited == 2
|
||||
assert data["input"][0]["content"][0]["text"] == "[REDACTED] user"
|
||||
assert data["input"][1] == {
|
||||
"type": "function_call",
|
||||
"call_id": "c1",
|
||||
"name": "get_weather",
|
||||
"arguments": '{"AKIAEXAMPLE": 1}',
|
||||
}
|
||||
assert data["input"][2]["output"][0]["text"] == "[REDACTED] tool"
|
||||
|
||||
|
||||
def test_walk_user_text_redacts_function_call_output_string_output():
|
||||
"""LIT-4294: ``function_call_output.output`` is also a plain string in
|
||||
OpenAI's Responses spec; the redact walker must handle both forms."""
|
||||
data = {
|
||||
"input": [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "c1",
|
||||
"output": "AKIAEXAMPLE tool",
|
||||
},
|
||||
]
|
||||
}
|
||||
visited = walk_user_text(data, lambda s: s.replace("AKIAEXAMPLE", "[REDACTED]"))
|
||||
assert visited == 1
|
||||
assert data["input"][0]["output"] == "[REDACTED] tool"
|
||||
|
||||
|
||||
def test_walk_user_text_redacts_mixed_list_input():
|
||||
"""Read and write helpers must agree on coverage — bare strings inside
|
||||
a mixed ``input`` list are inspected by both."""
|
||||
|
|
@ -206,17 +337,13 @@ def test_build_inspection_messages_joins_multimodal_text_parts():
|
|||
}
|
||||
]
|
||||
}
|
||||
assert build_inspection_messages(data) == [
|
||||
{"role": "user", "content": "first part\nsecond part"}
|
||||
]
|
||||
assert build_inspection_messages(data) == [{"role": "user", "content": "first part\nsecond part"}]
|
||||
|
||||
|
||||
def test_build_inspection_messages_lifts_responses_api_input():
|
||||
"""fniVO9-F: ``input`` must be visible to hooks that POST messages to a remote API."""
|
||||
data = {"input": "responses-api content"}
|
||||
assert build_inspection_messages(data) == [
|
||||
{"role": "user", "content": "responses-api content"}
|
||||
]
|
||||
assert build_inspection_messages(data) == [{"role": "user", "content": "responses-api content"}]
|
||||
|
||||
|
||||
def test_build_inspection_messages_drops_messages_with_no_text():
|
||||
|
|
@ -233,6 +360,102 @@ def test_build_inspection_messages_drops_messages_with_no_text():
|
|||
assert build_inspection_messages(data) == [{"role": "user", "content": "kept"}]
|
||||
|
||||
|
||||
def test_build_inspection_messages_responses_api_tool_call_taxonomy():
|
||||
"""LIT-4294: mixed Responses ``input`` (message + function_call +
|
||||
function_call_output) must produce a non-empty inspection list. The
|
||||
customer's writeup reproduced a 422 from AIM's ``/fw/v1/analyze``
|
||||
(``No messages in the request``) when this synthesised list came back
|
||||
empty; every other guardrail silently scanned nothing on the same
|
||||
input."""
|
||||
data = {
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello"}],
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "c1",
|
||||
"name": "get_weather",
|
||||
"arguments": "{}",
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "c1",
|
||||
"output": [{"type": "input_text", "text": "sunny"}],
|
||||
},
|
||||
]
|
||||
}
|
||||
assert build_inspection_messages(data) == [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "tool", "content": "sunny"},
|
||||
]
|
||||
|
||||
|
||||
def test_build_inspection_messages_function_call_output_defaults_to_tool():
|
||||
"""LIT-4294: a Responses ``function_call_output`` item is the semantic
|
||||
equivalent of a chat-completions ``role: "tool"`` message, so the shared
|
||||
helper synthesises ``role: "tool"`` when the item has no explicit role.
|
||||
AIM's schema-safe coercion happens at the AIM call site, not here."""
|
||||
data = {
|
||||
"input": [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "c1",
|
||||
"output": [{"type": "input_text", "text": "tool text"}],
|
||||
},
|
||||
]
|
||||
}
|
||||
assert build_inspection_messages(data) == [{"role": "tool", "content": "tool text"}]
|
||||
|
||||
|
||||
def test_build_inspection_messages_function_call_output_preserves_explicit_role():
|
||||
"""When ``function_call_output`` carries a caller-supplied ``role`` the
|
||||
shared helper preserves it rather than synthesising ``tool``."""
|
||||
data = {
|
||||
"input": [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"role": "assistant",
|
||||
"call_id": "c1",
|
||||
"output": [{"type": "input_text", "text": "tool text"}],
|
||||
},
|
||||
]
|
||||
}
|
||||
assert build_inspection_messages(data) == [{"role": "assistant", "content": "tool text"}]
|
||||
|
||||
|
||||
def test_build_inspection_messages_bare_content_part_preserves_explicit_role():
|
||||
"""A bare content-part dict with an explicit ``role`` keeps it. Only
|
||||
absent roles get defaulted to ``user``."""
|
||||
data = {
|
||||
"input": [
|
||||
{"type": "input_text", "text": "no role"},
|
||||
{"type": "output_text", "role": "assistant", "text": "with role"},
|
||||
]
|
||||
}
|
||||
assert build_inspection_messages(data) == [
|
||||
{"role": "user", "content": "no role"},
|
||||
{"role": "assistant", "content": "with role"},
|
||||
]
|
||||
|
||||
|
||||
def test_build_inspection_messages_message_item_preserves_role():
|
||||
"""Responses message items carry a role explicitly; the shared helper
|
||||
passes it through untouched."""
|
||||
data = {
|
||||
"input": [
|
||||
{"type": "message", "role": "system", "content": [{"type": "input_text", "text": "sys"}]},
|
||||
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "asst"}]},
|
||||
]
|
||||
}
|
||||
assert build_inspection_messages(data) == [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "assistant", "content": "asst"},
|
||||
]
|
||||
|
||||
|
||||
def test_build_inspection_messages_empty_data():
|
||||
assert build_inspection_messages({}) == []
|
||||
assert build_inspection_messages({"messages": []}) == []
|
||||
|
|
|
|||
4
uv.lock
generated
4
uv.lock
generated
|
|
@ -9,7 +9,7 @@ resolution-markers = [
|
|||
]
|
||||
|
||||
[options]
|
||||
exclude-newer = "2026-07-05T22:43:25.371327Z"
|
||||
exclude-newer = "2026-07-08T21:04:25.956775Z"
|
||||
exclude-newer-span = "P3D"
|
||||
|
||||
[manifest]
|
||||
|
|
@ -3232,7 +3232,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.91.2"
|
||||
version = "1.91.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue