genai-otel-instrument 0.1.1.dev0__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.
Potentially problematic release.
This version of genai-otel-instrument might be problematic. Click here for more details.
- genai_otel/__init__.py +129 -0
- genai_otel/__version__.py +34 -0
- genai_otel/auto_instrument.py +413 -0
- genai_otel/cli.py +92 -0
- genai_otel/config.py +187 -0
- genai_otel/cost_calculator.py +276 -0
- genai_otel/exceptions.py +17 -0
- genai_otel/gpu_metrics.py +240 -0
- genai_otel/instrumentors/__init__.py +47 -0
- genai_otel/instrumentors/anthropic_instrumentor.py +134 -0
- genai_otel/instrumentors/anyscale_instrumentor.py +27 -0
- genai_otel/instrumentors/aws_bedrock_instrumentor.py +94 -0
- genai_otel/instrumentors/azure_openai_instrumentor.py +69 -0
- genai_otel/instrumentors/base.py +528 -0
- genai_otel/instrumentors/cohere_instrumentor.py +76 -0
- genai_otel/instrumentors/google_ai_instrumentor.py +87 -0
- genai_otel/instrumentors/groq_instrumentor.py +106 -0
- genai_otel/instrumentors/huggingface_instrumentor.py +97 -0
- genai_otel/instrumentors/langchain_instrumentor.py +75 -0
- genai_otel/instrumentors/llamaindex_instrumentor.py +36 -0
- genai_otel/instrumentors/mistralai_instrumentor.py +119 -0
- genai_otel/instrumentors/ollama_instrumentor.py +83 -0
- genai_otel/instrumentors/openai_instrumentor.py +241 -0
- genai_otel/instrumentors/replicate_instrumentor.py +42 -0
- genai_otel/instrumentors/togetherai_instrumentor.py +42 -0
- genai_otel/instrumentors/vertexai_instrumentor.py +42 -0
- genai_otel/llm_pricing.json +589 -0
- genai_otel/logging_config.py +45 -0
- genai_otel/mcp_instrumentors/__init__.py +14 -0
- genai_otel/mcp_instrumentors/api_instrumentor.py +144 -0
- genai_otel/mcp_instrumentors/base.py +105 -0
- genai_otel/mcp_instrumentors/database_instrumentor.py +336 -0
- genai_otel/mcp_instrumentors/kafka_instrumentor.py +31 -0
- genai_otel/mcp_instrumentors/manager.py +139 -0
- genai_otel/mcp_instrumentors/redis_instrumentor.py +31 -0
- genai_otel/mcp_instrumentors/vector_db_instrumentor.py +265 -0
- genai_otel/metrics.py +148 -0
- genai_otel/py.typed +2 -0
- genai_otel_instrument-0.1.1.dev0.dist-info/METADATA +463 -0
- genai_otel_instrument-0.1.1.dev0.dist-info/RECORD +44 -0
- genai_otel_instrument-0.1.1.dev0.dist-info/WHEEL +5 -0
- genai_otel_instrument-0.1.1.dev0.dist-info/entry_points.txt +2 -0
- genai_otel_instrument-0.1.1.dev0.dist-info/licenses/LICENSE +201 -0
- genai_otel_instrument-0.1.1.dev0.dist-info/top_level.txt +1 -0
genai_otel/config.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Configuration management for the GenAI OpenTelemetry instrumentation library.
|
|
2
|
+
|
|
3
|
+
This module defines the `OTelConfig` dataclass, which encapsulates all configurable
|
|
4
|
+
parameters for the OpenTelemetry setup, including service name, exporter endpoint,
|
|
5
|
+
enablement flags for various features (GPU metrics, cost tracking, MCP instrumentation),
|
|
6
|
+
and error handling behavior. Configuration values are primarily loaded from
|
|
7
|
+
environment variables, with sensible defaults provided.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Default list of instrumentors to enable if not specified by the user.
|
|
20
|
+
# This maintains the "instrument everything available" behavior.
|
|
21
|
+
# Note: "mcp" is excluded by default because it requires the 'mcp' library (>= 1.6.0)
|
|
22
|
+
# which is a specialized dependency for Model Context Protocol servers/clients.
|
|
23
|
+
# Users can enable it by setting GENAI_ENABLED_INSTRUMENTORS="...,mcp" if needed.
|
|
24
|
+
#
|
|
25
|
+
# Note: "smolagents" and "litellm" OpenInference instrumentors require Python >= 3.10
|
|
26
|
+
# They are only added to the default list if Python version is compatible.
|
|
27
|
+
DEFAULT_INSTRUMENTORS = [
|
|
28
|
+
"openai",
|
|
29
|
+
"anthropic",
|
|
30
|
+
"google.generativeai",
|
|
31
|
+
"boto3",
|
|
32
|
+
"azure.ai.openai",
|
|
33
|
+
"cohere",
|
|
34
|
+
"mistralai",
|
|
35
|
+
"together",
|
|
36
|
+
"groq",
|
|
37
|
+
"ollama",
|
|
38
|
+
"vertexai",
|
|
39
|
+
"replicate",
|
|
40
|
+
"anyscale",
|
|
41
|
+
"langchain",
|
|
42
|
+
"llama_index",
|
|
43
|
+
"transformers",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Add OpenInference instrumentors only for Python >= 3.10
|
|
47
|
+
if sys.version_info >= (3, 10):
|
|
48
|
+
DEFAULT_INSTRUMENTORS.extend(["smolagents", "litellm"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _get_enabled_instrumentors() -> List[str]:
|
|
52
|
+
"""
|
|
53
|
+
Gets the list of enabled instrumentors from the environment variable.
|
|
54
|
+
Defaults to all supported instrumentors if the variable is not set.
|
|
55
|
+
"""
|
|
56
|
+
enabled_str = os.getenv("GENAI_ENABLED_INSTRUMENTORS")
|
|
57
|
+
if enabled_str:
|
|
58
|
+
return [s.strip() for s in enabled_str.split(",")]
|
|
59
|
+
return DEFAULT_INSTRUMENTORS
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class OTelConfig:
|
|
64
|
+
"""Configuration for OpenTelemetry instrumentation.
|
|
65
|
+
|
|
66
|
+
Loads settings from environment variables with sensible defaults.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
service_name: str = field(default_factory=lambda: os.getenv("OTEL_SERVICE_NAME", "genai-app"))
|
|
70
|
+
endpoint: str = field(
|
|
71
|
+
default_factory=lambda: os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
|
|
72
|
+
)
|
|
73
|
+
enabled_instrumentors: List[str] = field(default_factory=_get_enabled_instrumentors)
|
|
74
|
+
enable_gpu_metrics: bool = field(
|
|
75
|
+
default_factory=lambda: os.getenv("GENAI_ENABLE_GPU_METRICS", "true").lower() == "true"
|
|
76
|
+
)
|
|
77
|
+
enable_cost_tracking: bool = field(
|
|
78
|
+
default_factory=lambda: os.getenv("GENAI_ENABLE_COST_TRACKING", "true").lower() == "true"
|
|
79
|
+
)
|
|
80
|
+
enable_mcp_instrumentation: bool = field(
|
|
81
|
+
default_factory=lambda: os.getenv("GENAI_ENABLE_MCP_INSTRUMENTATION", "true").lower()
|
|
82
|
+
== "true"
|
|
83
|
+
)
|
|
84
|
+
enable_http_instrumentation: bool = field(
|
|
85
|
+
default_factory=lambda: os.getenv("GENAI_ENABLE_HTTP_INSTRUMENTATION", "false").lower()
|
|
86
|
+
== "true"
|
|
87
|
+
)
|
|
88
|
+
# Add fail_on_error configuration
|
|
89
|
+
fail_on_error: bool = field(
|
|
90
|
+
default_factory=lambda: os.getenv("GENAI_FAIL_ON_ERROR", "false").lower() == "true"
|
|
91
|
+
)
|
|
92
|
+
headers: Optional[Dict[str, str]] = None
|
|
93
|
+
|
|
94
|
+
enable_co2_tracking: bool = field(
|
|
95
|
+
default_factory=lambda: os.getenv("GENAI_ENABLE_CO2_TRACKING", "false").lower() == "true"
|
|
96
|
+
)
|
|
97
|
+
exporter_timeout: float = field(
|
|
98
|
+
default_factory=lambda: float(os.getenv("OTEL_EXPORTER_OTLP_TIMEOUT", "60.0"))
|
|
99
|
+
)
|
|
100
|
+
carbon_intensity: float = field(
|
|
101
|
+
default_factory=lambda: float(os.getenv("GENAI_CARBON_INTENSITY", "475.0"))
|
|
102
|
+
) # gCO2e/kWh
|
|
103
|
+
|
|
104
|
+
gpu_collection_interval: int = field(
|
|
105
|
+
default_factory=lambda: int(os.getenv("GENAI_GPU_COLLECTION_INTERVAL", "5"))
|
|
106
|
+
) # seconds - how often to collect GPU metrics and CO2 emissions
|
|
107
|
+
|
|
108
|
+
# OpenTelemetry semantic convention stability opt-in
|
|
109
|
+
# Supports "gen_ai" for new conventions, "gen_ai/dup" for dual emission
|
|
110
|
+
semconv_stability_opt_in: str = field(
|
|
111
|
+
default_factory=lambda: os.getenv("OTEL_SEMCONV_STABILITY_OPT_IN", "gen_ai")
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Enable content capture as span events
|
|
115
|
+
# WARNING: May capture sensitive data. Use with caution.
|
|
116
|
+
enable_content_capture: bool = field(
|
|
117
|
+
default_factory=lambda: os.getenv("GENAI_ENABLE_CONTENT_CAPTURE", "false").lower() == "true"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
import os
|
|
122
|
+
|
|
123
|
+
from opentelemetry import trace
|
|
124
|
+
from opentelemetry.sdk.resources import ( # noqa: F401
|
|
125
|
+
DEPLOYMENT_ENVIRONMENT,
|
|
126
|
+
SERVICE_NAME,
|
|
127
|
+
TELEMETRY_SDK_NAME,
|
|
128
|
+
Resource,
|
|
129
|
+
)
|
|
130
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
131
|
+
from opentelemetry.sdk.trace.export import (
|
|
132
|
+
BatchSpanProcessor,
|
|
133
|
+
ConsoleSpanExporter,
|
|
134
|
+
SimpleSpanProcessor,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL") == "grpc":
|
|
138
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
|
139
|
+
else:
|
|
140
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def setup_tracing(
|
|
144
|
+
config: "OTelConfig", # Use OTelConfig from this module
|
|
145
|
+
tracer_name: str,
|
|
146
|
+
disable_batch: bool = False,
|
|
147
|
+
):
|
|
148
|
+
"""
|
|
149
|
+
Sets up tracing with OpenTelemetry.
|
|
150
|
+
Initializes the tracer provider and configures the span processor and exporter.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
# Disable Haystack Auto Tracing
|
|
155
|
+
os.environ["HAYSTACK_AUTO_TRACE_ENABLED"] = "false"
|
|
156
|
+
|
|
157
|
+
# Create a resource with the service name attribute.
|
|
158
|
+
resource = Resource.create(
|
|
159
|
+
attributes={
|
|
160
|
+
SERVICE_NAME: config.service_name,
|
|
161
|
+
DEPLOYMENT_ENVIRONMENT: os.getenv("ENVIRONMENT", "dev"),
|
|
162
|
+
TELEMETRY_SDK_NAME: "genai_otel_instrument",
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Initialize the TracerProvider with the created resource.
|
|
167
|
+
trace.set_tracer_provider(TracerProvider(resource=resource))
|
|
168
|
+
|
|
169
|
+
# Configure the span exporter and processor based on whether the endpoint is effectively set.
|
|
170
|
+
if config.endpoint:
|
|
171
|
+
span_exporter = OTLPSpanExporter(headers=config.headers)
|
|
172
|
+
span_processor = (
|
|
173
|
+
BatchSpanProcessor(span_exporter)
|
|
174
|
+
if not disable_batch
|
|
175
|
+
else SimpleSpanProcessor(span_exporter)
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
span_exporter = ConsoleSpanExporter()
|
|
179
|
+
span_processor = SimpleSpanProcessor(span_exporter)
|
|
180
|
+
|
|
181
|
+
trace.get_tracer_provider().add_span_processor(span_processor)
|
|
182
|
+
|
|
183
|
+
return trace.get_tracer(tracer_name)
|
|
184
|
+
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logger.error("Failed to initialize OpenTelemetry: %s", e, exc_info=True)
|
|
187
|
+
return None
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Module for calculating estimated costs of LLM API calls."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CostCalculator:
|
|
11
|
+
"""Calculate estimated costs for LLM API calls based on loaded pricing data."""
|
|
12
|
+
|
|
13
|
+
DEFAULT_PRICING_FILE = "llm_pricing.json"
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
"""Initializes the CostCalculator by loading pricing data from a JSON file."""
|
|
17
|
+
self.pricing_data: Dict[str, Any] = {}
|
|
18
|
+
self._load_pricing()
|
|
19
|
+
|
|
20
|
+
def _load_pricing(self):
|
|
21
|
+
"""Load pricing data from the JSON configuration file."""
|
|
22
|
+
try:
|
|
23
|
+
try:
|
|
24
|
+
from importlib.resources import files
|
|
25
|
+
|
|
26
|
+
pricing_file = files("genai_otel").joinpath(self.DEFAULT_PRICING_FILE)
|
|
27
|
+
data = json.loads(pricing_file.read_text(encoding="utf-8"))
|
|
28
|
+
except (ImportError, AttributeError):
|
|
29
|
+
try:
|
|
30
|
+
import importlib_resources
|
|
31
|
+
|
|
32
|
+
pricing_file = importlib_resources.files("genai_otel").joinpath(
|
|
33
|
+
self.DEFAULT_PRICING_FILE
|
|
34
|
+
)
|
|
35
|
+
data = json.loads(pricing_file.read_text(encoding="utf-8"))
|
|
36
|
+
except ImportError:
|
|
37
|
+
import pkg_resources
|
|
38
|
+
|
|
39
|
+
pricing_file_path = pkg_resources.resource_filename(
|
|
40
|
+
"genai_otel", self.DEFAULT_PRICING_FILE
|
|
41
|
+
)
|
|
42
|
+
with open(pricing_file_path, "r", encoding="utf-8") as f:
|
|
43
|
+
data = json.load(f)
|
|
44
|
+
|
|
45
|
+
if isinstance(data, dict):
|
|
46
|
+
self.pricing_data = data
|
|
47
|
+
logger.info("Successfully loaded pricing data.")
|
|
48
|
+
else:
|
|
49
|
+
logger.error("Invalid format in pricing file. Root element is not a dictionary.")
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
logger.warning(
|
|
52
|
+
"Pricing file '%s' not found. Cost tracking will be disabled.",
|
|
53
|
+
self.DEFAULT_PRICING_FILE,
|
|
54
|
+
)
|
|
55
|
+
except json.JSONDecodeError as e:
|
|
56
|
+
logger.error(
|
|
57
|
+
"Failed to decode JSON from pricing file: %s. Cost tracking will be disabled.", e
|
|
58
|
+
)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.error("An unexpected error occurred while loading pricing: %s", e, exc_info=True)
|
|
61
|
+
|
|
62
|
+
def calculate_cost(
|
|
63
|
+
self,
|
|
64
|
+
model: str,
|
|
65
|
+
usage: Dict[str, Any],
|
|
66
|
+
call_type: str,
|
|
67
|
+
) -> float:
|
|
68
|
+
"""Calculate cost in USD for a request based on model, usage, and call type.
|
|
69
|
+
|
|
70
|
+
Note: For chat requests, use calculate_granular_cost() to get prompt/completion/reasoning/cache breakdown.
|
|
71
|
+
This method returns total cost for backwards compatibility.
|
|
72
|
+
"""
|
|
73
|
+
if not self.pricing_data:
|
|
74
|
+
return 0.0
|
|
75
|
+
|
|
76
|
+
if call_type == "chat":
|
|
77
|
+
return self._calculate_chat_cost(model, usage)
|
|
78
|
+
if call_type == "embedding":
|
|
79
|
+
return self._calculate_embedding_cost(model, usage)
|
|
80
|
+
if call_type == "image":
|
|
81
|
+
return self._calculate_image_cost(model, usage)
|
|
82
|
+
if call_type == "audio":
|
|
83
|
+
return self._calculate_audio_cost(model, usage)
|
|
84
|
+
|
|
85
|
+
logger.warning("Unknown call type '%s' for cost calculation.", call_type)
|
|
86
|
+
return 0.0
|
|
87
|
+
|
|
88
|
+
def calculate_granular_cost(
|
|
89
|
+
self,
|
|
90
|
+
model: str,
|
|
91
|
+
usage: Dict[str, Any],
|
|
92
|
+
call_type: str,
|
|
93
|
+
) -> Dict[str, float]:
|
|
94
|
+
"""Calculate granular cost breakdown for a request.
|
|
95
|
+
|
|
96
|
+
Returns a dictionary with:
|
|
97
|
+
- total: Total cost
|
|
98
|
+
- prompt: Prompt tokens cost
|
|
99
|
+
- completion: Completion tokens cost
|
|
100
|
+
- reasoning: Reasoning tokens cost (OpenAI o1 models)
|
|
101
|
+
- cache_read: Cache read cost (Anthropic)
|
|
102
|
+
- cache_write: Cache write cost (Anthropic)
|
|
103
|
+
"""
|
|
104
|
+
if not self.pricing_data:
|
|
105
|
+
return {
|
|
106
|
+
"total": 0.0,
|
|
107
|
+
"prompt": 0.0,
|
|
108
|
+
"completion": 0.0,
|
|
109
|
+
"reasoning": 0.0,
|
|
110
|
+
"cache_read": 0.0,
|
|
111
|
+
"cache_write": 0.0,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if call_type == "chat":
|
|
115
|
+
return self._calculate_chat_cost_granular(model, usage)
|
|
116
|
+
|
|
117
|
+
# For non-chat requests, only return total cost
|
|
118
|
+
total_cost = self.calculate_cost(model, usage, call_type)
|
|
119
|
+
return {
|
|
120
|
+
"total": total_cost,
|
|
121
|
+
"prompt": 0.0,
|
|
122
|
+
"completion": 0.0,
|
|
123
|
+
"reasoning": 0.0,
|
|
124
|
+
"cache_read": 0.0,
|
|
125
|
+
"cache_write": 0.0,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
def _calculate_chat_cost(self, model: str, usage: Dict[str, int]) -> float:
|
|
129
|
+
"""Calculate cost for chat models."""
|
|
130
|
+
granular = self._calculate_chat_cost_granular(model, usage)
|
|
131
|
+
return granular["total"]
|
|
132
|
+
|
|
133
|
+
def _calculate_chat_cost_granular(self, model: str, usage: Dict[str, int]) -> Dict[str, float]:
|
|
134
|
+
"""Calculate granular cost breakdown for chat models.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Dict with keys: total, prompt, completion, reasoning, cache_read, cache_write
|
|
138
|
+
"""
|
|
139
|
+
model_key = self._normalize_model_name(model, "chat")
|
|
140
|
+
if not model_key:
|
|
141
|
+
logger.debug("Pricing not found for chat model: %s", model)
|
|
142
|
+
return {
|
|
143
|
+
"total": 0.0,
|
|
144
|
+
"prompt": 0.0,
|
|
145
|
+
"completion": 0.0,
|
|
146
|
+
"reasoning": 0.0,
|
|
147
|
+
"cache_read": 0.0,
|
|
148
|
+
"cache_write": 0.0,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
pricing = self.pricing_data["chat"][model_key]
|
|
152
|
+
|
|
153
|
+
# Standard prompt and completion tokens
|
|
154
|
+
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
155
|
+
completion_tokens = usage.get("completion_tokens", 0)
|
|
156
|
+
|
|
157
|
+
prompt_cost = (prompt_tokens / 1000) * pricing.get("promptPrice", 0.0)
|
|
158
|
+
completion_cost = (completion_tokens / 1000) * pricing.get("completionPrice", 0.0)
|
|
159
|
+
|
|
160
|
+
# Reasoning tokens (OpenAI o1 models)
|
|
161
|
+
reasoning_tokens = usage.get("completion_tokens_details", {}).get("reasoning_tokens", 0)
|
|
162
|
+
reasoning_cost = 0.0
|
|
163
|
+
if reasoning_tokens > 0 and "reasoningPrice" in pricing:
|
|
164
|
+
reasoning_cost = (reasoning_tokens / 1000) * pricing.get("reasoningPrice", 0.0)
|
|
165
|
+
|
|
166
|
+
# Cache costs (Anthropic models)
|
|
167
|
+
cache_read_tokens = usage.get("cache_read_input_tokens", 0)
|
|
168
|
+
cache_write_tokens = usage.get("cache_creation_input_tokens", 0)
|
|
169
|
+
cache_read_cost = 0.0
|
|
170
|
+
cache_write_cost = 0.0
|
|
171
|
+
|
|
172
|
+
if cache_read_tokens > 0 and "cacheReadPrice" in pricing:
|
|
173
|
+
cache_read_cost = (cache_read_tokens / 1000) * pricing.get("cacheReadPrice", 0.0)
|
|
174
|
+
if cache_write_tokens > 0 and "cacheWritePrice" in pricing:
|
|
175
|
+
cache_write_cost = (cache_write_tokens / 1000) * pricing.get("cacheWritePrice", 0.0)
|
|
176
|
+
|
|
177
|
+
total_cost = (
|
|
178
|
+
prompt_cost + completion_cost + reasoning_cost + cache_read_cost + cache_write_cost
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"total": total_cost,
|
|
183
|
+
"prompt": prompt_cost,
|
|
184
|
+
"completion": completion_cost,
|
|
185
|
+
"reasoning": reasoning_cost,
|
|
186
|
+
"cache_read": cache_read_cost,
|
|
187
|
+
"cache_write": cache_write_cost,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
def _calculate_embedding_cost(self, model: str, usage: Dict[str, int]) -> float:
|
|
191
|
+
"""Calculate cost for embedding models."""
|
|
192
|
+
model_key = self._normalize_model_name(model, "embeddings")
|
|
193
|
+
if not model_key:
|
|
194
|
+
logger.debug("Pricing not found for embedding model: %s", model)
|
|
195
|
+
return 0.0
|
|
196
|
+
|
|
197
|
+
price_per_1k_tokens = self.pricing_data["embeddings"][model_key]
|
|
198
|
+
total_tokens = usage.get("prompt_tokens", 0) or usage.get("total_tokens", 0)
|
|
199
|
+
return (total_tokens / 1000) * price_per_1k_tokens
|
|
200
|
+
|
|
201
|
+
def _calculate_image_cost(self, model: str, usage: Dict[str, Any]) -> float:
|
|
202
|
+
"""Calculate cost for image generation models."""
|
|
203
|
+
model_key = self._normalize_model_name(model, "images")
|
|
204
|
+
if not model_key:
|
|
205
|
+
logger.debug("Pricing not found for image model: %s", model)
|
|
206
|
+
return 0.0
|
|
207
|
+
|
|
208
|
+
pricing_info = self.pricing_data["images"][model_key]
|
|
209
|
+
quality = usage.get("quality", "standard")
|
|
210
|
+
size = usage.get("size")
|
|
211
|
+
n = usage.get("n", 1)
|
|
212
|
+
|
|
213
|
+
if quality not in pricing_info:
|
|
214
|
+
logger.warning("Quality '%s' not found for image model %s", quality, model_key)
|
|
215
|
+
return 0.0
|
|
216
|
+
|
|
217
|
+
# Handle pricing per million pixels
|
|
218
|
+
if "1000000" in pricing_info[quality]:
|
|
219
|
+
price_per_million_pixels = pricing_info[quality]["1000000"]
|
|
220
|
+
height = usage.get("height", 0)
|
|
221
|
+
width = usage.get("width", 0)
|
|
222
|
+
return (height * width / 1_000_000) * price_per_million_pixels * n
|
|
223
|
+
|
|
224
|
+
if not size:
|
|
225
|
+
logger.warning("Image size not provided for model %s", model_key)
|
|
226
|
+
return 0.0
|
|
227
|
+
|
|
228
|
+
if size not in pricing_info[quality]:
|
|
229
|
+
logger.warning(
|
|
230
|
+
"Size '%s' not found for image model %s with quality '%s'", size, model_key, quality
|
|
231
|
+
)
|
|
232
|
+
return 0.0
|
|
233
|
+
|
|
234
|
+
price_per_image = pricing_info[quality][size]
|
|
235
|
+
return price_per_image * n
|
|
236
|
+
|
|
237
|
+
def _calculate_audio_cost(self, model: str, usage: Dict[str, int]) -> float:
|
|
238
|
+
"""Calculate cost for audio models."""
|
|
239
|
+
model_key = self._normalize_model_name(model, "audio")
|
|
240
|
+
if not model_key:
|
|
241
|
+
logger.debug("Pricing not found for audio model: %s", model)
|
|
242
|
+
return 0.0
|
|
243
|
+
|
|
244
|
+
pricing = self.pricing_data["audio"][model_key]
|
|
245
|
+
|
|
246
|
+
if "characters" in usage:
|
|
247
|
+
# Price is per 1000 characters
|
|
248
|
+
return (usage["characters"] / 1000) * pricing
|
|
249
|
+
if "seconds" in usage:
|
|
250
|
+
# Price is per second
|
|
251
|
+
return usage["seconds"] * pricing
|
|
252
|
+
|
|
253
|
+
logger.warning(
|
|
254
|
+
"Could not determine usage unit for audio model %s. Expected 'characters' or 'seconds'.",
|
|
255
|
+
model_key,
|
|
256
|
+
)
|
|
257
|
+
return 0.0
|
|
258
|
+
|
|
259
|
+
def _normalize_model_name(self, model: str, category: str) -> Optional[str]:
|
|
260
|
+
"""Normalize model name to match pricing keys for a specific category."""
|
|
261
|
+
if category not in self.pricing_data:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
normalized_model = model.lower()
|
|
265
|
+
|
|
266
|
+
# Exact match (case-insensitive)
|
|
267
|
+
for key in self.pricing_data[category]:
|
|
268
|
+
if normalized_model == key.lower():
|
|
269
|
+
return key
|
|
270
|
+
|
|
271
|
+
# Substring match (case-insensitive)
|
|
272
|
+
sorted_keys = sorted(self.pricing_data[category].keys(), key=len, reverse=True)
|
|
273
|
+
for key in sorted_keys:
|
|
274
|
+
if key.lower() in normalized_model:
|
|
275
|
+
return key
|
|
276
|
+
return None
|
genai_otel/exceptions.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Custom exceptions for better error handling"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InstrumentationError(Exception):
|
|
5
|
+
"""Base exception for instrumentation errors"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProviderInstrumentationError(InstrumentationError):
|
|
9
|
+
"""Error instrumenting a specific provider"""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TelemetryExportError(InstrumentationError):
|
|
13
|
+
"""Error exporting telemetry data"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigurationError(InstrumentationError):
|
|
17
|
+
"""Error in configuration"""
|