opentelemetry-instrumentation-openai 0.40.13__py3-none-any.whl → 0.41.0__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.
Potentially problematic release.
This version of opentelemetry-instrumentation-openai might be problematic. Click here for more details.
- opentelemetry/instrumentation/openai/__init__.py +3 -2
- opentelemetry/instrumentation/openai/shared/__init__.py +125 -28
- opentelemetry/instrumentation/openai/shared/chat_wrappers.py +191 -55
- opentelemetry/instrumentation/openai/shared/completion_wrappers.py +93 -36
- opentelemetry/instrumentation/openai/shared/config.py +8 -2
- opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py +79 -28
- opentelemetry/instrumentation/openai/shared/event_emitter.py +100 -0
- opentelemetry/instrumentation/openai/shared/event_models.py +41 -0
- opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py +1 -1
- opentelemetry/instrumentation/openai/shared/span_utils.py +0 -0
- opentelemetry/instrumentation/openai/utils.py +30 -4
- opentelemetry/instrumentation/openai/v0/__init__.py +31 -11
- opentelemetry/instrumentation/openai/v1/__init__.py +176 -69
- opentelemetry/instrumentation/openai/v1/assistant_wrappers.py +121 -42
- opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py +31 -15
- opentelemetry/instrumentation/openai/v1/responses_wrappers.py +623 -0
- opentelemetry/instrumentation/openai/version.py +1 -1
- {opentelemetry_instrumentation_openai-0.40.13.dist-info → opentelemetry_instrumentation_openai-0.41.0.dist-info}/METADATA +2 -2
- opentelemetry_instrumentation_openai-0.41.0.dist-info/RECORD +21 -0
- opentelemetry_instrumentation_openai-0.40.13.dist-info/RECORD +0 -17
- {opentelemetry_instrumentation_openai-0.40.13.dist-info → opentelemetry_instrumentation_openai-0.41.0.dist-info}/WHEEL +0 -0
- {opentelemetry_instrumentation_openai-0.40.13.dist-info → opentelemetry_instrumentation_openai-0.41.0.dist-info}/entry_points.txt +0 -0
|
@@ -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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
219
|
-
and
|
|
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:
|
|
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
|
|
6
|
-
from
|
|
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
|
-
|
|
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
|
-
|
|
17
|
+
_set_span_stream_usage,
|
|
29
18
|
_token_type,
|
|
30
|
-
|
|
31
|
-
_set_response_attributes,
|
|
19
|
+
get_token_count_from_string,
|
|
32
20
|
is_streaming_response,
|
|
33
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
289
|
-
|
|
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
|
|
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
|
|
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.
|
|
613
|
-
raise
|
|
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.
|
|
624
|
-
raise
|
|
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
|
|
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
|
-
|
|
688
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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
|
-
|
|
825
|
-
|
|
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)
|