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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. lmnr/__init__.py +6 -15
  2. lmnr/cli/__init__.py +270 -0
  3. lmnr/cli/datasets.py +371 -0
  4. lmnr/{cli.py → cli/evals.py} +20 -102
  5. lmnr/cli/rules.py +42 -0
  6. lmnr/opentelemetry_lib/__init__.py +9 -2
  7. lmnr/opentelemetry_lib/decorators/__init__.py +274 -168
  8. lmnr/opentelemetry_lib/litellm/__init__.py +352 -38
  9. lmnr/opentelemetry_lib/litellm/utils.py +82 -0
  10. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/__init__.py +849 -0
  11. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/config.py +13 -0
  12. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/event_emitter.py +211 -0
  13. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/event_models.py +41 -0
  14. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/span_utils.py +401 -0
  15. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/streaming.py +425 -0
  16. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/utils.py +332 -0
  17. lmnr/opentelemetry_lib/opentelemetry/instrumentation/anthropic/version.py +1 -0
  18. lmnr/opentelemetry_lib/opentelemetry/instrumentation/claude_agent/__init__.py +451 -0
  19. lmnr/opentelemetry_lib/opentelemetry/instrumentation/claude_agent/proxy.py +144 -0
  20. lmnr/opentelemetry_lib/opentelemetry/instrumentation/cua_agent/__init__.py +100 -0
  21. lmnr/opentelemetry_lib/opentelemetry/instrumentation/cua_computer/__init__.py +476 -0
  22. lmnr/opentelemetry_lib/opentelemetry/instrumentation/cua_computer/utils.py +12 -0
  23. lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/__init__.py +191 -129
  24. lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/schema_utils.py +26 -0
  25. lmnr/opentelemetry_lib/opentelemetry/instrumentation/google_genai/utils.py +126 -41
  26. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/__init__.py +488 -0
  27. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/config.py +8 -0
  28. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/event_emitter.py +143 -0
  29. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/event_models.py +41 -0
  30. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/span_utils.py +229 -0
  31. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/utils.py +92 -0
  32. lmnr/opentelemetry_lib/opentelemetry/instrumentation/groq/version.py +1 -0
  33. lmnr/opentelemetry_lib/opentelemetry/instrumentation/kernel/__init__.py +381 -0
  34. lmnr/opentelemetry_lib/opentelemetry/instrumentation/kernel/utils.py +36 -0
  35. lmnr/opentelemetry_lib/opentelemetry/instrumentation/langgraph/__init__.py +16 -16
  36. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/__init__.py +61 -0
  37. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/__init__.py +472 -0
  38. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/chat_wrappers.py +1185 -0
  39. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/completion_wrappers.py +305 -0
  40. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/config.py +16 -0
  41. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py +312 -0
  42. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/event_emitter.py +100 -0
  43. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/event_models.py +41 -0
  44. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py +68 -0
  45. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/utils.py +197 -0
  46. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v0/__init__.py +176 -0
  47. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/__init__.py +368 -0
  48. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/assistant_wrappers.py +325 -0
  49. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py +135 -0
  50. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/v1/responses_wrappers.py +786 -0
  51. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openai/version.py +1 -0
  52. lmnr/opentelemetry_lib/opentelemetry/instrumentation/openhands_ai/__init__.py +388 -0
  53. lmnr/opentelemetry_lib/opentelemetry/instrumentation/opentelemetry/__init__.py +69 -0
  54. lmnr/opentelemetry_lib/opentelemetry/instrumentation/skyvern/__init__.py +59 -61
  55. lmnr/opentelemetry_lib/opentelemetry/instrumentation/threading/__init__.py +197 -0
  56. lmnr/opentelemetry_lib/tracing/__init__.py +119 -18
  57. lmnr/opentelemetry_lib/tracing/_instrument_initializers.py +124 -25
  58. lmnr/opentelemetry_lib/tracing/attributes.py +4 -0
  59. lmnr/opentelemetry_lib/tracing/context.py +200 -0
  60. lmnr/opentelemetry_lib/tracing/exporter.py +109 -15
  61. lmnr/opentelemetry_lib/tracing/instruments.py +22 -5
  62. lmnr/opentelemetry_lib/tracing/processor.py +128 -30
  63. lmnr/opentelemetry_lib/tracing/span.py +398 -0
  64. lmnr/opentelemetry_lib/tracing/tracer.py +40 -1
  65. lmnr/opentelemetry_lib/tracing/utils.py +62 -0
  66. lmnr/opentelemetry_lib/utils/package_check.py +9 -0
  67. lmnr/opentelemetry_lib/utils/wrappers.py +11 -0
  68. lmnr/sdk/browser/background_send_events.py +158 -0
  69. lmnr/sdk/browser/browser_use_cdp_otel.py +100 -0
  70. lmnr/sdk/browser/browser_use_otel.py +12 -12
  71. lmnr/sdk/browser/bubus_otel.py +71 -0
  72. lmnr/sdk/browser/cdp_utils.py +518 -0
  73. lmnr/sdk/browser/inject_script.js +514 -0
  74. lmnr/sdk/browser/patchright_otel.py +18 -44
  75. lmnr/sdk/browser/playwright_otel.py +104 -187
  76. lmnr/sdk/browser/pw_utils.py +249 -210
  77. lmnr/sdk/browser/recorder/record.umd.min.cjs +84 -0
  78. lmnr/sdk/browser/utils.py +1 -1
  79. lmnr/sdk/client/asynchronous/async_client.py +47 -15
  80. lmnr/sdk/client/asynchronous/resources/__init__.py +2 -7
  81. lmnr/sdk/client/asynchronous/resources/browser_events.py +1 -0
  82. lmnr/sdk/client/asynchronous/resources/datasets.py +131 -0
  83. lmnr/sdk/client/asynchronous/resources/evals.py +122 -18
  84. lmnr/sdk/client/asynchronous/resources/evaluators.py +85 -0
  85. lmnr/sdk/client/asynchronous/resources/tags.py +4 -10
  86. lmnr/sdk/client/synchronous/resources/__init__.py +2 -2
  87. lmnr/sdk/client/synchronous/resources/datasets.py +131 -0
  88. lmnr/sdk/client/synchronous/resources/evals.py +83 -17
  89. lmnr/sdk/client/synchronous/resources/evaluators.py +85 -0
  90. lmnr/sdk/client/synchronous/resources/tags.py +4 -10
  91. lmnr/sdk/client/synchronous/sync_client.py +47 -15
  92. lmnr/sdk/datasets/__init__.py +94 -0
  93. lmnr/sdk/datasets/file_utils.py +91 -0
  94. lmnr/sdk/decorators.py +103 -23
  95. lmnr/sdk/evaluations.py +122 -33
  96. lmnr/sdk/laminar.py +816 -333
  97. lmnr/sdk/log.py +7 -2
  98. lmnr/sdk/types.py +124 -143
  99. lmnr/sdk/utils.py +115 -2
  100. lmnr/version.py +1 -1
  101. {lmnr-0.6.16.dist-info → lmnr-0.7.26.dist-info}/METADATA +71 -78
  102. lmnr-0.7.26.dist-info/RECORD +116 -0
  103. lmnr-0.7.26.dist-info/WHEEL +4 -0
  104. lmnr-0.7.26.dist-info/entry_points.txt +3 -0
  105. lmnr/opentelemetry_lib/tracing/context_properties.py +0 -65
  106. lmnr/sdk/browser/rrweb/rrweb.umd.min.cjs +0 -98
  107. lmnr/sdk/client/asynchronous/resources/agent.py +0 -329
  108. lmnr/sdk/client/synchronous/resources/agent.py +0 -323
  109. lmnr/sdk/datasets.py +0 -60
  110. lmnr-0.6.16.dist-info/LICENSE +0 -75
  111. lmnr-0.6.16.dist-info/RECORD +0 -61
  112. lmnr-0.6.16.dist-info/WHEEL +0 -4
  113. lmnr-0.6.16.dist-info/entry_points.txt +0 -3
