lmnr 0.6.18__py3-none-any.whl → 0.6.20__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.
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/__init__.py +55 -20
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/schema_utils.py +23 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/__init__.py +61 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/__init__.py +442 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/chat_wrappers.py +1024 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/completion_wrappers.py +297 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/config.py +16 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py +308 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/event_emitter.py +100 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/event_models.py +41 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py +68 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/utils.py +185 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v0/__init__.py +176 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/__init__.py +358 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/assistant_wrappers.py +319 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py +132 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/responses_wrappers.py +626 -0
- lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/version.py +1 -0
- lmnr/opentelemetry_lib/tracing/_instrument_initializers.py +1 -3
- lmnr/sdk/browser/browser_use_otel.py +1 -1
- lmnr/sdk/browser/patchright_otel.py +0 -14
- lmnr/sdk/browser/playwright_otel.py +16 -130
- lmnr/sdk/browser/pw_utils.py +45 -31
- lmnr/sdk/client/asynchronous/async_client.py +13 -0
- lmnr/sdk/client/asynchronous/resources/__init__.py +2 -0
- lmnr/sdk/client/asynchronous/resources/evaluators.py +85 -0
- lmnr/sdk/client/asynchronous/resources/tags.py +4 -10
- lmnr/sdk/client/synchronous/resources/__init__.py +2 -1
- lmnr/sdk/client/synchronous/resources/evaluators.py +85 -0
- lmnr/sdk/client/synchronous/resources/tags.py +4 -10
- lmnr/sdk/client/synchronous/sync_client.py +14 -0
- lmnr/sdk/utils.py +23 -0
- lmnr/version.py +1 -1
- {lmnr-0.6.18.dist-info → lmnr-0.6.20.dist-info}/METADATA +2 -5
- {lmnr-0.6.18.dist-info → lmnr-0.6.20.dist-info}/RECORD +37 -18
- {lmnr-0.6.18.dist-info → lmnr-0.6.20.dist-info}/WHEEL +1 -1
- {lmnr-0.6.18.dist-info → lmnr-0.6.20.dist-info}/entry_points.txt +0 -0
@@ -11,6 +11,7 @@ from google.genai import types
|
|
11
11
|
from .config import (
|
12
12
|
Config,
|
13
13
|
)
|
14
|
+
from .schema_utils import SchemaJSONEncoder, process_schema
|
14
15
|
from .utils import (
|
15
16
|
dont_throw,
|
16
17
|
get_content,
|
@@ -24,8 +25,9 @@ from opentelemetry.trace import Tracer
|
|
24
25
|
from wrapt import wrap_function_wrapper
|
25
26
|
|
26
27
|
from opentelemetry import context as context_api
|
27
|
-
from opentelemetry.trace import get_tracer, SpanKind, Span
|
28
|
+
from opentelemetry.trace import get_tracer, SpanKind, Span, Status, StatusCode
|
28
29
|
from opentelemetry.semconv._incubating.attributes import gen_ai_attributes
|
30
|
+
from opentelemetry.semconv.attributes.error_attributes import ERROR_TYPE
|
29
31
|
|
30
32
|
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
|
31
33
|
from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY, unwrap
|
@@ -78,7 +80,7 @@ WRAPPED_METHODS = [
|
|
78
80
|
|
79
81
|
def should_send_prompts():
|
80
82
|
return (
|
81
|
-
os.getenv("
|
83
|
+
os.getenv("LAMINAR_TRACE_CONTENT") or "true"
|
82
84
|
).lower() == "true" or context_api.get_value("override_enable_content_tracing")
|
83
85
|
|
84
86
|
|
@@ -128,6 +130,29 @@ def _set_request_attributes(span, args, kwargs):
|
|
128
130
|
span, gen_ai_attributes.GEN_AI_REQUEST_SEED, config_dict.get("seed")
|
129
131
|
)
|
130
132
|
|
133
|
+
if schema := config_dict.get("response_schema"):
|
134
|
+
try:
|
135
|
+
set_span_attribute(
|
136
|
+
span,
|
137
|
+
# TODO: change to SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA
|
138
|
+
# when we upgrade to opentelemetry-semantic-conventions-ai>=0.4.10
|
139
|
+
"gen_ai.request.structured_output_schema",
|
140
|
+
json.dumps(process_schema(schema), cls=SchemaJSONEncoder),
|
141
|
+
)
|
142
|
+
except Exception:
|
143
|
+
pass
|
144
|
+
elif json_schema := config_dict.get("response_json_schema"):
|
145
|
+
try:
|
146
|
+
set_span_attribute(
|
147
|
+
span,
|
148
|
+
# TODO: change to SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA
|
149
|
+
# when we upgrade to opentelemetry-semantic-conventions-ai>=0.4.10
|
150
|
+
"gen_ai.request.structured_output_schema",
|
151
|
+
json.dumps(json_schema),
|
152
|
+
)
|
153
|
+
except Exception:
|
154
|
+
pass
|
155
|
+
|
131
156
|
tools: list[types.FunctionDeclaration] = []
|
132
157
|
arg_tools = config_dict.get("tools", kwargs.get("tools"))
|
133
158
|
if arg_tools:
|
@@ -454,16 +479,20 @@ def _wrap(tracer: Tracer, to_wrap, wrapped, instance, args, kwargs):
|
|
454
479
|
if span.is_recording():
|
455
480
|
_set_request_attributes(span, args, kwargs)
|
456
481
|
|
457
|
-
|
458
|
-
return _build_from_streaming_response(span, wrapped(*args, **kwargs))
|
459
|
-
else:
|
482
|
+
try:
|
460
483
|
response = wrapped(*args, **kwargs)
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
484
|
+
if to_wrap.get("is_streaming"):
|
485
|
+
return _build_from_streaming_response(span, response)
|
486
|
+
if span.is_recording():
|
487
|
+
_set_response_attributes(span, response)
|
488
|
+
span.end()
|
489
|
+
return response
|
490
|
+
except Exception as e:
|
491
|
+
span.set_attribute(ERROR_TYPE, e.__class__.__name__)
|
492
|
+
span.record_exception(e)
|
493
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
494
|
+
span.end()
|
495
|
+
raise e
|
467
496
|
|
468
497
|
|
469
498
|
@with_tracer_wrapper
|
@@ -485,16 +514,22 @@ async def _awrap(tracer: Tracer, to_wrap, wrapped, instance, args, kwargs):
|
|
485
514
|
if span.is_recording():
|
486
515
|
_set_request_attributes(span, args, kwargs)
|
487
516
|
|
488
|
-
|
489
|
-
return _abuild_from_streaming_response(span, await wrapped(*args, **kwargs))
|
490
|
-
else:
|
517
|
+
try:
|
491
518
|
response = await wrapped(*args, **kwargs)
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
519
|
+
if to_wrap.get("is_streaming"):
|
520
|
+
return _abuild_from_streaming_response(span, response)
|
521
|
+
else:
|
522
|
+
if span.is_recording():
|
523
|
+
_set_response_attributes(span, response)
|
524
|
+
|
525
|
+
span.end()
|
526
|
+
return response
|
527
|
+
except Exception as e:
|
528
|
+
span.set_attribute(ERROR_TYPE, e.__class__.__name__)
|
529
|
+
span.record_exception(e)
|
530
|
+
span.set_status(Status(StatusCode.ERROR, str(e)))
|
531
|
+
span.end()
|
532
|
+
raise e
|
498
533
|
|
499
534
|
|
500
535
|
class GoogleGenAiSdkInstrumentor(BaseInstrumentor):
|
@@ -0,0 +1,23 @@
|
|
1
|
+
from typing import Any
|
2
|
+
from google.genai._api_client import BaseApiClient
|
3
|
+
from google.genai._transformers import t_schema
|
4
|
+
from google.genai.types import JSONSchemaType
|
5
|
+
|
6
|
+
import json
|
7
|
+
|
8
|
+
DUMMY_CLIENT = BaseApiClient(api_key="dummy")
|
9
|
+
|
10
|
+
|
11
|
+
def process_schema(schema: Any) -> dict[str, Any]:
|
12
|
+
# The only thing we need from the client is the t_schema function
|
13
|
+
json_schema = t_schema(DUMMY_CLIENT, schema).json_schema.model_dump(
|
14
|
+
exclude_unset=True, exclude_none=True
|
15
|
+
)
|
16
|
+
return json_schema
|
17
|
+
|
18
|
+
|
19
|
+
class SchemaJSONEncoder(json.JSONEncoder):
|
20
|
+
def default(self, o: Any) -> Any:
|
21
|
+
if isinstance(o, JSONSchemaType):
|
22
|
+
return o.value
|
23
|
+
return super().default(o)
|
@@ -0,0 +1,61 @@
|
|
1
|
+
"""
|
2
|
+
Initially copied over from openllmetry, commit
|
3
|
+
b3a18c9f7e6ff2368c8fb0bc35fd9123f11121c4
|
4
|
+
"""
|
5
|
+
|
6
|
+
from typing import Callable, Collection, Optional
|
7
|
+
|
8
|
+
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
|
9
|
+
from .shared.config import Config
|
10
|
+
from .utils import is_openai_v1
|
11
|
+
from typing_extensions import Coroutine
|
12
|
+
|
13
|
+
_instruments = ("openai >= 0.27.0",)
|
14
|
+
|
15
|
+
|
16
|
+
class OpenAIInstrumentor(BaseInstrumentor):
|
17
|
+
"""An instrumentor for OpenAI's client library."""
|
18
|
+
|
19
|
+
def __init__(
|
20
|
+
self,
|
21
|
+
enrich_assistant: bool = False,
|
22
|
+
enrich_token_usage: bool = False,
|
23
|
+
exception_logger=None,
|
24
|
+
get_common_metrics_attributes: Callable[[], dict] = lambda: {},
|
25
|
+
upload_base64_image: Optional[
|
26
|
+
Callable[[str, str, str, str], Coroutine[None, None, str]]
|
27
|
+
] = lambda *args: "",
|
28
|
+
enable_trace_context_propagation: bool = True,
|
29
|
+
use_legacy_attributes: bool = True,
|
30
|
+
):
|
31
|
+
super().__init__()
|
32
|
+
Config.enrich_assistant = enrich_assistant
|
33
|
+
Config.enrich_token_usage = enrich_token_usage
|
34
|
+
Config.exception_logger = exception_logger
|
35
|
+
Config.get_common_metrics_attributes = get_common_metrics_attributes
|
36
|
+
Config.upload_base64_image = upload_base64_image
|
37
|
+
Config.enable_trace_context_propagation = enable_trace_context_propagation
|
38
|
+
Config.use_legacy_attributes = use_legacy_attributes
|
39
|
+
|
40
|
+
def instrumentation_dependencies(self) -> Collection[str]:
|
41
|
+
return _instruments
|
42
|
+
|
43
|
+
def _instrument(self, **kwargs):
|
44
|
+
if is_openai_v1():
|
45
|
+
from .v1 import OpenAIV1Instrumentor
|
46
|
+
|
47
|
+
OpenAIV1Instrumentor().instrument(**kwargs)
|
48
|
+
else:
|
49
|
+
from .v0 import OpenAIV0Instrumentor
|
50
|
+
|
51
|
+
OpenAIV0Instrumentor().instrument(**kwargs)
|
52
|
+
|
53
|
+
def _uninstrument(self, **kwargs):
|
54
|
+
if is_openai_v1():
|
55
|
+
from .v1 import OpenAIV1Instrumentor
|
56
|
+
|
57
|
+
OpenAIV1Instrumentor().uninstrument(**kwargs)
|
58
|
+
else:
|
59
|
+
from .v0 import OpenAIV0Instrumentor
|
60
|
+
|
61
|
+
OpenAIV0Instrumentor().uninstrument(**kwargs)
|
@@ -0,0 +1,442 @@
|
|
1
|
+
import json
|
2
|
+
import logging
|
3
|
+
import types
|
4
|
+
from importlib.metadata import version
|
5
|
+
|
6
|
+
from ..shared.config import Config
|
7
|
+
from ..utils import (
|
8
|
+
dont_throw,
|
9
|
+
is_openai_v1,
|
10
|
+
should_record_stream_token_usage,
|
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
|
20
|
+
|
21
|
+
OPENAI_LLM_USAGE_TOKEN_TYPES = ["prompt_tokens", "completion_tokens"]
|
22
|
+
PROMPT_FILTER_KEY = "prompt_filter_results"
|
23
|
+
PROMPT_ERROR = "prompt_error"
|
24
|
+
|
25
|
+
_PYDANTIC_VERSION = version("pydantic")
|
26
|
+
|
27
|
+
# tiktoken encodings map for different model, key is model_name, value is tiktoken encoding
|
28
|
+
tiktoken_encodings = {}
|
29
|
+
|
30
|
+
logger = logging.getLogger(__name__)
|
31
|
+
|
32
|
+
|
33
|
+
def _set_span_attribute(span, name, value):
|
34
|
+
if value is None or value == "":
|
35
|
+
return
|
36
|
+
|
37
|
+
if hasattr(openai, "NOT_GIVEN") and value == openai.NOT_GIVEN:
|
38
|
+
return
|
39
|
+
|
40
|
+
span.set_attribute(name, value)
|
41
|
+
|
42
|
+
|
43
|
+
def _set_client_attributes(span, instance):
|
44
|
+
if not span.is_recording():
|
45
|
+
return
|
46
|
+
|
47
|
+
if not is_openai_v1():
|
48
|
+
return
|
49
|
+
|
50
|
+
client = instance._client # pylint: disable=protected-access
|
51
|
+
if isinstance(client, (openai.AsyncOpenAI, openai.OpenAI)):
|
52
|
+
_set_span_attribute(
|
53
|
+
span, SpanAttributes.LLM_OPENAI_API_BASE, str(client.base_url)
|
54
|
+
)
|
55
|
+
if isinstance(client, (openai.AsyncAzureOpenAI, openai.AzureOpenAI)):
|
56
|
+
_set_span_attribute(
|
57
|
+
span, SpanAttributes.LLM_OPENAI_API_VERSION, client._api_version
|
58
|
+
) # pylint: disable=protected-access
|
59
|
+
|
60
|
+
|
61
|
+
def _set_api_attributes(span):
|
62
|
+
if not span.is_recording():
|
63
|
+
return
|
64
|
+
|
65
|
+
if is_openai_v1():
|
66
|
+
return
|
67
|
+
|
68
|
+
base_url = openai.base_url if hasattr(openai, "base_url") else openai.api_base
|
69
|
+
|
70
|
+
_set_span_attribute(span, SpanAttributes.LLM_OPENAI_API_BASE, base_url)
|
71
|
+
_set_span_attribute(span, SpanAttributes.LLM_OPENAI_API_TYPE, openai.api_type)
|
72
|
+
_set_span_attribute(span, SpanAttributes.LLM_OPENAI_API_VERSION, openai.api_version)
|
73
|
+
|
74
|
+
return
|
75
|
+
|
76
|
+
|
77
|
+
def _set_functions_attributes(span, functions):
|
78
|
+
if not functions:
|
79
|
+
return
|
80
|
+
|
81
|
+
for i, function in enumerate(functions):
|
82
|
+
prefix = f"{SpanAttributes.LLM_REQUEST_FUNCTIONS}.{i}"
|
83
|
+
_set_span_attribute(span, f"{prefix}.name", function.get("name"))
|
84
|
+
_set_span_attribute(span, f"{prefix}.description", function.get("description"))
|
85
|
+
_set_span_attribute(
|
86
|
+
span, f"{prefix}.parameters", json.dumps(function.get("parameters"))
|
87
|
+
)
|
88
|
+
|
89
|
+
|
90
|
+
def set_tools_attributes(span, tools):
|
91
|
+
if not tools:
|
92
|
+
return
|
93
|
+
|
94
|
+
for i, tool in enumerate(tools):
|
95
|
+
function = tool.get("function")
|
96
|
+
if not function:
|
97
|
+
continue
|
98
|
+
|
99
|
+
prefix = f"{SpanAttributes.LLM_REQUEST_FUNCTIONS}.{i}"
|
100
|
+
_set_span_attribute(span, f"{prefix}.name", function.get("name"))
|
101
|
+
_set_span_attribute(span, f"{prefix}.description", function.get("description"))
|
102
|
+
_set_span_attribute(
|
103
|
+
span, f"{prefix}.parameters", json.dumps(function.get("parameters"))
|
104
|
+
)
|
105
|
+
|
106
|
+
|
107
|
+
def _set_request_attributes(span, kwargs, instance=None):
|
108
|
+
if not span.is_recording():
|
109
|
+
return
|
110
|
+
|
111
|
+
_set_api_attributes(span)
|
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)
|
124
|
+
_set_span_attribute(
|
125
|
+
span, SpanAttributes.LLM_REQUEST_MAX_TOKENS, kwargs.get("max_tokens")
|
126
|
+
)
|
127
|
+
_set_span_attribute(
|
128
|
+
span, SpanAttributes.LLM_REQUEST_TEMPERATURE, kwargs.get("temperature")
|
129
|
+
)
|
130
|
+
_set_span_attribute(span, SpanAttributes.LLM_REQUEST_TOP_P, kwargs.get("top_p"))
|
131
|
+
_set_span_attribute(
|
132
|
+
span, SpanAttributes.LLM_FREQUENCY_PENALTY, kwargs.get("frequency_penalty")
|
133
|
+
)
|
134
|
+
_set_span_attribute(
|
135
|
+
span, SpanAttributes.LLM_PRESENCE_PENALTY, kwargs.get("presence_penalty")
|
136
|
+
)
|
137
|
+
_set_span_attribute(span, SpanAttributes.LLM_USER, kwargs.get("user"))
|
138
|
+
_set_span_attribute(span, SpanAttributes.LLM_HEADERS, str(kwargs.get("headers")))
|
139
|
+
# The new OpenAI SDK removed the `headers` and create new field called `extra_headers`
|
140
|
+
if kwargs.get("extra_headers") is not None:
|
141
|
+
_set_span_attribute(
|
142
|
+
span, SpanAttributes.LLM_HEADERS, str(kwargs.get("extra_headers"))
|
143
|
+
)
|
144
|
+
_set_span_attribute(
|
145
|
+
span, SpanAttributes.LLM_IS_STREAMING, kwargs.get("stream") or False
|
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
|
+
# TODO: change to SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA
|
160
|
+
# when we upgrade to opentelemetry-semantic-conventions-ai>=0.4.10
|
161
|
+
"gen_ai.request.structured_output_schema",
|
162
|
+
json.dumps(schema),
|
163
|
+
)
|
164
|
+
else:
|
165
|
+
try:
|
166
|
+
from openai.lib._parsing._completions import (
|
167
|
+
type_to_response_format_param,
|
168
|
+
)
|
169
|
+
|
170
|
+
response_format_param = type_to_response_format_param(response_format)
|
171
|
+
if response_format_param.get("type") == "json_schema":
|
172
|
+
schema = response_format_param.get("json_schema").get("schema")
|
173
|
+
if schema:
|
174
|
+
_set_span_attribute(
|
175
|
+
span,
|
176
|
+
# TODO: change to SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA
|
177
|
+
# when we upgrade to opentelemetry-semantic-conventions-ai>=0.4.10
|
178
|
+
"gen_ai.request.structured_output_schema",
|
179
|
+
json.dumps(schema),
|
180
|
+
)
|
181
|
+
except (ImportError, TypeError, AttributeError):
|
182
|
+
# if we fail to import from openai.lib._parsing._completions,
|
183
|
+
# we fallback to the pydantic-based approach
|
184
|
+
if isinstance(response_format, pydantic.BaseModel) or (
|
185
|
+
hasattr(response_format, "model_json_schema")
|
186
|
+
and callable(response_format.model_json_schema)
|
187
|
+
):
|
188
|
+
_set_span_attribute(
|
189
|
+
span,
|
190
|
+
# TODO: change to SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA
|
191
|
+
# when we upgrade to opentelemetry-semantic-conventions-ai>=0.4.10
|
192
|
+
"gen_ai.request.structured_output_schema",
|
193
|
+
json.dumps(response_format.model_json_schema()),
|
194
|
+
)
|
195
|
+
else:
|
196
|
+
schema = None
|
197
|
+
try:
|
198
|
+
schema = json.dumps(
|
199
|
+
pydantic.TypeAdapter(response_format).json_schema()
|
200
|
+
)
|
201
|
+
except Exception:
|
202
|
+
try:
|
203
|
+
schema = json.dumps(response_format)
|
204
|
+
except Exception:
|
205
|
+
pass
|
206
|
+
|
207
|
+
if schema:
|
208
|
+
_set_span_attribute(
|
209
|
+
span,
|
210
|
+
# TODO: change to SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA
|
211
|
+
# when we upgrade to opentelemetry-semantic-conventions-ai>=0.4.10
|
212
|
+
"gen_ai.request.structured_output_schema",
|
213
|
+
schema,
|
214
|
+
)
|
215
|
+
|
216
|
+
|
217
|
+
@dont_throw
|
218
|
+
def _set_response_attributes(span, response):
|
219
|
+
if not span.is_recording():
|
220
|
+
return
|
221
|
+
|
222
|
+
if "error" in response:
|
223
|
+
_set_span_attribute(
|
224
|
+
span,
|
225
|
+
f"{SpanAttributes.LLM_PROMPTS}.{PROMPT_ERROR}",
|
226
|
+
json.dumps(response.get("error")),
|
227
|
+
)
|
228
|
+
return
|
229
|
+
|
230
|
+
response_model = response.get("model")
|
231
|
+
if response_model:
|
232
|
+
response_model = _extract_model_name_from_provider_format(response_model)
|
233
|
+
_set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, response_model)
|
234
|
+
_set_span_attribute(span, GEN_AI_RESPONSE_ID, response.get("id"))
|
235
|
+
|
236
|
+
_set_span_attribute(
|
237
|
+
span,
|
238
|
+
SpanAttributes.LLM_OPENAI_RESPONSE_SYSTEM_FINGERPRINT,
|
239
|
+
response.get("system_fingerprint"),
|
240
|
+
)
|
241
|
+
_log_prompt_filter(span, response)
|
242
|
+
usage = response.get("usage")
|
243
|
+
if not usage:
|
244
|
+
return
|
245
|
+
|
246
|
+
if is_openai_v1() and not isinstance(usage, dict):
|
247
|
+
usage = usage.__dict__
|
248
|
+
|
249
|
+
_set_span_attribute(
|
250
|
+
span, SpanAttributes.LLM_USAGE_TOTAL_TOKENS, usage.get("total_tokens")
|
251
|
+
)
|
252
|
+
_set_span_attribute(
|
253
|
+
span,
|
254
|
+
SpanAttributes.LLM_USAGE_COMPLETION_TOKENS,
|
255
|
+
usage.get("completion_tokens"),
|
256
|
+
)
|
257
|
+
_set_span_attribute(
|
258
|
+
span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, usage.get("prompt_tokens")
|
259
|
+
)
|
260
|
+
prompt_tokens_details = dict(usage.get("prompt_tokens_details", {}))
|
261
|
+
_set_span_attribute(
|
262
|
+
span,
|
263
|
+
SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS,
|
264
|
+
prompt_tokens_details.get("cached_tokens", 0),
|
265
|
+
)
|
266
|
+
return
|
267
|
+
|
268
|
+
|
269
|
+
def _log_prompt_filter(span, response_dict):
|
270
|
+
if response_dict.get("prompt_filter_results"):
|
271
|
+
_set_span_attribute(
|
272
|
+
span,
|
273
|
+
f"{SpanAttributes.LLM_PROMPTS}.{PROMPT_FILTER_KEY}",
|
274
|
+
json.dumps(response_dict.get("prompt_filter_results")),
|
275
|
+
)
|
276
|
+
|
277
|
+
|
278
|
+
@dont_throw
|
279
|
+
def _set_span_stream_usage(span, prompt_tokens, completion_tokens):
|
280
|
+
if not span.is_recording():
|
281
|
+
return
|
282
|
+
|
283
|
+
if isinstance(completion_tokens, int) and completion_tokens >= 0:
|
284
|
+
_set_span_attribute(
|
285
|
+
span, SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, completion_tokens
|
286
|
+
)
|
287
|
+
|
288
|
+
if isinstance(prompt_tokens, int) and prompt_tokens >= 0:
|
289
|
+
_set_span_attribute(span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, prompt_tokens)
|
290
|
+
|
291
|
+
if (
|
292
|
+
isinstance(prompt_tokens, int)
|
293
|
+
and isinstance(completion_tokens, int)
|
294
|
+
and completion_tokens + prompt_tokens >= 0
|
295
|
+
):
|
296
|
+
_set_span_attribute(
|
297
|
+
span,
|
298
|
+
SpanAttributes.LLM_USAGE_TOTAL_TOKENS,
|
299
|
+
completion_tokens + prompt_tokens,
|
300
|
+
)
|
301
|
+
|
302
|
+
|
303
|
+
def _get_openai_base_url(instance):
|
304
|
+
if hasattr(instance, "_client"):
|
305
|
+
client = instance._client # pylint: disable=protected-access
|
306
|
+
if isinstance(client, (openai.AsyncOpenAI, openai.OpenAI)):
|
307
|
+
return str(client.base_url)
|
308
|
+
|
309
|
+
return ""
|
310
|
+
|
311
|
+
|
312
|
+
def _get_vendor_from_url(base_url):
|
313
|
+
if not base_url:
|
314
|
+
return "openai"
|
315
|
+
|
316
|
+
if "openai.azure.com" in base_url:
|
317
|
+
return "Azure"
|
318
|
+
elif "amazonaws.com" in base_url or "bedrock" in base_url:
|
319
|
+
return "AWS"
|
320
|
+
elif "googleapis.com" in base_url or "vertex" in base_url:
|
321
|
+
return "Google"
|
322
|
+
elif "openrouter.ai" in base_url:
|
323
|
+
return "OpenRouter"
|
324
|
+
|
325
|
+
return "openai"
|
326
|
+
|
327
|
+
|
328
|
+
def _cross_region_check(value):
|
329
|
+
if not value or "." not in value:
|
330
|
+
return value
|
331
|
+
|
332
|
+
prefixes = ["us", "us-gov", "eu", "apac"]
|
333
|
+
if any(value.startswith(prefix + ".") for prefix in prefixes):
|
334
|
+
parts = value.split(".")
|
335
|
+
if len(parts) > 2:
|
336
|
+
return parts[2]
|
337
|
+
else:
|
338
|
+
return value
|
339
|
+
else:
|
340
|
+
vendor, model = value.split(".", 1)
|
341
|
+
return model
|
342
|
+
|
343
|
+
|
344
|
+
def _extract_model_name_from_provider_format(model_name):
|
345
|
+
"""
|
346
|
+
Extract model name from provider/model format.
|
347
|
+
E.g., 'openai/gpt-4o' -> 'gpt-4o', 'anthropic/claude-3-sonnet' -> 'claude-3-sonnet'
|
348
|
+
"""
|
349
|
+
if not model_name:
|
350
|
+
return model_name
|
351
|
+
|
352
|
+
if "/" in model_name:
|
353
|
+
parts = model_name.split("/")
|
354
|
+
return parts[-1] # Return the last part (actual model name)
|
355
|
+
|
356
|
+
return model_name
|
357
|
+
|
358
|
+
|
359
|
+
def is_streaming_response(response):
|
360
|
+
if is_openai_v1():
|
361
|
+
return isinstance(response, openai.Stream) or isinstance(
|
362
|
+
response, openai.AsyncStream
|
363
|
+
)
|
364
|
+
|
365
|
+
return isinstance(response, types.GeneratorType) or isinstance(
|
366
|
+
response, types.AsyncGeneratorType
|
367
|
+
)
|
368
|
+
|
369
|
+
|
370
|
+
def model_as_dict(model):
|
371
|
+
if isinstance(model, dict):
|
372
|
+
return model
|
373
|
+
if _PYDANTIC_VERSION < "2.0.0":
|
374
|
+
return model.dict()
|
375
|
+
if hasattr(model, "model_dump"):
|
376
|
+
return model.model_dump()
|
377
|
+
elif hasattr(model, "parse"): # Raw API response
|
378
|
+
return model_as_dict(model.parse())
|
379
|
+
else:
|
380
|
+
return model
|
381
|
+
|
382
|
+
|
383
|
+
def get_token_count_from_string(string: str, model_name: str):
|
384
|
+
if not should_record_stream_token_usage():
|
385
|
+
return None
|
386
|
+
|
387
|
+
import tiktoken
|
388
|
+
|
389
|
+
if tiktoken_encodings.get(model_name) is None:
|
390
|
+
try:
|
391
|
+
encoding = tiktoken.encoding_for_model(model_name)
|
392
|
+
except KeyError as ex:
|
393
|
+
# no such model_name in tiktoken
|
394
|
+
logger.warning(
|
395
|
+
f"Failed to get tiktoken encoding for model_name {model_name}, error: {str(ex)}"
|
396
|
+
)
|
397
|
+
return None
|
398
|
+
|
399
|
+
tiktoken_encodings[model_name] = encoding
|
400
|
+
else:
|
401
|
+
encoding = tiktoken_encodings.get(model_name)
|
402
|
+
|
403
|
+
token_count = len(encoding.encode(string))
|
404
|
+
return token_count
|
405
|
+
|
406
|
+
|
407
|
+
def _token_type(token_type: str):
|
408
|
+
if token_type == "prompt_tokens":
|
409
|
+
return "input"
|
410
|
+
elif token_type == "completion_tokens":
|
411
|
+
return "output"
|
412
|
+
|
413
|
+
return None
|
414
|
+
|
415
|
+
|
416
|
+
def metric_shared_attributes(
|
417
|
+
response_model: str, operation: str, server_address: str, is_streaming: bool = False
|
418
|
+
):
|
419
|
+
attributes = Config.get_common_metrics_attributes()
|
420
|
+
vendor = _get_vendor_from_url(server_address)
|
421
|
+
|
422
|
+
return {
|
423
|
+
**attributes,
|
424
|
+
SpanAttributes.LLM_SYSTEM: vendor,
|
425
|
+
SpanAttributes.LLM_RESPONSE_MODEL: response_model,
|
426
|
+
"gen_ai.operation.name": operation,
|
427
|
+
"server.address": server_address,
|
428
|
+
"stream": is_streaming,
|
429
|
+
}
|
430
|
+
|
431
|
+
|
432
|
+
def propagate_trace_context(span, kwargs):
|
433
|
+
if is_openai_v1():
|
434
|
+
extra_headers = kwargs.get("extra_headers", {})
|
435
|
+
ctx = set_span_in_context(span)
|
436
|
+
TraceContextTextMapPropagator().inject(extra_headers, context=ctx)
|
437
|
+
kwargs["extra_headers"] = extra_headers
|
438
|
+
else:
|
439
|
+
headers = kwargs.get("headers", {})
|
440
|
+
ctx = set_span_in_context(span)
|
441
|
+
TraceContextTextMapPropagator().inject(headers, context=ctx)
|
442
|
+
kwargs["headers"] = headers
|