chore: initial snapshot for gitea/github upload
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
Opik Logger that logs LLM events to an Opik server
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.integrations.custom_batch_logger import CustomBatchLogger
|
||||
from litellm.llms.custom_httpx.http_handler import (
|
||||
_get_httpx_client,
|
||||
get_async_httpx_client,
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
|
||||
from . import opik_payload_builder, utils
|
||||
|
||||
try:
|
||||
from opik.api_objects import opik_client
|
||||
except Exception:
|
||||
opik_client = None
|
||||
|
||||
|
||||
def _should_skip_event(kwargs: Dict[str, Any]) -> bool:
|
||||
"""Check if event should be skipped due to missing standard_logging_object."""
|
||||
if kwargs.get("standard_logging_object") is None:
|
||||
verbose_logger.debug(
|
||||
"OpikLogger skipping event; no standard_logging_object found"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class OpikLogger(CustomBatchLogger):
|
||||
"""
|
||||
Opik Logger for logging events to an Opik Server
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.async_httpx_client = get_async_httpx_client(
|
||||
llm_provider=httpxSpecialProvider.LoggingCallback
|
||||
)
|
||||
self.sync_httpx_client = _get_httpx_client()
|
||||
|
||||
self.opik_project_name: str = (
|
||||
utils.get_opik_config_variable(
|
||||
"project_name",
|
||||
user_value=kwargs.get("project_name", None),
|
||||
default_value="Default Project",
|
||||
)
|
||||
or "Default Project"
|
||||
)
|
||||
|
||||
opik_base_url: str = (
|
||||
utils.get_opik_config_variable(
|
||||
"url_override",
|
||||
user_value=kwargs.get("url", None),
|
||||
default_value="https://www.comet.com/opik/api",
|
||||
)
|
||||
or "https://www.comet.com/opik/api"
|
||||
)
|
||||
opik_api_key: Optional[str] = utils.get_opik_config_variable(
|
||||
"api_key", user_value=kwargs.get("api_key", None), default_value=None
|
||||
)
|
||||
opik_workspace: Optional[str] = utils.get_opik_config_variable(
|
||||
"workspace", user_value=kwargs.get("workspace", None), default_value=None
|
||||
)
|
||||
|
||||
self.trace_url: str = f"{opik_base_url}/v1/private/traces/batch"
|
||||
self.span_url: str = f"{opik_base_url}/v1/private/spans/batch"
|
||||
|
||||
self.headers: Dict[str, str] = {}
|
||||
if opik_workspace:
|
||||
self.headers["Comet-Workspace"] = opik_workspace
|
||||
|
||||
if opik_api_key:
|
||||
self.headers["authorization"] = opik_api_key
|
||||
|
||||
self.opik_workspace: Optional[str] = opik_workspace
|
||||
self.opik_api_key: Optional[str] = opik_api_key
|
||||
try:
|
||||
asyncio.create_task(self.periodic_flush())
|
||||
self.flush_lock: Optional[asyncio.Lock] = asyncio.Lock()
|
||||
except Exception as e:
|
||||
verbose_logger.exception(
|
||||
f"OpikLogger - Asynchronous processing not initialized as we are not running in an async context {str(e)}"
|
||||
)
|
||||
self.flush_lock = None
|
||||
|
||||
# Initialize _opik_client attribute
|
||||
if opik_client is not None:
|
||||
self._opik_client = opik_client.get_client_cached()
|
||||
else:
|
||||
self._opik_client = None
|
||||
|
||||
super().__init__(**kwargs, flush_lock=self.flush_lock)
|
||||
|
||||
async def async_log_success_event(
|
||||
self,
|
||||
kwargs: Dict[str, Any],
|
||||
response_obj: Any,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> None:
|
||||
try:
|
||||
if _should_skip_event(kwargs):
|
||||
return
|
||||
|
||||
# Build payload using the payload builder
|
||||
trace_payload, span_payload = opik_payload_builder.build_opik_payload(
|
||||
kwargs=kwargs,
|
||||
response_obj=response_obj,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
project_name=self.opik_project_name,
|
||||
)
|
||||
|
||||
if self._opik_client is not None:
|
||||
# Opik native client is available, use it to send data
|
||||
if trace_payload is not None:
|
||||
self._opik_client.trace(
|
||||
id=trace_payload.id,
|
||||
name=trace_payload.name,
|
||||
start_time=datetime.fromisoformat(trace_payload.start_time),
|
||||
end_time=datetime.fromisoformat(trace_payload.end_time),
|
||||
input=trace_payload.input,
|
||||
output=trace_payload.output,
|
||||
metadata=trace_payload.metadata,
|
||||
tags=trace_payload.tags,
|
||||
thread_id=trace_payload.thread_id,
|
||||
project_name=trace_payload.project_name,
|
||||
)
|
||||
|
||||
self._opik_client.span(
|
||||
id=span_payload.id,
|
||||
trace_id=span_payload.trace_id,
|
||||
parent_span_id=span_payload.parent_span_id,
|
||||
name=span_payload.name,
|
||||
type=span_payload.type,
|
||||
model=span_payload.model,
|
||||
start_time=datetime.fromisoformat(span_payload.start_time),
|
||||
end_time=datetime.fromisoformat(span_payload.end_time),
|
||||
input=span_payload.input,
|
||||
output=span_payload.output,
|
||||
metadata=span_payload.metadata,
|
||||
tags=span_payload.tags,
|
||||
usage=span_payload.usage,
|
||||
project_name=span_payload.project_name,
|
||||
provider=span_payload.provider,
|
||||
total_cost=span_payload.total_cost,
|
||||
)
|
||||
else:
|
||||
# Add payloads to LiteLLM queue
|
||||
if trace_payload is not None:
|
||||
self.log_queue.append(trace_payload.__dict__)
|
||||
self.log_queue.append(span_payload.__dict__)
|
||||
|
||||
verbose_logger.debug(
|
||||
f"OpikLogger added event to log_queue - Will flush in {self.flush_interval} seconds..."
|
||||
)
|
||||
|
||||
if len(self.log_queue) >= self.batch_size:
|
||||
verbose_logger.debug("OpikLogger - Flushing batch")
|
||||
await self.flush_queue()
|
||||
except Exception as e:
|
||||
verbose_logger.exception(
|
||||
f"OpikLogger failed to log success event - {str(e)}\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
def _sync_send(
|
||||
self, url: str, headers: Dict[str, str], batch: Dict[str, Any]
|
||||
) -> None:
|
||||
try:
|
||||
response = self.sync_httpx_client.post(
|
||||
url=url, headers=headers, json=batch # type: ignore
|
||||
)
|
||||
response.raise_for_status()
|
||||
if response.status_code != 204:
|
||||
raise Exception(
|
||||
f"Response from opik API status_code: {response.status_code}, text: {response.text}"
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.exception(
|
||||
f"OpikLogger failed to send batch - {str(e)}\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
def log_success_event(
|
||||
self,
|
||||
kwargs: Dict[str, Any],
|
||||
response_obj: Any,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
) -> None:
|
||||
try:
|
||||
if _should_skip_event(kwargs):
|
||||
return
|
||||
|
||||
# Build payload using the payload builder
|
||||
trace_payload, span_payload = opik_payload_builder.build_opik_payload(
|
||||
kwargs=kwargs,
|
||||
response_obj=response_obj,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
project_name=self.opik_project_name,
|
||||
)
|
||||
if self._opik_client is not None:
|
||||
# Opik native client is available, use it to send data
|
||||
if trace_payload is not None:
|
||||
self._opik_client.trace(
|
||||
id=trace_payload.id,
|
||||
name=trace_payload.name,
|
||||
start_time=datetime.fromisoformat(trace_payload.start_time),
|
||||
end_time=datetime.fromisoformat(trace_payload.end_time),
|
||||
input=trace_payload.input,
|
||||
output=trace_payload.output,
|
||||
metadata=trace_payload.metadata,
|
||||
tags=trace_payload.tags,
|
||||
thread_id=trace_payload.thread_id,
|
||||
project_name=trace_payload.project_name,
|
||||
)
|
||||
|
||||
self._opik_client.span(
|
||||
id=span_payload.id,
|
||||
trace_id=span_payload.trace_id,
|
||||
parent_span_id=span_payload.parent_span_id,
|
||||
name=span_payload.name,
|
||||
type=span_payload.type,
|
||||
model=span_payload.model,
|
||||
start_time=datetime.fromisoformat(span_payload.start_time),
|
||||
end_time=datetime.fromisoformat(span_payload.end_time),
|
||||
input=span_payload.input,
|
||||
output=span_payload.output,
|
||||
metadata=span_payload.metadata,
|
||||
tags=span_payload.tags,
|
||||
usage=span_payload.usage,
|
||||
project_name=span_payload.project_name,
|
||||
provider=span_payload.provider,
|
||||
total_cost=span_payload.total_cost,
|
||||
)
|
||||
else:
|
||||
# Opik native client is not available, use LiteLLM queue to send data
|
||||
if trace_payload is not None:
|
||||
self._sync_send(
|
||||
url=self.trace_url,
|
||||
headers=self.headers,
|
||||
batch={"traces": [trace_payload.__dict__]},
|
||||
)
|
||||
|
||||
# Always send span
|
||||
self._sync_send(
|
||||
url=self.span_url,
|
||||
headers=self.headers,
|
||||
batch={"spans": [span_payload.__dict__]},
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.exception(
|
||||
f"OpikLogger failed to log success event - {str(e)}\n{traceback.format_exc()}"
|
||||
)
|
||||
|
||||
async def _submit_batch(
|
||||
self, url: str, headers: Dict[str, str], batch: Dict[str, Any]
|
||||
) -> None:
|
||||
try:
|
||||
response = await self.async_httpx_client.post(
|
||||
url=url, headers=headers, json=batch # type: ignore
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
if response.status_code >= 300:
|
||||
verbose_logger.error(
|
||||
f"OpikLogger - Error: {response.status_code} - {response.text}"
|
||||
)
|
||||
else:
|
||||
verbose_logger.info(
|
||||
f"OpikLogger - {len(self.log_queue)} Opik events submitted"
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_logger.exception(f"OpikLogger failed to send batch - {str(e)}")
|
||||
|
||||
def _create_opik_headers(self) -> Dict[str, str]:
|
||||
headers: Dict[str, str] = {}
|
||||
if self.opik_workspace:
|
||||
headers["Comet-Workspace"] = self.opik_workspace
|
||||
|
||||
if self.opik_api_key:
|
||||
headers["authorization"] = self.opik_api_key
|
||||
return headers
|
||||
|
||||
async def async_send_batch(self) -> None:
|
||||
verbose_logger.info("Calling async_send_batch")
|
||||
if not self.log_queue:
|
||||
return
|
||||
|
||||
# Split the log_queue into traces and spans
|
||||
traces, spans = utils.get_traces_and_spans_from_payload(self.log_queue)
|
||||
|
||||
# Send trace batch
|
||||
if len(traces) > 0:
|
||||
await self._submit_batch(
|
||||
url=self.trace_url, headers=self.headers, batch={"traces": traces}
|
||||
)
|
||||
verbose_logger.info(f"Sent {len(traces)} traces")
|
||||
if len(spans) > 0:
|
||||
await self._submit_batch(
|
||||
url=self.span_url, headers=self.headers, batch={"spans": spans}
|
||||
)
|
||||
verbose_logger.info(f"Sent {len(spans)} spans")
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Opik payload builder namespace.
|
||||
|
||||
Public API:
|
||||
build_opik_payload - Main function to create Opik trace and span payloads
|
||||
"""
|
||||
|
||||
from .api import build_opik_payload
|
||||
|
||||
__all__ = ["build_opik_payload"]
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Public API for Opik payload building."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from litellm.integrations.opik import utils
|
||||
|
||||
from . import extractors, payload_builders, types
|
||||
|
||||
|
||||
def build_opik_payload(
|
||||
kwargs: Dict[str, Any],
|
||||
response_obj: Dict[str, Any],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
project_name: str,
|
||||
) -> Tuple[Optional[types.TracePayload], types.SpanPayload]:
|
||||
"""
|
||||
Build Opik trace and span payloads from LiteLLM completion data.
|
||||
|
||||
This is the main public API for creating Opik payloads. It:
|
||||
1. Extracts all necessary data from LiteLLM kwargs and response
|
||||
2. Decides whether to create a new trace or attach to existing
|
||||
3. Builds trace payload (if new trace)
|
||||
4. Builds span payload (always)
|
||||
|
||||
Args:
|
||||
kwargs: LiteLLM kwargs containing request metadata and logging data
|
||||
response_obj: LiteLLM response object containing model response
|
||||
start_time: Request start time
|
||||
end_time: Request end time
|
||||
project_name: Default Opik project name
|
||||
|
||||
Returns:
|
||||
Tuple of (optional trace payload, span payload)
|
||||
- First element is TracePayload if creating a new trace, None if attaching to existing
|
||||
- Second element is always SpanPayload
|
||||
"""
|
||||
standard_logging_object = kwargs["standard_logging_object"]
|
||||
|
||||
# Extract litellm params and metadata
|
||||
litellm_params = kwargs.get("litellm_params", {}) or {}
|
||||
litellm_metadata = litellm_params.get("metadata", {}) or {}
|
||||
standard_logging_metadata = standard_logging_object.get("metadata", {}) or {}
|
||||
|
||||
# Extract and merge Opik metadata
|
||||
opik_metadata = extractors.extract_opik_metadata(
|
||||
litellm_metadata, standard_logging_metadata
|
||||
)
|
||||
|
||||
# Extract project name
|
||||
current_project_name = opik_metadata.get("project_name", project_name)
|
||||
|
||||
# Extract trace identifiers
|
||||
current_span_data = opik_metadata.get("current_span_data")
|
||||
trace_id, parent_span_id = extractors.extract_span_identifiers(current_span_data)
|
||||
|
||||
# Extract tags and thread_id
|
||||
tags = extractors.extract_tags(opik_metadata, kwargs.get("custom_llm_provider"))
|
||||
thread_id = opik_metadata.get("thread_id")
|
||||
|
||||
# Apply proxy header overrides
|
||||
proxy_request = litellm_params.get("proxy_server_request", {}) or {}
|
||||
proxy_headers = proxy_request.get("headers", {}) or {}
|
||||
current_project_name, tags, thread_id = extractors.apply_proxy_header_overrides(
|
||||
current_project_name, tags, thread_id, proxy_headers
|
||||
)
|
||||
|
||||
# Build shared metadata
|
||||
metadata = extractors.extract_and_build_metadata(
|
||||
opik_metadata=opik_metadata,
|
||||
standard_logging_metadata=standard_logging_metadata,
|
||||
standard_logging_object=standard_logging_object,
|
||||
litellm_kwargs=kwargs,
|
||||
)
|
||||
|
||||
# Get input/output data
|
||||
input_data = standard_logging_object.get("messages", {})
|
||||
output_data = standard_logging_object.get("response", {})
|
||||
|
||||
# Decide whether to create a new trace or attach to existing
|
||||
trace_payload: Optional[types.TracePayload] = None
|
||||
if trace_id is None:
|
||||
trace_id = utils.create_uuid7()
|
||||
trace_payload = payload_builders.build_trace_payload(
|
||||
project_name=current_project_name,
|
||||
trace_id=trace_id,
|
||||
response_obj=response_obj,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
input_data=input_data,
|
||||
output_data=output_data,
|
||||
metadata=metadata,
|
||||
tags=tags,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
# Always create a span
|
||||
usage = utils.create_usage_object(response_obj["usage"])
|
||||
|
||||
# Extract provider and cost
|
||||
provider = extractors.normalize_provider_name(kwargs.get("custom_llm_provider"))
|
||||
cost = kwargs.get("response_cost")
|
||||
|
||||
span_payload = payload_builders.build_span_payload(
|
||||
project_name=current_project_name,
|
||||
trace_id=trace_id,
|
||||
parent_span_id=parent_span_id,
|
||||
response_obj=response_obj,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
input_data=input_data,
|
||||
output_data=output_data,
|
||||
metadata=metadata,
|
||||
tags=tags,
|
||||
usage=usage,
|
||||
provider=provider,
|
||||
cost=cost,
|
||||
)
|
||||
|
||||
return trace_payload, span_payload
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Data extraction functions for Opik payload building."""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from litellm import _logging
|
||||
|
||||
|
||||
def normalize_provider_name(provider: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Normalize LiteLLM provider names to standardized string names.
|
||||
|
||||
Args:
|
||||
provider: LiteLLM internal provider name
|
||||
|
||||
Returns:
|
||||
Normalized provider name or the original if no mapping exists
|
||||
"""
|
||||
if provider is None:
|
||||
return None
|
||||
|
||||
# Provider mapping to names used in Opik
|
||||
provider_mapping = {
|
||||
"openai": "openai",
|
||||
"vertex_ai-language-models": "google_vertexai",
|
||||
"gemini": "google_ai",
|
||||
"anthropic": "anthropic",
|
||||
"vertex_ai-anthropic_models": "anthropic_vertexai",
|
||||
"bedrock": "bedrock",
|
||||
"bedrock_converse": "bedrock",
|
||||
"groq": "groq",
|
||||
}
|
||||
|
||||
return provider_mapping.get(provider, provider)
|
||||
|
||||
|
||||
def extract_opik_metadata(
|
||||
litellm_metadata: Dict[str, Any],
|
||||
standard_logging_metadata: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract and merge Opik metadata from request and requester.
|
||||
|
||||
Args:
|
||||
litellm_metadata: Metadata from litellm_params
|
||||
standard_logging_metadata: Metadata from standard_logging_object
|
||||
|
||||
Returns:
|
||||
Merged Opik metadata dictionary
|
||||
"""
|
||||
opik_meta = litellm_metadata.get("opik", {}).copy()
|
||||
|
||||
requester_metadata = standard_logging_metadata.get("requester_metadata", {}) or {}
|
||||
requester_opik = requester_metadata.get("opik", {}) or {}
|
||||
opik_meta.update(requester_opik)
|
||||
|
||||
_logging.verbose_logger.debug(
|
||||
f"litellm_opik_metadata - {json.dumps(opik_meta, default=str)}"
|
||||
)
|
||||
|
||||
return opik_meta
|
||||
|
||||
|
||||
def extract_span_identifiers(
|
||||
current_span_data: Any,
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Extract trace_id and parent_span_id from current_span_data.
|
||||
|
||||
Args:
|
||||
current_span_data: Either dict with trace_id/id keys or Opik object
|
||||
|
||||
Returns:
|
||||
Tuple of (trace_id, parent_span_id), both optional
|
||||
"""
|
||||
if current_span_data is None:
|
||||
return None, None
|
||||
|
||||
if isinstance(current_span_data, dict):
|
||||
return (current_span_data.get("trace_id"), current_span_data.get("id"))
|
||||
|
||||
try:
|
||||
return current_span_data.trace_id, current_span_data.id
|
||||
except AttributeError:
|
||||
_logging.verbose_logger.warning(
|
||||
f"Unexpected current_span_data format: {type(current_span_data)}"
|
||||
)
|
||||
return None, None
|
||||
|
||||
|
||||
def extract_tags(
|
||||
opik_metadata: Dict[str, Any],
|
||||
custom_llm_provider: Optional[str],
|
||||
) -> List[str]:
|
||||
"""
|
||||
Extract and build list of tags.
|
||||
|
||||
Args:
|
||||
opik_metadata: Opik metadata dictionary
|
||||
custom_llm_provider: LLM provider name to add as tag
|
||||
|
||||
Returns:
|
||||
List of tags
|
||||
"""
|
||||
tags = list(opik_metadata.get("tags", []))
|
||||
|
||||
if custom_llm_provider:
|
||||
tags.append(custom_llm_provider)
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
def apply_proxy_header_overrides(
|
||||
project_name: str,
|
||||
tags: List[str],
|
||||
thread_id: Optional[str],
|
||||
proxy_headers: Dict[str, Any],
|
||||
) -> Tuple[str, List[str], Optional[str]]:
|
||||
"""
|
||||
Apply overrides from proxy request headers (opik_* prefix).
|
||||
|
||||
Args:
|
||||
project_name: Current project name
|
||||
tags: Current tags list
|
||||
thread_id: Current thread ID
|
||||
proxy_headers: HTTP headers from proxy request
|
||||
|
||||
Returns:
|
||||
Tuple of (project_name, tags, thread_id) with overrides applied
|
||||
"""
|
||||
for key, value in proxy_headers.items():
|
||||
if not key.startswith("opik_") or not value:
|
||||
continue
|
||||
|
||||
param_key = key.replace("opik_", "", 1)
|
||||
|
||||
if param_key == "project_name":
|
||||
project_name = value
|
||||
elif param_key == "thread_id":
|
||||
thread_id = value
|
||||
elif param_key == "tags":
|
||||
try:
|
||||
parsed_tags = json.loads(value)
|
||||
if isinstance(parsed_tags, list):
|
||||
tags.extend(parsed_tags)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
_logging.verbose_logger.warning(
|
||||
f"Failed to parse tags from header: {value}"
|
||||
)
|
||||
|
||||
return project_name, tags, thread_id
|
||||
|
||||
|
||||
def extract_and_build_metadata(
|
||||
opik_metadata: Dict[str, Any],
|
||||
standard_logging_metadata: Dict[str, Any],
|
||||
standard_logging_object: Dict[str, Any],
|
||||
litellm_kwargs: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build the complete metadata dictionary from all available sources.
|
||||
|
||||
This combines:
|
||||
- Opik-specific metadata (tags, etc.)
|
||||
- Standard logging metadata
|
||||
- Fields from standard_logging_object (model info, status, etc.)
|
||||
- Cost information from litellm_kwargs (calculated after completion)
|
||||
|
||||
Args:
|
||||
opik_metadata: Opik-specific metadata from request
|
||||
standard_logging_metadata: Standard logging metadata
|
||||
standard_logging_object: Full standard logging object with call details
|
||||
litellm_kwargs: Original LiteLLM kwargs (includes response_cost)
|
||||
|
||||
Returns:
|
||||
Complete metadata dictionary for trace/span
|
||||
"""
|
||||
# Start with opik metadata (excluding current_span_data which is used for trace linking)
|
||||
metadata = {k: v for k, v in opik_metadata.items() if k != "current_span_data"}
|
||||
metadata["created_from"] = "litellm"
|
||||
|
||||
# Merge with standard logging metadata
|
||||
metadata.update(standard_logging_metadata)
|
||||
|
||||
# Add fields from standard_logging_object
|
||||
# These come from the LiteLLM logging infrastructure
|
||||
field_mappings = {
|
||||
"call_type": "type",
|
||||
"status": "status",
|
||||
"model": "model",
|
||||
"model_id": "model_id",
|
||||
"model_group": "model_group",
|
||||
"api_base": "api_base",
|
||||
"cache_hit": "cache_hit",
|
||||
"saved_cache_cost": "saved_cache_cost",
|
||||
"error_str": "error_str",
|
||||
"model_parameters": "model_parameters",
|
||||
"hidden_params": "hidden_params",
|
||||
"model_map_information": "model_map_information",
|
||||
}
|
||||
|
||||
for source_key, dest_key in field_mappings.items():
|
||||
if source_key in standard_logging_object:
|
||||
metadata[dest_key] = standard_logging_object[source_key]
|
||||
|
||||
# Add cost information
|
||||
# response_cost is calculated by LiteLLM after completion and added to kwargs
|
||||
# See: litellm/litellm_core_utils/llm_response_utils/response_metadata.py
|
||||
if "response_cost" in litellm_kwargs:
|
||||
metadata["cost"] = {
|
||||
"total_tokens": litellm_kwargs["response_cost"],
|
||||
"currency": "USD",
|
||||
}
|
||||
|
||||
# Add debug info if cost calculation failed
|
||||
if "response_cost_failure_debug_info" in litellm_kwargs:
|
||||
metadata["response_cost_failure_debug_info"] = litellm_kwargs[
|
||||
"response_cost_failure_debug_info"
|
||||
]
|
||||
|
||||
return metadata
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Payload builders for Opik traces and spans."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from litellm import _logging
|
||||
from litellm.integrations.opik import utils
|
||||
|
||||
from . import types
|
||||
|
||||
|
||||
def build_trace_payload(
|
||||
project_name: str,
|
||||
trace_id: str,
|
||||
response_obj: Dict[str, Any],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
input_data: Any,
|
||||
output_data: Any,
|
||||
metadata: Dict[str, Any],
|
||||
tags: List[str],
|
||||
thread_id: Optional[str],
|
||||
) -> types.TracePayload:
|
||||
"""Build a complete trace payload."""
|
||||
trace_name = response_obj.get("object", "unknown type")
|
||||
|
||||
return types.TracePayload(
|
||||
project_name=project_name,
|
||||
id=trace_id,
|
||||
name=trace_name,
|
||||
start_time=(
|
||||
start_time.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
end_time=end_time.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
input=input_data,
|
||||
output=output_data,
|
||||
metadata=metadata,
|
||||
tags=tags,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
|
||||
def build_span_payload(
|
||||
project_name: str,
|
||||
trace_id: str,
|
||||
parent_span_id: Optional[str],
|
||||
response_obj: Dict[str, Any],
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
input_data: Any,
|
||||
output_data: Any,
|
||||
metadata: Dict[str, Any],
|
||||
tags: List[str],
|
||||
usage: Dict[str, int],
|
||||
provider: Optional[str] = None,
|
||||
cost: Optional[float] = None,
|
||||
) -> types.SpanPayload:
|
||||
"""Build a complete span payload."""
|
||||
span_id = utils.create_uuid7()
|
||||
|
||||
model = response_obj.get("model", "unknown-model")
|
||||
obj_type = response_obj.get("object", "unknown-object")
|
||||
created = response_obj.get("created", 0)
|
||||
span_name = f"{model}_{obj_type}_{created}"
|
||||
|
||||
_logging.verbose_logger.debug(
|
||||
f"OpikLogger creating span with id {span_id} for trace {trace_id}"
|
||||
)
|
||||
|
||||
return types.SpanPayload(
|
||||
id=span_id,
|
||||
project_name=project_name,
|
||||
trace_id=trace_id,
|
||||
parent_span_id=parent_span_id,
|
||||
name=span_name,
|
||||
type="llm",
|
||||
model=model,
|
||||
start_time=(
|
||||
start_time.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
),
|
||||
end_time=end_time.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
input=input_data,
|
||||
output=output_data,
|
||||
metadata=metadata,
|
||||
tags=tags,
|
||||
usage=usage,
|
||||
provider=provider,
|
||||
total_cost=cost,
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Type definitions for Opik payload building."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
|
||||
|
||||
|
||||
@dataclass
|
||||
class TracePayload:
|
||||
"""Opik trace payload structure"""
|
||||
|
||||
project_name: str
|
||||
id: str
|
||||
name: str
|
||||
start_time: str
|
||||
end_time: str
|
||||
input: Any
|
||||
output: Any
|
||||
metadata: Dict[str, Any]
|
||||
tags: List[str]
|
||||
thread_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpanPayload:
|
||||
"""Opik span payload structure"""
|
||||
|
||||
id: str
|
||||
project_name: str
|
||||
trace_id: str
|
||||
name: str
|
||||
type: Literal["llm"]
|
||||
model: str
|
||||
start_time: str
|
||||
end_time: str
|
||||
input: Any
|
||||
output: Any
|
||||
metadata: Dict[str, Any]
|
||||
tags: List[str]
|
||||
usage: Dict[str, int]
|
||||
parent_span_id: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
total_cost: Optional[float] = None
|
||||
|
||||
|
||||
PayloadItem = Union[TracePayload, SpanPayload]
|
||||
TraceSpanPayloadTuple = Tuple[Optional[TracePayload], SpanPayload]
|
||||
@@ -0,0 +1,124 @@
|
||||
import configparser
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, Final, List, Optional, Tuple
|
||||
|
||||
CONFIG_FILE_PATH_DEFAULT: Final[str] = "~/.opik.config"
|
||||
|
||||
|
||||
def create_uuid7():
|
||||
ns = time.time_ns()
|
||||
last = [0, 0, 0, 0]
|
||||
|
||||
# Simple uuid7 implementation
|
||||
sixteen_secs = 16_000_000_000
|
||||
t1, rest1 = divmod(ns, sixteen_secs)
|
||||
t2, rest2 = divmod(rest1 << 16, sixteen_secs)
|
||||
t3, _ = divmod(rest2 << 12, sixteen_secs)
|
||||
t3 |= 7 << 12 # Put uuid version in top 4 bits, which are 0 in t3
|
||||
|
||||
# The next two bytes are an int (t4) with two bits for
|
||||
# the variant 2 and a 14 bit sequence counter which increments
|
||||
# if the time is unchanged.
|
||||
if t1 == last[0] and t2 == last[1] and t3 == last[2]:
|
||||
# Stop the seq counter wrapping past 0x3FFF.
|
||||
# This won't happen in practice, but if it does,
|
||||
# uuids after the 16383rd with that same timestamp
|
||||
# will not longer be correctly ordered but
|
||||
# are still unique due to the 6 random bytes.
|
||||
if last[3] < 0x3FFF:
|
||||
last[3] += 1
|
||||
else:
|
||||
last[:] = (t1, t2, t3, 0)
|
||||
t4 = (2 << 14) | last[3] # Put variant 0b10 in top two bits
|
||||
|
||||
# Six random bytes for the lower part of the uuid
|
||||
rand = os.urandom(6)
|
||||
return f"{t1:>08x}-{t2:>04x}-{t3:>04x}-{t4:>04x}-{rand.hex()}"
|
||||
|
||||
|
||||
def _read_opik_config_file() -> Dict[str, str]:
|
||||
config_path = os.path.expanduser(CONFIG_FILE_PATH_DEFAULT)
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(config_path)
|
||||
|
||||
config_values = {
|
||||
section: dict(config.items(section)) for section in config.sections()
|
||||
}
|
||||
|
||||
if "opik" in config_values:
|
||||
return config_values["opik"]
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _get_env_variable(key: str) -> Optional[str]:
|
||||
env_prefix = "opik_"
|
||||
return os.getenv((env_prefix + key).upper(), None)
|
||||
|
||||
|
||||
def get_opik_config_variable(
|
||||
key: str, user_value: Optional[str] = None, default_value: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Get the configuration value of a variable, order priority is:
|
||||
1. user provided value
|
||||
2. environment variable
|
||||
3. Opik configuration file
|
||||
4. default value
|
||||
"""
|
||||
# Return user provided value if it is not None
|
||||
if user_value is not None:
|
||||
return user_value
|
||||
|
||||
# Return environment variable if it is not None
|
||||
env_value = _get_env_variable(key)
|
||||
if env_value is not None:
|
||||
return env_value
|
||||
|
||||
# Return value from Opik configuration file if it is not None
|
||||
config_values = _read_opik_config_file()
|
||||
|
||||
if key in config_values:
|
||||
return config_values[key]
|
||||
|
||||
# Return default value if it is not None
|
||||
return default_value
|
||||
|
||||
|
||||
def create_usage_object(usage):
|
||||
usage_dict = {}
|
||||
|
||||
if usage.completion_tokens is not None:
|
||||
usage_dict["completion_tokens"] = usage.completion_tokens
|
||||
if usage.prompt_tokens is not None:
|
||||
usage_dict["prompt_tokens"] = usage.prompt_tokens
|
||||
if usage.total_tokens is not None:
|
||||
usage_dict["total_tokens"] = usage.total_tokens
|
||||
return usage_dict
|
||||
|
||||
|
||||
def _remove_nulls(x: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Remove None values from dict."""
|
||||
return {k: v for k, v in x.items() if v is not None}
|
||||
|
||||
|
||||
def get_traces_and_spans_from_payload(
|
||||
payload: List[Dict[str, Any]]
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Separate traces and spans from payload.
|
||||
|
||||
Traces are identified by not having a "type" field.
|
||||
Spans are identified by having a "type" field.
|
||||
|
||||
Args:
|
||||
payload: List of dicts containing trace and span data
|
||||
|
||||
Returns:
|
||||
Tuple of (traces, spans) where both are lists of dicts with null values removed
|
||||
"""
|
||||
traces = [_remove_nulls(x) for x in payload if "type" not in x]
|
||||
spans = [_remove_nulls(x) for x in payload if "type" in x]
|
||||
return traces, spans
|
||||
Reference in New Issue
Block a user