lmnr 0.6.16__py3-none-any.whl → 0.7.26__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. lmnr/__init__.py +6 -15
  2. lmnr/cli/__init__.py +270 -0
  3. lmnr/cli/datasets.py +371 -0
  4. lmnr/{cli.py → cli/evals.py} +20 -102
  5. lmnr/cli/rules.py +42 -0
  6. lmnr/opentelemetry_lib/__init__.py +9 -2
  7. lmnr/opentelemetry_lib/decorators/__init__.py +274 -168
  8. lmnr/opentelemetry_lib/litellm/__init__.py +352 -38
  9. lmnr/opentelemetry_lib/litellm/utils.py +82 -0
  10. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/__init__.py +849 -0
  11. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/config.py +13 -0
  12. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/event_emitter.py +211 -0
  13. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/event_models.py +41 -0
  14. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/span_utils.py +401 -0
  15. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/streaming.py +425 -0
  16. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/utils.py +332 -0
  17. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/version.py +1 -0
  18. lmnr/opentelemetry_lib/opentelemetry/instrumentation/claude_agent/__init__.py +451 -0
  19. lmnr/opentelemetry_lib/opentelemetry/instrumentation/claude_agent/proxy.py +144 -0
  20. lmnr/opentelemetry_lib/opentelemetry/instrumentation/cua_agent/__init__.py +100 -0
  21. lmnr/opentelemetry_lib/opentelemetry/instrumentation/cua_computer/__init__.py +476 -0
  22. lmnr/opentelemetry_lib/opentelemetry/instrumentation/cua_computer/utils.py +12 -0
  23. lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/__init__.py +191 -129
  24. lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/schema_utils.py +26 -0
  25. lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/utils.py +126 -41
  26. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/__init__.py +488 -0
  27. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/config.py +8 -0
  28. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/event_emitter.py +143 -0
  29. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/event_models.py +41 -0
  30. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/span_utils.py +229 -0
  31. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/utils.py +92 -0
  32. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/version.py +1 -0
  33. lmnr/opentelemetry_lib/opentelemetry/instrumentation/kernel/__init__.py +381 -0
  34. lmnr/opentelemetry_lib/opentelemetry/instrumentation/kernel/utils.py +36 -0
  35. lmnr/opentelemetry_lib/opentelemetry/instrumentation/langgraph/__init__.py +16 -16
  36. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/__init__.py +61 -0
  37. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/__init__.py +472 -0
  38. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/chat_wrappers.py +1185 -0
  39. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/completion_wrappers.py +305 -0
  40. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/config.py +16 -0
  41. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py +312 -0
  42. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/event_emitter.py +100 -0
  43. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/event_models.py +41 -0
  44. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py +68 -0
  45. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/utils.py +197 -0
  46. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v0/__init__.py +176 -0
  47. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/__init__.py +368 -0
  48. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/assistant_wrappers.py +325 -0
  49. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py +135 -0
  50. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/responses_wrappers.py +786 -0
  51. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/version.py +1 -0
  52. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openhands_ai/__init__.py +388 -0
  53. lmnr/opentelemetry_lib/opentelemetry/instrumentation/opentelemetry/__init__.py +69 -0
  54. lmnr/opentelemetry_lib/opentelemetry/instrumentation/skyvern/__init__.py +59 -61
  55. lmnr/opentelemetry_lib/opentelemetry/instrumentation/threading/__init__.py +197 -0
  56. lmnr/opentelemetry_lib/tracing/__init__.py +119 -18
  57. lmnr/opentelemetry_lib/tracing/_instrument_initializers.py +124 -25
  58. lmnr/opentelemetry_lib/tracing/attributes.py +4 -0
  59. lmnr/opentelemetry_lib/tracing/context.py +200 -0
  60. lmnr/opentelemetry_lib/tracing/exporter.py +109 -15
  61. lmnr/opentelemetry_lib/tracing/instruments.py +22 -5
  62. lmnr/opentelemetry_lib/tracing/processor.py +128 -30
  63. lmnr/opentelemetry_lib/tracing/span.py +398 -0
  64. lmnr/opentelemetry_lib/tracing/tracer.py +40 -1
  65. lmnr/opentelemetry_lib/tracing/utils.py +62 -0
  66. lmnr/opentelemetry_lib/utils/package_check.py +9 -0
  67. lmnr/opentelemetry_lib/utils/wrappers.py +11 -0
  68. lmnr/sdk/browser/background_send_events.py +158 -0
  69. lmnr/sdk/browser/browser_use_cdp_otel.py +100 -0
  70. lmnr/sdk/browser/browser_use_otel.py +12 -12
  71. lmnr/sdk/browser/bubus_otel.py +71 -0
  72. lmnr/sdk/browser/cdp_utils.py +518 -0
  73. lmnr/sdk/browser/inject_script.js +514 -0
  74. lmnr/sdk/browser/patchright_otel.py +18 -44
  75. lmnr/sdk/browser/playwright_otel.py +104 -187
  76. lmnr/sdk/browser/pw_utils.py +249 -210
  77. lmnr/sdk/browser/recorder/record.umd.min.cjs +84 -0
  78. lmnr/sdk/browser/utils.py +1 -1
  79. lmnr/sdk/client/asynchronous/async_client.py +47 -15
  80. lmnr/sdk/client/asynchronous/resources/__init__.py +2 -7
  81. lmnr/sdk/client/asynchronous/resources/browser_events.py +1 -0
  82. lmnr/sdk/client/asynchronous/resources/datasets.py +131 -0
  83. lmnr/sdk/client/asynchronous/resources/evals.py +122 -18
  84. lmnr/sdk/client/asynchronous/resources/evaluators.py +85 -0
  85. lmnr/sdk/client/asynchronous/resources/tags.py +4 -10
  86. lmnr/sdk/client/synchronous/resources/__init__.py +2 -2
  87. lmnr/sdk/client/synchronous/resources/datasets.py +131 -0
  88. lmnr/sdk/client/synchronous/resources/evals.py +83 -17
  89. lmnr/sdk/client/synchronous/resources/evaluators.py +85 -0
  90. lmnr/sdk/client/synchronous/resources/tags.py +4 -10
  91. lmnr/sdk/client/synchronous/sync_client.py +47 -15
  92. lmnr/sdk/datasets/__init__.py +94 -0
  93. lmnr/sdk/datasets/file_utils.py +91 -0
  94. lmnr/sdk/decorators.py +103 -23
  95. lmnr/sdk/evaluations.py +122 -33
  96. lmnr/sdk/laminar.py +816 -333
  97. lmnr/sdk/log.py +7 -2
  98. lmnr/sdk/types.py +124 -143
  99. lmnr/sdk/utils.py +115 -2
  100. lmnr/version.py +1 -1
  101. {lmnr-0.6.16.dist-info → lmnr-0.7.26.dist-info}/METADATA +71 -78
  102. lmnr-0.7.26.dist-info/RECORD +116 -0
  103. lmnr-0.7.26.dist-info/WHEEL +4 -0
  104. lmnr-0.7.26.dist-info/entry_points.txt +3 -0
  105. lmnr/opentelemetry_lib/tracing/context_properties.py +0 -65
  106. lmnr/sdk/browser/rrweb/rrweb.umd.min.cjs +0 -98
  107. lmnr/sdk/client/asynchronous/resources/agent.py +0 -329
  108. lmnr/sdk/client/synchronous/resources/agent.py +0 -323
  109. lmnr/sdk/datasets.py +0 -60
  110. lmnr-0.6.16.dist-info/LICENSE +0 -75
  111. lmnr-0.6.16.dist-info/RECORD +0 -61
  112. lmnr-0.6.16.dist-info/WHEEL +0 -4
  113. lmnr-0.6.16.dist-info/entry_points.txt +0 -3
