mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Fix Python OTel usage detail attributes (#6493)
* fix python otel usage detail attributes Map cached/read/reasoning usage detail fields to standard OTel GenAI attributes while preserving provider-specific legacy keys. Add focused coverage for direct response spans, aggregated agent spans, and provider usage parsing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address usage detail review feedback Omit missing OpenAI Responses usage detail counts while preserving zero-valued counts. Record zero-valued token usage in OTel histograms and add regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
d7027fc1f9
commit
d7e8d2206d
@@ -2979,10 +2979,16 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
output_token_count=usage.output_tokens,
|
||||
total_token_count=usage.total_tokens,
|
||||
)
|
||||
if usage.input_tokens_details and usage.input_tokens_details.cached_tokens:
|
||||
details["openai.cached_input_tokens"] = usage.input_tokens_details.cached_tokens # type: ignore[typeddict-unknown-key]
|
||||
if usage.output_tokens_details and usage.output_tokens_details.reasoning_tokens:
|
||||
details["openai.reasoning_tokens"] = usage.output_tokens_details.reasoning_tokens # type: ignore[typeddict-unknown-key]
|
||||
if usage.input_tokens_details:
|
||||
cached_tokens = cast("int | None", getattr(usage.input_tokens_details, "cached_tokens", None))
|
||||
if cached_tokens is not None:
|
||||
details["openai.cached_input_tokens"] = cached_tokens # type: ignore[typeddict-unknown-key]
|
||||
details["cache_read_input_token_count"] = cached_tokens
|
||||
if usage.output_tokens_details:
|
||||
reasoning_tokens = cast("int | None", getattr(usage.output_tokens_details, "reasoning_tokens", None))
|
||||
if reasoning_tokens is not None:
|
||||
details["openai.reasoning_tokens"] = reasoning_tokens # type: ignore[typeddict-unknown-key]
|
||||
details["reasoning_output_token_count"] = reasoning_tokens
|
||||
return details
|
||||
|
||||
def _get_metadata_from_response(self, output: Any) -> dict[str, Any]:
|
||||
|
||||
@@ -765,15 +765,17 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc]
|
||||
details["completion/accepted_prediction_tokens"] = tokens # type: ignore[typeddict-unknown-key]
|
||||
if tokens := usage.completion_tokens_details.audio_tokens:
|
||||
details["completion/audio_tokens"] = tokens # type: ignore[typeddict-unknown-key]
|
||||
if tokens := usage.completion_tokens_details.reasoning_tokens:
|
||||
if (tokens := usage.completion_tokens_details.reasoning_tokens) is not None:
|
||||
details["completion/reasoning_tokens"] = tokens # type: ignore[typeddict-unknown-key]
|
||||
details["reasoning_output_token_count"] = tokens
|
||||
if tokens := usage.completion_tokens_details.rejected_prediction_tokens:
|
||||
details["completion/rejected_prediction_tokens"] = tokens # type: ignore[typeddict-unknown-key]
|
||||
if usage.prompt_tokens_details:
|
||||
if tokens := usage.prompt_tokens_details.audio_tokens:
|
||||
details["prompt/audio_tokens"] = tokens # type: ignore[typeddict-unknown-key]
|
||||
if tokens := usage.prompt_tokens_details.cached_tokens:
|
||||
if (tokens := usage.prompt_tokens_details.cached_tokens) is not None:
|
||||
details["prompt/cached_tokens"] = tokens # type: ignore[typeddict-unknown-key]
|
||||
details["cache_read_input_token_count"] = tokens
|
||||
return details
|
||||
|
||||
def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None:
|
||||
|
||||
@@ -3301,6 +3301,7 @@ def test_usage_details_with_cached_tokens() -> None:
|
||||
assert details is not None
|
||||
assert details["input_token_count"] == 200
|
||||
assert details["openai.cached_input_tokens"] == 25
|
||||
assert details["cache_read_input_token_count"] == 25
|
||||
|
||||
|
||||
def test_usage_details_with_reasoning_tokens() -> None:
|
||||
@@ -3319,6 +3320,49 @@ def test_usage_details_with_reasoning_tokens() -> None:
|
||||
assert details is not None
|
||||
assert details["output_token_count"] == 80
|
||||
assert details["openai.reasoning_tokens"] == 30
|
||||
assert details["reasoning_output_token_count"] == 30
|
||||
|
||||
|
||||
def test_usage_details_with_zero_cached_and_reasoning_tokens() -> None:
|
||||
"""Test _parse_usage_from_openai preserves zero-valued mapped usage details."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.input_tokens = 150
|
||||
mock_usage.output_tokens = 80
|
||||
mock_usage.total_tokens = 230
|
||||
mock_usage.input_tokens_details = MagicMock()
|
||||
mock_usage.input_tokens_details.cached_tokens = 0
|
||||
mock_usage.output_tokens_details = MagicMock()
|
||||
mock_usage.output_tokens_details.reasoning_tokens = 0
|
||||
|
||||
details = client._parse_usage_from_openai(mock_usage) # type: ignore
|
||||
assert details is not None
|
||||
assert details["openai.cached_input_tokens"] == 0
|
||||
assert details["cache_read_input_token_count"] == 0
|
||||
assert details["openai.reasoning_tokens"] == 0
|
||||
assert details["reasoning_output_token_count"] == 0
|
||||
|
||||
|
||||
def test_usage_details_omits_missing_cached_and_reasoning_tokens() -> None:
|
||||
"""Test _parse_usage_from_openai omits missing mapped usage details."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.input_tokens = 150
|
||||
mock_usage.output_tokens = 80
|
||||
mock_usage.total_tokens = 230
|
||||
mock_usage.input_tokens_details = MagicMock()
|
||||
mock_usage.input_tokens_details.cached_tokens = None
|
||||
mock_usage.output_tokens_details = MagicMock()
|
||||
mock_usage.output_tokens_details.reasoning_tokens = None
|
||||
|
||||
details = client._parse_usage_from_openai(mock_usage) # type: ignore
|
||||
assert details is not None
|
||||
assert "openai.cached_input_tokens" not in details
|
||||
assert "cache_read_input_token_count" not in details
|
||||
assert "openai.reasoning_tokens" not in details
|
||||
assert "reasoning_output_token_count" not in details
|
||||
|
||||
|
||||
def test_get_metadata_from_response() -> None:
|
||||
|
||||
@@ -1099,6 +1099,31 @@ def test_usage_content_in_streaming_response(
|
||||
assert usage_content.usage_details["total_token_count"] == 150
|
||||
|
||||
|
||||
def test_parse_usage_includes_standard_and_legacy_mapped_token_details() -> None:
|
||||
"""Test _parse_usage_from_openai emits standard and legacy mapped token details."""
|
||||
client = OpenAIChatCompletionClient(model="test-model", api_key="test-key")
|
||||
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.prompt_tokens = 100
|
||||
mock_usage.completion_tokens = 50
|
||||
mock_usage.total_tokens = 150
|
||||
mock_usage.completion_tokens_details = MagicMock()
|
||||
mock_usage.completion_tokens_details.accepted_prediction_tokens = None
|
||||
mock_usage.completion_tokens_details.audio_tokens = None
|
||||
mock_usage.completion_tokens_details.reasoning_tokens = 0
|
||||
mock_usage.completion_tokens_details.rejected_prediction_tokens = None
|
||||
mock_usage.prompt_tokens_details = MagicMock()
|
||||
mock_usage.prompt_tokens_details.audio_tokens = None
|
||||
mock_usage.prompt_tokens_details.cached_tokens = 0
|
||||
|
||||
details = client._parse_usage_from_openai(mock_usage) # type: ignore[arg-type]
|
||||
|
||||
assert details["completion/reasoning_tokens"] == 0
|
||||
assert details["reasoning_output_token_count"] == 0
|
||||
assert details["prompt/cached_tokens"] == 0
|
||||
assert details["cache_read_input_token_count"] == 0
|
||||
|
||||
|
||||
def test_streaming_chunk_with_usage_and_text(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user