opik 1.9.39__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.
- opik/api_objects/attachment/attachment_context.py +36 -0
- opik/api_objects/attachment/attachments_extractor.py +153 -0
- opik/api_objects/attachment/client.py +1 -0
- opik/api_objects/attachment/converters.py +2 -0
- opik/api_objects/attachment/decoder.py +18 -0
- opik/api_objects/attachment/decoder_base64.py +83 -0
- opik/api_objects/attachment/decoder_helpers.py +137 -0
- opik/api_objects/constants.py +2 -0
- opik/api_objects/dataset/dataset.py +133 -40
- opik/api_objects/dataset/rest_operations.py +2 -0
- opik/api_objects/experiment/experiment.py +6 -0
- opik/api_objects/helpers.py +8 -4
- opik/api_objects/local_recording.py +6 -5
- opik/api_objects/observation_data.py +101 -0
- opik/api_objects/opik_client.py +78 -45
- opik/api_objects/opik_query_language.py +9 -3
- opik/api_objects/prompt/chat/chat_prompt.py +18 -1
- opik/api_objects/prompt/client.py +8 -1
- opik/api_objects/span/span_data.py +3 -88
- opik/api_objects/threads/threads_client.py +7 -4
- opik/api_objects/trace/trace_data.py +3 -74
- opik/api_objects/validation_helpers.py +3 -3
- opik/cli/exports/__init__.py +131 -0
- opik/cli/exports/dataset.py +278 -0
- opik/cli/exports/experiment.py +784 -0
- opik/cli/exports/project.py +685 -0
- opik/cli/exports/prompt.py +578 -0
- opik/cli/exports/utils.py +406 -0
- opik/cli/harbor.py +39 -0
- opik/cli/imports/__init__.py +439 -0
- opik/cli/imports/dataset.py +143 -0
- opik/cli/imports/experiment.py +1192 -0
- opik/cli/imports/project.py +262 -0
- opik/cli/imports/prompt.py +177 -0
- opik/cli/imports/utils.py +280 -0
- opik/cli/main.py +14 -12
- opik/config.py +12 -1
- opik/datetime_helpers.py +12 -0
- opik/decorator/arguments_helpers.py +4 -1
- opik/decorator/base_track_decorator.py +111 -37
- opik/decorator/context_manager/span_context_manager.py +5 -1
- opik/decorator/generator_wrappers.py +5 -4
- opik/decorator/span_creation_handler.py +13 -4
- opik/evaluation/engine/engine.py +111 -28
- opik/evaluation/engine/evaluation_tasks_executor.py +71 -19
- opik/evaluation/evaluator.py +12 -0
- opik/evaluation/metrics/conversation/llm_judges/conversational_coherence/metric.py +3 -1
- opik/evaluation/metrics/conversation/llm_judges/session_completeness/metric.py +3 -1
- opik/evaluation/metrics/conversation/llm_judges/user_frustration/metric.py +3 -1
- opik/evaluation/metrics/heuristics/equals.py +11 -7
- opik/evaluation/metrics/llm_judges/answer_relevance/metric.py +3 -1
- opik/evaluation/metrics/llm_judges/context_precision/metric.py +3 -1
- opik/evaluation/metrics/llm_judges/context_recall/metric.py +3 -1
- opik/evaluation/metrics/llm_judges/factuality/metric.py +1 -1
- opik/evaluation/metrics/llm_judges/g_eval/metric.py +3 -1
- opik/evaluation/metrics/llm_judges/hallucination/metric.py +3 -1
- opik/evaluation/metrics/llm_judges/moderation/metric.py +3 -1
- opik/evaluation/metrics/llm_judges/structure_output_compliance/metric.py +3 -1
- opik/evaluation/metrics/llm_judges/syc_eval/metric.py +4 -2
- opik/evaluation/metrics/llm_judges/trajectory_accuracy/metric.py +3 -1
- opik/evaluation/metrics/llm_judges/usefulness/metric.py +3 -1
- opik/evaluation/metrics/ragas_metric.py +43 -23
- opik/evaluation/models/litellm/litellm_chat_model.py +7 -2
- opik/evaluation/models/litellm/util.py +4 -20
- opik/evaluation/models/models_factory.py +19 -5
- opik/evaluation/rest_operations.py +3 -3
- opik/evaluation/threads/helpers.py +3 -2
- opik/file_upload/file_uploader.py +13 -0
- opik/file_upload/upload_options.py +2 -0
- opik/integrations/adk/legacy_opik_tracer.py +9 -11
- opik/integrations/adk/opik_tracer.py +2 -2
- opik/integrations/adk/patchers/adk_otel_tracer/opik_adk_otel_tracer.py +2 -2
- opik/integrations/dspy/callback.py +100 -14
- opik/integrations/dspy/parsers.py +168 -0
- opik/integrations/harbor/__init__.py +17 -0
- opik/integrations/harbor/experiment_service.py +269 -0
- opik/integrations/harbor/opik_tracker.py +528 -0
- opik/integrations/haystack/opik_tracer.py +2 -2
- opik/integrations/langchain/__init__.py +15 -2
- opik/integrations/langchain/langgraph_tracer_injector.py +88 -0
- opik/integrations/langchain/opik_tracer.py +258 -160
- opik/integrations/langchain/provider_usage_extractors/langchain_run_helpers/helpers.py +7 -4
- opik/integrations/llama_index/callback.py +43 -6
- opik/integrations/openai/agents/opik_tracing_processor.py +8 -10
- opik/integrations/openai/opik_tracker.py +99 -4
- opik/integrations/openai/videos/__init__.py +9 -0
- opik/integrations/openai/videos/binary_response_write_to_file_decorator.py +88 -0
- opik/integrations/openai/videos/videos_create_decorator.py +159 -0
- opik/integrations/openai/videos/videos_download_decorator.py +110 -0
- opik/message_processing/batching/base_batcher.py +14 -21
- opik/message_processing/batching/batch_manager.py +22 -10
- opik/message_processing/batching/batchers.py +32 -40
- opik/message_processing/batching/flushing_thread.py +0 -3
- opik/message_processing/emulation/emulator_message_processor.py +36 -1
- opik/message_processing/emulation/models.py +21 -0
- opik/message_processing/messages.py +9 -0
- opik/message_processing/preprocessing/__init__.py +0 -0
- opik/message_processing/preprocessing/attachments_preprocessor.py +70 -0
- opik/message_processing/preprocessing/batching_preprocessor.py +53 -0
- opik/message_processing/preprocessing/constants.py +1 -0
- opik/message_processing/preprocessing/file_upload_preprocessor.py +38 -0
- opik/message_processing/preprocessing/preprocessor.py +36 -0
- opik/message_processing/processors/__init__.py +0 -0
- opik/message_processing/processors/attachments_extraction_processor.py +146 -0
- opik/message_processing/{message_processors.py → processors/message_processors.py} +15 -1
- opik/message_processing/{message_processors_chain.py → processors/message_processors_chain.py} +3 -2
- opik/message_processing/{online_message_processor.py → processors/online_message_processor.py} +11 -9
- opik/message_processing/queue_consumer.py +4 -2
- opik/message_processing/streamer.py +71 -33
- opik/message_processing/streamer_constructors.py +36 -8
- opik/plugins/pytest/experiment_runner.py +1 -1
- opik/plugins/pytest/hooks.py +5 -3
- opik/rest_api/__init__.py +42 -0
- opik/rest_api/datasets/client.py +321 -123
- opik/rest_api/datasets/raw_client.py +470 -145
- opik/rest_api/experiments/client.py +26 -0
- opik/rest_api/experiments/raw_client.py +26 -0
- opik/rest_api/llm_provider_key/client.py +4 -4
- opik/rest_api/llm_provider_key/raw_client.py +4 -4
- opik/rest_api/llm_provider_key/types/provider_api_key_write_provider.py +2 -1
- opik/rest_api/manual_evaluation/client.py +101 -0
- opik/rest_api/manual_evaluation/raw_client.py +172 -0
- opik/rest_api/optimizations/client.py +0 -166
- opik/rest_api/optimizations/raw_client.py +0 -248
- opik/rest_api/projects/client.py +9 -0
- opik/rest_api/projects/raw_client.py +13 -0
- opik/rest_api/projects/types/project_metric_request_public_metric_type.py +4 -0
- opik/rest_api/prompts/client.py +130 -2
- opik/rest_api/prompts/raw_client.py +175 -0
- opik/rest_api/traces/client.py +101 -0
- opik/rest_api/traces/raw_client.py +120 -0
- opik/rest_api/types/__init__.py +50 -0
- opik/rest_api/types/audio_url.py +19 -0
- opik/rest_api/types/audio_url_public.py +19 -0
- opik/rest_api/types/audio_url_write.py +19 -0
- opik/rest_api/types/automation_rule_evaluator.py +38 -2
- opik/rest_api/types/automation_rule_evaluator_object_object_public.py +33 -2
- opik/rest_api/types/automation_rule_evaluator_public.py +33 -2
- opik/rest_api/types/automation_rule_evaluator_span_user_defined_metric_python.py +22 -0
- opik/rest_api/types/automation_rule_evaluator_span_user_defined_metric_python_public.py +22 -0
- opik/rest_api/types/automation_rule_evaluator_span_user_defined_metric_python_write.py +22 -0
- opik/rest_api/types/automation_rule_evaluator_update.py +27 -1
- opik/rest_api/types/automation_rule_evaluator_update_span_user_defined_metric_python.py +22 -0
- opik/rest_api/types/automation_rule_evaluator_write.py +27 -1
- opik/rest_api/types/dataset.py +2 -0
- opik/rest_api/types/dataset_item.py +1 -1
- opik/rest_api/types/dataset_item_batch.py +4 -0
- opik/rest_api/types/dataset_item_changes_public.py +5 -0
- opik/rest_api/types/dataset_item_compare.py +1 -1
- opik/rest_api/types/dataset_item_filter.py +4 -0
- opik/rest_api/types/dataset_item_page_compare.py +0 -1
- opik/rest_api/types/dataset_item_page_public.py +0 -1
- opik/rest_api/types/dataset_item_public.py +1 -1
- opik/rest_api/types/dataset_public.py +2 -0
- opik/rest_api/types/dataset_version_public.py +10 -0
- opik/rest_api/types/dataset_version_summary.py +46 -0
- opik/rest_api/types/dataset_version_summary_public.py +46 -0
- opik/rest_api/types/experiment.py +9 -0
- opik/rest_api/types/experiment_public.py +9 -0
- opik/rest_api/types/group_content_with_aggregations.py +1 -0
- opik/rest_api/types/llm_as_judge_message_content.py +2 -0
- opik/rest_api/types/llm_as_judge_message_content_public.py +2 -0
- opik/rest_api/types/llm_as_judge_message_content_write.py +2 -0
- opik/rest_api/types/manual_evaluation_request_entity_type.py +1 -1
- opik/rest_api/types/project.py +1 -0
- opik/rest_api/types/project_detailed.py +1 -0
- opik/rest_api/types/project_metric_response_public_metric_type.py +4 -0
- opik/rest_api/types/project_reference.py +31 -0
- opik/rest_api/types/project_reference_public.py +31 -0
- opik/rest_api/types/project_stats_summary_item.py +1 -0
- opik/rest_api/types/prompt_version.py +1 -0
- opik/rest_api/types/prompt_version_detail.py +1 -0
- opik/rest_api/types/prompt_version_page_public.py +5 -0
- opik/rest_api/types/prompt_version_public.py +1 -0
- opik/rest_api/types/prompt_version_update.py +33 -0
- opik/rest_api/types/provider_api_key.py +5 -1
- opik/rest_api/types/provider_api_key_provider.py +2 -1
- opik/rest_api/types/provider_api_key_public.py +5 -1
- opik/rest_api/types/provider_api_key_public_provider.py +2 -1
- opik/rest_api/types/service_toggles_config.py +11 -1
- opik/rest_api/types/span_user_defined_metric_python_code.py +20 -0
- opik/rest_api/types/span_user_defined_metric_python_code_public.py +20 -0
- opik/rest_api/types/span_user_defined_metric_python_code_write.py +20 -0
- opik/types.py +36 -0
- opik/validation/chat_prompt_messages.py +241 -0
- opik/validation/feedback_score.py +3 -3
- opik/validation/validator.py +28 -0
- {opik-1.9.39.dist-info → opik-1.9.86.dist-info}/METADATA +7 -7
- {opik-1.9.39.dist-info → opik-1.9.86.dist-info}/RECORD +193 -142
- opik/cli/export.py +0 -791
- opik/cli/import_command.py +0 -575
- {opik-1.9.39.dist-info → opik-1.9.86.dist-info}/WHEEL +0 -0
- {opik-1.9.39.dist-info → opik-1.9.86.dist-info}/entry_points.txt +0 -0
- {opik-1.9.39.dist-info → opik-1.9.86.dist-info}/licenses/LICENSE +0 -0
- {opik-1.9.39.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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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] =
|
|
93
|
-
self._opik_context_storage.add_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
|
-
] =
|
|
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(**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
69
|
-
self._accumulated_messages
|
|
70
|
-
|
|
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
|
-
|
|
85
|
-
return len(self._accumulated_messages)
|
|
78
|
+
return len(self._accumulated_messages)
|