@@ -0,0 +1,36 @@
1
+ # import base64
2
+ import base64
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from lmnr.sdk.utils import json_dumps
7
+ from pydantic import BaseModel
8
+
9
+
10
+ def screenshot_tool_output_formatter(output: Any) -> str:
11
+ # output is of type BinaryAPIResponse, which implements
12
+ # the iter_bytes method from httpx.Response
13
+
14
+ return "<BINARY_BLOB_SCREENSHOT>"
15
+ # The below implementation works, but it may consume the entire iterator,
16
+ # making the response unusable after the formatter is called.
17
+ # This is UNLESS somewhere in code output.read() (httpx.Response.read())
18
+ # is called.
19
+ # We cannot rely on that now, so we return a placeholder.
20
+ # response_bytes = []
21
+ # for chunk in output.iter_bytes():
22
+ # response_bytes.append(chunk)
23
+ # response_base64 = base64.b64encode(response_bytes).decode("utf-8")
24
+ # return f"data:image/png;base64,{response_base64}"
25
+
26
+
27
+ def process_tool_output_formatter(output: Any) -> str:
28
+ if not isinstance(output, (dict, BaseModel)):
29
+ return json_dumps(output)
30
+
31
+ output = output.model_dump() if isinstance(output, BaseModel) else deepcopy(output)
32
+ if "stderr_b64" in output:
33
+ output["stderr"] = base64.b64decode(output["stderr_b64"]).decode("utf-8")
34
+ if "stdout_b64" in output:
35
+ output["stdout"] = base64.b64decode(output["stdout_b64"]).decode("utf-8")
36
+ return json_dumps(output)
@@ -12,10 +12,7 @@ from langchain_core.runnables.graph import Graph
12
12
  from opentelemetry.trace import Tracer