@@ -3,16 +3,28 @@
3
3
  import json
4
4
  from datetime import datetime
5
5
 
6
+ from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_PROMPT
6
7
  from opentelemetry.trace import SpanKind, Status, StatusCode, Tracer
7
- from lmnr.opentelemetry_lib.litellm.utils import model_as_dict, set_span_attribute
8
+ from lmnr.opentelemetry_lib.litellm.utils import (
9
+ get_tool_definition,
10
+ is_validator_iterator,
11
+ model_as_dict,
12
+ set_span_attribute,
13
+ )
8
14
  from lmnr.opentelemetry_lib.tracing import TracerWrapper
9
15
 
16
+ from lmnr.opentelemetry_lib.tracing.context import (
17
+ get_current_context,
18
+ get_event_attributes_from_context,
19
+ )
20
+ from lmnr.opentelemetry_lib.tracing.attributes import ASSOCIATION_PROPERTIES
10
21
  from lmnr.opentelemetry_lib.utils.package_check import is_package_installed
11
22
  from lmnr.sdk.log import get_default_logger
23
+ from lmnr.sdk.utils import json_dumps
12
24
 
13
25
  logger = get_default_logger(__name__)
14
26
 
15
- SUPPORTED_CALL_TYPES = ["completion", "acompletion"]
27
+ SUPPORTED_CALL_TYPES = ["completion", "acompletion", "responses", "aresponses"]
16
28
 
