traceAI-openai 0.1.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.
- traceai_openai-0.1.0/PKG-INFO +18 -0
- traceai_openai-0.1.0/README.md +0 -0
- traceai_openai-0.1.0/pyproject.toml +18 -0
- traceai_openai-0.1.0/traceai_openai/__init__.py +60 -0
- traceai_openai-0.1.0/traceai_openai/_request.py +491 -0
- traceai_openai-0.1.0/traceai_openai/_request_attributes_extractor.py +243 -0
- traceai_openai-0.1.0/traceai_openai/_response_accumulator.py +292 -0
- traceai_openai-0.1.0/traceai_openai/_response_attributes_extractor.py +262 -0
- traceai_openai-0.1.0/traceai_openai/_span_io_handler.py +168 -0
- traceai_openai-0.1.0/traceai_openai/_stream.py +182 -0
- traceai_openai-0.1.0/traceai_openai/_utils.py +155 -0
- traceai_openai-0.1.0/traceai_openai/_with_span.py +95 -0
- traceai_openai-0.1.0/traceai_openai/package.py +2 -0
- traceai_openai-0.1.0/traceai_openai/py.typed +0 -0
- traceai_openai-0.1.0/traceai_openai/version.py +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: traceAI-openai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenTelemetry instrumentation for OpenAI
|
|
5
|
+
Author: Future AGI
|
|
6
|
+
Author-email: no-reply@futureagi.com
|
|
7
|
+
Requires-Python: >=3.9,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Dist: fi-instrumentation-otel
|
|
15
|
+
Requires-Dist: openai (>=1.57.1,<2.0.0)
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "traceAI-openai"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "OpenTelemetry instrumentation for OpenAI"
|
|
5
|
+
authors = ["Future AGI <no-reply@futureagi.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
packages = [
|
|
8
|
+
{ include = "traceai_openai" }
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[tool.poetry.dependencies]
|
|
12
|
+
python = ">=3.9,<4.0"
|
|
13
|
+
fi-instrumentation-otel = "*"
|
|
14
|
+
openai = "^1.57.1"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["poetry-core"]
|
|
18
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from importlib import import_module
|
|
3
|
+
from typing import Any, Collection
|
|
4
|
+
|
|
5
|
+
from fi_instrumentation import FITracer, TraceConfig
|
|
6
|
+
from opentelemetry import trace as trace_api
|
|
7
|
+
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor # type: ignore
|
|
8
|
+
from traceai_openai._request import _AsyncRequest, _Request
|
|
9
|
+
from traceai_openai.package import _instruments
|
|
10
|
+
from traceai_openai.version import __version__
|
|
11
|
+
from wrapt import wrap_function_wrapper
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
logger.addHandler(logging.NullHandler())
|
|
15
|
+
|
|
16
|
+
_MODULE = "openai"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OpenAIInstrumentor(BaseInstrumentor): # type: ignore
|
|
20
|
+
"""
|
|
21
|
+
An instrumentor for openai
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
__slots__ = (
|
|
25
|
+
"_original_request",
|
|
26
|
+
"_original_async_request",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def instrumentation_dependencies(self) -> Collection[str]:
|
|
30
|
+
return _instruments
|
|
31
|
+
|
|
32
|
+
def _instrument(self, **kwargs: Any) -> None:
|
|
33
|
+
if not (tracer_provider := kwargs.get("tracer_provider")):
|
|
34
|
+
tracer_provider = trace_api.get_tracer_provider()
|
|
35
|
+
if not (config := kwargs.get("config")):
|
|
36
|
+
config = TraceConfig()
|
|
37
|
+
else:
|
|
38
|
+
assert isinstance(config, TraceConfig)
|
|
39
|
+
tracer = FITracer(
|
|
40
|
+
trace_api.get_tracer(__name__, __version__, tracer_provider),
|
|
41
|
+
config=config,
|
|
42
|
+
)
|
|
43
|
+
openai = import_module(_MODULE)
|
|
44
|
+
self._original_request = openai.OpenAI.request
|
|
45
|
+
self._original_async_request = openai.AsyncOpenAI.request
|
|
46
|
+
wrap_function_wrapper(
|
|
47
|
+
module=_MODULE,
|
|
48
|
+
name="OpenAI.request",
|
|
49
|
+
wrapper=_Request(tracer=tracer, openai=openai),
|
|
50
|
+
)
|
|
51
|
+
wrap_function_wrapper(
|
|
52
|
+
module=_MODULE,
|
|
53
|
+
name="AsyncOpenAI.request",
|
|
54
|
+
wrapper=_AsyncRequest(tracer=tracer, openai=openai),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def _uninstrument(self, **kwargs: Any) -> None:
|
|
58
|
+
openai = import_module(_MODULE)
|
|
59
|
+
openai.OpenAI.request = self._original_request
|
|
60
|
+
openai.AsyncOpenAI.request = self._original_async_request
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from abc import ABC
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from itertools import chain
|
|
5
|
+
from types import ModuleType
|
|
6
|
+
from typing import Any, Awaitable, Callable, Iterable, Iterator, Mapping, Tuple
|
|
7
|
+
|
|
8
|
+
from fi_instrumentation import get_attributes_from_context
|
|
9
|
+
from fi_instrumentation.fi_types import (
|
|
10
|
+
FiLLMProviderValues,
|
|
11
|
+
FiLLMSystemValues,
|
|
12
|
+
FiSpanKindValues,
|
|
13
|
+
SpanAttributes,
|
|
14
|
+
)
|
|
15
|
+
from openai._legacy_response import LegacyAPIResponse
|
|
16
|
+
from openai.types.chat.chat_completion import ChatCompletion
|
|
17
|
+
from openai.types.create_embedding_response import CreateEmbeddingResponse
|
|
18
|
+
from opentelemetry import context as context_api
|
|
19
|
+
from opentelemetry import trace as trace_api
|
|
20
|
+
from opentelemetry.context import _SUPPRESS_INSTRUMENTATION_KEY
|
|
21
|
+
from opentelemetry.trace import INVALID_SPAN
|
|
22
|
+
from opentelemetry.util.types import AttributeValue
|
|
23
|
+
from traceai_openai._request_attributes_extractor import _RequestAttributesExtractor
|
|
24
|
+
from traceai_openai._response_accumulator import (
|
|
25
|
+
_ChatCompletionAccumulator,
|
|
26
|
+
_CompletionAccumulator,
|
|
27
|
+
)
|
|
28
|
+
from traceai_openai._response_attributes_extractor import _ResponseAttributesExtractor
|
|
29
|
+
from traceai_openai._span_io_handler import add_io_to_span_attributes
|
|
30
|
+
from traceai_openai._stream import _AsyncStream, _ResponseAccumulator, _Stream
|
|
31
|
+
from traceai_openai._utils import (
|
|
32
|
+
_as_input_attributes,
|
|
33
|
+
_as_output_attributes,
|
|
34
|
+
_finish_tracing,
|
|
35
|
+
_io_value_and_type,
|
|
36
|
+
safe_json_dumps,
|
|
37
|
+
)
|
|
38
|
+
from traceai_openai._with_span import _WithSpan
|
|
39
|
+
from typing_extensions import TypeAlias
|
|
40
|
+
|
|
41
|
+
__all__ = (
|
|
42
|
+
"_Request",
|
|
43
|
+
"_AsyncRequest",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
logger.addHandler(logging.NullHandler())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _WithTracer(ABC):
|
|
51
|
+
def __init__(self, tracer: trace_api.Tracer, *args: Any, **kwargs: Any) -> None:
|
|
52
|
+
super().__init__(*args, **kwargs)
|
|
53
|
+
self._tracer = tracer
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def _start_as_current_span(
|
|
57
|
+
self,
|
|
58
|
+
span_name: str,
|
|
59
|
+
attributes: Iterable[Tuple[str, AttributeValue]],
|
|
60
|
+
context_attributes: Iterable[Tuple[str, AttributeValue]],
|
|
61
|
+
extra_attributes: Iterable[Tuple[str, AttributeValue]],
|
|
62
|
+
) -> Iterator[_WithSpan]:
|
|
63
|
+
# Because OTEL has a default limit of 128 attributes, we split our attributes into
|
|
64
|
+
# two tiers, where the addition of "extra_attributes" is deferred until the end
|
|
65
|
+
# and only after the "attributes" are added.
|
|
66
|
+
try:
|
|
67
|
+
span = self._tracer.start_span(name=span_name, attributes=dict(attributes))
|
|
68
|
+
except Exception:
|
|
69
|
+
logger.exception("Failed to start span")
|
|
70
|
+
span = INVALID_SPAN
|
|
71
|
+
with trace_api.use_span(
|
|
72
|
+
span,
|
|
73
|
+
end_on_exit=False,
|
|
74
|
+
record_exception=False,
|
|
75
|
+
set_status_on_exception=False,
|
|
76
|
+
) as span:
|
|
77
|
+
yield _WithSpan(
|
|
78
|
+
span=span,
|
|
79
|
+
context_attributes=dict(context_attributes),
|
|
80
|
+
extra_attributes=dict(extra_attributes),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_RequestParameters: TypeAlias = Mapping[str, Any]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _WithOpenAI(ABC):
|
|
88
|
+
__slots__ = (
|
|
89
|
+
"_openai",
|
|
90
|
+
"_stream_types",
|
|
91
|
+
"_request_attributes_extractor",
|
|
92
|
+
"_response_attributes_extractor",
|
|
93
|
+
"_response_accumulator_factories",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def __init__(self, openai: ModuleType, *args: Any, **kwargs: Any) -> None:
|
|
97
|
+
super().__init__(*args, **kwargs)
|
|
98
|
+
self._openai = openai
|
|
99
|
+
self._stream_types = (openai.Stream, openai.AsyncStream)
|
|
100
|
+
self._request_attributes_extractor = _RequestAttributesExtractor(openai=openai)
|
|
101
|
+
self._response_attributes_extractor = _ResponseAttributesExtractor(
|
|
102
|
+
openai=openai
|
|
103
|
+
)
|
|
104
|
+
self._response_accumulator_factories: Mapping[
|
|
105
|
+
type, Callable[[_RequestParameters], _ResponseAccumulator]
|
|
106
|
+
] = {
|
|
107
|
+
openai.types.Completion: lambda request_parameters: _CompletionAccumulator(
|
|
108
|
+
request_parameters=request_parameters,
|
|
109
|
+
completion_type=openai.types.Completion,
|
|
110
|
+
response_attributes_extractor=self._response_attributes_extractor,
|
|
111
|
+
),
|
|
112
|
+
openai.types.chat.ChatCompletion: lambda request_parameters: _ChatCompletionAccumulator(
|
|
113
|
+
request_parameters=request_parameters,
|
|
114
|
+
chat_completion_type=openai.types.chat.ChatCompletion,
|
|
115
|
+
response_attributes_extractor=self._response_attributes_extractor,
|
|
116
|
+
),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
def _get_span_kind(self, cast_to: type) -> str:
|
|
120
|
+
return (
|
|
121
|
+
FiSpanKindValues.EMBEDDING.value
|
|
122
|
+
if cast_to is self._openai.types.CreateEmbeddingResponse
|
|
123
|
+
else FiSpanKindValues.LLM.value
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _get_attributes_from_instance(
|
|
127
|
+
self, instance: Any
|
|
128
|
+
) -> Iterator[Tuple[str, AttributeValue]]:
|
|
129
|
+
if (
|
|
130
|
+
not (base_url := getattr(instance, "base_url", None))
|
|
131
|
+
or not (host := getattr(base_url, "host", None))
|
|
132
|
+
or not isinstance(host, str)
|
|
133
|
+
):
|
|
134
|
+
return
|
|
135
|
+
if host.endswith("api.openai.com"):
|
|
136
|
+
yield SpanAttributes.LLM_PROVIDER, FiLLMProviderValues.OPENAI.value
|
|
137
|
+
elif host.endswith("openai.azure.com"):
|
|
138
|
+
yield SpanAttributes.LLM_PROVIDER, FiLLMProviderValues.AZURE.value
|
|
139
|
+
elif host.endswith("googleapis.com"):
|
|
140
|
+
yield SpanAttributes.LLM_PROVIDER, FiLLMProviderValues.GOOGLE.value
|
|
141
|
+
|
|
142
|
+
def _get_attributes_from_request(
|
|
143
|
+
self,
|
|
144
|
+
cast_to: type,
|
|
145
|
+
request_parameters: Mapping[str, Any],
|
|
146
|
+
) -> Iterator[Tuple[str, AttributeValue]]:
|
|
147
|
+
yield SpanAttributes.FI_SPAN_KIND, self._get_span_kind(cast_to=cast_to)
|
|
148
|
+
yield SpanAttributes.LLM_SYSTEM, FiLLMSystemValues.OPENAI.value
|
|
149
|
+
try:
|
|
150
|
+
yield from _as_input_attributes(
|
|
151
|
+
_io_value_and_type(request_parameters),
|
|
152
|
+
)
|
|
153
|
+
except Exception:
|
|
154
|
+
logger.exception(
|
|
155
|
+
f"Failed to get input attributes from request parameters of "
|
|
156
|
+
f"type {type(request_parameters)}"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _get_extra_attributes_from_request(
|
|
160
|
+
self,
|
|
161
|
+
cast_to: type,
|
|
162
|
+
request_parameters: Mapping[str, Any],
|
|
163
|
+
) -> Iterator[Tuple[str, AttributeValue]]:
|
|
164
|
+
# Secondary attributes should be added after input and output to ensure
|
|
165
|
+
# that input and output are not dropped if there are too many attributes.
|
|
166
|
+
try:
|
|
167
|
+
yield from self._request_attributes_extractor.get_attributes_from_request(
|
|
168
|
+
cast_to=cast_to,
|
|
169
|
+
request_parameters=request_parameters,
|
|
170
|
+
)
|
|
171
|
+
except Exception:
|
|
172
|
+
logger.exception(
|
|
173
|
+
f"Failed to get extra attributes from request options of "
|
|
174
|
+
f"type {type(request_parameters)}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _is_streaming(self, response: Any) -> bool:
|
|
178
|
+
return isinstance(response, self._stream_types)
|
|
179
|
+
|
|
180
|
+
def _is_async_stream(self, response: Any) -> bool:
|
|
181
|
+
"""Check if the response is an asynchronous stream."""
|
|
182
|
+
return hasattr(response, "__aiter__") and callable(response.__aiter__)
|
|
183
|
+
|
|
184
|
+
def _finalize_response(
|
|
185
|
+
self,
|
|
186
|
+
response: Any,
|
|
187
|
+
with_span: _WithSpan,
|
|
188
|
+
cast_to: type,
|
|
189
|
+
request_parameters: Mapping[str, Any],
|
|
190
|
+
) -> Any:
|
|
191
|
+
"""
|
|
192
|
+
Monkey-patch the response object to trace the stream, or finish tracing if the response is
|
|
193
|
+
not a stream.
|
|
194
|
+
"""
|
|
195
|
+
if hasattr(response, "parse") and callable(response.parse):
|
|
196
|
+
try:
|
|
197
|
+
response.parse()
|
|
198
|
+
except Exception:
|
|
199
|
+
logger.exception(f"Failed to parse response of type {type(response)}")
|
|
200
|
+
|
|
201
|
+
if self._is_streaming(response):
|
|
202
|
+
# Determine if the response is asynchronous
|
|
203
|
+
if self._is_async_stream(response):
|
|
204
|
+
stream_wrapper = _AsyncStream
|
|
205
|
+
else:
|
|
206
|
+
stream_wrapper = _Stream
|
|
207
|
+
|
|
208
|
+
# Get the response accumulator if available
|
|
209
|
+
response_accumulator_factory = self._response_accumulator_factories.get(
|
|
210
|
+
cast_to
|
|
211
|
+
)
|
|
212
|
+
response_accumulator = (
|
|
213
|
+
response_accumulator_factory(request_parameters)
|
|
214
|
+
if response_accumulator_factory
|
|
215
|
+
else None
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Wrap the response with the appropriate stream wrapper
|
|
219
|
+
wrapped_stream = stream_wrapper(
|
|
220
|
+
stream=response,
|
|
221
|
+
with_span=with_span,
|
|
222
|
+
response_accumulator=response_accumulator,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return wrapped_stream
|
|
226
|
+
|
|
227
|
+
else:
|
|
228
|
+
_finish_tracing(
|
|
229
|
+
status=trace_api.Status(status_code=trace_api.StatusCode.OK),
|
|
230
|
+
with_span=with_span,
|
|
231
|
+
has_attributes=_ResponseAttributes(
|
|
232
|
+
request_parameters=request_parameters,
|
|
233
|
+
response=response,
|
|
234
|
+
response_attributes_extractor=self._response_attributes_extractor,
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
return response
|
|
238
|
+
|
|
239
|
+
def response_to_dict(self, response: Any) -> Any:
|
|
240
|
+
if hasattr(response, "to_dict") and callable(response.to_dict):
|
|
241
|
+
return response.to_dict()
|
|
242
|
+
elif isinstance(response, dict):
|
|
243
|
+
return response
|
|
244
|
+
elif hasattr(response, "__dict__"):
|
|
245
|
+
return response.__dict__
|
|
246
|
+
else:
|
|
247
|
+
return str(response)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class _Request(_WithTracer, _WithOpenAI):
|
|
251
|
+
def __call__(
|
|
252
|
+
self,
|
|
253
|
+
wrapped: Callable[..., Any],
|
|
254
|
+
instance: Any,
|
|
255
|
+
args: Tuple[type, Any],
|
|
256
|
+
kwargs: Mapping[str, Any],
|
|
257
|
+
) -> Any:
|
|
258
|
+
if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
|
|
259
|
+
return wrapped(*args, **kwargs)
|
|
260
|
+
try:
|
|
261
|
+
cast_to, request_parameters = _parse_request_args(args)
|
|
262
|
+
# Extract input data more robustly
|
|
263
|
+
input_data = (
|
|
264
|
+
request_parameters.get("messages")
|
|
265
|
+
or request_parameters.get("prompt")
|
|
266
|
+
or request_parameters.get("input")
|
|
267
|
+
)
|
|
268
|
+
llm_tools = request_parameters.get("tools")
|
|
269
|
+
span_name: str = cast_to.__name__.split(".")[-1]
|
|
270
|
+
except Exception:
|
|
271
|
+
logger.exception("Failed to parse request args")
|
|
272
|
+
return wrapped(*args, **kwargs)
|
|
273
|
+
|
|
274
|
+
with self._start_as_current_span(
|
|
275
|
+
span_name=span_name,
|
|
276
|
+
attributes=chain(
|
|
277
|
+
self._get_attributes_from_instance(instance),
|
|
278
|
+
self._get_attributes_from_request(
|
|
279
|
+
cast_to=cast_to,
|
|
280
|
+
request_parameters=request_parameters,
|
|
281
|
+
),
|
|
282
|
+
),
|
|
283
|
+
context_attributes=get_attributes_from_context(),
|
|
284
|
+
extra_attributes=self._get_extra_attributes_from_request(
|
|
285
|
+
cast_to=cast_to,
|
|
286
|
+
request_parameters=request_parameters,
|
|
287
|
+
),
|
|
288
|
+
) as with_span:
|
|
289
|
+
# Add input data to span attributes before the request
|
|
290
|
+
with_span.set_attribute(
|
|
291
|
+
SpanAttributes.RAW_INPUT, safe_json_dumps(request_parameters)
|
|
292
|
+
)
|
|
293
|
+
with_span.set_attribute(
|
|
294
|
+
SpanAttributes.LLM_TOOLS, safe_json_dumps(llm_tools)
|
|
295
|
+
)
|
|
296
|
+
add_io_to_span_attributes(with_span, input_data, None)
|
|
297
|
+
try:
|
|
298
|
+
response = wrapped(*args, **kwargs)
|
|
299
|
+
|
|
300
|
+
if isinstance(response, CreateEmbeddingResponse):
|
|
301
|
+
embedding = response.to_dict()
|
|
302
|
+
with_span.set_attribute(
|
|
303
|
+
SpanAttributes.EMBEDDING_EMBEDDINGS, safe_json_dumps(embedding)
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Add output data to span attributes after getting response
|
|
307
|
+
if not self._is_streaming(response):
|
|
308
|
+
with_span.set_attribute(
|
|
309
|
+
SpanAttributes.RAW_OUTPUT, _get_raw_output(response)
|
|
310
|
+
)
|
|
311
|
+
add_io_to_span_attributes(
|
|
312
|
+
with_span,
|
|
313
|
+
None,
|
|
314
|
+
response,
|
|
315
|
+
is_streaming=self._is_streaming(response),
|
|
316
|
+
)
|
|
317
|
+
return self._finalize_response(
|
|
318
|
+
response=response,
|
|
319
|
+
with_span=with_span,
|
|
320
|
+
cast_to=cast_to,
|
|
321
|
+
request_parameters=request_parameters,
|
|
322
|
+
)
|
|
323
|
+
except Exception as e:
|
|
324
|
+
status = trace_api.Status(
|
|
325
|
+
status_code=trace_api.StatusCode.ERROR,
|
|
326
|
+
description=f"{type(e).__name__}: {e}",
|
|
327
|
+
)
|
|
328
|
+
with_span.record_exception(e)
|
|
329
|
+
with_span.finish_tracing(status=status)
|
|
330
|
+
raise
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class _AsyncRequest(_WithTracer, _WithOpenAI):
|
|
334
|
+
async def __call__(
|
|
335
|
+
self,
|
|
336
|
+
wrapped: Callable[..., Awaitable[Any]],
|
|
337
|
+
instance: Any,
|
|
338
|
+
args: Tuple[type, Any],
|
|
339
|
+
kwargs: Mapping[str, Any],
|
|
340
|
+
) -> Any:
|
|
341
|
+
if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY):
|
|
342
|
+
return await wrapped(*args, **kwargs)
|
|
343
|
+
try:
|
|
344
|
+
cast_to, request_parameters = _parse_request_args(args)
|
|
345
|
+
# Extract input data more robustly
|
|
346
|
+
input_data = request_parameters.get(
|
|
347
|
+
"messages", request_parameters.get("prompt")
|
|
348
|
+
)
|
|
349
|
+
llm_tools = request_parameters.get("tools")
|
|
350
|
+
|
|
351
|
+
span_name: str = cast_to.__name__.split(".")[-1]
|
|
352
|
+
except Exception:
|
|
353
|
+
logger.exception("Failed to parse request args")
|
|
354
|
+
return await wrapped(*args, **kwargs)
|
|
355
|
+
with self._start_as_current_span(
|
|
356
|
+
span_name=span_name,
|
|
357
|
+
attributes=chain(
|
|
358
|
+
self._get_attributes_from_instance(instance),
|
|
359
|
+
self._get_attributes_from_request(
|
|
360
|
+
cast_to=cast_to,
|
|
361
|
+
request_parameters=request_parameters,
|
|
362
|
+
),
|
|
363
|
+
),
|
|
364
|
+
context_attributes=get_attributes_from_context(),
|
|
365
|
+
extra_attributes=self._get_extra_attributes_from_request(
|
|
366
|
+
cast_to=cast_to,
|
|
367
|
+
request_parameters=request_parameters,
|
|
368
|
+
),
|
|
369
|
+
) as with_span:
|
|
370
|
+
# Add input data to span attributes before the request
|
|
371
|
+
with_span.set_attribute(
|
|
372
|
+
SpanAttributes.RAW_INPUT, safe_json_dumps(request_parameters)
|
|
373
|
+
)
|
|
374
|
+
with_span.set_attribute(
|
|
375
|
+
SpanAttributes.LLM_TOOLS, safe_json_dumps(llm_tools)
|
|
376
|
+
)
|
|
377
|
+
add_io_to_span_attributes(with_span, input_data, None)
|
|
378
|
+
try:
|
|
379
|
+
response = await wrapped(*args, **kwargs)
|
|
380
|
+
|
|
381
|
+
if isinstance(response, CreateEmbeddingResponse):
|
|
382
|
+
embedding = response.to_dict()
|
|
383
|
+
with_span.set_attribute(
|
|
384
|
+
SpanAttributes.EMBEDDING_EMBEDDINGS, safe_json_dumps(embedding)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
if not self._is_streaming(response):
|
|
388
|
+
with_span.set_attribute(
|
|
389
|
+
SpanAttributes.RAW_OUTPUT, _get_raw_output(response)
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
add_io_to_span_attributes(
|
|
393
|
+
with_span, None, response, is_streaming=self._is_streaming(response)
|
|
394
|
+
)
|
|
395
|
+
except Exception as exception:
|
|
396
|
+
with_span.record_exception(exception)
|
|
397
|
+
status = trace_api.Status(
|
|
398
|
+
status_code=trace_api.StatusCode.ERROR,
|
|
399
|
+
# Follow the format in OTEL SDK for description, see:
|
|
400
|
+
# https://github.com/open-telemetry/opentelemetry-python/blob/2b9dcfc5d853d1c10176937a6bcaade54cda1a31/opentelemetry-api/src/opentelemetry/trace/__init__.py#L588 # noqa E501
|
|
401
|
+
description=f"{type(exception).__name__}: {exception}",
|
|
402
|
+
)
|
|
403
|
+
with_span.finish_tracing(status=status)
|
|
404
|
+
raise
|
|
405
|
+
try:
|
|
406
|
+
response = self._finalize_response(
|
|
407
|
+
response=response,
|
|
408
|
+
with_span=with_span,
|
|
409
|
+
cast_to=cast_to,
|
|
410
|
+
request_parameters=request_parameters,
|
|
411
|
+
)
|
|
412
|
+
except Exception:
|
|
413
|
+
logger.exception(
|
|
414
|
+
f"Failed to finalize response of type {type(response)}"
|
|
415
|
+
)
|
|
416
|
+
with_span.finish_tracing()
|
|
417
|
+
return response
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _parse_request_args(args: Tuple[type, Any]) -> Tuple[type, Mapping[str, Any]]:
|
|
421
|
+
# We don't use `signature(request).bind()` because `request` could have been monkey-patched
|
|
422
|
+
# (incorrectly) by others and the signature at runtime may not match the original.
|
|
423
|
+
# The targeted signature of `request` is here:
|
|
424
|
+
# https://github.com/openai/openai-python/blob/f1c7d714914e3321ca2e72839fe2d132a8646e7f/src/openai/_base_client.py#L846-L847 # noqa: E501
|
|
425
|
+
cast_to: type = args[0]
|
|
426
|
+
request_parameters: Mapping[str, Any] = (
|
|
427
|
+
json_data
|
|
428
|
+
# See https://github.com/openai/openai-python/blob/f1c7d714914e3321ca2e72839fe2d132a8646e7f/src/openai/_models.py#L427 # noqa: E501
|
|
429
|
+
if hasattr(args[1], "json_data")
|
|
430
|
+
and isinstance(json_data := args[1].json_data, Mapping)
|
|
431
|
+
else {}
|
|
432
|
+
)
|
|
433
|
+
# FIXME: Because request parameters is just a Mapping, it can contain any value as long as it
|
|
434
|
+
# serializes correctly in an HTTP request body. For example, Enum values may be present if a
|
|
435
|
+
# third-party library puts them there. Enums can turn into their intended string values via
|
|
436
|
+
# `json.dumps` when the final HTTP request body is serialized, but can pose problems when we
|
|
437
|
+
# try to extract attributes. However, this round-trip seems expensive, so we opted to treat
|
|
438
|
+
# only the Enums that we know about: e.g. message role sometimes can be an Enum, so we will
|
|
439
|
+
# convert it only when it's encountered.
|
|
440
|
+
# try:
|
|
441
|
+
# request_parameters = json.loads(json.dumps(request_parameters))
|
|
442
|
+
# except Exception:
|
|
443
|
+
# pass
|
|
444
|
+
return cast_to, request_parameters
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
class _ResponseAttributes:
|
|
448
|
+
__slots__ = ("_response", "_request_parameters", "_response_attributes_extractor")
|
|
449
|
+
|
|
450
|
+
def __init__(
|
|
451
|
+
self,
|
|
452
|
+
response: Any,
|
|
453
|
+
request_parameters: Mapping[str, Any],
|
|
454
|
+
response_attributes_extractor: _ResponseAttributesExtractor,
|
|
455
|
+
) -> None:
|
|
456
|
+
if hasattr(response, "parse") and callable(response.parse):
|
|
457
|
+
# E.g. see https://github.com/openai/openai-python/blob/f1c7d714914e3321ca2e72839fe2d132a8646e7f/src/openai/_base_client.py#L518 # noqa: E501
|
|
458
|
+
try:
|
|
459
|
+
response = response.parse()
|
|
460
|
+
except Exception:
|
|
461
|
+
logger.exception(f"Failed to parse response of type {type(response)}")
|
|
462
|
+
self._request_parameters = request_parameters
|
|
463
|
+
self._response = response
|
|
464
|
+
self._response_attributes_extractor = response_attributes_extractor
|
|
465
|
+
|
|
466
|
+
def get_attributes(self) -> Iterator[Tuple[str, AttributeValue]]:
|
|
467
|
+
yield from _as_output_attributes(
|
|
468
|
+
_io_value_and_type(self._response),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
def get_extra_attributes(self) -> Iterator[Tuple[str, AttributeValue]]:
|
|
472
|
+
yield from self._response_attributes_extractor.get_attributes_from_response(
|
|
473
|
+
response=self._response,
|
|
474
|
+
request_parameters=self._request_parameters,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _get_raw_output(response):
|
|
479
|
+
if isinstance(response, ChatCompletion):
|
|
480
|
+
return safe_json_dumps(response.to_dict())
|
|
481
|
+
elif isinstance(response, LegacyAPIResponse):
|
|
482
|
+
parsed_response = response.parse()
|
|
483
|
+
if hasattr(parsed_response, "dict") and callable(parsed_response.dict):
|
|
484
|
+
parsed_response = parsed_response.dict()
|
|
485
|
+
return safe_json_dumps(parsed_response)
|
|
486
|
+
else:
|
|
487
|
+
return safe_json_dumps(str(parsed_response))
|
|
488
|
+
elif hasattr(response, "to_dict") and callable(response.to_dict):
|
|
489
|
+
return safe_json_dumps(response.to_dict())
|
|
490
|
+
else:
|
|
491
|
+
return safe_json_dumps(str(response))
|