opentelemetry-instrumentation-openai 0.40.14__tar.gz → 0.42.0__tar.gz

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.

Potentially problematic release.


This version of opentelemetry-instrumentation-openai might be problematic. Click here for more details.

Files changed (22) hide show
  1. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/PKG-INFO +2 -2
  2. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/opentelemetry/instrumentation/openai/__init__.py +3 -2
  3. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/opentelemetry/instrumentation/openai/shared/__init__.py +125 -28
  4. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/opentelemetry/instrumentation/openai/shared/chat_wrappers.py +191 -55
  5. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/opentelemetry/instrumentation/openai/shared/completion_wrappers.py +93 -36
  6. opentelemetry_instrumentation_openai-0.42.0/opentelemetry/instrumentation/openai/shared/config.py +16 -0
  7. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py +79 -28
  8. opentelemetry_instrumentation_openai-0.42.0/opentelemetry/instrumentation/openai/shared/event_emitter.py +100 -0
  9. opentelemetry_instrumentation_openai-0.42.0/opentelemetry/instrumentation/openai/shared/event_models.py +41 -0
  10. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py +1 -1
  11. opentelemetry_instrumentation_openai-0.42.0/opentelemetry/instrumentation/openai/shared/span_utils.py +0 -0
  12. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/opentelemetry/instrumentation/openai/utils.py +30 -4
  13. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/opentelemetry/instrumentation/openai/v0/__init__.py +31 -11
  14. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/opentelemetry/instrumentation/openai/v1/__init__.py +176 -69
  15. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/opentelemetry/instrumentation/openai/v1/assistant_wrappers.py +121 -42
  16. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py +31 -15
  17. opentelemetry_instrumentation_openai-0.42.0/opentelemetry/instrumentation/openai/v1/responses_wrappers.py +623 -0
  18. opentelemetry_instrumentation_openai-0.42.0/opentelemetry/instrumentation/openai/version.py +1 -0
  19. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/pyproject.toml +3 -3
  20. opentelemetry_instrumentation_openai-0.40.14/opentelemetry/instrumentation/openai/shared/config.py +0 -10
  21. opentelemetry_instrumentation_openai-0.40.14/opentelemetry/instrumentation/openai/version.py +0 -1
  22. {opentelemetry_instrumentation_openai-0.40.14 → opentelemetry_instrumentation_openai-0.42.0}/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: opentelemetry-instrumentation-openai
3
- Version: 0.40.14
3
+ Version: 0.42.0
4
4
  Summary: OpenTelemetry OpenAI instrumentation
5
5
  License: Apache-2.0
6
6
  Author: Gal Kleinman
@@ -17,7 +17,7 @@ Provides-Extra: instruments
17
17
  Requires-Dist: opentelemetry-api (>=1.28.0,<2.0.0)
18
18
  Requires-Dist: opentelemetry-instrumentation (>=0.50b0)
19
19
  Requires-Dist: opentelemetry-semantic-conventions (>=0.50b0)
20
- Requires-Dist: opentelemetry-semantic-conventions-ai (==0.4.9)
20
+ Requires-Dist: opentelemetry-semantic-conventions-ai (==0.4.11)
21
21
  Requires-Dist: tiktoken (>=0.6.0,<1)
22
22
  Project-URL: Repository, https://github.com/traceloop/openllmetry/tree/main/packages/opentelemetry-instrumentation-openai
23
23
  Description-Content-Type: text/markdown
@@ -1,10 +1,9 @@
1
1
  from typing import Callable, Collection, Optional
2
- from typing_extensions import Coroutine
3
2
 
4
3
  from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
5
-
6
4
  from opentelemetry.instrumentation.openai.shared.config import Config
7
5
  from opentelemetry.instrumentation.openai.utils import is_openai_v1
6
+ from typing_extensions import Coroutine
8
7
 
9
8
  _instruments = ("openai >= 0.27.0",)
10
9
 
@@ -22,6 +21,7 @@ class OpenAIInstrumentor(BaseInstrumentor):
22
21
  Callable[[str, str, str, str], Coroutine[None, None, str]]
23
22
  ] = lambda *args: "",
24
23
  enable_trace_context_propagation: bool = True,
24
+ use_legacy_attributes: bool = True,
25
25
  ):
