From 8bc2c189a9b5a3a1defb892c1a10a078da269eb3 Mon Sep 17 00:00:00 2001 From: kamilbenkirane Date: Wed, 6 May 2026 11:10:19 +0200 Subject: [PATCH] feat(telemetry): emit gen_ai.response.model from Output.metadata["model"] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `output_attributes` now emits `gen_ai.response.model` set to `output.metadata["model"]` — the model id celeste already populates on every Output. Both `_predict` (via `ModalityClient._build_metadata`) and `_stream` (via the `stream_metadata` constructor arg) write `model` to metadata, so the field is universally present; no guards needed. For providers that echo a different served snapshot in their response (Anthropic latest-tag → dated, OpenAI alias → snapshot), provider mixins can opt in to overwrite `metadata["model"]` with the served value. None do that today — `metadata["model"]` is the request id everywhere — and the emitted attribute equals `gen_ai.request.model` in practice. That's semconv-clean for the common case where served = requested. Test fixture `TelemetryStream` now defaults `stream_metadata={"model": "test-model"}` to mirror production wiring; one updated test passes the metadata explicitly. New positive test asserts `gen_ai.response.model` reads from `metadata["model"]`. Real-call validated against running chat-backend (Gemini text + image streaming): every GenAI span now carries `gen_ai.response.model`. --- src/celeste/telemetry.py | 1 + tests/unit_tests/_telemetry_helpers.py | 15 ++++++++++++++- tests/unit_tests/test_telemetry_streaming.py | 17 ++++++++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/celeste/telemetry.py b/src/celeste/telemetry.py index c6ce838..a4fb07e 100644 --- a/src/celeste/telemetry.py +++ b/src/celeste/telemetry.py @@ -224,6 +224,7 @@ def output_attributes(output: Output[Any]) -> dict[str, Any]: attrs[semconv_key or f"celeste.usage.{field_name}"] = value if output.finish_reason is not None and output.finish_reason.reason is not None: attrs["gen_ai.response.finish_reasons"] = (output.finish_reason.reason,) + attrs["gen_ai.response.model"] = output.metadata["model"] return attrs diff --git a/tests/unit_tests/_telemetry_helpers.py b/tests/unit_tests/_telemetry_helpers.py index 56f1c6f..451c88d 100644 --- a/tests/unit_tests/_telemetry_helpers.py +++ b/tests/unit_tests/_telemetry_helpers.py @@ -1,7 +1,7 @@ """Shared fixtures and helpers for telemetry-related unit tests.""" from collections.abc import AsyncIterator -from typing import Any, ClassVar +from typing import Any, ClassVar, Unpack from celeste.io import Chunk, Output, Usage from celeste.parameters import Parameters @@ -30,6 +30,19 @@ class TelemetryStream(Stream[TelemetryOutput, Parameters, Chunk]): _output_class: ClassVar[type[Output]] = TelemetryOutput _empty_content: ClassVar[str] = "" + def __init__( + self, + sse_iterator: AsyncIterator[dict[str, Any]], + stream_metadata: dict[str, Any] | None = None, + **parameters: Unpack[Parameters], + ) -> None: + """Default `stream_metadata={"model": "test-model"}` to mirror production wiring.""" + super().__init__( + sse_iterator, + stream_metadata=stream_metadata or {"model": "test-model"}, + **parameters, + ) + def _aggregate_content(self, chunks: list[Chunk]) -> str: """Concatenate chunk content.""" return "".join(chunk.content for chunk in chunks) diff --git a/tests/unit_tests/test_telemetry_streaming.py b/tests/unit_tests/test_telemetry_streaming.py index fa91081..ee95a01 100644 --- a/tests/unit_tests/test_telemetry_streaming.py +++ b/tests/unit_tests/test_telemetry_streaming.py @@ -16,6 +16,7 @@ from tests.unit_tests._telemetry_helpers import ( TelemetryOutput, TelemetryStream, + TelemetryUsage, async_iter, ) from tests.unit_tests.conftest import start_test_span @@ -213,10 +214,24 @@ async def test_total_reasoning_cached_tokens_emitted( def test_off_spec_usage_emitted_under_celeste_namespace(self) -> None: """Modality-specific Usage fields fall through to `celeste.usage.`.""" usage = _OffSpecUsage(images_generated=4, audio_seconds=1.5) - output: Output[Any] = TelemetryOutput(content="", usage=usage) + output: Output[Any] = TelemetryOutput( + content="", usage=usage, metadata={"model": "test-model"} + ) attrs = telemetry.output_attributes(output) assert attrs["celeste.usage.images_generated"] == 4 assert attrs["celeste.usage.audio_seconds"] == 1.5 assert "gen_ai.usage.images_generated" not in attrs + + def test_response_model_emitted_from_metadata(self) -> None: + """`gen_ai.response.model` reads from `output.metadata["model"]`.""" + output: Output[Any] = TelemetryOutput( + content="", + usage=TelemetryUsage(), + metadata={"model": "claude-opus-4-1-20250805"}, + ) + + attrs = telemetry.output_attributes(output) + + assert attrs["gen_ai.response.model"] == "claude-opus-4-1-20250805"