opik 1.9.41__py3-none-any.whl → 1.9.86__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 (192) hide show
  1. opik/api_objects/attachment/attachment_context.py +36 -0
  2. opik/api_objects/attachment/attachments_extractor.py +153 -0
  3. opik/api_objects/attachment/client.py +1 -0
  4. opik/api_objects/attachment/converters.py +2 -0
  5. opik/api_objects/attachment/decoder.py +18 -0
  6. opik/api_objects/attachment/decoder_base64.py +83 -0
  7. opik/api_objects/attachment/decoder_helpers.py +137 -0
  8. opik/api_objects/constants.py +2 -0
  9. opik/api_objects/dataset/dataset.py +133 -40
  10. opik/api_objects/dataset/rest_operations.py +2 -0
  11. opik/api_objects/experiment/experiment.py +6 -0
  12. opik/api_objects/helpers.py +8 -4
  13. opik/api_objects/local_recording.py +6 -5
  14. opik/api_objects/observation_data.py +101 -0
  15. opik/api_objects/opik_client.py +78 -45
  16. opik/api_objects/opik_query_language.py +9 -3
  17. opik/api_objects/prompt/chat/chat_prompt.py +18 -1
  18. opik/api_objects/prompt/client.py +8 -1
  19. opik/api_objects/span/span_data.py +3 -88
  20. opik/api_objects/threads/threads_client.py +7 -4
  21. opik/api_objects/trace/trace_data.py +3 -74
  22. opik/api_objects/validation_helpers.py +3 -3
  23. opik/cli/exports/__init__.py +131 -0
  24. opik/cli/exports/dataset.py +278 -0
  25. opik/cli/exports/experiment.py +784 -0
  26. opik/cli/exports/project.py +685 -0
  27. opik/cli/exports/prompt.py +578 -0
  28. opik/cli/exports/utils.py +406 -0
  29. opik/cli/harbor.py +39 -0
  30. opik/cli/imports/__init__.py +439 -0
  31. opik/cli/imports/dataset.py +143 -0
  32. opik/cli/imports/experiment.py +1192 -0
  33. opik/cli/imports/project.py +262 -0
  34. opik/cli/imports/prompt.py +177 -0
  35. opik/cli/imports/utils.py +280 -0
  36. opik/cli/main.py +14 -12
  37. opik/config.py +12 -1
  38. opik/datetime_helpers.py +12 -0
  39. opik/decorator/arguments_helpers.py +4 -1
  40. opik/decorator/base_track_decorator.py +111 -37
  41. opik/decorator/context_manager/span_context_manager.py +5 -1
  42. opik/decorator/generator_wrappers.py +5 -4
  43. opik/decorator/span_creation_handler.py +13 -4
  44. opik/evaluation/engine/engine.py +111 -28
  45. opik/evaluation/engine/evaluation_tasks_executor.py +71 -19
  46. opik/evaluation/evaluator.py +12 -0
  47. opik/evaluation/metrics/conversation/llm_judges/conversational_coherence/metric.py +3 -1
  48. opik/evaluation/metrics/conversation/llm_judges/session_completeness/metric.py +3 -1
  49. opik/evaluation/metrics/conversation/llm_judges/user_frustration/metric.py +3 -1
  50. opik/evaluation/metrics/heuristics/equals.py +11 -7
  51. opik/evaluation/metrics/llm_judges/answer_relevance/metric.py +3 -1
  52. opik/evaluation/metrics/llm_judges/context_precision/metric.py +3 -1
  53. opik/evaluation/metrics/llm_judges/context_recall/metric.py +3 -1
  54. opik/evaluation/metrics/llm_judges/factuality/metric.py +1 -1
  55. opik/evaluation/metrics/llm_judges/g_eval/metric.py +3 -1
  56. opik/evaluation/metrics/llm_judges/hallucination/metric.py +3 -1
  57. opik/evaluation/metrics/llm_judges/moderation/metric.py +3 -1
  58. opik/evaluation/metrics/llm_judges/structure_output_compliance/metric.py +3 -1
  59. opik/evaluation/metrics/llm_judges/syc_eval/metric.py +4 -2
  60. opik/evaluation/metrics/llm_judges/trajectory_accuracy/metric.py +3 -1
  61. opik/evaluation/metrics/llm_judges/usefulness/metric.py +3 -1
  62. opik/evaluation/metrics/ragas_metric.py +43 -23
  63. opik/evaluation/models/litellm/litellm_chat_model.py +7 -2
  64. opik/evaluation/models/litellm/util.py +4 -20
  65. opik/evaluation/models/models_factory.py +19 -5
  66. opik/evaluation/rest_operations.py +3 -3
  67. opik/evaluation/threads/helpers.py +3 -2
  68. opik/file_upload/file_uploader.py +13 -0
  69. opik/file_upload/upload_options.py +2 -0
  70. opik/integrations/adk/legacy_opik_tracer.py +9 -11
  71. opik/integrations/adk/opik_tracer.py +2 -2
  72. opik/integrations/adk/patchers/adk_otel_tracer/opik_adk_otel_tracer.py +2 -2
  73. opik/integrations/dspy/callback.py +100 -14
  74. opik/integrations/dspy/parsers.py +168 -0
  75. opik/integrations/harbor/__init__.py +17 -0
  76. opik/integrations/harbor/experiment_service.py +269 -0
  77. opik/integrations/harbor/opik_tracker.py +528 -0
  78. opik/integrations/haystack/opik_tracer.py +2 -2
  79. opik/integrations/langchain/__init__.py +15 -2
  80. opik/integrations/langchain/langgraph_tracer_injector.py +88 -0
  81. opik/integrations/langchain/opik_tracer.py +258 -160
  82. opik/integrations/langchain/provider_usage_extractors/langchain_run_helpers/helpers.py +7 -4
  83. opik/integrations/llama_index/callback.py +43 -6
  84. opik/integrations/openai/agents/opik_tracing_processor.py +8 -10
  85. opik/integrations/openai/opik_tracker.py +99 -4
  86. opik/integrations/openai/videos/__init__.py +9 -0
  87. opik/integrations/openai/videos/binary_response_write_to_file_decorator.py +88 -0
  88. opik/integrations/openai/videos/videos_create_decorator.py +159 -0
  89. opik/integrations/openai/videos/videos_download_decorator.py +110 -0
  90. opik/message_processing/batching/base_batcher.py +14 -21
  91. opik/message_processing/batching/batch_manager.py +22 -10
  92. opik/message_processing/batching/batchers.py +32 -40
  93. opik/message_processing/batching/flushing_thread.py +0 -3
  94. opik/message_processing/emulation/emulator_message_processor.py +36 -1
  95. opik/message_processing/emulation/models.py +21 -0
  96. opik/message_processing/messages.py +9 -0
  97. opik/message_processing/preprocessing/__init__.py +0 -0
  98. opik/message_processing/preprocessing/attachments_preprocessor.py +70 -0
  99. opik/message_processing/preprocessing/batching_preprocessor.py +53 -0
  100. opik/message_processing/preprocessing/constants.py +1 -0
  101. opik/message_processing/preprocessing/file_upload_preprocessor.py +38 -0
  102. opik/message_processing/preprocessing/preprocessor.py +36 -0
  103. opik/message_processing/processors/__init__.py +0 -0
  104. opik/message_processing/processors/attachments_extraction_processor.py +146 -0
  105. opik/message_processing/{message_processors.py → processors/message_processors.py} +15 -1
  106. opik/message_processing/{message_processors_chain.py → processors/message_processors_chain.py} +3 -2
  107. opik/message_processing/{online_message_processor.py → processors/online_message_processor.py} +11 -9
  108. opik/message_processing/queue_consumer.py +4 -2
  109. opik/message_processing/streamer.py +71 -33
  110. opik/message_processing/streamer_constructors.py +36 -8
  111. opik/plugins/pytest/experiment_runner.py +1 -1
  112. opik/plugins/pytest/hooks.py +5 -3
  113. opik/rest_api/__init__.py +38 -0
  114. opik/rest_api/datasets/client.py +249 -148
  115. opik/rest_api/datasets/raw_client.py +356 -217
  116. opik/rest_api/experiments/client.py +26 -0
  117. opik/rest_api/experiments/raw_client.py +26 -0
  118. opik/rest_api/llm_provider_key/client.py +4 -4
  119. opik/rest_api/llm_provider_key/raw_client.py +4 -4
  120. opik/rest_api/llm_provider_key/types/provider_api_key_write_provider.py +2 -1
  121. opik/rest_api/manual_evaluation/client.py +101 -0
  122. opik/rest_api/manual_evaluation/raw_client.py +172 -0
  123. opik/rest_api/optimizations/client.py +0 -166
  124. opik/rest_api/optimizations/raw_client.py +0 -248
  125. opik/rest_api/projects/client.py +9 -0
  126. opik/rest_api/projects/raw_client.py +13 -0
  127. opik/rest_api/projects/types/project_metric_request_public_metric_type.py +4 -0
  128. opik/rest_api/prompts/client.py +130 -2
  129. opik/rest_api/prompts/raw_client.py +175 -0
  130. opik/rest_api/traces/client.py +101 -0
  131. opik/rest_api/traces/raw_client.py +120 -0
  132. opik/rest_api/types/__init__.py +46 -0
  133. opik/rest_api/types/audio_url.py +19 -0
  134. opik/rest_api/types/audio_url_public.py +19 -0
  135. opik/rest_api/types/audio_url_write.py +19 -0
  136. opik/rest_api/types/automation_rule_evaluator.py +38 -2
  137. opik/rest_api/types/automation_rule_evaluator_object_object_public.py +33 -2
  138. opik/rest_api/types/automation_rule_evaluator_public.py +33 -2
  139. opik/rest_api/types/automation_rule_evaluator_span_user_defined_metric_python.py +22 -0
  140. opik/rest_api/types/automation_rule_evaluator_span_user_defined_metric_python_public.py +22 -0
  141. opik/rest_api/types/automation_rule_evaluator_span_user_defined_metric_python_write.py +22 -0
  142. opik/rest_api/types/automation_rule_evaluator_update.py +27 -1
  143. opik/rest_api/types/automation_rule_evaluator_update_span_user_defined_metric_python.py +22 -0
  144. opik/rest_api/types/automation_rule_evaluator_write.py +27 -1
  145. opik/rest_api/types/dataset_item.py +1 -1
  146. opik/rest_api/types/dataset_item_batch.py +4 -0
  147. opik/rest_api/types/dataset_item_changes_public.py +5 -0
  148. opik/rest_api/types/dataset_item_compare.py +1 -1
  149. opik/rest_api/types/dataset_item_filter.py +4 -0
  150. opik/rest_api/types/dataset_item_page_compare.py +0 -1
  151. opik/rest_api/types/dataset_item_page_public.py +0 -1
  152. opik/rest_api/types/dataset_item_public.py +1 -1
  153. opik/rest_api/types/dataset_version_public.py +5 -0
  154. opik/rest_api/types/dataset_version_summary.py +5 -0
  155. opik/rest_api/types/dataset_version_summary_public.py +5 -0
  156. opik/rest_api/types/experiment.py +9 -0
  157. opik/rest_api/types/experiment_public.py +9 -0
  158. opik/rest_api/types/llm_as_judge_message_content.py +2 -0
  159. opik/rest_api/types/llm_as_judge_message_content_public.py +2 -0
  160. opik/rest_api/types/llm_as_judge_message_content_write.py +2 -0
  161. opik/rest_api/types/manual_evaluation_request_entity_type.py +1 -1
  162. opik/rest_api/types/project.py +1 -0
  163. opik/rest_api/types/project_detailed.py +1 -0
  164. opik/rest_api/types/project_metric_response_public_metric_type.py +4 -0
  165. opik/rest_api/types/project_reference.py +31 -0
  166. opik/rest_api/types/project_reference_public.py +31 -0
  167. opik/rest_api/types/project_stats_summary_item.py +1 -0
  168. opik/rest_api/types/prompt_version.py +1 -0
  169. opik/rest_api/types/prompt_version_detail.py +1 -0
  170. opik/rest_api/types/prompt_version_page_public.py +5 -0
  171. opik/rest_api/types/prompt_version_public.py +1 -0
  172. opik/rest_api/types/prompt_version_update.py +33 -0
  173. opik/rest_api/types/provider_api_key.py +5 -1
  174. opik/rest_api/types/provider_api_key_provider.py +2 -1
  175. opik/rest_api/types/provider_api_key_public.py +5 -1
  176. opik/rest_api/types/provider_api_key_public_provider.py +2 -1
  177. opik/rest_api/types/service_toggles_config.py +11 -1
  178. opik/rest_api/types/span_user_defined_metric_python_code.py +20 -0
  179. opik/rest_api/types/span_user_defined_metric_python_code_public.py +20 -0
  180. opik/rest_api/types/span_user_defined_metric_python_code_write.py +20 -0
  181. opik/types.py +36 -0
  182. opik/validation/chat_prompt_messages.py +241 -0
  183. opik/validation/feedback_score.py +3 -3
  184. opik/validation/validator.py +28 -0
  185. {opik-1.9.41.dist-info → opik-1.9.86.dist-info}/METADATA +5 -5
  186. {opik-1.9.41.dist-info → opik-1.9.86.dist-info}/RECORD +190 -141
  187. opik/cli/export.py +0 -791
  188. opik/cli/import_command.py +0 -575
  189. {opik-1.9.41.dist-info → opik-1.9.86.dist-info}/WHEEL +0 -0
  190. {opik-1.9.41.dist-info → opik-1.9.86.dist-info}/entry_points.txt +0 -0
  191. {opik-1.9.41.dist-info → opik-1.9.86.dist-info}/licenses/LICENSE +0 -0
  192. {opik-1.9.41.dist-info → opik-1.9.86.dist-info}/top_level.txt +0 -0