13
13
  from wrapt import wrap_function_wrapper
14
14
  from opentelemetry.trace import get_tracer
15
-
16
- from lmnr.opentelemetry_lib.tracing.context_properties import (
17
- update_association_properties,
18
- )
15
+ from opentelemetry.context import get_value, attach, set_value
19
16
 
20
17
  from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
21
18
  from opentelemetry.instrumentation.utils import unwrap
@@ -45,12 +42,13 @@ def wrap_pregel_stream(tracer: Tracer, to_wrap, wrapped, instance, args, kwargs)
45
42
  }
46
43
  for edge in graph.edges
47
44
  ]
48
- update_association_properties(
49
- {
50
- "langgraph.edges": json.dumps(edges),
51
- "langgraph.nodes": json.dumps(nodes),
52
- },
53
- )
45
+ d = {
46
+ "langgraph.edges": json.dumps(edges),
47
+ "langgraph.nodes": json.dumps(nodes),
48
+ }
49
+ association_properties = get_value("lmnr.langgraph.graph") or {}
50
+ association_properties.update(d)
51
+ attach(set_value("lmnr.langgraph.graph", association_properties))
54
52
  return wrapped(*args, **kwargs)
55
53
 
56
54
 
@@ -75,12 +73,14 @@ async def async_wrap_pregel_stream(
75
73
  }
76
74
  for edge in graph.edges
77
75
  ]
78
- update_association_properties(
79
- {
80
- "langgraph.edges": json.dumps(edges),
81
- "langgraph.nodes": json.dumps(nodes),
82
- },
83
- )
76
+
77
+ d = {
78
+ "langgraph.edges": json.dumps(edges),
79
+ "langgraph.nodes": json.dumps(nodes),
80
+ }
81
+ association_properties = get_value("lmnr.langgraph.graph") or {}
82
+ association_properties.update(d)
83
+ attach(set_value("lmnr.langgraph.graph", association_properties))
84
84
 
85
85
  async for item in wrapped(*args, **kwargs):
86
86
  yield item