26
26
  super().__init__()
27
27
  Config.enrich_assistant = enrich_assistant
@@ -30,6 +30,7 @@ class OpenAIInstrumentor(BaseInstrumentor):
30
30
  Config.get_common_metrics_attributes = get_common_metrics_attributes
31
31
  Config.upload_base64_image = upload_base64_image
32
32
  Config.enable_trace_context_propagation = enable_trace_context_propagation
33
+ Config.use_legacy_attributes = use_legacy_attributes
33
34
 
34
35
  def instrumentation_dependencies(self) -> Collection[str]:
35
36
  return _instruments
@@ -1,25 +1,22 @@
1
- import os
2
- import openai
3
1
  import json
4
- import types
5
2
  import logging
6
-
3
+ import types
7
4
  from importlib.metadata import version
8
5
 
9
- from opentelemetry import context as context_api
10
- from opentelemetry.trace.propagation import set_span_in_context
11
- from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
12
-
13
6
  from opentelemetry.instrumentation.openai.shared.config import Config
14
- from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import (
15
- GEN_AI_RESPONSE_ID,
16
- )
17
- from opentelemetry.semconv_ai import SpanAttributes
18
7
  from opentelemetry.instrumentation.openai.utils import (
19
8
  dont_throw,
20
9
  is_openai_v1,
21
10
  should_record_stream_token_usage,
22
11
  )
12
+ from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import (
13
+ GEN_AI_RESPONSE_ID,
14
+ )
15
+ from opentelemetry.semconv_ai import SpanAttributes
16
+ from opentelemetry.trace.propagation import set_span_in_context
17
+ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
18
+ import openai
19
+ import pydantic
23
20
 
24
21
  OPENAI_LLM_USAGE_TOKEN_TYPES = ["prompt_tokens", "completion_tokens"]
25
22
  PROMPT_FILTER_KEY = "prompt_filter_results"
@@ -33,12 +30,6 @@ tiktoken_encodings = {}
33
30
  logger = logging.getLogger(__name__)
34
31
 
35
32
 
36
- def should_send_prompts():
37
- return (
38
- os.getenv("TRACELOOP_TRACE_CONTENT") or "true"
39
- ).lower() == "true" or context_api.get_value("override_enable_content_tracing")
40
-
41
-
42
33
  def _set_span_attribute(span, name, value):
43
34
  if value is None or value == "":
44
35
  return
@@ -113,13 +104,23 @@ def set_tools_attributes(span, tools):
113
104
  )
114
105
 
115
106
 
116
- def _set_request_attributes(span, kwargs):
107
+ def _set_request_attributes(span, kwargs, instance=None):
117
108
  if not span.is_recording():
118
109
  return
119
110
 
120
111
  _set_api_attributes(span)
121
- _set_span_attribute(span, SpanAttributes.LLM_SYSTEM, "OpenAI")
122
- _set_span_attribute(span, SpanAttributes.LLM_REQUEST_MODEL, kwargs.get("model"))
112
+
113
+ base_url = _get_openai_base_url(instance) if instance else ""
114
+ vendor = _get_vendor_from_url(base_url)
115
+ _set_span_attribute(span, SpanAttributes.LLM_SYSTEM, vendor)
116
+
117
+ model = kwargs.get("model")
118
+ if vendor == "AWS" and model and "." in model:
119
+ model = _cross_region_check(model)
120
+ elif vendor == "OpenRouter":
121
+ model = _extract_model_name_from_provider_format(model)
122
+
123
+ _set_span_attribute(span, SpanAttributes.LLM_REQUEST_MODEL, model)
123
124
  _set_span_attribute(
124
125
  span, SpanAttributes.LLM_REQUEST_MAX_TOKENS, kwargs.get("max_tokens")
125
126
  )
@@ -143,6 +144,49 @@ def _set_request_attributes(span, kwargs):
143
144
  _set_span_attribute(
144
145
  span, SpanAttributes.LLM_IS_STREAMING, kwargs.get("stream") or False
145
146
  )