@@ -67,6 +67,21 @@ class LlamaIndexCallbackHandler(base_handler.BaseCallbackHandler):
67
67
  self._map_event_id_to_span_data: Dict[str, span.SpanData] = {}
68
68
  self._map_event_id_to_output: Dict[str, Any] = {}
69
69
 
70
+ # For streaming: end_trace may be called before event_end, so we need to
71
+ # defer the trace output update until the event output is available
72
+ self._pending_root_output_updates: Dict[
73
+ str, Union[span.SpanData, trace.TraceData]
74
+ ] = {}
75
+
76
+ def _send_root_to_backend(
77
+ self, root: Union[span.SpanData, trace.TraceData]
78
+ ) -> None:
79
+ """Send root trace or span data to the backend."""
80
+ if isinstance(root, span.SpanData):
81
+ self._opik_client.span(**root.as_parameters)
82
+ elif isinstance(root, trace.TraceData):
83
+ self._opik_client.trace(**root.as_parameters)
84
+
70
85
  def start_trace(self, trace_id: Optional[str] = None) -> None:
71
86
  if (
72
87
  self._skip_index_construction_trace
@@ -111,17 +126,29 @@ class LlamaIndexCallbackHandler(base_handler.BaseCallbackHandler):
111
126
  return
112
127
 
113
128
  last_event = _get_last_event(trace_map)
114
- last_event_output = self._map_event_id_to_output.get(last_event, None)
115
129
 
116
- root.init_end_time().update(output=last_event_output)
130
+ # Check if the output for the last event is already available.
131
+ # For streaming calls, LlamaIndex calls end_trace() BEFORE event_end(),
132
+ # so the output won't be stored yet.
133
+ if last_event in self._map_event_id_to_output:
134
+ last_event_output = self._map_event_id_to_output.get(last_event)
135
+ root.init_end_time().update(output=last_event_output)
117
136
 
137
+ # Send the trace/span with output
138
+ self._send_root_to_backend(root)
139
+ else:
140
+ # Output not available yet (streaming scenario).
141
+ # Store the root so we can update it when event_end is called.
142
+ # Don't send the trace/span yet - it will be sent in on_event_end
143
+ # with the output and correct end_time to avoid race conditions.
144
+ # Note: We don't set end_time here because the actual end is when
145
+ # the last event ends, not when LlamaIndex calls end_trace().
146
+ self._pending_root_output_updates[last_event] = root
147
+
148
+ # Clean up context storage
118
149
  if isinstance(root, span.SpanData):
119
- # We created a wrapper span (external trace existed)
120
- self._opik_client.span(**root.as_parameters)
121
150
  self._opik_context_storage.pop_span_data(ensure_id=root.id)
122
151
  elif isinstance(root, trace.TraceData):
123
- # We created a trace (no external trace existed)
124
- self._opik_client.trace(**root.as_parameters)
125
152
  self._opik_context_storage.pop_trace_data(ensure_id=root.id)
126
153
 
127
154
  # Clean up
@@ -213,6 +240,16 @@ class LlamaIndexCallbackHandler(base_handler.BaseCallbackHandler):
213
240
  # Store output for end_trace
214
241
  self._map_event_id_to_output[event_id] = span_output
215
242
 
243
+ # Check if there's a pending root trace/span output update for this event.
244
+ # This happens when end_trace() was called before event_end() (streaming scenario).
245
+ if event_id in self._pending_root_output_updates:
246
+ root = self._pending_root_output_updates.pop(event_id)
247
+ # Set end_time now (the actual end) and update with output
248
+ root.init_end_time().update(output=span_output)
249
+
250
+ # Send the trace/span to the backend with correct end_time and output
251
+ self._send_root_to_backend(root)
252
+
216
253
  # Finalize span if it exists
217
254
  if event_id in self._map_event_id_to_span_data:
218
255
  span_data = self._map_event_id_to_span_data[event_id]
@@ -82,24 +82,22 @@ class OpikTracingProcessor(tracing.TracingProcessor):
82
82
  metadata=trace_metadata,
83
83
  type="general",
84
84
  )