@@ -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,472 @@
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
+ _set_span_attribute(
148
+ span,
149
+ SpanAttributes.LLM_REQUEST_REASONING_EFFORT,
150
+ kwargs.get("reasoning_effort"),
151
+ )
152
+ _set_span_attribute(
153
+ span,
154
+ "openai.request.service_tier",
155
+ kwargs.get("service_tier"),
156
+ )
157
+ if response_format := kwargs.get("response_format"):
158
+ # backward-compatible check for
159
+ # openai.types.shared_params.response_format_json_schema.ResponseFormatJSONSchema
160
+ if (
161
+ isinstance(response_format, dict)
162
+ and response_format.get("type") == "json_schema"
163
+ and response_format.get("json_schema")
164
+ ):
165
+ schema = dict(response_format.get("json_schema")).get("schema")
166
+ if schema:
167
+ _set_span_attribute(
168
+ span,
169
+ # TODO: change to SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA
170
+ # when we upgrade to opentelemetry-semantic-conventions-ai>=0.4.10
171
+ "gen_ai.request.structured_output_schema",
172
+ json.dumps(schema),
173
+ )
174
+ else:
175
+ try:
176
+ from openai.lib._parsing._completions import (
177
+ type_to_response_format_param,
178
+ )
179
+
180
+ response_format_param = type_to_response_format_param(response_format)
181
+ if response_format_param.get("type") == "json_schema":
182
+ schema = response_format_param.get("json_schema").get("schema")
183
+ if schema:
184
+ _set_span_attribute(
185
+ span,
186
+ # TODO: change to SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA
187
+ # when we upgrade to opentelemetry-semantic-conventions-ai>=0.4.10
188
+ "gen_ai.request.structured_output_schema",
189
+ json.dumps(schema),
190
+ )
191
+ except (ImportError, TypeError, AttributeError):
192
+ # if we fail to import from openai.lib._parsing._completions,
193
+ # we fallback to the pydantic-based approach
194
+ if isinstance(response_format, pydantic.BaseModel) or (
195
+ hasattr(response_format, "model_json_schema")
196
+ and callable(response_format.model_json_schema)
197
+ ):
198
+ _set_span_attribute(
199
+ span,
200
+ # TODO: change to SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA
201
+ # when we upgrade to opentelemetry-semantic-conventions-ai>=0.4.10
202
+ "gen_ai.request.structured_output_schema",
203
+ json.dumps(response_format.model_json_schema()),
204
+ )
205
+ else:
206
+ schema = None
207
+ try:
208
+ schema = json.dumps(
209
+ pydantic.TypeAdapter(response_format).json_schema()
210
+ )
211
+ except Exception:
212
+ try:
213
+ schema = json.dumps(response_format)
214
+ except Exception:
215
+ pass
216
+
217
+ if schema:
218
+ _set_span_attribute(
219
+ span,
220
+ # TODO: change to SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA
221
+ # when we upgrade to opentelemetry-semantic-conventions-ai>=0.4.10
222
+ "gen_ai.request.structured_output_schema",
223
+ schema,
224
+ )
225
+
226
+
227
+ @dont_throw
228
+ def _set_response_attributes(span, response):
229
+ if not span.is_recording():
230
+ return
231
+
232
+ if "error" in response:
233
+ _set_span_attribute(
234
+ span,
235
+ f"{SpanAttributes.LLM_PROMPTS}.{PROMPT_ERROR}",
236
+ json.dumps(response.get("error")),
237
+ )
238
+ return
239
+
240
+ response_model = response.get("model")
241
+ if response_model:
242
+ response_model = _extract_model_name_from_provider_format(response_model)
243
+ _set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, response_model)
244
+ _set_span_attribute(span, GEN_AI_RESPONSE_ID, response.get("id"))
245
+
246
+ _set_span_attribute(
247
+ span,
248
+ SpanAttributes.LLM_OPENAI_RESPONSE_SYSTEM_FINGERPRINT,
249
+ response.get("system_fingerprint"),
250
+ )
251
+ _log_prompt_filter(span, response)
252
+ usage = response.get("usage")
253
+ _set_span_attribute(
254
+ span,
255
+ "openai.response.service_tier",
256
+ response.get("service_tier"),
257
+ )
258
+ if not usage:
259
+ return
260
+
261
+ if is_openai_v1() and not isinstance(usage, dict):
262
+ usage = usage.__dict__
263
+
264
+ _set_span_attribute(
265
+ span, SpanAttributes.LLM_USAGE_TOTAL_TOKENS, usage.get("total_tokens")
266
+ )
267
+ _set_span_attribute(
268
+ span,
269
+ SpanAttributes.LLM_USAGE_COMPLETION_TOKENS,
270
+ usage.get("completion_tokens"),
271
+ )
272
+ _set_span_attribute(
273
+ span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, usage.get("prompt_tokens")
274
+ )
275
+ prompt_tokens_details = dict(usage.get("prompt_tokens_details", {}))
276
+ _set_span_attribute(
277
+ span,
278
+ SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS,
279
+ prompt_tokens_details.get("cached_tokens", 0),
280
+ )
281
+
282
+ if completion_token_details := dict(usage.get("completion_tokens_details", {})):
283
+ reasoning_tokens = completion_token_details.get("reasoning_tokens")
284
+ _set_span_attribute(
285
+ span,
286
+ SpanAttributes.LLM_USAGE_REASONING_TOKENS,
287
+ reasoning_tokens or 0,
288
+ )
289
+
290
+ return
291
+
292
+
293
+ def _log_prompt_filter(span, response_dict):
294
+ if response_dict.get("prompt_filter_results"):
295
+ _set_span_attribute(
296
+ span,
297
+ f"{SpanAttributes.LLM_PROMPTS}.{PROMPT_FILTER_KEY}",
298
+ json.dumps(response_dict.get("prompt_filter_results")),
299
+ )
300
+
301
+
302
+ @dont_throw
303
+ def _set_span_stream_usage(span, prompt_tokens, completion_tokens):
304
+ if not span.is_recording():
305
+ return
306
+
307
+ if isinstance(completion_tokens, int) and completion_tokens >= 0:
308
+ _set_span_attribute(
309
+ span, SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, completion_tokens
310
+ )
311
+
312
+ if isinstance(prompt_tokens, int) and prompt_tokens >= 0:
313
+ _set_span_attribute(span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, prompt_tokens)
314
+
315
+ if (
316
+ isinstance(prompt_tokens, int)
317
+ and isinstance(completion_tokens, int)
318
+ and completion_tokens + prompt_tokens >= 0
319
+ ):
320
+ _set_span_attribute(
321
+ span,
322
+ SpanAttributes.LLM_USAGE_TOTAL_TOKENS,
323
+ completion_tokens + prompt_tokens,
324
+ )
325
+
326
+
327
+ def _get_openai_base_url(instance):
328
+ if hasattr(instance, "_client"):
329
+ client = instance._client # pylint: disable=protected-access
330
+ if isinstance(client, (openai.AsyncOpenAI, openai.OpenAI)):
331
+ return str(client.base_url)
332
+
333
+ return ""
334
+
335
+
336
+ def _get_vendor_from_url(base_url):
337
+ if not base_url:
338
+ return "openai"
339
+
340
+ if "openai.azure.com" in base_url:
341
+ return "Azure"
342
+ elif "amazonaws.com" in base_url or "bedrock" in base_url:
343
+ return "AWS"
344
+ elif "googleapis.com" in base_url or "vertex" in base_url:
345
+ return "Google"
346
+ elif "openrouter.ai" in base_url:
347
+ return "OpenRouter"
348
+
349
+ return "openai"
350
+
351
+
352
+ def _cross_region_check(value):
353
+ if not value or "." not in value:
354
+ return value
355
+
356
+ prefixes = ["us", "us-gov", "eu", "apac"]
357
+ if any(value.startswith(prefix + ".") for prefix in prefixes):
358
+ parts = value.split(".")
359
+ if len(parts) > 2:
360
+ return parts[2]
361
+ else:
362
+ return value
363
+ else:
364
+ vendor, model = value.split(".", 1)
365
+ return model
366
+
367
+
368
+ def _extract_model_name_from_provider_format(model_name):
369
+ """
370
+ Extract model name from provider/model format.
371
+ E.g., 'openai/gpt-4o' -> 'gpt-4o', 'anthropic/claude-3-sonnet' -> 'claude-3-sonnet'
372
+ """
373
+ if not model_name:
374
+ return model_name
375
+
376
+ if "/" in model_name:
377
+ parts = model_name.split("/")
378
+ return parts[-1] # Return the last part (actual model name)
379
+
380
+ return model_name
381
+
382
+
383
+ def is_streaming_response(response):
384
+ if is_openai_v1():
385
+ return isinstance(response, openai.Stream) or isinstance(
386
+ response, openai.AsyncStream
387
+ )
388
+
389
+ return isinstance(response, types.GeneratorType) or isinstance(
390
+ response, types.AsyncGeneratorType
391
+ )
392
+
393
+
394
+ def model_as_dict(model):
395
+ if isinstance(model, dict):
396
+ return model
397
+ if _PYDANTIC_VERSION < "2.0.0":
398
+ return model.dict()
399
+ if hasattr(model, "model_dump"):
400
+ return model.model_dump()
401
+ elif hasattr(model, "parse"): # Raw API response
402
+ return model_as_dict(model.parse())
403
+ else:
404
+ return model
405
+
406
+
407
+ def get_token_count_from_string(string: str, model_name: str):
408
+ if not should_record_stream_token_usage():
409
+ return None
410
+
411
+ import tiktoken
412
+
413
+ if tiktoken_encodings.get(model_name) is None:
414
+ try:
415
+ encoding = tiktoken.encoding_for_model(model_name)
416
+ except KeyError as ex:
417
+ # no such model_name in tiktoken
418
+ logger.warning(
419
+ f"Failed to get tiktoken encoding for model_name {model_name}, error: {str(ex)}"
420
+ )
421
+ return None
422
+ except Exception as ex:
423
+ # Other exceptions in tiktoken
424
+ logger.warning(
425
+ f"Failed to get tiktoken encoding for model_name {model_name}, error: {str(ex)}"
426
+ )
427
+ return None
428
+
429
+ tiktoken_encodings[model_name] = encoding
430
+ else:
431
+ encoding = tiktoken_encodings.get(model_name)
432
+
433
+ token_count = len(encoding.encode(string))
434
+ return token_count
435
+
436
+
437
+ def _token_type(token_type: str):
438
+ if token_type == "prompt_tokens":
439
+ return "input"
440
+ elif token_type == "completion_tokens":
441
+ return "output"
442
+
443
+ return None
444
+
445
+
446
+ def metric_shared_attributes(
447
+ response_model: str, operation: str, server_address: str, is_streaming: bool = False
448
+ ):
449
+ attributes = Config.get_common_metrics_attributes()
450
+ vendor = _get_vendor_from_url(server_address)
451
+
452
+ return {
453
+ **attributes,
454
+ SpanAttributes.LLM_SYSTEM: vendor,
455
+ SpanAttributes.LLM_RESPONSE_MODEL: response_model,
456
+ "gen_ai.operation.name": operation,
457
+ "server.address": server_address,
458
+ "stream": is_streaming,
459
+ }
460
+
461
+
462
+ def propagate_trace_context(span, kwargs):
463
+ if is_openai_v1():
464
+ extra_headers = kwargs.get("extra_headers", {})
465
+ ctx = set_span_in_context(span)
466
+ TraceContextTextMapPropagator().inject(extra_headers, context=ctx)
467
+ kwargs["extra_headers"] = extra_headers
468
+ else:
469
+ headers = kwargs.get("headers", {})
470
+ ctx = set_span_in_context(span)
471
+ TraceContextTextMapPropagator().inject(headers, context=ctx)
472
+ kwargs["headers"] = headers