diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index a9bd93e6420..a3e4a94e92d 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1747,7 +1747,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): self._token_usage_histogram.record(usage.get("completion_tokens", 0), attributes=out_attrs) cost: Final = kwargs.get("response_cost") - if self._cost_histogram and cost: + if self._cost_histogram and cost is not None: self._cost_histogram.record(cost, attributes=common_attrs) # Record latency metrics (TTFT, TPOT, and Total Generation Time) diff --git a/litellm/integrations/otel/plumbing/metrics.py b/litellm/integrations/otel/plumbing/metrics.py index 1197ae055d0..f349f892b40 100644 --- a/litellm/integrations/otel/plumbing/metrics.py +++ b/litellm/integrations/otel/plumbing/metrics.py @@ -234,7 +234,7 @@ class GenAIMetricRecorder: self._record_token_usage(response_obj, common_attrs) cost: Final = kwargs.get("response_cost") - if cost: + if cost is not None: self._metrics.token_cost.record(cost, attributes=common_attrs) self._record_time_to_first_token(kwargs, common_attrs) diff --git a/tests/unit/integrations/otel/test_otel_v2_metrics.py b/tests/unit/integrations/otel/test_otel_v2_metrics.py index 74921512825..d2dd0f31a45 100644 --- a/tests/unit/integrations/otel/test_otel_v2_metrics.py +++ b/tests/unit/integrations/otel/test_otel_v2_metrics.py @@ -193,6 +193,26 @@ def test_all_six_metrics_emitted_when_enabled(): assert cost_points[0].sum == pytest.approx(RESPONSE_COST) +@pytest.mark.parametrize("response_cost", [0, 0.0123, None]) +def test_cost_metric_records_known_values_including_zero(response_cost: float | None): + reader = InMemoryMetricReader() + logger = _logger(reader, enable_metrics=True) + kwargs, response_obj, start, end = _build_call() + kwargs["response_cost"] = response_cost + asyncio.run(logger.async_log_success_event(kwargs, response_obj, start, end)) + + metrics = _metrics_by_name(reader) + cost_points = metrics.get(TOKEN_COST, []) + if response_cost is None: + assert cost_points == [] + return + + assert len(cost_points) == 1 + assert cost_points[0].sum == pytest.approx(response_cost) + assert cost_points[0].count == 1 + assert cost_points[0].attributes == metrics[OPERATION_DURATION][0].attributes + + def test_time_to_first_token_is_streaming_only(): """time_to_first_token is gated on streaming: a non-streaming call emits the other five metrics but never that one.""" diff --git a/tests/unit/integrations/test_opentelemetry.py b/tests/unit/integrations/test_opentelemetry.py index 5f749e951b1..d5eb8f319e9 100644 --- a/tests/unit/integrations/test_opentelemetry.py +++ b/tests/unit/integrations/test_opentelemetry.py @@ -6255,7 +6255,7 @@ class TestOpenTelemetryMetricAttributeFiltering(unittest.TestCase): response_obj = json.load(f) return kwargs, response_obj - def _record(self, attributes): + def _record(self, attributes, response_cost: float | None = None): """Run a real success hook with metrics enabled and return the reader.""" metric_reader = InMemoryMetricReader() meter_provider = MeterProvider(metric_readers=[metric_reader]) @@ -6271,11 +6271,28 @@ class TestOpenTelemetryMetricAttributeFiltering(unittest.TestCase): otel.tracer = tracer_provider.get_tracer(__name__) kwargs, response_obj = self._load_fixtures() + if response_cost is not None: + kwargs["response_cost"] = response_cost start = datetime.utcnow() end = start + timedelta(seconds=1) otel._handle_success(kwargs, response_obj, start, end) return metric_reader + def test_zero_cost_is_recorded(self): + reader = self._record(None, response_cost=0) + data = reader.get_metrics_data() + cost_metrics = tuple( + metric + for resource_metrics in data.resource_metrics + for scope_metrics in resource_metrics.scope_metrics + for metric in scope_metrics.metrics + if metric.name == "gen_ai.usage.cost" + ) + + self.assertEqual(len(cost_metrics), 1) + self.assertEqual(cost_metrics[0].data.data_points[0].sum, 0) + self.assertEqual(cost_metrics[0].data.data_points[0].count, 1) + def _keysets(self, reader, metric_name): """Attribute-key sets, one per recorded data point of `metric_name`.""" deadline = time.time() + self.POLL_TIMEOUT