85
- _, opik_span_data = (
86
- span_creation_handler.create_span_respecting_context(
87
- start_span_arguments=start_span_arguments,
88
- distributed_trace_headers=None,
89
- opik_context_storage=self._opik_context_storage,
90
- )
85
+ result = span_creation_handler.create_span_respecting_context(
86
+ start_span_arguments=start_span_arguments,
87
+ distributed_trace_headers=None,
88
+ opik_context_storage=self._opik_context_storage,
91
89
  )
92
- self._opik_spans_data_map[trace.trace_id] = opik_span_data
93
- self._opik_context_storage.add_span_data(opik_span_data)
90
+ self._opik_spans_data_map[trace.trace_id] = result.span_data
91
+ self._opik_context_storage.add_span_data(result.span_data)
94
92
  self._openai_trace_id_to_external_opik_parent_span_id[
95
93
  trace.trace_id
96
- ] = opik_span_data.parent_span_id
94
+ ] = result.span_data.parent_span_id
97
95
 
98
96
  if (
99
97
  self._opik_client.config.log_start_trace_span
100
98
  and tracing_runtime_config.is_tracing_active()
101
99
  ):
102
- self._opik_client.span(**opik_span_data.as_start_parameters)
100
+ self._opik_client.span(**result.span_data.as_start_parameters)
103
101
 