147
+ if response_format := kwargs.get("response_format"):
148
+ # backward-compatible check for
149
+ # openai.types.shared_params.response_format_json_schema.ResponseFormatJSONSchema
150
+ if (
151
+ isinstance(response_format, dict)
152
+ and response_format.get("type") == "json_schema"
153
+ and response_format.get("json_schema")
154
+ ):
155
+ schema = dict(response_format.get("json_schema")).get("schema")
156
+ if schema:
157
+ _set_span_attribute(
158
+ span,
159
+ SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA,
160
+ json.dumps(schema),
161
+ )
162
+ elif (
163
+ isinstance(response_format, pydantic.BaseModel)
164
+ or (
165
+ hasattr(response_format, "model_json_schema")
166
+ and callable(response_format.model_json_schema)
167
+ )
168
+ ):
169
+ _set_span_attribute(
170
+ span,
171
+ SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA,
172
+ json.dumps(response_format.model_json_schema()),
173
+ )
174
+ else:
175
+ schema = None
176
+ try:
177
+ schema = json.dumps(pydantic.TypeAdapter(response_format).json_schema())
178
+ except Exception:
179
+ try:
180
+ schema = json.dumps(response_format)
181
+ except Exception:
182
+ pass
183
+
184
+ if schema:
185
+ _set_span_attribute(
186
+ span,
187
+ SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA,
188
+ schema,
189
+ )
146
190
 
147
191
 
148
192
  @dont_throw
@@ -158,7 +202,10 @@ def _set_response_attributes(span, response):
158
202
  )
159
203
  return
160
204
 
161
- _set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, response.get("model"))
205
+ response_model = response.get("model")
206
+ if response_model:
207
+ response_model = _extract_model_name_from_provider_format(response_model)
208
+ _set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, response_model)
162
209
  _set_span_attribute(span, GEN_AI_RESPONSE_ID, response.get("id"))
163
210
 
164
211
  _set_span_attribute(
@@ -187,7 +234,9 @@ def _set_response_attributes(span, response):
187
234
  )
188
235
  prompt_tokens_details = dict(usage.get("prompt_tokens_details", {}))
189
236
  _set_span_attribute(
190
- span, SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS, prompt_tokens_details.get("cached_tokens", 0)
237
+ span,
238
+ SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS,
239
+ prompt_tokens_details.get("cached_tokens", 0),
191
240
  )
192
241
  return
193
242
 
@@ -206,17 +255,17 @@ def _set_span_stream_usage(span, prompt_tokens, completion_tokens):
206
255
  if not span.is_recording():
207
256
  return
208
257
 
209
- if type(completion_tokens) is int and completion_tokens >= 0:
258
+ if isinstance(completion_tokens, int) and completion_tokens >= 0:
210
259
  _set_span_attribute(
211
260
  span, SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, completion_tokens
212
261
  )
213
262
 
214
- if type(prompt_tokens) is int and prompt_tokens >= 0:
263
+ if isinstance(prompt_tokens, int) and prompt_tokens >= 0:
215
264
  _set_span_attribute(span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, prompt_tokens)
216
265
 
217
266
  if (
218
- type(prompt_tokens) is int
219
- and type(completion_tokens) is int
267
+ isinstance(prompt_tokens, int)
268
+ and isinstance(completion_tokens, int)
220
269
  and completion_tokens + prompt_tokens >= 0
221
270
  ):
