Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions litellm/a2a_protocol/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,7 @@ def _build_streaming_logging_obj(
logging_obj.custom_llm_provider = "a2a_agent"
logging_obj.model_call_details["model"] = model
logging_obj.model_call_details["custom_llm_provider"] = "a2a_agent"
logging_obj.model_call_details["call_type"] = logging_obj.call_type
if agent_id:
logging_obj.model_call_details["agent_id"] = agent_id

Expand Down
1 change: 1 addition & 0 deletions litellm/integrations/opentelemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
VALID_METRIC_ATTRIBUTE_NAMES: FrozenSet[str] = frozenset(
(
"gen_ai.operation.name",
"gen_ai.provider.name",
"gen_ai.system",
"gen_ai.request.model",
"gen_ai.framework",
Expand Down
72 changes: 67 additions & 5 deletions litellm/integrations/otel/model/semconv.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,30 @@
from enum import Enum
from typing import Final

from litellm._logging import verbose_logger


class GenAIOperation(str, Enum):
"""Values for ``gen_ai.operation.name``."""
"""Values for ``gen_ai.operation.name``.

The first block is the convention's own vocabulary. The ``LITELLM_`` members
are vendor values for operations the convention names nothing for; its note
on this attribute directs instrumentation to use a system-specific name in
exactly that case, the same allowance :func:`resolve_provider` relies on for
unmapped providers. They stay under the ``litellm.`` prefix so a value the
convention adds later can never collide with one of ours.
"""

CHAT = "chat"
TEXT_COMPLETION = "text_completion"
EMBEDDINGS = "embeddings"
GENERATE_CONTENT = "generate_content"
RETRIEVAL = "retrieval" # vector-store search / RAG query spans
CREATE_AGENT = "create_agent" # reserved for future agent spans
INVOKE_AGENT = "invoke_agent" # reserved for future agent spans
INVOKE_AGENT = "invoke_agent" # agent (A2A) message spans
EXECUTE_TOOL = "execute_tool" # MCP tool-call spans
LITELLM_VECTOR_STORE_MANAGEMENT = "litellm.vector_store_management"
LITELLM_VECTOR_STORE_FILE_MANAGEMENT = "litellm.vector_store_file_management"


class GenAIProvider(str, Enum):
Expand Down Expand Up @@ -49,11 +62,17 @@ class MCPMethod(str, Enum):


class GenAI:
"""Canonical OTel GenAI span-attribute keys."""
"""Canonical OTel GenAI attribute keys.

``SYSTEM`` is the one exception: the convention deprecated it in favor of
``PROVIDER_NAME``, and it survives here only so already-shipped series keep
resolving for consumers that query it. Nothing new should use it.
"""

# request
OPERATION_NAME: Final = "gen_ai.operation.name"
PROVIDER_NAME: Final = "gen_ai.provider.name"
SYSTEM: Final = "gen_ai.system"
REQUEST_MODEL: Final = "gen_ai.request.model"
REQUEST_TEMPERATURE: Final = "gen_ai.request.temperature"
REQUEST_TOP_P: Final = "gen_ai.request.top_p"
Expand Down Expand Up @@ -316,6 +335,35 @@ class Metric:
"responses": GenAIOperation.CHAT,
"aresponses": GenAIOperation.CHAT,
"call_mcp_tool": GenAIOperation.EXECUTE_TOOL,
"vector_store_search": GenAIOperation.RETRIEVAL,
"avector_store_search": GenAIOperation.RETRIEVAL,
"query": GenAIOperation.RETRIEVAL,
"aquery": GenAIOperation.RETRIEVAL,
"send_message": GenAIOperation.INVOKE_AGENT,
"asend_message": GenAIOperation.INVOKE_AGENT,
Comment thread
greptile-apps[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Streaming agent still labeled chat

Medium Severity

send_message / asend_message map to invoke_agent, but the streaming A2A path records call_type as asend_message_streaming and still falls through to chat. That path dispatches the same success handlers, so streaming agent latency and cost keep polluting chat series.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bb885fa. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it legit?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legit, and worse than it reads. Fixed in 61c6742e3c.

The symptom is exactly right: a streamed A2A turn records as chat. The mechanism is only half right, and the half that is missing is why the one-line map entry Bugbot implies is not enough. I added "asend_message_streaming" to the map first, reran the live rig, and every streaming datapoint still came out chat.

_build_streaming_logging_obj (litellm/a2a_protocol/main.py) builds its Logging by hand and never calls update_environment_variables, which is the only place call_type is written into model_call_details (litellm/litellm_core_utils/litellm_logging.py:553); Logging.__init__ seeds that dict at line 398 without it. So the recorder was reading kwargs.get("call_type") is None and falling back to chat on the not call_type branch. The call type never reached the map at all, which also means it never reached any other callback either. The fix stamps it, and maps the streaming spelling.

Live proof

One proxy on the real streaming path, no mocks: a real A2A agent (built on the a2a-sdk's own server routes, streaming a task plus three status updates plus a completed status), registered in agent_list, driven over HTTP through POST /a2a/{agent} with method: message/stream. OTel v2 with the console metric exporter, no database. Each run sends 3 streamed turns and 1 non-streamed turn, so the non-streamed one is the control that was already correct.

python agent_server.py 8959                       # real A2A agent, /.well-known/agent-card.json + JSON-RPC
export OTEL_ENDPOINT="" OTEL_EXPORTER=console LITELLM_OTEL_V2=1 \
       LITELLM_OTEL_INTEGRATION_ENABLE_METRICS=1
python litellm/proxy/proxy_cli.py --config config.yaml --port 4959 > run.log 2>&1 &

for i in 1 2 3; do
  curl -sS -N -X POST http://127.0.0.1:4959/a2a/streaming-proof-agent \
    -H 'Content-Type: application/json' -H 'Authorization: Bearer sk-1234' \
    -d "{\"jsonrpc\":\"2.0\",\"id\":\"$i\",\"method\":\"message/stream\",\"params\":{\"message\":{\"role\":\"user\",\"parts\":[{\"kind\":\"text\",\"text\":\"streamed turn $i\"}],\"messageId\":\"m$i\"}}}"
done
curl -sS -X POST http://127.0.0.1:4959/a2a/streaming-proof-agent \
  -H 'Content-Type: application/json' -H 'Authorization: Bearer sk-1234' \
  -d '{"jsonrpc":"2.0","id":"4","method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":"non-streamed turn"}],"messageId":"m4"}}}'

All four calls HTTP 200 at every SHA, and the agent streamed its chunks back through the proxy each time. Datapoint attribute sets are counted by brace-matching each "attributes": {...} object out of the exporter output and json.loads-ing it, rather than by regex, since a regex stops at the first } inside a JSON-string label value and silently merges distinct series.

Before, at bb885fa0f7:

x18  operation='chat'          provider=None          model='a2a_agent/otel-streaming-proof-agent'
x6   operation='invoke_agent'  provider='a2a_agent'   model='a2a_agent/otel-streaming-proof-agent'

With only the map entry added, which is the fix the finding implies:

x12  operation='chat'          provider=None          model='a2a_agent/otel-streaming-proof-agent'
x4   operation='invoke_agent'  provider='a2a_agent'   model='a2a_agent/otel-streaming-proof-agent'

Unchanged, because the call type was never arriving.

After, at 61c6742e3c:

x12  operation='invoke_agent'  provider=None          model='a2a_agent/otel-streaming-proof-agent'
x4   operation='invoke_agent'  provider='a2a_agent'   model='a2a_agent/otel-streaming-proof-agent'

Not one datapoint reads chat. The 3:1 split is the 3 streamed turns against the 1 non-streamed control.

Tests

Three, each mutation-checked by reverting the production line and rerunning:

  • test_main.py::test_streaming_logging_obj_carries_call_type_into_model_call_details pins the root cause, that the hand-built logging object carries its call type into model_call_details. This is the one that fails on the pre-fix tree with a KeyError, i.e. it catches the real defect rather than the map.
  • test_otel_v2_sources_of_truth.py::test_agent_message_is_an_invoke_agent_operation gains the streaming spelling, and a new test_every_call_type_the_a2a_package_stamps_is_an_agent_operation scans litellm/a2a_protocol for call_type="..." literals and asserts each one resolves to invoke_agent, so a future spelling added there fails here instead of quietly landing in the chat series.
  • test_otel_v2_metrics.py::test_agent_message_is_not_labelled_as_chat is parametrized over both spellings, driving the real success hook into an in-memory reader.

Reverting the map entry alone fails 3, reverting the stamping alone fails 1; both restored, tests/test_litellm/a2a_protocol plus tests/test_litellm/integrations/otel are green at 394 passed.

One thing deliberately left alone

The streamed datapoints carry no gen_ai.provider.name, while the non-streamed ones carry a2a_agent. That is this PR's own rule working as intended rather than a second bug in this fix: the recorder reads the provider from litellm_params["custom_llm_provider"], and on the non-bridge streaming path those params legitimately carry no provider, so the label is omitted instead of invented. Making the two paths agree means changing what litellm_params holds for A2A streaming, which the cost calculator also reads, so it belongs in its own change rather than riding along here.

"asend_message_streaming": GenAIOperation.INVOKE_AGENT,
"vector_store_create": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
"avector_store_create": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
"vector_store_retrieve": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
"avector_store_retrieve": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
"vector_store_list": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
"avector_store_list": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
"vector_store_update": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
"avector_store_update": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
"vector_store_delete": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
"avector_store_delete": GenAIOperation.LITELLM_VECTOR_STORE_MANAGEMENT,
"vector_store_file_create": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
"avector_store_file_create": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
"vector_store_file_list": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
"avector_store_file_list": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
"vector_store_file_retrieve": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
"avector_store_file_retrieve": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
"vector_store_file_content": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
"avector_store_file_content": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
"vector_store_file_update": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
"avector_store_file_update": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
"vector_store_file_delete": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
"avector_store_file_delete": GenAIOperation.LITELLM_VECTOR_STORE_FILE_MANAGEMENT,
}


Expand All @@ -332,7 +380,21 @@ def resolve_provider(custom_llm_provider: str | None) -> str:


def resolve_operation(call_type: str | None) -> GenAIOperation:
"""Map a litellm ``call_type`` to a ``gen_ai.operation.name`` value."""
"""Map a litellm ``call_type`` to a ``gen_ai.operation.name`` value.

An unmapped call type still falls back to ``chat`` so every series keeps an
operation label, but it logs at debug rather than falling through silently:
a new call type mislabelled as ``chat`` mixes its latency and cost into
everyone's chat charts, which is invisible until someone reads the numbers.
"""
if not call_type:
return GenAIOperation.CHAT
return _OPERATION_BY_CALL_TYPE.get(call_type.lower(), GenAIOperation.CHAT)
mapped = _OPERATION_BY_CALL_TYPE.get(call_type.lower())
if mapped is not None:
return mapped
verbose_logger.debug(
"otel: call_type %r has no gen_ai.operation.name mapping; labelling it %r. Add it to _OPERATION_BY_CALL_TYPE.",
call_type,
GenAIOperation.CHAT.value,
)
return GenAIOperation.CHAT
33 changes: 28 additions & 5 deletions litellm/integrations/otel/plumbing/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,34 @@
_resolve_metric_attribute_filter,
)
from litellm.integrations.otel.model.metadata import time_to_first_chunk_seconds
from litellm.integrations.otel.model.semconv import Error, Metric, resolve_operation
from litellm.integrations.otel.model.semconv import (
Error,
GenAI,
Metric,
resolve_operation,
resolve_provider,
)
from litellm.integrations.otel.model.utils import to_seconds
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps


def _provider_attributes(custom_llm_provider: object) -> Mapping[str, str]:
"""The provider labels for one call's metrics.

``gen_ai.provider.name`` carries the semconv-mapped value; the deprecated
``gen_ai.system`` spelling is dual-emitted with the raw litellm provider
string it has always carried, so a dashboard already querying it keeps
matching. A call with no provider gets neither label: a placeholder value
would mint a permanent series that no operator can act on.
"""
if not isinstance(custom_llm_provider, str) or not custom_llm_provider:
return {}
return {
GenAI.PROVIDER_NAME: resolve_provider(custom_llm_provider),
GenAI.SYSTEM: custom_llm_provider,
}


@dataclass(frozen=True)
class GenAIMetrics:
operation_duration: Histogram
Expand Down Expand Up @@ -98,6 +121,7 @@ def create_genai_metrics(meter: Meter) -> GenAIMetrics:
METRIC_ATTRIBUTE_CEILING: Final[frozenset[str]] = frozenset(
(
"gen_ai.operation.name",
"gen_ai.provider.name",
"gen_ai.system",
"gen_ai.request.model",
"gen_ai.framework",
Expand Down Expand Up @@ -223,11 +247,10 @@ def record_failure(

def _common_attributes(self, kwargs: Mapping[str, Any]) -> dict:
params = kwargs.get("litellm_params") or {}
provider = params.get("custom_llm_provider", "Unknown")
common_attrs: dict = {
"gen_ai.operation.name": resolve_operation(kwargs.get("call_type")).value,
"gen_ai.system": provider,
"gen_ai.request.model": kwargs.get("model"),
GenAI.OPERATION_NAME: resolve_operation(kwargs.get("call_type")).value,
**_provider_attributes(params.get("custom_llm_provider")),
GenAI.REQUEST_MODEL: kwargs.get("model"),
"gen_ai.framework": "litellm",
}

Expand Down
29 changes: 29 additions & 0 deletions tests/test_litellm/a2a_protocol/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,32 @@ async def _capture(*, base_url, extra_headers=None, streaming=False, **_):
pass

assert captured["extra_headers"]["X-LiteLLM-Trace-Id"] == "trace-from-logging"


def test_streaming_logging_obj_carries_call_type_into_model_call_details():
"""The streaming logging object is built by hand rather than through
``update_environment_variables``, which is the only place ``call_type`` normally
reaches ``model_call_details``. Callbacks read the call type from there, so
without this the streamed turn arrives at every logger with no call type at all
and OTel's GenAI metrics label it ``chat`` instead of ``invoke_agent``."""
from a2a.compat.v0_3.types import MessageSendParams, SendStreamingMessageRequest

from litellm.a2a_protocol.main import _build_streaming_logging_obj

request = SendStreamingMessageRequest(
id="rpc-call-type",
params=MessageSendParams(
message={"messageId": "m1", "role": "user", "parts": [{"kind": "text", "text": "hi"}]}
),
)

logging_obj = _build_streaming_logging_obj(
request=request,
agent_name="some-agent",
agent_id=None,
litellm_params=None,
metadata=None,
proxy_server_request=None,
)

assert logging_obj.model_call_details["call_type"] == "asend_message_streaming"
117 changes: 111 additions & 6 deletions tests/test_litellm/integrations/otel/test_otel_v2_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@

TOKEN_TYPE = "gen_ai.token.type"
MODEL_KEY = "gen_ai.request.model"
OPERATION_KEY = "gen_ai.operation.name"
PROVIDER_NAME_KEY = "gen_ai.provider.name"
SYSTEM_KEY = "gen_ai.system"

# Keys inside the ceiling that an operator's filter must still be able to remove.
# Every one is bounded, so it survives the ceiling and only the operator's own
Expand All @@ -83,18 +86,25 @@
RESPONSE_COST = 0.0023


def _build_call(stream: bool = True):
def _build_call(
stream: bool = True,
provider: str | None = "openai",
call_type: str = "completion",
):
"""A captured success-call (kwargs, response_obj, start, end) that exercises
every one of the six metrics: usage for token.usage, response_cost for cost,
streaming + timing for the response-time histograms."""
streaming + timing for the response-time histograms.

``provider=None`` omits ``custom_llm_provider`` entirely, reproducing a call
litellm could not attribute to a provider."""
start = datetime(2026, 6, 12, 12, 0, 0)
api_call_start = start + timedelta(seconds=0.1)
completion_start = start + timedelta(seconds=0.5)
end = start + timedelta(seconds=1.0)
kwargs = {
"model": "gpt-4o-mini",
"call_type": "completion",
"litellm_params": {"custom_llm_provider": "openai"},
"call_type": call_type,
"litellm_params": ({"custom_llm_provider": provider} if provider is not None else {}),
"optional_params": {"stream": stream},
"response_cost": RESPONSE_COST,
"api_call_start_time": api_call_start,
Expand Down Expand Up @@ -142,7 +152,7 @@ def _metrics_by_name(reader):
return out


def _drive_success(reader, callback_settings_attributes=None):
def _drive_success(reader, callback_settings_attributes=None, **call_overrides):
"""Construct a metrics-on logger, optionally populate callback_settings AFTER
construction (mirroring the proxy ordering), run the real success hook."""
logger = _logger(reader, enable_metrics=True)
Expand All @@ -152,7 +162,7 @@ def _drive_success(reader, callback_settings_attributes=None):
"otel": {"attributes": callback_settings_attributes}
}
try:
kwargs, response_obj, start, end = _build_call()
kwargs, response_obj, start, end = _build_call(**call_overrides)
asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end))
finally:
litellm.callback_settings = previous
Expand Down Expand Up @@ -376,6 +386,100 @@ def test_success_attributes_are_capped_at_the_ceiling():
for dp in metrics[name]:
leaked = set(dp.attributes) - set(BOUNDED_KEYS) - {TOKEN_TYPE}
assert not leaked, f"{name} leaked {leaked}"
def test_provider_is_labelled_with_semconv_provider_name():
"""Every recorded point carries gen_ai.provider.name holding the semconv
provider value (bedrock -> aws.bedrock), the key the GenAI convention and the
dashboards built on it query. The deprecated gen_ai.system spelling alone is
unreadable to them."""
metrics = _drive_success(InMemoryMetricReader(), provider="bedrock")

for name in ALL_METRICS:
points = metrics[name]
assert points, f"{name} was not recorded"
for dp in points:
assert dp.attributes[PROVIDER_NAME_KEY] == "aws.bedrock"


def test_deprecated_gen_ai_system_is_dual_emitted_verbatim():
"""gen_ai.system keeps its raw litellm provider value alongside the new key
for one release, so a dashboard already filtering on it keeps matching. Its
value must not be swapped for the mapped one, which would break exactly the
queries the dual emission exists to protect."""
metrics = _drive_success(InMemoryMetricReader(), provider="bedrock")

for dp in metrics[OPERATION_DURATION]:
assert dp.attributes[SYSTEM_KEY] == "bedrock"
assert dp.attributes[PROVIDER_NAME_KEY] == "aws.bedrock"


def test_no_provider_attribute_when_provider_is_absent():
"""A call litellm could not attribute to a provider carries no provider label
at all. A placeholder value ("Unknown") would mint a permanent series that
aggregates every unattributable request and that no operator can act on."""
metrics = _drive_success(InMemoryMetricReader(), provider=None)

for name in ALL_METRICS:
points = metrics[name]
assert points, f"{name} was not recorded"
for dp in points:
keys = set(dp.attributes.keys())
assert PROVIDER_NAME_KEY not in keys
assert SYSTEM_KEY not in keys
assert "Unknown" not in set(dp.attributes.values())


def test_vector_store_search_is_not_labelled_as_chat():
"""A vector-store search records under gen_ai.operation.name=retrieval, so its
latency and cost stay out of the chat series."""
metrics = _drive_success(InMemoryMetricReader(), call_type="avector_store_search")

for name in (OPERATION_DURATION, TOKEN_COST):
for dp in metrics[name]:
assert dp.attributes[OPERATION_KEY] == "retrieval"


@pytest.mark.parametrize(
"call_type,expected",
[
("avector_store_create", "litellm.vector_store_management"),
("avector_store_delete", "litellm.vector_store_management"),
("avector_store_file_create", "litellm.vector_store_file_management"),
("avector_store_file_list", "litellm.vector_store_file_management"),
],
)
def test_vector_store_management_is_not_labelled_as_chat(call_type, expected):
"""Store and file management reach the recorder through the same success hook as a
completion, so leaving them unmapped kept billing- and latency-relevant admin calls
inside the chat series."""
metrics = _drive_success(InMemoryMetricReader(), call_type=call_type)

for dp in metrics[OPERATION_DURATION]:
assert dp.attributes[OPERATION_KEY] == expected


@pytest.mark.parametrize("call_type", ["asend_message", "asend_message_streaming"])
def test_agent_message_is_not_labelled_as_chat(call_type):
"""An A2A agent send records under gen_ai.operation.name=invoke_agent, streamed or
not. The streaming iterator dispatches the same success handlers under its own
``asend_message_streaming`` call type, so an unmapped streaming spelling puts every
streamed agent turn's latency and cost back into the chat series."""
metrics = _drive_success(InMemoryMetricReader(), call_type=call_type)

for name in (OPERATION_DURATION, TOKEN_COST):
for dp in metrics[name]:
assert dp.attributes[OPERATION_KEY] == "invoke_agent"


def test_provider_name_is_filterable():
"""gen_ai.provider.name is a member of the metric-attribute allowlist, so an
operator can include or exclude it; an unlisted name raises instead."""
metrics = _drive_success(
InMemoryMetricReader(),
callback_settings_attributes={"include_list": [PROVIDER_NAME_KEY]},
)

for dp in metrics[OPERATION_DURATION]:
assert set(dp.attributes.keys()) == {PROVIDER_NAME_KEY}


def test_metrics_reach_operator_configured_global_provider(monkeypatch):
Expand Down Expand Up @@ -481,6 +585,7 @@ def test_token_type_rejected_from_either_list(attributes, monkeypatch):
BOUNDED_KEYS = (
"hidden_params",
"gen_ai.operation.name",
"gen_ai.provider.name",
"gen_ai.system",
"gen_ai.request.model",
"gen_ai.framework",
Expand Down
Loading
Loading