fix(otel): update semantic conventions to 1.38 (gen_ai attributes) (#18793)

This commit is contained in:
Harshit Jain
2026-01-09 23:43:44 +05:30
committed by GitHub
parent c27bfddca0
commit 1203e84162
3 changed files with 452 additions and 100 deletions
+128 -46
View File
@@ -594,9 +594,9 @@ class OpenTelemetry(CustomLogger):
def _get_dynamic_otel_headers_from_kwargs(self, kwargs) -> Optional[dict]:
"""Extract dynamic headers from kwargs if available."""
standard_callback_dynamic_params: Optional[
StandardCallbackDynamicParams
] = kwargs.get("standard_callback_dynamic_params")
standard_callback_dynamic_params: Optional[StandardCallbackDynamicParams] = (
kwargs.get("standard_callback_dynamic_params")
)
if not standard_callback_dynamic_params:
return None
@@ -797,7 +797,7 @@ class OpenTelemetry(CustomLogger):
and self._token_usage_histogram
):
in_attrs = {**common_attrs, "gen_ai.token.type": "input"}
out_attrs = {**common_attrs, "gen_ai.token.type": "completion"}
out_attrs = {**common_attrs, "gen_ai.token.type": "output"}
self._token_usage_histogram.record(
usage.get("prompt_tokens", 0), attributes=in_attrs
)
@@ -1488,21 +1488,21 @@ class OpenTelemetry(CustomLogger):
if usage:
self.safe_set_attribute(
span=span,
key=SpanAttributes.LLM_USAGE_TOTAL_TOKENS.value,
key=SpanAttributes.GEN_AI_USAGE_TOTAL_TOKENS.value,
value=usage.get("total_tokens"),
)
# The number of tokens used in the LLM response (completion).
self.safe_set_attribute(
span=span,
key=SpanAttributes.LLM_USAGE_COMPLETION_TOKENS.value,
key=SpanAttributes.GEN_AI_USAGE_OUTPUT_TOKENS.value,
value=usage.get("completion_tokens"),
)
# The number of tokens used in the LLM prompt.
self.safe_set_attribute(
span=span,
key=SpanAttributes.LLM_USAGE_PROMPT_TOKENS.value,
key=SpanAttributes.GEN_AI_USAGE_INPUT_TOKENS.value,
value=usage.get("prompt_tokens"),
)
@@ -1520,53 +1520,75 @@ class OpenTelemetry(CustomLogger):
self.set_tools_attributes(span, tools)
if kwargs.get("messages"):
for idx, prompt in enumerate(kwargs.get("messages")):
if prompt.get("role"):
self.safe_set_attribute(
span=span,
key=f"{SpanAttributes.LLM_PROMPTS.value}.{idx}.role",
value=prompt.get("role"),
)
transformed_messages = (
self._transform_messages_to_otel_semantic_conventions(
kwargs.get("messages")
)
)
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_INPUT_MESSAGES.value,
value=safe_dumps(transformed_messages),
)
if prompt.get("content"):
if not isinstance(prompt.get("content"), str):
prompt["content"] = str(prompt.get("content"))
self.safe_set_attribute(
span=span,
key=f"{SpanAttributes.LLM_PROMPTS.value}.{idx}.content",
value=prompt.get("content"),
)
if kwargs.get("system_instructions"):
transformed_system_instructions = (
self._transform_messages_to_otel_semantic_conventions(
kwargs.get("system_instructions")
)
)
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value,
value=safe_dumps(transformed_system_instructions),
)
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_OPERATION_NAME.value,
value=(
"chat"
if standard_logging_payload.get("call_type") == "completion"
else standard_logging_payload.get("call_type") or "chat"
),
)
if standard_logging_payload.get("request_id"):
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_REQUEST_ID.value,
value=standard_logging_payload.get("request_id"),
)
#############################################
########## LLM Response Attributes ##########
#############################################
if response_obj is not None:
if response_obj.get("choices"):
transformed_choices = (
self._transform_choices_to_otel_semantic_conventions(
response_obj.get("choices")
)
)
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_OUTPUT_MESSAGES.value,
value=safe_dumps(transformed_choices),
)
finish_reasons = []
for idx, choice in enumerate(response_obj.get("choices")):
if choice.get("finish_reason"):
finish_reasons.append(choice.get("finish_reason"))
if finish_reasons:
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value,
value=safe_dumps(finish_reasons),
)
for idx, choice in enumerate(response_obj.get("choices")):
if choice.get("finish_reason"):
self.safe_set_attribute(
span=span,
key=f"{SpanAttributes.LLM_COMPLETIONS.value}.{idx}.finish_reason",
value=choice.get("finish_reason"),
)
if choice.get("message"):
if choice.get("message").get("role"):
self.safe_set_attribute(
span=span,
key=f"{SpanAttributes.LLM_COMPLETIONS.value}.{idx}.role",
value=choice.get("message").get("role"),
)
if choice.get("message").get("content"):
if not isinstance(
choice.get("message").get("content"), str
):
choice["message"]["content"] = str(
choice.get("message").get("content")
)
self.safe_set_attribute(
span=span,
key=f"{SpanAttributes.LLM_COMPLETIONS.value}.{idx}.content",
value=choice.get("message").get("content"),
)
message = choice.get("message")
tool_calls = message.get("tool_calls")
@@ -1608,6 +1630,66 @@ class OpenTelemetry(CustomLogger):
primitive_value = self._cast_as_primitive_value_type(value)
span.set_attribute(key, primitive_value)
def _transform_messages_to_otel_semantic_conventions(
self, messages: Union[List[dict], str]
) -> List[dict]:
"""
Transforms LiteLLM/OpenAI style messages into OTEL GenAI 1.38 compliant format.
OTEL expects a 'parts' array instead of a single 'content' string.
"""
if isinstance(messages, str):
# Handle system_instructions passed as a string
return [
{"role": "system", "parts": [{"type": "text", "content": messages}]}
]
transformed = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
parts = []
if isinstance(content, str):
parts.append({"type": "text", "content": content})
elif isinstance(content, list):
# Handle multi-modal content if necessary
for part in content:
if isinstance(part, dict):
parts.append(part)
else:
parts.append({"type": "text", "content": str(part)})
transformed_msg = {"role": role, "parts": parts}
if "id" in msg:
transformed_msg["id"] = msg["id"]
if "tool_calls" in msg:
transformed_msg["tool_calls"] = msg["tool_calls"]
if "tool_call_id" in msg:
transformed_msg["tool_call_id"] = msg["tool_call_id"]
transformed.append(transformed_msg)
return transformed
def _transform_choices_to_otel_semantic_conventions(
self, choices: List[dict]
) -> List[dict]:
"""
Transforms choices into OTEL GenAI 1.38 compliant format for output.messages.
"""
transformed = []
for choice in choices:
message = choice.get("message") or {}
finish_reason = choice.get("finish_reason")
transformed_msg = self._transform_messages_to_otel_semantic_conventions(
[message]
)[0]
if finish_reason:
transformed_msg["finish_reason"] = finish_reason
transformed.append(transformed_msg)
return transformed
def set_raw_request_attributes(self, span: Span, kwargs, response_obj):
try:
kwargs.get("optional_params", {})
+90 -54
View File
@@ -830,9 +830,9 @@ class GenerateRequestBase(LiteLLMPydanticObjectBase):
allowed_cache_controls: Optional[list] = []
config: Optional[dict] = {}
permissions: Optional[dict] = {}
model_max_budget: Optional[
dict
] = {} # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {}
model_max_budget: Optional[dict] = (
{}
) # {"gpt-4": 5.0, "gpt-3.5-turbo": 5.0}, defaults to {}
model_config = ConfigDict(protected_namespaces=())
model_rpm_limit: Optional[dict] = None
@@ -1364,12 +1364,12 @@ class NewCustomerRequest(BudgetNewRequest):
blocked: bool = False # allow/disallow requests for this end-user
budget_id: Optional[str] = None # give either a budget_id or max_budget
spend: Optional[float] = None
allowed_model_region: Optional[
AllowedModelRegion
] = None # require all user requests to use models in this specific region
default_model: Optional[
str
] = None # if no equivalent model in allowed region - default all requests to this model
allowed_model_region: Optional[AllowedModelRegion] = (
None # require all user requests to use models in this specific region
)
default_model: Optional[str] = (
None # if no equivalent model in allowed region - default all requests to this model
)
@model_validator(mode="before")
@classmethod
@@ -1391,12 +1391,12 @@ class UpdateCustomerRequest(LiteLLMPydanticObjectBase):
blocked: bool = False # allow/disallow requests for this end-user
max_budget: Optional[float] = None
budget_id: Optional[str] = None # give either a budget_id or max_budget
allowed_model_region: Optional[
AllowedModelRegion
] = None # require all user requests to use models in this specific region
default_model: Optional[
str
] = None # if no equivalent model in allowed region - default all requests to this model
allowed_model_region: Optional[AllowedModelRegion] = (
None # require all user requests to use models in this specific region
)
default_model: Optional[str] = (
None # if no equivalent model in allowed region - default all requests to this model
)
class DeleteCustomerRequest(LiteLLMPydanticObjectBase):
@@ -1482,15 +1482,15 @@ class NewTeamRequest(TeamBase):
] = None # raise an error if 'guaranteed_throughput' is set and we're overallocating tpm
model_tpm_limit: Optional[Dict[str, int]] = None
team_member_budget: Optional[
float
] = None # allow user to set a budget for all team members
team_member_rpm_limit: Optional[
int
] = None # allow user to set RPM limit for all team members
team_member_tpm_limit: Optional[
int
] = None # allow user to set TPM limit for all team members
team_member_budget: Optional[float] = (
None # allow user to set a budget for all team members
)
team_member_rpm_limit: Optional[int] = (
None # allow user to set RPM limit for all team members
)
team_member_tpm_limit: Optional[int] = (
None # allow user to set TPM limit for all team members
)
team_member_key_duration: Optional[str] = None # e.g. "1d", "1w", "1m"
allowed_vector_store_indexes: Optional[List[AllowedVectorStoreIndexItem]] = None
@@ -1578,9 +1578,9 @@ class BlockKeyRequest(LiteLLMPydanticObjectBase):
class AddTeamCallback(LiteLLMPydanticObjectBase):
callback_name: str
callback_type: Optional[
Literal["success", "failure", "success_and_failure"]
] = "success_and_failure"
callback_type: Optional[Literal["success", "failure", "success_and_failure"]] = (
"success_and_failure"
)
callback_vars: Dict[str, str]
@model_validator(mode="before")
@@ -1893,9 +1893,9 @@ class ConfigList(LiteLLMPydanticObjectBase):
stored_in_db: Optional[bool]
field_default_value: Any
premium_field: bool = False
nested_fields: Optional[
List[FieldDetail]
] = None # For nested dictionary or Pydantic fields
nested_fields: Optional[List[FieldDetail]] = (
None # For nested dictionary or Pydantic fields
)
class UserHeaderMapping(LiteLLMPydanticObjectBase):
@@ -2289,9 +2289,9 @@ class LiteLLM_OrganizationMembershipTable(LiteLLMPydanticObjectBase):
budget_id: Optional[str] = None
created_at: datetime
updated_at: datetime
user: Optional[
Any
] = None # You might want to replace 'Any' with a more specific type if available
user: Optional[Any] = (
None # You might want to replace 'Any' with a more specific type if available
)
litellm_budget_table: Optional[LiteLLM_BudgetTable] = None
model_config = ConfigDict(protected_namespaces=())
@@ -2826,6 +2826,18 @@ class SpanAttributes(str, enum.Enum):
LLM_RESPONSE_MODEL = "gen_ai.response.model"
LLM_USAGE_COMPLETION_TOKENS = "gen_ai.usage.completion_tokens"
LLM_USAGE_PROMPT_TOKENS = "gen_ai.usage.prompt_tokens"
# OTEL 1.38 attributes
GEN_AI_INPUT_MESSAGES = "gen_ai.input.messages"
GEN_AI_OUTPUT_MESSAGES = "gen_ai.output.messages"
GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
GEN_AI_USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens"
GEN_AI_OPERATION_NAME = "gen_ai.operation.name"
GEN_AI_REQUEST_ID = "gen_ai.request.id"
GEN_AI_SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
GEN_AI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons"
LLM_TOKEN_TYPE = "gen_ai.token.type"
# To be added
# LLM_RESPONSE_FINISH_REASON = "gen_ai.response.finish_reasons"
@@ -3251,9 +3263,9 @@ class TeamModelDeleteRequest(BaseModel):
# Organization Member Requests
class OrganizationMemberAddRequest(OrgMemberAddRequest):
organization_id: str
max_budget_in_organization: Optional[
float
] = None # Users max budget within the organization
max_budget_in_organization: Optional[float] = (
None # Users max budget within the organization
)
class OrganizationMemberDeleteRequest(MemberDeleteRequest):
@@ -3468,9 +3480,9 @@ class ProviderBudgetResponse(LiteLLMPydanticObjectBase):
Maps provider names to their budget configs.
"""
providers: Dict[
str, ProviderBudgetResponseObject
] = {} # Dictionary mapping provider names to their budget configurations
providers: Dict[str, ProviderBudgetResponseObject] = (
{}
) # Dictionary mapping provider names to their budget configurations
class ProxyStateVariables(TypedDict):
@@ -3613,9 +3625,9 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
enforce_rbac: bool = False
roles_jwt_field: Optional[str] = None # v2 on role mappings
role_mappings: Optional[List[RoleMapping]] = None
object_id_jwt_field: Optional[
str
] = None # can be either user / team, inferred from the role mapping
object_id_jwt_field: Optional[str] = (
None # can be either user / team, inferred from the role mapping
)
scope_mappings: Optional[List[ScopeMapping]] = None
enforce_scope_based_access: bool = False
enforce_team_based_model_access: bool = False
@@ -3866,20 +3878,44 @@ class CostEstimateResponse(LiteLLMPydanticObjectBase):
num_requests_per_day: Optional[int] = None
num_requests_per_month: Optional[int] = None
# Per-request costs
cost_per_request: float = Field(description="Total cost per request (includes margin)")
input_cost_per_request: float = Field(description="Input token cost per request (before margin)")
output_cost_per_request: float = Field(description="Output token cost per request (before margin)")
margin_cost_per_request: float = Field(default=0.0, description="Margin/fee added per request")
cost_per_request: float = Field(
description="Total cost per request (includes margin)"
)
input_cost_per_request: float = Field(
description="Input token cost per request (before margin)"
)
output_cost_per_request: float = Field(
description="Output token cost per request (before margin)"
)
margin_cost_per_request: float = Field(
default=0.0, description="Margin/fee added per request"
)
# Daily costs (if num_requests_per_day provided)
daily_cost: Optional[float] = Field(default=None, description="Total daily cost (includes margin)")
daily_input_cost: Optional[float] = Field(default=None, description="Daily input token cost")
daily_output_cost: Optional[float] = Field(default=None, description="Daily output token cost")
daily_margin_cost: Optional[float] = Field(default=None, description="Daily margin/fee")
daily_cost: Optional[float] = Field(
default=None, description="Total daily cost (includes margin)"
)
daily_input_cost: Optional[float] = Field(
default=None, description="Daily input token cost"
)
daily_output_cost: Optional[float] = Field(
default=None, description="Daily output token cost"
)
daily_margin_cost: Optional[float] = Field(
default=None, description="Daily margin/fee"
)
# Monthly costs (if num_requests_per_month provided)
monthly_cost: Optional[float] = Field(default=None, description="Total monthly cost (includes margin)")
monthly_input_cost: Optional[float] = Field(default=None, description="Monthly input token cost")
monthly_output_cost: Optional[float] = Field(default=None, description="Monthly output token cost")
monthly_margin_cost: Optional[float] = Field(default=None, description="Monthly margin/fee")
monthly_cost: Optional[float] = Field(
default=None, description="Total monthly cost (includes margin)"
)
monthly_input_cost: Optional[float] = Field(
default=None, description="Monthly input token cost"
)
monthly_output_cost: Optional[float] = Field(
default=None, description="Monthly output token cost"
)
monthly_margin_cost: Optional[float] = Field(
default=None, description="Monthly margin/fee"
)
# Pricing info
input_cost_per_token: Optional[float] = None
output_cost_per_token: Optional[float] = None
@@ -1869,3 +1869,237 @@ class TestOpenTelemetryExternalSpan(unittest.TestCase):
parent_span_finished.attributes,
"Parent span should have model attribute from LiteLLM even on failure"
)
class TestOpenTelemetrySemanticConventions138(unittest.TestCase):
"""
Test suite for OpenTelemetry 1.38 Semantic Conventions compliance.
These tests verify that LiteLLM emits span attributes following the
OpenTelemetry GenAI semantic conventions v1.38, including:
- gen_ai.input.messages (JSON string with parts array)
- gen_ai.output.messages (JSON string with parts array)
- gen_ai.usage.input_tokens / output_tokens (new naming)
- gen_ai.response.finish_reasons (JSON array)
See: https://github.com/BerriAI/litellm/issues/17794
"""
def test_input_messages_uses_parts_structure(self):
"""
Test that gen_ai.input.messages uses the OTEL 1.38 parts array structure.
Expected format:
[{"role": "user", "parts": [{"type": "text", "content": "Hello"}]}]
"""
otel = OpenTelemetry()
mock_span = MagicMock()
kwargs = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello world"}],
"optional_params": {},
"litellm_params": {"custom_llm_provider": "openai"},
"standard_logging_object": {
"id": "test-id",
"call_type": "completion",
"metadata": {},
},
}
response_obj = {
"id": "test-response-id",
"model": "gpt-4",
"choices": [
{
"finish_reason": "stop",
"message": {"role": "assistant", "content": "Hi there!"},
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
}
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
# Find the call that set gen_ai.input.messages
input_messages_calls = [
call for call in mock_span.set_attribute.call_args_list
if call[0][0] == "gen_ai.input.messages"
]
self.assertEqual(len(input_messages_calls), 1, "Should have exactly one gen_ai.input.messages attribute")
input_messages_value = input_messages_calls[0][0][1]
parsed = json.loads(input_messages_value)
# Verify structure
self.assertIsInstance(parsed, list)
self.assertEqual(len(parsed), 1)
self.assertEqual(parsed[0]["role"], "user")
self.assertIn("parts", parsed[0])
self.assertEqual(parsed[0]["parts"][0]["type"], "text")
self.assertEqual(parsed[0]["parts"][0]["content"], "Hello world")
def test_output_messages_uses_parts_structure(self):
"""
Test that gen_ai.output.messages uses the OTEL 1.38 parts array structure.
Expected format:
[{"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}], "finish_reason": "stop"}]
"""
otel = OpenTelemetry()
mock_span = MagicMock()
kwargs = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"optional_params": {},
"litellm_params": {"custom_llm_provider": "openai"},
"standard_logging_object": {
"id": "test-id",
"call_type": "completion",
"metadata": {},
},
}
response_obj = {
"id": "test-response-id",
"model": "gpt-4",
"choices": [
{
"finish_reason": "stop",
"message": {"role": "assistant", "content": "Hello back!"},
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
}
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
# Find the call that set gen_ai.output.messages
output_messages_calls = [
call for call in mock_span.set_attribute.call_args_list
if call[0][0] == "gen_ai.output.messages"
]
self.assertEqual(len(output_messages_calls), 1, "Should have exactly one gen_ai.output.messages attribute")
output_messages_value = output_messages_calls[0][0][1]
parsed = json.loads(output_messages_value)
# Verify structure
self.assertIsInstance(parsed, list)
self.assertEqual(len(parsed), 1)
self.assertEqual(parsed[0]["role"], "assistant")
self.assertIn("parts", parsed[0])
self.assertEqual(parsed[0]["parts"][0]["type"], "text")
self.assertEqual(parsed[0]["parts"][0]["content"], "Hello back!")
self.assertEqual(parsed[0]["finish_reason"], "stop")
def test_usage_tokens_use_new_naming_convention(self):
"""
Test that token usage uses the OTEL 1.38 naming convention:
- gen_ai.usage.input_tokens (not prompt_tokens)
- gen_ai.usage.output_tokens (not completion_tokens)
"""
otel = OpenTelemetry()
mock_span = MagicMock()
kwargs = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"optional_params": {},
"litellm_params": {"custom_llm_provider": "openai"},
"standard_logging_object": {
"id": "test-id",
"call_type": "completion",
"metadata": {},
},
}
response_obj = {
"id": "test-response-id",
"model": "gpt-4",
"choices": [],
"usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150},
}
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
# Verify new naming convention is used
mock_span.set_attribute.assert_any_call("gen_ai.usage.input_tokens", 100)
mock_span.set_attribute.assert_any_call("gen_ai.usage.output_tokens", 50)
mock_span.set_attribute.assert_any_call("gen_ai.usage.total_tokens", 150)
def test_finish_reasons_is_json_array(self):
"""
Test that gen_ai.response.finish_reasons is a proper JSON array.
Expected: '["stop"]' (not "['stop']")
"""
otel = OpenTelemetry()
mock_span = MagicMock()
kwargs = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"optional_params": {},
"litellm_params": {"custom_llm_provider": "openai"},
"standard_logging_object": {
"id": "test-id",
"call_type": "completion",
"metadata": {},
},
}
response_obj = {
"id": "test-response-id",
"model": "gpt-4",
"choices": [
{"finish_reason": "stop", "message": {"role": "assistant", "content": "Hi"}},
],
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
}
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
# Find the call that set gen_ai.response.finish_reasons
finish_reasons_calls = [
call for call in mock_span.set_attribute.call_args_list
if call[0][0] == "gen_ai.response.finish_reasons"
]
self.assertEqual(len(finish_reasons_calls), 1, "Should have exactly one gen_ai.response.finish_reasons attribute")
finish_reasons_value = finish_reasons_calls[0][0][1]
# Verify it's valid JSON (not Python repr)
parsed = json.loads(finish_reasons_value)
self.assertEqual(parsed, ["stop"])
def test_operation_name_is_chat_for_completion(self):
"""
Test that gen_ai.operation.name is 'chat' for completion calls.
"""
otel = OpenTelemetry()
mock_span = MagicMock()
kwargs = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"optional_params": {},
"litellm_params": {"custom_llm_provider": "openai"},
"standard_logging_object": {
"id": "test-id",
"call_type": "completion",
"metadata": {},
},
}
response_obj = {
"id": "test-response-id",
"model": "gpt-4",
"choices": [],
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
}
otel.set_attributes(span=mock_span, kwargs=kwargs, response_obj=response_obj)
mock_span.set_attribute.assert_any_call("gen_ai.operation.name", "chat")