222
271
  _set_span_attribute(
@@ -235,6 +284,53 @@ def _get_openai_base_url(instance):
235
284
  return ""
236
285
 
237
286
 
287
+ def _get_vendor_from_url(base_url):
288
+ if not base_url:
289
+ return "openai"
290
+
291
+ if "openai.azure.com" in base_url:
292
+ return "Azure"
293
+ elif "amazonaws.com" in base_url or "bedrock" in base_url:
294
+ return "AWS"
295
+ elif "googleapis.com" in base_url or "vertex" in base_url:
296
+ return "Google"
297
+ elif "openrouter.ai" in base_url:
298
+ return "OpenRouter"
299
+
300
+ return "openai"
301
+
302
+
303
+ def _cross_region_check(value):
304
+ if not value or "." not in value:
305
+ return value
306
+
307
+ prefixes = ["us", "us-gov", "eu", "apac"]
308
+ if any(value.startswith(prefix + ".") for prefix in prefixes):
309
+ parts = value.split(".")
310
+ if len(parts) > 2:
311
+ return parts[2]
312
+ else:
313
+ return value
314
+ else:
315
+ vendor, model = value.split(".", 1)
316
+ return model
317
+
318
+
319
+ def _extract_model_name_from_provider_format(model_name):
320
+ """
321
+ Extract model name from provider/model format.
322
+ E.g., 'openai/gpt-4o' -> 'gpt-4o', 'anthropic/claude-3-sonnet' -> 'claude-3-sonnet'
323
+ """
324
+ if not model_name:
325
+ return model_name
326
+
327
+ if "/" in model_name:
328
+ parts = model_name.split("/")
329
+ return parts[-1] # Return the last part (actual model name)
330
+
331
+ return model_name
332
+
333
+
238
334
  def is_streaming_response(response):
239
335
  if is_openai_v1():
240
336
  return isinstance(response, openai.Stream) or isinstance(
@@ -296,10 +392,11 @@ def metric_shared_attributes(
296
392
  response_model: str, operation: str, server_address: str, is_streaming: bool = False
297
393
  ):
298
394
  attributes = Config.get_common_metrics_attributes()
395
+ vendor = _get_vendor_from_url(server_address)
299
396
 
300
397
  return {
301
398
  **attributes,
302
- SpanAttributes.LLM_SYSTEM: "openai",
399
+ SpanAttributes.LLM_SYSTEM: vendor,
303
400
  SpanAttributes.LLM_RESPONSE_MODEL: response_model,
304
401
  "gen_ai.operation.name": operation,
305
402
  "server.address": server_address,
@@ -2,47 +2,57 @@ import copy
2
2
  import json
3
3
  import logging
4
4
  import time
5
- from opentelemetry.instrumentation.openai.shared.config import Config
6
- from wrapt import ObjectProxy
7
-
5
+ from functools import singledispatch
6
+ from typing import List, Optional, Union
8
7
 
9
8
  from opentelemetry import context as context_api
10
- from opentelemetry.metrics import Counter, Histogram
11
- from opentelemetry.semconv_ai import (
12
- SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY,
13
- SpanAttributes,
14
- LLMRequestTypeValues,
15
- )
16
-
17
- from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY
18
- from opentelemetry.instrumentation.openai.utils import (
19
- _with_chat_telemetry_wrapper,
20
- dont_throw,
21
- run_async,
22
- )
23
9
  from opentelemetry.instrumentation.openai.shared import (
24
- metric_shared_attributes,
10
+ OPENAI_LLM_USAGE_TOKEN_TYPES,
11
+ _get_openai_base_url,
25
12
  _set_client_attributes,
13
+ _set_functions_attributes,
26
14
  _set_request_attributes,
15
+ _set_response_attributes,
27
16
  _set_span_attribute,
28
- _set_functions_attributes,
17
+ _set_span_stream_usage,
29
18
  _token_type,
30
- set_tools_attributes,
31
- _set_response_attributes,
19
+ get_token_count_from_string,
32
20
  is_streaming_response,
33
- should_send_prompts,
21
+ metric_shared_attributes,
34
22
  model_as_dict,
35
- _get_openai_base_url,
36
- OPENAI_LLM_USAGE_TOKEN_TYPES,
37
- should_record_stream_token_usage,
38
- get_token_count_from_string,
39
- _set_span_stream_usage,
40
23
  propagate_trace_context,
24
+ set_tools_attributes,
25
+ should_record_stream_token_usage,
26
+ )
27
+ from opentelemetry.instrumentation.openai.shared.config import Config
28
+ from opentelemetry.instrumentation.openai.shared.event_emitter import emit_event
29
+ from opentelemetry.instrumentation.openai.shared.event_models import (
30
+ ChoiceEvent,
31
+ MessageEvent,
32
+ ToolCall,
33
+ )
34
+ from opentelemetry.instrumentation.openai.utils import (
35
+ _with_chat_telemetry_wrapper,
36
+ dont_throw,
37
+ is_openai_v1,
38
+ run_async,
39
+ should_emit_events,
40
+ should_send_prompts,
41
+ )
42
+ from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY
43
+ from opentelemetry.metrics import Counter, Histogram
44
+ from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
45
+ from opentelemetry.semconv_ai import (
46
+ SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY,
47
+ LLMRequestTypeValues,
48
+ SpanAttributes,
41
49
  )
42
50
  from opentelemetry.trace import SpanKind, Tracer
43
51
  from opentelemetry.trace.status import Status, StatusCode
52
+ from wrapt import ObjectProxy
44
53
 
45
- from opentelemetry.instrumentation.openai.utils import is_openai_v1
54
+ from openai.types.chat import ChatCompletionMessageToolCall
55
+ from openai.types.chat.chat_completion_message import FunctionCall
46
56
 
47
57
  SPAN_NAME = "openai.chat"
48
58
  PROMPT_FILTER_KEY = "prompt_filter_results"
@@ -80,7 +90,6 @@ def chat_wrapper(
80
90
  )
81
91
 
82
92
  run_async(_handle_request(span, kwargs, instance))
83
-
84
93
  try:
85
94
  start_time = time.time()
86
95
  response = wrapped(*args, **kwargs)
@@ -98,10 +107,12 @@ def chat_wrapper(
98
107
  if exception_counter:
99
108
  exception_counter.add(1, attributes=attributes)
100
109
 
110
+ span.set_attribute(ERROR_TYPE, e.__class__.__name__)
111
+ span.record_exception(e)
101
112
  span.set_status(Status(StatusCode.ERROR, str(e)))
102
113
  span.end()
103
114
 
104
- raise e
115
+ raise
105
116
 
106
117
  if is_streaming_response(response):
107
118
  # span will be closed after the generator is done
@@ -143,6 +154,7 @@ def chat_wrapper(
143
154
  duration_histogram,
144
155
  duration,
145
156
  )
157
+
146
158
  span.end()
147
159
 
148
160
  return response
@@ -172,6 +184,7 @@ async def achat_wrapper(
172
184
  kind=SpanKind.CLIENT,
173
185
  attributes={SpanAttributes.LLM_REQUEST_TYPE: LLM_REQUEST_TYPE.value},
174
186
  )
187
+
175
188
  await _handle_request(span, kwargs, instance)
176
189
 
177
190
  try:
@@ -193,10 +206,12 @@ async def achat_wrapper(
193
206
  if exception_counter:
194
207
  exception_counter.add(1, attributes=attributes)
195
208
 
209
+ span.set_attribute(ERROR_TYPE, e.__class__.__name__)
210
+ span.record_exception(e)
196
211
  span.set_status(Status(StatusCode.ERROR, str(e)))
197
212
  span.end()
198
213
 
199
- raise e
214
+ raise
200
215
 
201
216
  if is_streaming_response(response):
202
217
  # span will be closed after the generator is done
@@ -238,6 +253,7 @@ async def achat_wrapper(
238
253
  duration_histogram,
239
254
  duration,
240
255
  )
256
+
241
257
  span.end()
242
258
 
243
259
  return response
@@ -245,14 +261,24 @@ async def achat_wrapper(
245
261
 
246
262
  @dont_throw
247
263
  async def _handle_request(span, kwargs, instance):
248
- _set_request_attributes(span, kwargs)
264
+ _set_request_attributes(span, kwargs, instance)
249
265
  _set_client_attributes(span, instance)
250
- if should_send_prompts():
251
- await _set_prompts(span, kwargs.get("messages"))
252
- if kwargs.get("functions"):
253
- _set_functions_attributes(span, kwargs.get("functions"))
254
- elif kwargs.get("tools"):
255
- set_tools_attributes(span, kwargs.get("tools"))
266
+ if should_emit_events():
267
+ for message in kwargs.get("messages", []):
268
+ emit_event(
269
+ MessageEvent(
270
+ content=message.get("content"),
271
+ role=message.get("role"),
272
+ tool_calls=_parse_tool_calls(message.get("tool_calls", None)),
273
+ )
274
+ )
275
+ else:
276
+ if should_send_prompts():
277
+ await _set_prompts(span, kwargs.get("messages"))
278
+ if kwargs.get("functions"):
279
+ _set_functions_attributes(span, kwargs.get("functions"))
280
+ elif kwargs.get("tools"):
281
+ set_tools_attributes(span, kwargs.get("tools"))
256
282
  if Config.enable_trace_context_propagation:
257
283
  propagate_trace_context(span, kwargs)
258
284
 
@@ -285,8 +311,13 @@ def _handle_response(
285
311
  # span attributes
286
312
  _set_response_attributes(span, response_dict)
287
313
 
288
- if should_send_prompts():
289
- _set_completions(span, response_dict.get("choices"))
314
+ if should_emit_events():
315
+ if response.choices is not None:
316
+ for choice in response.choices:
317
+ emit_event(_parse_choice_event(choice))
318
+ else:
319
+ if should_send_prompts():
320
+ _set_completions(span, response_dict.get("choices"))
290
321
 
291
322
  return response
292
323
 
@@ -528,14 +559,14 @@ def _set_streaming_token_metrics(
528
559
 
529
560
  # metrics record
530
561
  if token_counter:
531
- if type(prompt_usage) is int and prompt_usage >= 0:
562
+ if isinstance(prompt_usage, int) and prompt_usage >= 0:
532
563
  attributes_with_token_type = {
533
564
  **shared_attributes,
534
565
  SpanAttributes.LLM_TOKEN_TYPE: "input",
535
566
  }
536
567
  token_counter.record(prompt_usage, attributes=attributes_with_token_type)
537
568
 
538
- if type(completion_usage) is int and completion_usage >= 0:
569
+ if isinstance(completion_usage, int) and completion_usage >= 0:
539
570
  attributes_with_token_type = {
540
571
  **shared_attributes,
541
572
  SpanAttributes.LLM_TOKEN_TYPE: "output",
@@ -609,8 +640,8 @@ class ChatStream(ObjectProxy):
609
640
  chunk = self.__wrapped__.__next__()
610
641
  except Exception as e:
611
642
  if isinstance(e, StopIteration):
612
- self._close_span()
613
- raise e
643
+ self._process_complete_response()
644
+ raise
614
645
  else:
615
646
  self._process_item(chunk)
616
647
  return chunk
@@ -620,8 +651,8 @@ class ChatStream(ObjectProxy):
620
651
  chunk = await self.__wrapped__.__anext__()
621
652
  except Exception as e:
622
653
  if isinstance(e, StopAsyncIteration):
623
- self._close_span()
624
- raise e
654
+ self._process_complete_response()
655
+ raise
625
656
  else:
626
657
  self._process_item(chunk)
627
658
  return chunk
@@ -650,7 +681,7 @@ class ChatStream(ObjectProxy):
650
681
  )
651
682
 
652
683
  @dont_throw
653
- def _close_span(self):
684
+ def _process_complete_response(self):
654
685
  _set_streaming_token_metrics(
655
686
  self._request_kwargs,
656
687
  self._complete_response,
@@ -683,9 +714,12 @@ class ChatStream(ObjectProxy):
683
714
  )
684
715
 
685
716
  _set_response_attributes(self._span, self._complete_response)
686
-
687
- if should_send_prompts():
688
- _set_completions(self._span, self._complete_response.get("choices"))
717
+ if should_emit_events():
718
+ for choice in self._complete_response.get("choices", []):
719
+ emit_event(_parse_choice_event(choice))
720
+ else:
721
+ if should_send_prompts():
722
+ _set_completions(self._span, self._complete_response.get("choices"))
689
723
 
690
724
  self._span.set_status(Status(StatusCode.OK))
691
725
  self._span.end()
@@ -753,9 +787,12 @@ def _build_from_streaming_response(
753
787
  streaming_time_to_generate.record(time.time() - time_of_first_token)
754
788
 
755
789
  _set_response_attributes(span, complete_response)
756
-
757
- if should_send_prompts():
758
- _set_completions(span, complete_response.get("choices"))
790
+ if should_emit_events():
791
+ for choice in complete_response.get("choices", []):
792
+ emit_event(_parse_choice_event(choice))
793
+ else:
794
+ if should_send_prompts():
795
+ _set_completions(span, complete_response.get("choices"))
759
796
 
760
797
  span.set_status(Status(StatusCode.OK))
761
798
  span.end()
@@ -820,14 +857,113 @@ async def _abuild_from_streaming_response(
820
857
  streaming_time_to_generate.record(time.time() - time_of_first_token)
821
858
 
822
859
  _set_response_attributes(span, complete_response)
823
-
824
- if should_send_prompts():
825
- _set_completions(span, complete_response.get("choices"))
860
+ if should_emit_events():
861
+ for choice in complete_response.get("choices", []):
862
+ emit_event(_parse_choice_event(choice))
863
+ else:
864
+ if should_send_prompts():
865
+ _set_completions(span, complete_response.get("choices"))
826
866
 
827
867
  span.set_status(Status(StatusCode.OK))
828
868
  span.end()
829
869
 
830
870
 
871
+ def _parse_tool_calls(
872
+ tool_calls: Optional[List[Union[dict, ChatCompletionMessageToolCall]]],
873
+ ) -> Union[List[ToolCall], None]:
874
+ """
875
+ Util to correctly parse the tool calls data from the OpenAI API to this module's
876
+ standard `ToolCall`.
877
+ """
878
+ if tool_calls is None:
879
+ return tool_calls
880
+
881
+ result = []
882
+
883
+ for tool_call in tool_calls:
884
+ tool_call_data = None
885
+
886
+ # Handle dict or ChatCompletionMessageToolCall
887
+ if isinstance(tool_call, dict):
888
+ tool_call_data = copy.deepcopy(tool_call)
889
+ elif isinstance(tool_call, ChatCompletionMessageToolCall):
890
+ tool_call_data = tool_call.model_dump()
891
+ elif isinstance(tool_call, FunctionCall):
892
+ function_call = tool_call.model_dump()
893
+ tool_call_data = ToolCall(
894
+ id="",
895
+ function={
896
+ "name": function_call.get("name"),
897
+ "arguments": function_call.get("arguments"),
898
+ },
899
+ type="function",
900
+ )
901
+
902
+ result.append(tool_call_data)
903
+ return result
904
+
905
+
906
+ @singledispatch
907
+ def _parse_choice_event(choice) -> ChoiceEvent:
908
+ has_message = choice.message is not None
909
+ has_finish_reason = choice.finish_reason is not None
910
+ has_tool_calls = has_message and choice.message.tool_calls
911
+ has_function_call = has_message and choice.message.function_call
912
+
913
+ content = choice.message.content if has_message else None
914
+ role = choice.message.role if has_message else "unknown"
915
+ finish_reason = choice.finish_reason if has_finish_reason else "unknown"
916
+
917
+ if has_tool_calls and has_function_call:
918
+ tool_calls = choice.message.tool_calls + [choice.message.function_call]
919
+ elif has_tool_calls:
920
+ tool_calls = choice.message.tool_calls
921
+ elif has_function_call:
922
+ tool_calls = [choice.message.function_call]
923
+ else:
924
+ tool_calls = None
925
+
926
+ return ChoiceEvent(
927
+ index=choice.index,
928
+ message={"content": content, "role": role},
929
+ finish_reason=finish_reason,
930
+ tool_calls=_parse_tool_calls(tool_calls),
931
+ )
932
+
933
+
934
+ @_parse_choice_event.register
935
+ def _(choice: dict) -> ChoiceEvent:
936
+ message = choice.get("message")
937
+ has_message = message is not None
938
+ has_finish_reason = choice.get("finish_reason") is not None
939
+ has_tool_calls = has_message and message.get("tool_calls")
940
+ has_function_call = has_message and message.get("function_call")
941
+
942
+ content = choice.get("message").get("content", "") if has_message else None
943
+ role = choice.get("message").get("role") if has_message else "unknown"
944
+ finish_reason = choice.get("finish_reason") if has_finish_reason else "unknown"
945
+
946
+ if has_tool_calls and has_function_call:
947
+ tool_calls = message.get("tool_calls") + [message.get("function_call")]
948
+ elif has_tool_calls:
949
+ tool_calls = message.get("tool_calls")
950
+ elif has_function_call:
951
+ tool_calls = [message.get("function_call")]
952
+ else:
953
+ tool_calls = None
954
+
955
+ if tool_calls is not None:
956
+ for tool_call in tool_calls:
957
+ tool_call["type"] = "function"
958
+
959
+ return ChoiceEvent(
960
+ index=choice.get("index"),
961
+ message={"content": content, "role": role},
962
+ finish_reason=finish_reason,
963
+ tool_calls=tool_calls,
964
+ )
965
+
966
+
831
967
  def _accumulate_stream_items(item, complete_response):
832
968
  if is_openai_v1():
833
969
  item = model_as_dict(item)