Python: Improved telemetry setup (#421)

* test with stack and simplified names

* quick demo of agent decorator

* moved builder to protocol to enhance functionality

* undid chatclientAgent -> agent rename

* one more

* reverted AIAgent rename

* final reverts

* fixed foundry import

* revert changes

* streamlined otel and fcc decorators

* cleanup of telemetry

* further refinement

* lots of updates

* fixed typing

* fix for mypy

* added input and output atttributes

* fix import

* initial work on baking in otel

* major update to telemetry

* final fixes after rename

* fix

* fix test

* updated tests

* fix for tests

* fixes for tests

* updated based on comments

* removed agent decorator

* fix for Python: ServiceResponseException when using multiple tools
Fixes #649

* addressed comments

* fix tests

* fix tests

* fix tools tests

* fix for conversation_id in assistants client

* fix responses test

* fix tests and mypy

* updated test

* foundry fix

---------

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2025-09-10 16:52:42 +02:00
committed by GitHub
Unverified
parent 6aa746d891
commit 82ca4065cb
45 changed files with 3246 additions and 2984 deletions
@@ -20,8 +20,8 @@ from openai.types.beta.threads.run_submit_tool_outputs_params import ToolOutput
from openai.types.beta.threads.runs import RunStep
from pydantic import Field, PrivateAttr, SecretStr, ValidationError
from .._clients import BaseChatClient, use_tool_calling
from .._tools import AIFunction, HostedCodeInterpreterTool, HostedFileSearchTool
from .._clients import BaseChatClient
from .._tools import AIFunction, HostedCodeInterpreterTool, HostedFileSearchTool, use_function_invocation
from .._types import (
ChatMessage,
ChatOptions,
@@ -50,8 +50,8 @@ else:
__all__ = ["OpenAIAssistantsClient"]
@use_function_invocation
@use_telemetry
@use_tool_calling
class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
"""OpenAI Assistants client."""
@@ -166,7 +166,9 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
# Get the thread ID
thread_id: str | None = (
chat_options.conversation_id if chat_options.conversation_id is not None else self.thread_id
chat_options.conversation_id
if chat_options.conversation_id is not None
else run_options.get("conversation_id", self.thread_id)
)
if thread_id is None and tool_results is not None:
@@ -1,6 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import sys
from collections.abc import AsyncIterable, Mapping, MutableMapping, MutableSequence, Sequence
from datetime import datetime
from itertools import chain
@@ -15,9 +16,9 @@ from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall
from pydantic import BaseModel, SecretStr, ValidationError
from .._clients import BaseChatClient, use_tool_calling
from .._clients import BaseChatClient
from .._logging import get_logger
from .._tools import AIFunction, HostedWebSearchTool, ToolProtocol
from .._tools import AIFunction, HostedWebSearchTool, ToolProtocol, use_function_invocation
from .._types import (
ChatMessage,
ChatOptions,
@@ -41,14 +42,17 @@ from ..telemetry import use_telemetry
from ._exceptions import OpenAIContentFilterException
from ._shared import OpenAIBase, OpenAIConfigMixin, OpenAISettings, prepare_function_call_results
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
__all__ = ["OpenAIChatClient"]
logger = get_logger("agent_framework.openai")
# region Base Client
@use_telemetry
@use_tool_calling
class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
"""OpenAI Chat completion class."""
@@ -233,11 +237,26 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
)
def _usage_details_from_openai(self, usage: CompletionUsage) -> UsageDetails:
return UsageDetails(
prompt_tokens=usage.prompt_tokens,
completion_tokens=usage.completion_tokens,
total_tokens=usage.total_tokens,
details = UsageDetails(
input_token_count=usage.prompt_tokens,
output_token_count=usage.completion_tokens,
total_token_count=usage.total_tokens,
)
if usage.completion_tokens_details:
if tokens := usage.completion_tokens_details.accepted_prediction_tokens:
details["completion/accepted_prediction_tokens"] = tokens
if tokens := usage.completion_tokens_details.audio_tokens:
details["completion/audio_tokens"] = tokens
if tokens := usage.completion_tokens_details.reasoning_tokens:
details["completion/reasoning_tokens"] = tokens
if tokens := usage.completion_tokens_details.rejected_prediction_tokens:
details["completion/rejected_prediction_tokens"] = tokens
if usage.prompt_tokens_details:
if tokens := usage.prompt_tokens_details.audio_tokens:
details["prompt/audio_tokens"] = tokens
if tokens := usage.prompt_tokens_details.cached_tokens:
details["prompt/cached_tokens"] = tokens
return details
def _parse_text_from_choice(self, choice: Choice | ChunkChoice) -> TextContent | None:
"""Parse the choice into a TextContent object."""
@@ -362,13 +381,14 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
case _:
return content.model_dump(exclude_none=True)
def service_url(self) -> str | None:
@override
def service_url(self) -> str:
"""Get the URL of the service.
Override this in the subclass to return the proper URL.
If the service does not have a URL, return None.
"""
return str(self.client.base_url) if self.client else None
return str(self.client.base_url) if self.client else "Unknown"
# region Public client
@@ -376,6 +396,8 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
TOpenAIChatClient = TypeVar("TOpenAIChatClient", bound="OpenAIChatClient")
@use_function_invocation
@use_telemetry
class OpenAIChatClient(OpenAIConfigMixin, OpenAIBaseChatClient):
"""OpenAI Chat completion class."""
@@ -25,7 +25,7 @@ from openai.types.responses.web_search_tool_param import UserLocation as WebSear
from openai.types.responses.web_search_tool_param import WebSearchToolParam
from pydantic import BaseModel, SecretStr, ValidationError
from .._clients import BaseChatClient, use_tool_calling
from .._clients import BaseChatClient
from .._logging import get_logger
from .._tools import (
AIFunction,
@@ -34,6 +34,7 @@ from .._tools import (
HostedMCPTool,
HostedWebSearchTool,
ToolProtocol,
use_function_invocation,
)
from .._types import (
ChatMessage,
@@ -406,7 +407,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
tool_args["file_ids"] = []
for tool_input in tool.inputs:
if isinstance(tool_input, HostedFileContent):
tool_args["file_ids"].append(tool_input.file_id)
tool_args["file_ids"].append(tool_input.file_id) # type: ignore[attr-defined]
if not tool_args["file_ids"]:
tool_args.pop("file_ids")
response_tools.append(
@@ -1040,8 +1041,8 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
TOpenAIResponsesClient = TypeVar("TOpenAIResponsesClient", bound="OpenAIResponsesClient")
@use_function_invocation
@use_telemetry
@use_tool_calling
class OpenAIResponsesClient(OpenAIConfigMixin, OpenAIBaseResponsesClient):
"""OpenAI Responses client class."""
@@ -60,7 +60,7 @@ def prepare_function_call_results(content: Contents | Any | list[Contents | Any]
results.extend(res)
else:
results.append(res)
return results[0] if len(results) == 1 else results
return results[0] if len(results) == 1 else json.dumps(results)
if isinstance(content, BaseModel):
return content.model_dump_json(exclude_none=True, exclude={"raw_representation", "additional_properties"})
# fallback
@@ -127,7 +127,7 @@ class OpenAIBase(AFBaseModel):
class OpenAIConfigMixin(OpenAIBase):
"""Internal class for configuring a connection to an OpenAI service."""
MODEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc]
OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc]
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
def __init__(