17
29
  # Try to import the necessary LiteLLM components and gracefully handle ImportError
18
30
  try:
@@ -35,11 +47,29 @@ try:
35
47
  litellm.callbacks = [LaminarLiteLLMCallback()]
36
48
  """
37
49
 
50
+ logged_openai_responses: set[str]
51
+
38
52
  def __init__(self, **kwargs):
39
53
  super().__init__(**kwargs)
40
54
  if not hasattr(TracerWrapper, "instance") or TracerWrapper.instance is None:
41
55
  raise ValueError("Laminar must be initialized before LiteLLM callback")
42
56
 
57
+ self.logged_openai_responses = set()
58
+ if is_package_installed("openai"):
59
+ from lmnr.opentelemetry_lib.opentelemetry.instrumentation.openai import (
60
+ OpenAIInstrumentor,
61
+ )
62
+
63
+ openai_instrumentor = OpenAIInstrumentor()
64
+ if (
65
+ openai_instrumentor
66
+ and openai_instrumentor.is_instrumented_by_opentelemetry
67
+ ):
68
+ logger.debug(
69
+ "Disabling OpenTelemetry instrumentation for OpenAI to avoid double-instrumentation of LiteLLM."
70
+ )
71
+ openai_instrumentor.uninstrument()
72
+
43
73
  def _get_tracer(self) -> Tracer:
44
74
  if not hasattr(TracerWrapper, "instance") or TracerWrapper.instance is None:
45
75
  raise ValueError("Laminar must be initialized before LiteLLM callback")
@@ -50,6 +80,14 @@ try:
50
80
  ):
51
81
  if kwargs.get("call_type") not in SUPPORTED_CALL_TYPES:
52
82
  return
83
+ if kwargs.get("call_type") in ["responses", "aresponses"]:
84
+ # responses API may be called multiple times with the same response_obj
85
+ response_id = getattr(response_obj, "id", None)
86
+ if response_id in self.logged_openai_responses:
87
+ return
88
+ if response_id:
89
+ self.logged_openai_responses.add(response_id)
90
+ self.logged_openai_responses.add(response_obj.id)
53
91
  try:
54
92
  self._create_span(
55
93
  kwargs, response_obj, start_time, end_time, is_success=True
@@ -88,12 +126,18 @@ try:
88
126
  is_success: bool,
89
127
  ):
90
128
  """Create an OpenTelemetry span for the LiteLLM call"""
91
- span_name = "litellm.completion"
129
+ call_type = kwargs.get("call_type", "completion")
130
+ if call_type == "aresponses":
131
+ call_type = "responses"
132
+ if call_type == "acompletion":
133
+ call_type = "completion"
134
+ span_name = f"litellm.{call_type}"
92
135
  try:
93
136
  tracer = self._get_tracer()
94
137
  except Exception as e:
95
138
  logger.error(f"Error getting tracer: {e}")
96
139
  return
140
+
97
141
  span = tracer.start_span(
98
142
  span_name,
99
143
  kind=SpanKind.CLIENT,
@@ -101,6 +145,7 @@ try:
101
145
  attributes={
102
146
  "lmnr.internal.provider": "litellm",
103
147
  },
148
+ context=get_current_context(),
104
149
  )
105
150
  try:
106
151
  model = kwargs.get("model", "unknown")
@@ -129,6 +174,52 @@ try:
129
174
  if "top_p" in kwargs:
130
175
  set_span_attribute(span, "gen_ai.request.top_p", kwargs["top_p"])
131
176
 
177
+ metadata = (
178
+ kwargs.get("litellm_params").get(
179
+ "metadata", kwargs.get("metadata", {})
180
+ )
181
+ or {}
182
+ )
183
+ tags = metadata.get("tags", [])
184
+ if isinstance(tags, str):
185
+ try:
186
+ tags = json.loads(tags)
187
+ except Exception:
188
+ pass
189
+ if (
190
+ tags
191
+ and isinstance(tags, (list, tuple, set))
192
+ and all(isinstance(tag, str) for tag in tags)
193
+ ):
194
+ span.set_attribute(f"{ASSOCIATION_PROPERTIES}.tags", tags)
195
+
196
+ user_id = metadata.get("user_id")
197
+ if user_id:
198
+ span.set_attribute(f"{ASSOCIATION_PROPERTIES}.user_id", user_id)
199
+
200
+ session_id = metadata.get("session_id")
201
+ if session_id:
202
+ span.set_attribute(
203
+ f"{ASSOCIATION_PROPERTIES}.session_id", session_id
204
+ )
205
+
206
+ optional_params = kwargs.get("optional_params") or {}
207
+ if not optional_params:
208
+ hidden_params = metadata.get("hidden_params") or {}
209
+ optional_params = hidden_params.get("optional_params") or {}
210
+ response_format = optional_params.get("response_format")
211
+ if (
212
+ response_format
213
+ and isinstance(response_format, dict)
214
+ and response_format.get("type") == "json_schema"
215
+ ):
216
+ schema = (response_format.get("json_schema") or {}).get("schema")
217
+ if schema:
218
+ span.set_attribute(
219
+ "gen_ai.request.structured_output_schema",
220
+ json_dumps(schema),
221
+ )
222
+
132
223
  if is_success:
133
224
  span.set_status(Status(StatusCode.OK))
134
225
  if kwargs.get("complete_streaming_response"):
@@ -141,10 +232,12 @@ try:
141
232
  else:
142
233
  span.set_status(Status(StatusCode.ERROR))
143
234
  if isinstance(response_obj, Exception):
144
- span.record_exception(response_obj)
235
+ attributes = get_event_attributes_from_context()
236
+ span.record_exception(response_obj, attributes=attributes)
145
237
 
146
238
  except Exception as e:
147
- span.record_exception(e)
239
+ attributes = get_event_attributes_from_context()
240
+ span.record_exception(e, attributes=attributes)
148
241
  logger.error(f"Error in Laminar LiteLLM instrumentation: {e}")
149
242
  finally:
150
243
  span.end(int(end_time.timestamp() * 1e9))
@@ -154,35 +247,107 @@ try:
154
247
  if not isinstance(messages, list):
155
248
  return
156
249
 
157
- for i, message in enumerate(messages):
158
- message_dict = model_as_dict(message)
159
- role = message_dict.get("role", "unknown")
160
- set_span_attribute(span, f"gen_ai.prompt.{i}.role", role)
161
-
162
- tool_calls = message_dict.get("tool_calls", [])
163
- self._process_tool_calls(span, tool_calls, i, is_response=False)
250
+ prompt_index = 0
251
+ for item in messages:
252
+ block_dict = model_as_dict(item)
253
+ if block_dict.get("type", "message") == "message":
254
+ tool_calls = block_dict.get("tool_calls", [])
255
+ self._process_tool_calls(
256
+ span, tool_calls, prompt_index, is_response=False
257
+ )
258
+ content = block_dict.get("content")
259
+ if is_validator_iterator(content):
260
+ # Have not been able to catch this in the wild, but keeping
261
+ # just in case, as raw OpenAI responses do that
262
+ content = [self._process_content_part(part) for part in content]
263
+ try:
264
+ stringified_content = (
265
+ content if isinstance(content, str) else json_dumps(content)
266
+ )
267
+ except Exception:
268
+ stringified_content = (
269
+ str(content) if content is not None else ""
270
+ )
271
+ set_span_attribute(
272
+ span,
273
+ f"{GEN_AI_PROMPT}.{prompt_index}.content",
274
+ stringified_content,
275
+ )
276
+ set_span_attribute(
277
+ span,
278
+ f"{GEN_AI_PROMPT}.{prompt_index}.role",
279
+ block_dict.get("role"),
280
+ )
281
+ prompt_index += 1
164
282
 
165
- content = message_dict.get("content", "")
166
- if content is None:
167
- continue
168
- if isinstance(content, str):
169
- set_span_attribute(span, f"gen_ai.prompt.{i}.content", content)
170
- elif isinstance(content, list):
283
+ elif block_dict.get("type") == "computer_call_output":
171
284
  set_span_attribute(
172
- span, f"gen_ai.prompt.{i}.content", json.dumps(content)
285
+ span,
286
+ f"{GEN_AI_PROMPT}.{prompt_index}.role",
287
+ "computer_call_output",
288
+ )
289
+ output_image_url = block_dict.get("output", {}).get("image_url")
290
+ if output_image_url:
291
+ set_span_attribute(
292
+ span,
293
+ f"{GEN_AI_PROMPT}.{prompt_index}.content",
294
+ json.dumps(
295
+ [
296
+ {
297
+ "type": "image_url",
298
+ "image_url": {"url": output_image_url},
299
+ }
300
+ ]
301
+ ),
302
+ )
303
+ prompt_index += 1
304
+ elif block_dict.get("type") == "computer_call":
305
+ set_span_attribute(
306
+ span, f"{GEN_AI_PROMPT}.{prompt_index}.role", "assistant"
307
+ )
308
+ call_content = {}
309
+ if block_dict.get("id"):
310
+ call_content["id"] = block_dict.get("id")
311
+ if block_dict.get("action"):
312
+ call_content["action"] = block_dict.get("action")
313
+ set_span_attribute(
314
+ span,
315
+ f"{GEN_AI_PROMPT}.{prompt_index}.tool_calls.0.arguments",
316
+ json.dumps(call_content),
173
317
  )
174
- else:
175
318
  set_span_attribute(
176
319
  span,
177
- f"gen_ai.prompt.{i}.content",
178
- json.dumps(model_as_dict(content)),
320
+ f"{GEN_AI_PROMPT}.{prompt_index}.tool_calls.0.id",
321
+ block_dict.get("call_id"),
179
322
  )
180
- if role == "tool":
181
323
  set_span_attribute(
182
324
  span,
183
- f"gen_ai.prompt.{i}.tool_call_id",
184
- message_dict.get("tool_call_id"),
325
+ f"{GEN_AI_PROMPT}.{prompt_index}.tool_calls.0.name",
326
+ "computer_call",
185
327
  )
328
+ prompt_index += 1
329
+ elif block_dict.get("type") == "reasoning":
330
+ reasoning_summary = block_dict.get("summary")
331
+ if reasoning_summary and isinstance(reasoning_summary, list):
332
+ processed_chunks = [
333
+ {"type": "text", "text": chunk.get("text")}
334
+ for chunk in reasoning_summary
335
+ if isinstance(chunk, dict)
336
+ and chunk.get("type") == "summary_text"
337
+ ]
338
+ set_span_attribute(
339
+ span,
340
+ f"{GEN_AI_PROMPT}.{prompt_index}.reasoning",
341
+ json_dumps(processed_chunks),
342
+ )
343
+ set_span_attribute(
344
+ span,
345
+ f"{GEN_AI_PROMPT}.{prompt_index}.role",
346
+ "assistant",
347
+ )
348
+ # reasoning is followed by other content parts in the same messge,
349
+ # so we don't increment the prompt index
350
+ # TODO: handle other block types
186
351
 
187
352
  def _process_request_tool_definitions(self, span, tools):
188
353
  """Process and set tool definitions attributes on the span"""
@@ -191,14 +356,10 @@ try:
191
356
 
192
357
  for i, tool in enumerate(tools):
193
358
  tool_dict = model_as_dict(tool)
194
- if tool_dict.get("type") != "function":
195
- # TODO: parse other tool types
196
- continue
197
-
198
- function_dict = tool_dict.get("function", {})
199
- function_name = function_dict.get("name", "")
200
- function_description = function_dict.get("description", "")
201
- function_parameters = function_dict.get("parameters", {})
359
+ tool_definition = get_tool_definition(tool_dict)
360
+ function_name = tool_definition.get("name")
361
+ function_description = tool_definition.get("description")
362
+ function_parameters = tool_definition.get("parameters")
202
363
  set_span_attribute(
203
364
  span,
204
365
  f"llm.request.functions.{i}.name",
@@ -245,7 +406,15 @@ try:
245
406
  details.get("cached_tokens"),
246
407
  )
247
408
  # TODO: add audio/image/text token details
248
- # TODO: add completion tokens details (reasoning tokens)
409
+ if usage_dict.get("completion_tokens_details"):
410
+ details = usage_dict.get("completion_tokens_details", {})
411
+ details = model_as_dict(details)
412
+ if details.get("reasoning_tokens"):
413
+ set_span_attribute(
414
+ span,
415
+ "gen_ai.usage.reasoning_tokens",
416
+ details.get("reasoning_tokens"),
417
+ )
249
418
 
250
419
  def _process_tool_calls(self, span, tool_calls, choice_index, is_response=True):
251
420
  """Process and set tool call attributes on the span"""
@@ -306,19 +475,160 @@ try:
306
475
  content = message.get("content", "")
307
476
  if content is None:
308
477
  continue
478
+ reasoning_content = message.get("reasoning_content")
479
+ if reasoning_content:
480
+ if isinstance(reasoning_content, str):
481
+ reasoning_content = [
482
+ {
483
+ "type": "text",
484
+ "text": reasoning_content,
485
+ }
486
+ ]
487
+ elif not isinstance(reasoning_content, list):
488
+ reasoning_content = [
489
+ {
490
+ "type": "text",
491
+ "text": str(reasoning_content),
492
+ }
493
+ ]
494
+ else:
495
+ reasoning_content = []
309
496
  if isinstance(content, str):
310
- set_span_attribute(span, f"gen_ai.completion.{i}.content", content)
497
+ if reasoning_content:
498
+ set_span_attribute(
499
+ span,
500
+ f"gen_ai.completion.{i}.content",
501
+ json.dumps(
502
+ reasoning_content
503
+ + [
504
+ {
505
+ "type": "text",
506
+ "text": content,
507
+ }
508
+ ]
509
+ ),
510
+ )
511
+ else:
512
+ set_span_attribute(
513
+ span,
514
+ f"gen_ai.completion.{i}.content",
515
+ content,
516
+ )
311
517
  elif isinstance(content, list):
312
518
  set_span_attribute(
313
- span, f"gen_ai.completion.{i}.content", json.dumps(content)
519
+ span,
520
+ f"gen_ai.completion.{i}.content",
521
+ json.dumps(reasoning_content + content),
314
522
  )
315
523
  else:
316
524
  set_span_attribute(
317
525
  span,
318
526
  f"gen_ai.completion.{i}.content",
319
- json.dumps(model_as_dict(content)),
527
+ json.dumps(reasoning_content + [model_as_dict(content)]),
320
528
  )
321
529
 
530
+ def _process_content_part(self, content_part: dict) -> dict:
531
+ content_part_dict = model_as_dict(content_part)
532
+ if content_part_dict.get("type") == "output_text":
533
+ return {"type": "text", "text": content_part_dict.get("text")}
534
+ return content_part_dict
535
+
536
+ def _process_response_output(self, span, output):
537
+ """Response of OpenAI Responses API"""
538
+ if not isinstance(output, list):
539
+ return
540
+ set_span_attribute(span, "gen_ai.completion.0.role", "assistant")
541
+ tool_call_index = 0
542
+ for block in output:
543
+ block_dict = model_as_dict(block)
544
+ if block_dict.get("type") == "message":
545
+ content = block_dict.get("content")
546
+ if content is None:
547
+ continue
548
+ if isinstance(content, str):
549
+ set_span_attribute(span, "gen_ai.completion.0.content", content)
550
+ elif isinstance(content, list):
551
+ set_span_attribute(
552
+ span,
553
+ "gen_ai.completion.0.content",
554
+ json_dumps(
555
+ [self._process_content_part(part) for part in content]
556
+ ),
557
+ )
558
+ if block_dict.get("type") == "function_call":
559
+ set_span_attribute(
560
+ span,
561
+ f"gen_ai.completion.0.tool_calls.{tool_call_index}.id",
562
+ block_dict.get("id"),
563
+ )
564
+ set_span_attribute(
565
+ span,
566
+ f"gen_ai.completion.0.tool_calls.{tool_call_index}.name",
567
+ block_dict.get("name"),
568
+ )
569
+ set_span_attribute(
570
+ span,
571
+ f"gen_ai.completion.0.tool_calls.{tool_call_index}.arguments",
572
+ block_dict.get("arguments"),
573
+ )
574
+ tool_call_index += 1
575
+ elif block_dict.get("type") == "file_search_call":
576
+ set_span_attribute(
577
+ span,
578
+ f"gen_ai.completion.0.tool_calls.{tool_call_index}.id",
579
+ block_dict.get("id"),
580
+ )
581
+ set_span_attribute(
582
+ span,
583
+ f"gen_ai.completion.0.tool_calls.{tool_call_index}.name",
584
+ "file_search_call",
585
+ )
586
+ tool_call_index += 1
587
+ elif block_dict.get("type") == "web_search_call":
588
+ set_span_attribute(
589
+ span,
590
+ f"gen_ai.completion.0.tool_calls.{tool_call_index}.id",
591
+ block_dict.get("id"),
592
+ )
593
+ set_span_attribute(
594
+ span,
595
+ f"gen_ai.completion.0.tool_calls.{tool_call_index}.name",
596
+ "web_search_call",
597
+ )
598
+ tool_call_index += 1
599
+ elif block_dict.get("type") == "computer_call":
600
+ set_span_attribute(
601
+ span,
602
+ f"gen_ai.completion.0.tool_calls.{tool_call_index}.id",
603
+ block_dict.get("call_id"),
604
+ )
605
+ set_span_attribute(
606
+ span,
607
+ f"gen_ai.completion.0.tool_calls.{tool_call_index}.name",
608
+ "computer_call",
609
+ )
610
+ set_span_attribute(
611
+ span,
612
+ f"gen_ai.completion.0.tool_calls.{tool_call_index}.arguments",
613
+ json_dumps(block_dict.get("action")),
614
+ )
615
+ tool_call_index += 1
616
+ elif block_dict.get("type") == "reasoning":
617
+ reasoning_summary = block_dict.get("summary")
618
+ if reasoning_summary and isinstance(reasoning_summary, list):
619
+ processed_chunks = [
620
+ {"type": "text", "text": chunk.get("text")}
621
+ for chunk in reasoning_summary
622
+ if isinstance(chunk, dict)
623
+ and chunk.get("type") == "summary_text"
624
+ ]
625
+ set_span_attribute(
626
+ span,
627
+ "gen_ai.completion.0.reasoning",
628
+ json_dumps(processed_chunks),
629
+ )
630
+ # TODO: handle other block types, in particular other calls
631
+
322
632
  def _process_success_response(self, span, response_obj):
323
633
  """Process successful response attributes"""
324
634
  response_dict = model_as_dict(response_obj)
@@ -327,7 +637,9 @@ try:
327
637
  span, "gen_ai.response.model", response_dict.get("model")
328
638
  )
329
639
 
330
- if response_dict.get("usage"):
640
+ if getattr(response_obj, "usage", None):
641
+ self._process_response_usage(span, getattr(response_obj, "usage", None))
642
+ elif response_dict.get("usage"):
331
643
  self._process_response_usage(span, response_dict.get("usage"))
332
644
 
333
645
  if response_dict.get("cache_creation_input_tokens"):
@@ -345,6 +657,8 @@ try:
345
657
 
346
658
  if response_dict.get("choices"):
347
659
  self._process_response_choices(span, response_dict.get("choices"))
660
+ elif response_dict.get("output"):
661
+ self._process_response_output(span, response_dict.get("output"))
348
662
 
349
663
  except ImportError as e:
350
664
  logger.debug(f"LiteLLM callback unavailable: {e}")
@@ -1,6 +1,14 @@
1
+ import re
1
2
  from pydantic import BaseModel
2
3
  from opentelemetry.sdk.trace import Span
3
4
  from opentelemetry.util.types import AttributeValue
5
+ from typing_extensions import TypedDict
6
+
7
+
8
+ class ToolDefinition(TypedDict):
9
+ name: str | None
10
+ description: str | None
11
+ parameters: dict | None
4
12
 
5
13
 
6
14
  def model_as_dict(model: BaseModel | dict) -> dict:
@@ -16,3 +24,77 @@ def set_span_attribute(span: Span, key: str, value: AttributeValue | None):
16
24
  if value is None or value == "":
17
25
  return
18
26
  span.set_attribute(key, value)
27
+
28
+
29
+ def get_tool_definition(tool: dict) -> ToolDefinition:
30
+ parameters = None
31
+ description = None
32
+ name = (tool.get("function") or {}).get("name") or tool.get("name")
33
+ if tool.get("type") == "function":
34
+ function = tool.get("function") or {}
35
+ parameters = function.get("parameters") or tool.get("parameters")
36
+ description = function.get("description") or tool.get("description")
37
+ elif isinstance(tool.get("type"), str) and tool.get("type").startswith("computer"):
38
+ # Anthropic beta computer tools
39
+ # https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/computer-use-tool
40
+
41
+ # OpenAI computer use API
42
+ # https://platform.openai.com/docs/guides/tools-computer-use
43
+ if not name:
44
+ name = tool.get("type")
45
+
46
+ parameters = {}
47
+ tool_parameters = (tool.get("function") or {}).get("parameters") or {}
48
+ # Anthropic
49
+ display_width_px = tool_parameters.get("display_width_px") or tool.get(
50
+ "display_width_px"
51
+ )
52
+ display_height_px = tool_parameters.get("display_height_px") or tool.get(
53
+ "display_height_px"
54
+ )
55
+ display_number = tool_parameters.get("display_number") or tool.get(
56
+ "display_number"
57
+ )
58
+ if display_width_px:
59
+ parameters["display_width_px"] = display_width_px
60
+ if display_height_px:
61
+ parameters["display_height_px"] = display_height_px
62
+ if display_number:
63
+ parameters["display_number"] = display_number
64
+ # OpenAI
65
+ display_width = tool_parameters.get("display_width") or tool.get(
66
+ "display_width"
67
+ )
68
+ display_height = tool_parameters.get("display_height") or tool.get(
69
+ "display_height"
70
+ )
71
+ environment = tool_parameters.get("environment") or tool.get("environment")
72
+ if display_width:
73
+ parameters["display_width"] = display_width
74
+ if display_height:
75
+ parameters["display_height"] = tool.get("display_height")
76
+ if environment: # Literal['browser', 'mac', 'windows', 'ubuntu']
77
+ parameters["environment"] = environment
78
+ else:
79
+ # Some versions of LiteLLM (around 1.69.0) flatten the tool definition in
80
+ # anthropic style, not the OpenAI style as they do with other tool types.
81
+ function = tool.get("function") or tool
82
+ parameters = function.get("parameters") or function.get("input_schema")
83
+ description = function.get("description") or ""
84
+
85
+ return ToolDefinition(
86
+ name=name,
87
+ description=description,
88
+ parameters=parameters,
89
+ )
90
+
91
+
92
+ def is_validator_iterator(content):
93
+ """
94
+ Some OpenAI objects contain fields typed as Iterable, which pydantic
95
+ internally converts to a ValidatorIterator, and they cannot be trivially
96
+ serialized without consuming the iterator to, for example, a list.
97
+
98
+ See: https://github.com/pydantic/pydantic/issues/9541#issuecomment-2189045051
99
+ """
100
+ return re.search(r"pydantic.*ValidatorIterator'>$", str(type(content)))