104
102
  except Exception:
105
103
  LOGGER.error("on_trace_start failed", exc_info=True)
@@ -1,6 +1,7 @@
1
1
  from typing import Optional, TypeVar
2
2
 
3
3
  import openai
4
+ import opik
4
5
 
5
6
  from . import (
6
7
  chat_completion_chunks_aggregator,
@@ -11,6 +12,13 @@ import opik.semantic_version as semantic_version
11
12
  OpenAIClient = TypeVar("OpenAIClient", openai.OpenAI, openai.AsyncOpenAI)
12
13
 
13
14
 
15
+ def _get_provider(openai_client: OpenAIClient) -> str:
16
+ """Get the provider name from the OpenAI client's base URL."""
17
+ if openai_client.base_url.host != "api.openai.com":
18
+ return openai_client.base_url.host
19
+ return "openai"
20
+
21
+
14
22
  def track_openai(
15
23
  openai_client: OpenAIClient,
16
24
  project_name: Optional[str] = None,
@@ -27,6 +35,9 @@ def track_openai(
27
35
  * `openai_client.beta.chat.completions.parse()`
28
36
  * `openai_client.beta.chat.completions.stream()`
29
37
  * `openai_client.responses.create()`
38
+ * `openai_client.videos.create()`, `videos.create_and_poll()`, `videos.poll()`,
39
+ `videos.list()`, `videos.delete()`, `videos.remix()`, `videos.download_content()`,
40
+ and `write_to_file()` on downloaded content
30
41
 
31
42
  Can be used within other Opik-tracked functions.
32
43
 
@@ -47,6 +58,9 @@ def track_openai(
47
58
  if hasattr(openai_client, "responses"):
48
59
  _patch_openai_responses(openai_client, project_name)
49
60
 
61
+ if hasattr(openai_client, "videos"):
62
+ _patch_openai_videos(openai_client, project_name)
63
+
50
64
  return openai_client
51
65
 
52
66
 
@@ -57,8 +71,7 @@ def _patch_openai_chat_completions(
57
71
  chat_completions_decorator_factory = (
58
72
  openai_chat_completions_decorator.OpenaiChatCompletionsTrackDecorator()
59
73
  )
60
- if openai_client.base_url.host != "api.openai.com":
61
- chat_completions_decorator_factory.provider = openai_client.base_url.host
74
+ chat_completions_decorator_factory.provider = _get_provider(openai_client)
62
75
 
63
76
  completions_create_decorator = chat_completions_decorator_factory.track(
64
77
  type="llm",
@@ -119,8 +132,7 @@ def _patch_openai_responses(
119
132
  responses_decorator_factory = (
120
133
  openai_responses_decorator.OpenaiResponsesTrackDecorator()
121
134
  )
122
- if openai_client.base_url.host != "api.openai.com":
123
- responses_decorator_factory.provider = openai_client.base_url.host
135
+ responses_decorator_factory.provider = _get_provider(openai_client)
124
136
 
125
137
  if hasattr(openai_client.responses, "create"):
126
138
  responses_create_decorator = responses_decorator_factory.track(
@@ -143,3 +155,86 @@ def _patch_openai_responses(
143
155
  openai_client.responses.parse = responses_parse_decorator(
144
156
  openai_client.responses.parse
145
157
  )
158
+
159
+
160
+ def _patch_openai_videos(
161
+ openai_client: OpenAIClient,
162
+ project_name: Optional[str] = None,
163
+ ) -> None:
164
+ from . import videos
165
+
166
+ provider = _get_provider(openai_client)
167
+ create_decorator_factory = videos.VideosCreateTrackDecorator(provider=provider)
168
+ download_decorator_factory = videos.VideosDownloadTrackDecorator()
169
+
170
+ video_metadata = {"created_from": "openai", "type": "openai_videos"}
171
+ video_tags = ["openai"]
172
+
173
+ if hasattr(openai_client.videos, "create"):
174
+ decorator = create_decorator_factory.track(
175
+ type="llm",
176
+ name="videos.create",
177
+ project_name=project_name,
178
+ )
179
+ openai_client.videos.create = decorator(openai_client.videos.create)
180
+
181
+ if hasattr(openai_client.videos, "create_and_poll"):
182
+ decorator = opik.track(
183
+ name="videos.create_and_poll",
184
+ tags=video_tags,
185
+ metadata=video_metadata,
186
+ project_name=project_name,
187
+ )
188
+ openai_client.videos.create_and_poll = decorator(
189
+ openai_client.videos.create_and_poll
190
+ )
191
+
192
+ if hasattr(openai_client.videos, "remix"):
193
+ decorator = create_decorator_factory.track(
194
+ type="llm",
195
+ name="videos.remix",
196
+ project_name=project_name,
197
+ )
198
+ openai_client.videos.remix = decorator(openai_client.videos.remix)
199
+
200
+ # Note: videos.retrieve is intentionally NOT patched to avoid too many spans
201
+ # since it's called frequently during polling operations.
202
+
203
+ if hasattr(openai_client.videos, "poll"):
204
+ decorator = opik.track(
205
+ name="videos.poll",
206
+ tags=video_tags,
207
+ metadata=video_metadata,
208
+ project_name=project_name,
209
+ )
210
+ openai_client.videos.poll = decorator(openai_client.videos.poll)
211
+
212
+ if hasattr(openai_client.videos, "delete"):
213
+ decorator = opik.track(
214
+ name="videos.delete",
215
+ tags=video_tags,
216
+ metadata=video_metadata,
217
+ project_name=project_name,
218
+ )
219
+ openai_client.videos.delete = decorator(openai_client.videos.delete)
220
+
221
+ # Patch download_content - also patches write_to_file on returned instances
222
+ # download_content returns a lazy response object, write_to_file does the actual download
223
+ if hasattr(openai_client.videos, "download_content"):
224
+ decorator = download_decorator_factory.track(
225
+ type="general",
226
+ name="videos.download_content",
227
+ project_name=project_name,
228
+ )
229
+ openai_client.videos.download_content = decorator(
230
+ openai_client.videos.download_content
231
+ )
232
+
233
+ if hasattr(openai_client.videos, "list"):
234
+ decorator = opik.track(
235
+ name="videos.list",
236
+ tags=video_tags,
237
+ metadata=video_metadata,
238
+ project_name=project_name,
239
+ )
240
+ openai_client.videos.list = decorator(openai_client.videos.list)
@@ -0,0 +1,9 @@
1
+ from .videos_create_decorator import VideosCreateTrackDecorator
2
+ from .videos_download_decorator import VideosDownloadTrackDecorator
3
+ from . import binary_response_write_to_file_decorator
4
+
5
+ __all__ = [
6
+ "VideosCreateTrackDecorator",
7
+ "VideosDownloadTrackDecorator",
8
+ "binary_response_write_to_file_decorator",
9
+ ]
@@ -0,0 +1,88 @@
1
+ """
2
+ Decorator for HttpxBinaryResponseContent.write_to_file method.
3
+ """
4
+
5
+ import functools
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import tempfile
10
+ from typing import Callable, Optional, Any
11
+
12
+ import opik
13
+ from opik.api_objects import attachment, span
14
+
15
+ LOGGER = logging.getLogger(__name__)
16
+
17
+
18
+ def create_write_to_file_decorator(
19
+ project_name: Optional[str] = None,
20
+ ) -> Callable[[Callable[[str], None]], Callable[[str], None]]:
21
+ """
22
+ Create a decorator that tracks write_to_file calls.
23
+
24
+ Uses functools.wraps with opik context manager to avoid limitations
25
+ of BaseTrackDecorator when the wrapped function returns None.
26
+ """
27
+
28
+ def decorator(func: Callable[[str], None]) -> Callable[[str], None]:
29
+ @functools.wraps(func)
30
+ def wrapper(file: str) -> Any:
31
+ with opik.start_as_current_span(
32
+ name="videos.write_to_file",
33
+ input={"file": str(file)},
34
+ metadata={"created_from": "openai", "type": "openai_videos"},
35
+ tags=["openai"],
36
+ type="general",
37
+ project_name=project_name,
38
+ ) as span_data:
39
+ result = func(file)
40
+ _attach_video_file(file, span_data)
41
+
42
+ # The result is None but we still return it in case the return value
43
+ # is changed in the future.
44
+ return result
45
+
46
+ return wrapper
47
+
48
+ return decorator
49
+
50
+
51
+ def _attach_video_file(file_path: str, span_data: span.SpanData) -> None:
52
+ """
53
+ Create a temporary copy of the video file and attach it to the span.
54
+
55
+ A copy is made to ensure the file remains available for upload even if
56
+ the user deletes the original file. The temp file is created with
57
+ delete=False so it persists until the upload manager processes it.
58
+ """
59
+ try:
60
+ file_str = str(file_path)
61
+ file_name = os.path.basename(file_str)
62
+ _, extension = os.path.splitext(file_name)
63
+
64
+ # Create a temporary copy of the video file
65
+ temp_file = tempfile.NamedTemporaryFile(
66
+ mode="wb", delete=False, suffix=extension
67
+ )
68
+ shutil.copyfileobj(open(file_str, "rb"), temp_file)
69
+ temp_file.flush()
70
+ temp_file.close()
71
+
72
+ video_attachment = attachment.Attachment(
73
+ data=temp_file.name,
74
+ file_name=file_name,
75
+ content_type="video/mp4",
76
+ )
77
+ span_data.update(attachments=[video_attachment])
78
+
79
+ LOGGER.debug(
80
+ "Video attachment created from temporary copy: %s -> %s",
81
+ file_str,
82
+ temp_file.name,
83
+ )
84
+ except Exception as e:
85
+ LOGGER.error(
86
+ "Failed to attach video to span: %s",
87
+ str(e),
88
+ )
@@ -0,0 +1,159 @@
1
+ """
2
+ Decorator for OpenAI video creation methods (create, remix).
3
+
4
+ Output type: openai.types.video.Video
5
+
6
+ This decorator is used only for LLM spans that generate videos.
7
+ """
8
+
9
+ import logging
10
+ from typing import (
11
+ Any,
12
+ Callable,
13
+ Dict,
14
+ List,
15
+ Optional,
16
+ Tuple,
17
+ TYPE_CHECKING,
18
+ )
19
+
20
+ from typing_extensions import override
21
+
22
+ import opik.dict_utils as dict_utils
23
+ from opik.api_objects import span
24
+ from opik.decorator import arguments_helpers, base_track_decorator
25
+
26
+ if TYPE_CHECKING:
27
+ from openai.types.video import Video
28
+
29
+ LOGGER = logging.getLogger(__name__)
30
+
31
+ # Input parameters to log for video generation
32
+ VIDEO_CREATE_KWARGS_KEYS_TO_LOG_AS_INPUTS = [
33
+ "prompt",
34
+ "seconds",
35
+ "size",
36
+ ]
37
+
38
+ # Input parameters to log for video remix
39
+ VIDEO_REMIX_KWARGS_KEYS_TO_LOG_AS_INPUTS = [
40
+ "video_id",
41
+ "prompt",
42
+ ]
43
+
44
+ # Response keys to log as output for Video object
45
+ VIDEO_RESPONSE_KEYS_TO_LOG_AS_OUTPUT = [
46
+ "id",
47
+ "status",
48
+ "prompt",
49
+ "seconds",
50
+ "size",
51
+ "progress",
52
+ "error",
53
+ ]
54
+
55
+
56
+ class VideosCreateTrackDecorator(base_track_decorator.BaseTrackDecorator):
57
+ """
58
+ Decorator for tracking OpenAI video creation methods (LLM spans).
59
+
60
+ Handles: videos.create, videos.remix
61
+ """
62
+
63
+ def __init__(self, provider: str) -> None:
64
+ super().__init__()
65
+ self.provider = provider
66
+
67
+ @override
68
+ def _start_span_inputs_preprocessor(
69
+ self,
70
+ func: Callable,
71
+ track_options: arguments_helpers.TrackOptions,
72
+ args: Tuple,
73
+ kwargs: Dict[str, Any],
74
+ ) -> arguments_helpers.StartSpanParameters:
75
+ assert kwargs is not None, "Expected kwargs to be not None in videos API calls"
76
+
77
+ name = track_options.name if track_options.name is not None else func.__name__
78
+
79
+ metadata = track_options.metadata if track_options.metadata is not None else {}
80
+
81
+ # Determine which keys to log based on the method name
82
+ func_name = func.__name__
83
+ if func_name == "remix":
84
+ keys_to_log = VIDEO_REMIX_KWARGS_KEYS_TO_LOG_AS_INPUTS
85
+ else:
86
+ keys_to_log = VIDEO_CREATE_KWARGS_KEYS_TO_LOG_AS_INPUTS
87
+
88
+ input_data, new_metadata = dict_utils.split_dict_by_keys(
89
+ kwargs, keys=keys_to_log
90
+ )
91
+ metadata = dict_utils.deepmerge(metadata, new_metadata)
92
+ metadata.update(
93
+ {
94
+ "created_from": "openai",
95
+ "type": "openai_videos",
96
+ }
97
+ )
98
+
99
+ tags = ["openai"]
100
+ model = kwargs.get("model", None)
101
+
102
+ result = arguments_helpers.StartSpanParameters(
103
+ name=name,
104
+ input=input_data,
105
+ type=track_options.type,
106
+ tags=tags,
107
+ metadata=metadata,
108
+ project_name=track_options.project_name,
109
+ model=model,
110
+ provider=self.provider,
111
+ )
112
+
113
+ return result
114
+
115
+ @override
116
+ def _end_span_inputs_preprocessor(
117
+ self,
118
+ output: Optional["Video"],
119
+ capture_output: bool,
120
+ current_span_data: span.SpanData,
121
+ ) -> arguments_helpers.EndSpanParameters:
122
+ if output is None:
123
+ return arguments_helpers.EndSpanParameters(
124
+ output=None,
125
+ usage=None,
126
+ metadata={},
127
+ model=None,
128
+ provider=self.provider,
129
+ )
130
+
131
+ result_dict: Dict[str, Any] = output.model_dump(mode="json")
132
+
133
+ output_data, metadata = dict_utils.split_dict_by_keys(
134
+ result_dict, VIDEO_RESPONSE_KEYS_TO_LOG_AS_OUTPUT
135
+ )
136
+
137
+ # Add video generation info to metadata for cost calculation
138
+ if result_dict.get("seconds") is not None:
139
+ metadata["video_seconds"] = int(result_dict["seconds"])
140
+ if result_dict.get("size") is not None:
141
+ metadata["video_size"] = result_dict["size"]
142
+
143
+ return arguments_helpers.EndSpanParameters(
144
+ output=output_data,
145
+ usage=None,
146
+ metadata=metadata,
147
+ model=result_dict.get("model"),
148
+ provider=self.provider,
149
+ )
150
+
151
+ @override
152
+ def _streams_handler(
153
+ self,
154
+ output: Any,
155
+ capture_output: bool,
156
+ generations_aggregator: Optional[Callable[[List[Any]], Any]],
157
+ ) -> Optional[Any]:
158
+ NOT_A_STREAM = None
159
+ return NOT_A_STREAM
@@ -0,0 +1,110 @@
1
+ """
2
+ Decorator for OpenAI video download_content method.
3
+ """
4
+
5
+ import logging
6
+ from typing import (
7
+ Any,
8
+ Callable,
9
+ Dict,
10
+ List,
11
+ Optional,
12
+ Tuple,
13
+ )
14
+
15
+ from openai._legacy_response import HttpxBinaryResponseContent
16
+ from typing_extensions import override
17
+
18
+ from opik.api_objects import span
19
+ from opik.decorator import arguments_helpers, base_track_decorator
20
+ from . import binary_response_write_to_file_decorator
21
+
22
+ LOGGER = logging.getLogger(__name__)
23
+
24
+
25
+ class VideosDownloadTrackDecorator(base_track_decorator.BaseTrackDecorator):
26
+ """
27
+ Decorator for tracking OpenAI videos.download_content method.
28
+
29
+ Also patches the returned HttpxBinaryResponseContent instance's write_to_file
30
+ method to create a tracked span when the video is actually downloaded.
31
+ """
32
+
33
+ def __init__(self) -> None:
34
+ super().__init__()
35
+ self.provider = "openai"
36
+
37
+ @override
38
+ def _start_span_inputs_preprocessor(
39
+ self,
40
+ func: Callable,
41
+ track_options: arguments_helpers.TrackOptions,
42
+ args: Tuple,
43
+ kwargs: Dict[str, Any],
44
+ ) -> arguments_helpers.StartSpanParameters:
45
+ assert kwargs is not None, "Expected kwargs to be not None in videos API calls"
46
+
47
+ name = track_options.name if track_options.name is not None else func.__name__
48
+
49
+ metadata = track_options.metadata if track_options.metadata is not None else {}
50
+
51
+ metadata.update(
52
+ {
53
+ "created_from": "openai",
54
+ "type": "openai_videos",
55
+ }
56
+ )
57
+
58
+ tags = ["openai"]
59
+
60
+ result = arguments_helpers.StartSpanParameters(
61
+ name=name,
62
+ input=kwargs,
63
+ type=track_options.type,
64
+ tags=tags,
65
+ metadata=metadata,
66
+ project_name=track_options.project_name,
67
+ )
68
+
69
+ return result
70
+
71
+ @override
72
+ def _end_span_inputs_preprocessor(
73
+ self,
74
+ output: Any,
75
+ capture_output: bool,
76
+ current_span_data: span.SpanData,
77
+ ) -> arguments_helpers.EndSpanParameters:
78
+ # Patch write_to_file on the returned instance
79
+ if output is not None:
80
+ _track_instance_write_to_file(output, current_span_data.project_name)
81
+
82
+ result = arguments_helpers.EndSpanParameters(
83
+ output={"output": output} if not isinstance(output, dict) else output,
84
+ usage=None,
85
+ metadata={},
86
+ model=None,
87
+ )
88
+
89
+ return result
90
+
91
+ @override
92
+ def _streams_handler(
93
+ self,
94
+ output: Any,
95
+ capture_output: bool,
96
+ generations_aggregator: Optional[Callable[[List[Any]], Any]],
97
+ ) -> Optional[Any]:
98
+ NOT_A_STREAM = None
99
+ return NOT_A_STREAM
100
+
101
+
102
+ def _track_instance_write_to_file(
103
+ instance: HttpxBinaryResponseContent,
104
+ project_name: Optional[str],
105
+ ) -> None:
106
+ """Patch write_to_file on this specific instance to track the download."""
107
+ decorator = binary_response_write_to_file_decorator.create_write_to_file_decorator(
108
+ project_name=project_name,
109
+ )
110
+ instance.write_to_file = decorator(instance.write_to_file) # type: ignore[method-assign]
@@ -1,4 +1,3 @@
1
- import threading
2
1
  import time
3
2
  import abc
4
3
 
@@ -24,25 +23,22 @@ class BaseBatcher(abc.ABC):
24
23
  self._batch_memory_limit_mb: int = batch_memory_limit_mb
25
24
 
26
25
  self._last_time_flush_callback_called: float = time.time()
27
- self._lock = threading.RLock()
28
26
 
29
27
  def flush(self) -> None:
30
- with self._lock:
31
- if len(self._accumulated_messages) > 0:
32
- batch_messages = self._create_batches_from_accumulated_messages()
33
- self._accumulated_messages = []
28
+ if len(self._accumulated_messages) > 0:
29
+ batch_messages = self._create_batches_from_accumulated_messages()
30
+ self._accumulated_messages = []
34
31
 
35
- for batch_message in batch_messages:
36
- self._flush_callback(batch_message)
37
- self._last_time_flush_callback_called = time.time()
32
+ for batch_message in batch_messages:
33
+ self._flush_callback(batch_message)
34
+ self._last_time_flush_callback_called = time.time()
38
35
 
39
36
  def is_ready_to_flush(self) -> bool:
40
37
  elapsed = time.time() - self._last_time_flush_callback_called
41
38
  return elapsed >= self._flush_interval_seconds
42
39
 
43
40
  def is_empty(self) -> bool:
44
- with self._lock:
45
- return len(self._accumulated_messages) == 0
41
+ return len(self._accumulated_messages) == 0
46
42
 
47
43
  @abc.abstractmethod
48
44
  def _create_batches_from_accumulated_messages(
@@ -51,10 +47,9 @@ class BaseBatcher(abc.ABC):
51
47
 
52
48
  @abc.abstractmethod
53
49
  def add(self, message: messages.BaseMessage) -> None:
54
- with self._lock:
55
- self._accumulated_messages.append(message)
56
- if len(self._accumulated_messages) >= self._max_batch_size:
57
- self.flush()
50
+ self._accumulated_messages.append(message)
51
+ if len(self._accumulated_messages) >= self._max_batch_size:
52
+ self.flush()
58
53
 
59
54
  def _remove_matching_messages(
60
55
  self, filter_func: Callable[[messages.BaseMessage], bool]
@@ -65,10 +60,9 @@ class BaseBatcher(abc.ABC):
65
60
  Args:
66
61
  filter_func: A function that takes a BaseMessage and returns True if the message should be removed
67
62
  """
68
- with self._lock:
69
- self._accumulated_messages = list(
70
- filter(lambda x: not filter_func(x), self._accumulated_messages)
71
- )
63
+ self._accumulated_messages = list(
64
+ filter(lambda x: not filter_func(x), self._accumulated_messages)
65
+ )
72
66
 
73
67
  def size(self) -> int:
74
68
  """
@@ -81,5 +75,4 @@ class BaseBatcher(abc.ABC):
81
75
  Returns:
82
76
  int: The total number of accumulated messages.
83
77
  """
84
- with self._lock:
85
- return len(self._accumulated_messages)
78
+ return len(self._accumulated_messages)