mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: openai updates (#388)
* openai updates * rebuild of openai structure * updated responses structure * renamed sample * added file id support to code interpreter * added hosted file ids to code interpretor * mypy fixes * removed default az cred from codebase * updated agent name setup * added kwargs to entra methods * and further kwargs * extra comment * updated all samples * readded custom get methods for responses * updated int tests with ad credential * missed one
This commit is contained in:
committed by
GitHub
Unverified
parent
19676978e9
commit
df9d85d1f0
@@ -1,7 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, ClassVar
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.openai import OpenAIAssistantsClient
|
||||
@@ -9,9 +9,10 @@ from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from pydantic.networks import AnyUrl
|
||||
|
||||
from ._shared import (
|
||||
AzureOpenAISettings,
|
||||
)
|
||||
from ._shared import AzureOpenAISettings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from azure.identity import ChainedTokenCredential
|
||||
|
||||
__all__ = ["AzureAssistantsClient"]
|
||||
|
||||
@@ -20,7 +21,6 @@ class AzureAssistantsClient(OpenAIAssistantsClient):
|
||||
"""Azure OpenAI Assistants client."""
|
||||
|
||||
DEFAULT_AZURE_API_VERSION: ClassVar[str] = "2024-05-01-preview"
|
||||
MODEL_PROVIDER_NAME: ClassVar[str] = "azure_openai" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -35,6 +35,7 @@ class AzureAssistantsClient(OpenAIAssistantsClient):
|
||||
ad_token: str | None = None,
|
||||
ad_token_provider: AsyncAzureADTokenProvider | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
ad_credential: "ChainedTokenCredential | None" = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
async_client: AsyncAzureOpenAI | None = None,
|
||||
env_file_path: str | None = None,
|
||||
@@ -61,6 +62,7 @@ class AzureAssistantsClient(OpenAIAssistantsClient):
|
||||
ad_token: The Azure Active Directory token. (Optional)
|
||||
ad_token_provider: The Azure Active Directory token provider. (Optional)
|
||||
token_endpoint: The token endpoint to request an Azure token. (Optional)
|
||||
ad_credential: The Azure AD credential to use for authentication. (Optional)
|
||||
default_headers: The default headers mapping of string keys to
|
||||
string values for HTTP requests. (Optional)
|
||||
async_client: An existing client to use. (Optional)
|
||||
@@ -93,11 +95,9 @@ class AzureAssistantsClient(OpenAIAssistantsClient):
|
||||
and not ad_token
|
||||
and not ad_token_provider
|
||||
and azure_openai_settings.token_endpoint
|
||||
and ad_credential
|
||||
):
|
||||
# Try to get token using Entra ID if no other auth method is provided
|
||||
from ._entra_id_authentication import get_entra_auth_token
|
||||
|
||||
ad_token = get_entra_auth_token(azure_openai_settings.token_endpoint)
|
||||
ad_token = azure_openai_settings.get_azure_auth_token(ad_credential)
|
||||
|
||||
if not async_client and not azure_openai_settings.api_key and not ad_token and not ad_token_provider:
|
||||
raise ServiceInitializationError("The Azure OpenAI API key, ad_token, or ad_token_provider is required.")
|
||||
|
||||
@@ -2,25 +2,21 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from typing import Any, TypeVar
|
||||
from uuid import uuid4
|
||||
|
||||
from agent_framework import (
|
||||
ChatFinishReason,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
CitationAnnotation,
|
||||
TextContent,
|
||||
)
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.openai._chat_client import OpenAIChatClientBase
|
||||
from agent_framework.openai._shared import OpenAIModelTypes
|
||||
from azure.identity import ChainedTokenCredential
|
||||
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
|
||||
from openai.types.chat.chat_completion import ChatCompletion, Choice
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from pydantic.networks import AnyUrl
|
||||
@@ -30,9 +26,15 @@ from ._shared import (
|
||||
AzureOpenAISettings,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
TChatResponse = TypeVar("TChatResponse", ChatResponse, ChatResponseUpdate)
|
||||
TAzureChatClient = TypeVar("TAzureChatClient", bound="AzureChatClient")
|
||||
|
||||
|
||||
class AzureChatClient(AzureOpenAIConfigBase, OpenAIChatClientBase):
|
||||
@@ -48,6 +50,7 @@ class AzureChatClient(AzureOpenAIConfigBase, OpenAIChatClientBase):
|
||||
ad_token: str | None = None,
|
||||
ad_token_provider: AsyncAzureADTokenProvider | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
ad_credential: ChainedTokenCredential | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
async_client: AsyncAzureOpenAI | None = None,
|
||||
env_file_path: str | None = None,
|
||||
@@ -70,6 +73,7 @@ class AzureChatClient(AzureOpenAIConfigBase, OpenAIChatClientBase):
|
||||
ad_token: The Azure Active Directory token. (Optional)
|
||||
ad_token_provider: The Azure Active Directory token provider. (Optional)
|
||||
token_endpoint: The token endpoint to request an Azure token. (Optional)
|
||||
ad_credential: The Azure Active Directory credential. (Optional)
|
||||
default_headers: The default headers mapping of string keys to
|
||||
string values for HTTP requests. (Optional)
|
||||
async_client: An existing client to use. (Optional)
|
||||
@@ -107,14 +111,14 @@ class AzureChatClient(AzureOpenAIConfigBase, OpenAIChatClientBase):
|
||||
ad_token=ad_token,
|
||||
ad_token_provider=ad_token_provider,
|
||||
token_endpoint=azure_openai_settings.token_endpoint,
|
||||
ad_credential=ad_credential,
|
||||
default_headers=default_headers,
|
||||
ai_model_type=OpenAIModelTypes.CHAT,
|
||||
client=async_client,
|
||||
instruction_role=instruction_role,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, settings: dict[str, Any]) -> "AzureChatClient":
|
||||
def from_dict(cls: type[TAzureChatClient], settings: dict[str, Any]) -> TAzureChatClient:
|
||||
"""Initialize an Azure OpenAI service from a dictionary of settings.
|
||||
|
||||
Args:
|
||||
@@ -122,7 +126,7 @@ class AzureChatClient(AzureOpenAIConfigBase, OpenAIChatClientBase):
|
||||
should contain keys: service_id, and optionally:
|
||||
ad_auth, ad_token_provider, default_headers
|
||||
"""
|
||||
return AzureChatClient(
|
||||
return cls(
|
||||
api_key=settings.get("api_key"),
|
||||
deployment_name=settings.get("deployment_name"),
|
||||
endpoint=settings.get("endpoint"),
|
||||
@@ -134,99 +138,49 @@ class AzureChatClient(AzureOpenAIConfigBase, OpenAIChatClientBase):
|
||||
env_file_path=settings.get("env_file_path"),
|
||||
)
|
||||
|
||||
def _create_chat_message_content(
|
||||
self, response: ChatCompletion, choice: Choice, response_metadata: dict[str, Any]
|
||||
) -> ChatResponse:
|
||||
"""Create an Azure chat message content object from a choice."""
|
||||
content = super()._create_chat_message_content(response, choice, response_metadata)
|
||||
return self._add_tool_message_to_chat_message_content(content, choice)
|
||||
@override
|
||||
def _parse_text_from_choice(self, choice: Choice | ChunkChoice) -> TextContent | None:
|
||||
"""Parse the choice into a TextContent object.
|
||||
|
||||
def _create_streaming_chat_message_content(
|
||||
self,
|
||||
chunk: ChatCompletionChunk,
|
||||
choice: ChunkChoice,
|
||||
chunk_metadata: dict[str, Any],
|
||||
) -> ChatResponseUpdate:
|
||||
"""Create an Azure streaming chat message content object from a choice."""
|
||||
content = super()._create_streaming_chat_message_content(chunk, choice, chunk_metadata)
|
||||
assert isinstance(content, ChatResponseUpdate) and isinstance(choice, ChunkChoice) # nosec # noqa: S101
|
||||
return self._add_tool_message_to_chat_message_content(content, choice)
|
||||
|
||||
def _add_tool_message_to_chat_message_content(
|
||||
self,
|
||||
content: TChatResponse,
|
||||
choice: Choice | ChunkChoice,
|
||||
) -> TChatResponse:
|
||||
if tool_message := self._get_tool_message_from_chat_choice(choice=choice):
|
||||
if not isinstance(tool_message, dict):
|
||||
# try to json, to ensure it is a dictionary
|
||||
try:
|
||||
tool_message = json.loads(tool_message)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Tool message is not a dictionary, ignore context.")
|
||||
return content
|
||||
function_call = FunctionCallContent(
|
||||
call_id=str(uuid4()),
|
||||
name="Azure-OnYourData",
|
||||
arguments={"query": tool_message.get("intent", [])},
|
||||
)
|
||||
result = FunctionResultContent(
|
||||
call_id=function_call.call_id,
|
||||
result=tool_message["citations"],
|
||||
exception=function_call.exception,
|
||||
additional_properties=function_call.additional_properties,
|
||||
)
|
||||
|
||||
inner_content = content.messages[0].contents if isinstance(content, ChatResponse) else content.contents
|
||||
|
||||
inner_content.insert(0, function_call)
|
||||
inner_content.insert(1, result)
|
||||
return content
|
||||
|
||||
def _get_tool_message_from_chat_choice(self, choice: Choice | ChunkChoice) -> dict[str, Any] | None:
|
||||
"""Get the tool message from a choice."""
|
||||
content = choice.message if isinstance(choice, Choice) else choice.delta
|
||||
# When you enable asynchronous content filtering in Azure OpenAI, you may receive empty deltas
|
||||
if content and content.model_extra is not None:
|
||||
return content.model_extra.get("context", None)
|
||||
# openai allows extra content, so model_extra will be a dict, but we need to check anyway, but no way to test.
|
||||
return None # pragma: no cover
|
||||
|
||||
@staticmethod
|
||||
def _split_message(message: "ChatResponse") -> ChatResponse:
|
||||
"""Split an Azure On Your Data response into separate ChatMessages within the ChatResponse.
|
||||
|
||||
If the message does not have three contents, and those three are one each of:
|
||||
FunctionCallContent, FunctionResultContent, and TextContent,
|
||||
it will not return three messages, potentially only one or two.
|
||||
|
||||
The order of the returned messages is as expected by OpenAI.
|
||||
Overwritten from OpenAIChatClientBase to deal with Azure On Your Data function.
|
||||
For docs see:
|
||||
https://learn.microsoft.com/en-us/azure/ai-foundry/openai/references/on-your-data?tabs=python#context
|
||||
"""
|
||||
if len(message.messages) == 0:
|
||||
return message
|
||||
if len(message.messages[0].contents) != 3:
|
||||
return message
|
||||
messages = {
|
||||
"tool_call": deepcopy(message.messages[0]),
|
||||
"tool_result": deepcopy(message.messages[0]),
|
||||
"assistant": deepcopy(message.messages[0]),
|
||||
}
|
||||
for key, msg in messages.items():
|
||||
if key == "tool_call":
|
||||
msg.contents = [item for item in msg.contents if isinstance(item, FunctionCallContent)]
|
||||
message.finish_reason = ChatFinishReason.TOOL_CALLS
|
||||
if key == "tool_result":
|
||||
msg.contents = [item for item in msg.contents if isinstance(item, FunctionResultContent)]
|
||||
if key == "assistant":
|
||||
msg.contents = [item for item in msg.contents if isinstance(item, TextContent)]
|
||||
message = choice.message if isinstance(choice, Choice) else choice.delta
|
||||
if hasattr(message, "refusal") and message.refusal:
|
||||
return TextContent(text=message.refusal, raw_representation=choice)
|
||||
if not message.content:
|
||||
return None
|
||||
text_content = TextContent(text=message.content, raw_representation=choice)
|
||||
if not message.model_extra or "context" not in message.model_extra:
|
||||
return text_content
|
||||
|
||||
return ChatResponse(
|
||||
response_id=message.response_id,
|
||||
conversation_id=message.conversation_id,
|
||||
messages=[messages["tool_call"], messages["tool_result"], messages["assistant"]],
|
||||
created_at=message.created_at,
|
||||
model_id=message.ai_model_id,
|
||||
usage_details=message.usage_details,
|
||||
finish_reason=message.finish_reason,
|
||||
additional_properties=message.additional_properties,
|
||||
)
|
||||
context: dict[str, Any] | str = message.context # type: ignore[assignment, union-attr]
|
||||
if isinstance(context, str):
|
||||
try:
|
||||
context = json.loads(context)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Context is not a valid JSON string, ignoring context.")
|
||||
return text_content
|
||||
if not isinstance(context, dict):
|
||||
logger.warning("Context is not a valid dictionary, ignoring context.")
|
||||
return text_content
|
||||
# `all_retrieved_documents` is currently not used, but can be retrieved
|
||||
# through the raw_representation in the text content.
|
||||
if intent := context.get("intent"):
|
||||
text_content.additional_properties = {"intent": intent}
|
||||
if citations := context.get("citations"):
|
||||
text_content.annotations = []
|
||||
for citation in citations:
|
||||
text_content.annotations.append(
|
||||
CitationAnnotation(
|
||||
title=citation.get("title", ""),
|
||||
url=citation.get("url", ""),
|
||||
snippet=citation.get("content", ""),
|
||||
file_id=citation.get("filepath", ""),
|
||||
tool_name="Azure-on-your-Data",
|
||||
additional_properties={"chunk_id": citation.get("chunk_id", "")},
|
||||
raw_representation=citation,
|
||||
)
|
||||
)
|
||||
return text_content
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from agent_framework.exceptions import ServiceInvalidAuthError
|
||||
from azure.core.exceptions import ClientAuthenticationError
|
||||
from azure.identity import DefaultAzureCredential
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from azure.identity import ChainedTokenCredential
|
||||
from azure.identity.aio import ChainedTokenCredential as AsyncChainedTokenCredential
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_entra_auth_token(token_endpoint: str) -> str | None:
|
||||
def get_entra_auth_token(
|
||||
credential: "ChainedTokenCredential",
|
||||
token_endpoint: str,
|
||||
**kwargs: Any,
|
||||
) -> str | None:
|
||||
"""Retrieve a Microsoft Entra Auth Token for a given token endpoint.
|
||||
|
||||
The token endpoint may be specified as an environment variable, via the .env
|
||||
file or as an argument. If the token endpoint is not provided, the default is None.
|
||||
|
||||
Args:
|
||||
credential: The Azure credential to use for authentication.
|
||||
for dev, you can use `DefaultAzureCredential`, but this is not recommended for production.
|
||||
token_endpoint: The token endpoint to use to retrieve the authentication token.
|
||||
**kwargs: Additional keyword arguments to pass to the token retrieval method.
|
||||
|
||||
Returns:
|
||||
The Azure token or None if the token could not be retrieved.
|
||||
@@ -26,12 +37,41 @@ def get_entra_auth_token(token_endpoint: str) -> str | None:
|
||||
"A token endpoint must be provided either in settings, as an environment variable, or as an argument."
|
||||
)
|
||||
|
||||
credential = DefaultAzureCredential()
|
||||
|
||||
try:
|
||||
auth_token = credential.get_token(token_endpoint)
|
||||
except ClientAuthenticationError:
|
||||
logger.error(f"Failed to retrieve Azure token for the specified endpoint: `{token_endpoint}`.")
|
||||
auth_token = credential.get_token(token_endpoint, **kwargs)
|
||||
except ClientAuthenticationError as ex:
|
||||
logger.error(f"Failed to retrieve Azure token for the specified endpoint: `{token_endpoint}`, with error: {ex}")
|
||||
return None
|
||||
|
||||
return auth_token.token if auth_token else None
|
||||
|
||||
|
||||
async def get_entra_auth_token_async(
|
||||
credential: "AsyncChainedTokenCredential", token_endpoint: str, **kwargs: Any
|
||||
) -> str | None:
|
||||
"""Retrieve a async Microsoft Entra Auth Token for a given token endpoint.
|
||||
|
||||
The token endpoint may be specified as an environment variable, via the .env
|
||||
file or as an argument. If the token endpoint is not provided, the default is None.
|
||||
|
||||
Args:
|
||||
credential: The async Azure credential to use for authentication.
|
||||
for dev, you can use `DefaultAzureCredential`, but this is not recommended for production.
|
||||
token_endpoint: The token endpoint to use to retrieve the authentication token.
|
||||
**kwargs: Additional keyword arguments to pass to the token retrieval method.
|
||||
|
||||
Returns:
|
||||
The Azure token or None if the token could not be retrieved.
|
||||
"""
|
||||
if not token_endpoint:
|
||||
raise ServiceInvalidAuthError(
|
||||
"A token endpoint must be provided either in settings, as an environment variable, or as an argument."
|
||||
)
|
||||
|
||||
try:
|
||||
auth_token = await credential.get_token(token_endpoint, **kwargs)
|
||||
except ClientAuthenticationError as ex:
|
||||
logger.error(f"Failed to retrieve Azure token for the specified endpoint: `{token_endpoint}`, with error: {ex}")
|
||||
return None
|
||||
|
||||
return auth_token.token if auth_token else None
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, ClassVar
|
||||
from typing import Any, TypeVar
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from agent_framework import use_tool_calling
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.openai._responses_client import OpenAIResponsesClientBase
|
||||
from agent_framework.openai._shared import OpenAIModelTypes
|
||||
from agent_framework.telemetry import use_telemetry
|
||||
from azure.identity import ChainedTokenCredential
|
||||
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from pydantic.networks import AnyUrl
|
||||
@@ -18,14 +18,14 @@ from ._shared import (
|
||||
AzureOpenAISettings,
|
||||
)
|
||||
|
||||
TAzureResponsesClient = TypeVar("TAzureResponsesClient", bound="AzureResponsesClient")
|
||||
|
||||
|
||||
@use_telemetry
|
||||
@use_tool_calling
|
||||
class AzureResponsesClient(AzureOpenAIConfigBase, OpenAIResponsesClientBase):
|
||||
"""Azure Responses completion class."""
|
||||
|
||||
MODEL_PROVIDER_NAME: ClassVar[str] = "azure_openai" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str | None = None,
|
||||
@@ -36,6 +36,7 @@ class AzureResponsesClient(AzureOpenAIConfigBase, OpenAIResponsesClientBase):
|
||||
ad_token: str | None = None,
|
||||
ad_token_provider: AsyncAzureADTokenProvider | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
ad_credential: ChainedTokenCredential | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
async_client: AsyncAzureOpenAI | None = None,
|
||||
env_file_path: str | None = None,
|
||||
@@ -58,6 +59,7 @@ class AzureResponsesClient(AzureOpenAIConfigBase, OpenAIResponsesClientBase):
|
||||
ad_token: The Azure Active Directory token. (Optional)
|
||||
ad_token_provider: The Azure Active Directory token provider. (Optional)
|
||||
token_endpoint: The token endpoint to request an Azure token. (Optional)
|
||||
ad_credential: The Azure Active Directory credential. (Optional)
|
||||
default_headers: The default headers mapping of string keys to
|
||||
string values for HTTP requests. (Optional)
|
||||
async_client: An existing client to use. (Optional)
|
||||
@@ -105,20 +107,20 @@ class AzureResponsesClient(AzureOpenAIConfigBase, OpenAIResponsesClientBase):
|
||||
ad_token=ad_token,
|
||||
ad_token_provider=ad_token_provider,
|
||||
token_endpoint=azure_openai_settings.token_endpoint,
|
||||
ad_credential=ad_credential,
|
||||
default_headers=default_headers,
|
||||
ai_model_type=OpenAIModelTypes.RESPONSE,
|
||||
client=async_client,
|
||||
instruction_role=instruction_role,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, settings: dict[str, Any]) -> "AzureResponsesClient":
|
||||
def from_dict(cls: type[TAzureResponsesClient], settings: dict[str, Any]) -> TAzureResponsesClient:
|
||||
"""Initialize an Open AI service from a dictionary of settings.
|
||||
|
||||
Args:
|
||||
settings: A dictionary of settings for the service.
|
||||
"""
|
||||
return AzureResponsesClient(
|
||||
return cls(
|
||||
api_key=settings.get("api_key"),
|
||||
deployment_name=settings.get("deployment_name"),
|
||||
endpoint=settings.get("endpoint"),
|
||||
|
||||
@@ -8,8 +8,9 @@ from typing import Any, ClassVar, Final
|
||||
|
||||
from agent_framework._pydantic import AFBaseSettings, HttpsUrl
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.openai._shared import OpenAIHandler, OpenAIModelTypes
|
||||
from agent_framework.openai._shared import OpenAIHandler
|
||||
from agent_framework.telemetry import USER_AGENT_KEY
|
||||
from azure.identity import ChainedTokenCredential
|
||||
from openai.lib.azure import AsyncAzureOpenAI
|
||||
from pydantic import ConfigDict, SecretStr, model_validator, validate_call
|
||||
|
||||
@@ -20,6 +21,7 @@ if sys.version_info >= (3, 11):
|
||||
else:
|
||||
from typing_extensions import Self # pragma: no cover
|
||||
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -132,7 +134,9 @@ class AzureOpenAISettings(AFBaseSettings):
|
||||
default_api_version: str = DEFAULT_AZURE_API_VERSION
|
||||
default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT
|
||||
|
||||
def get_azure_openai_auth_token(self, token_endpoint: str | None = None) -> str | None:
|
||||
def get_azure_auth_token(
|
||||
self, credential: "ChainedTokenCredential", token_endpoint: str | None = None, **kwargs: Any
|
||||
) -> str | None:
|
||||
"""Retrieve a Microsoft Entra Auth Token for a given token endpoint for the use with Azure OpenAI.
|
||||
|
||||
The required role for the token is `Cognitive Services OpenAI Contributor`.
|
||||
@@ -141,7 +145,9 @@ class AzureOpenAISettings(AFBaseSettings):
|
||||
The `token_endpoint` argument takes precedence over the `token_endpoint` attribute.
|
||||
|
||||
Args:
|
||||
credential: The Azure AD credential to use.
|
||||
token_endpoint: The token endpoint to use. Defaults to `https://cognitiveservices.azure.com/.default`.
|
||||
**kwargs: Additional keyword arguments to pass to the token retrieval method.
|
||||
|
||||
Returns:
|
||||
The Azure token or None if the token could not be retrieved.
|
||||
@@ -149,10 +155,8 @@ class AzureOpenAISettings(AFBaseSettings):
|
||||
Raises:
|
||||
ServiceInitializationError: If the token endpoint is not provided.
|
||||
"""
|
||||
endpoint_to_use = token_endpoint or self.token_endpoint
|
||||
if endpoint_to_use is None: # type: ignore
|
||||
raise ServiceInitializationError("Please provide a token endpoint to retrieve the authentication token.")
|
||||
return get_entra_auth_token(endpoint_to_use)
|
||||
endpoint_to_use = token_endpoint or self.token_endpoint or self.default_token_endpoint
|
||||
return get_entra_auth_token(credential, endpoint_to_use, **kwargs)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_fields(self) -> Self:
|
||||
@@ -164,11 +168,12 @@ class AzureOpenAISettings(AFBaseSettings):
|
||||
class AzureOpenAIConfigBase(OpenAIHandler):
|
||||
"""Internal class for configuring a connection to an Azure OpenAI service."""
|
||||
|
||||
MODEL_PROVIDER_NAME: ClassVar[str] = "azure_openai" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
def __init__(
|
||||
self,
|
||||
deployment_name: str,
|
||||
ai_model_type: OpenAIModelTypes,
|
||||
endpoint: HttpsUrl | None = None,
|
||||
base_url: HttpsUrl | None = None,
|
||||
api_version: str = DEFAULT_AZURE_API_VERSION,
|
||||
@@ -176,6 +181,7 @@ class AzureOpenAIConfigBase(OpenAIHandler):
|
||||
ad_token: str | None = None,
|
||||
ad_token_provider: Callable[[], str | Awaitable[str]] | None = None,
|
||||
token_endpoint: str | None = None,
|
||||
ad_credential: ChainedTokenCredential | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
client: AsyncAzureOpenAI | None = None,
|
||||
instruction_role: str | None = None,
|
||||
@@ -187,20 +193,20 @@ class AzureOpenAIConfigBase(OpenAIHandler):
|
||||
This is necessary for types like `HttpsUrl` and `OpenAIModelTypes`.
|
||||
|
||||
Args:
|
||||
deployment_name (str): Name of the deployment.
|
||||
ai_model_type (OpenAIModelTypes): The type of OpenAI model to deploy.
|
||||
endpoint (HttpsUrl): The specific endpoint URL for the deployment. (Optional)
|
||||
base_url (Url): The base URL for Azure services. (Optional)
|
||||
api_version (str): Azure API version. Defaults to the defined DEFAULT_AZURE_API_VERSION.
|
||||
api_key (str): API key for Azure services. (Optional)
|
||||
ad_token (str): Azure AD token for authentication. (Optional)
|
||||
ad_token_provider (Callable[[], Union[str, Awaitable[str]]]): A callable
|
||||
or coroutine function providing Azure AD tokens. (Optional)
|
||||
token_endpoint (str): Azure AD token endpoint use to get the token. (Optional)
|
||||
default_headers (Union[Mapping[str, str], None]): Default headers for HTTP requests. (Optional)
|
||||
client (AsyncAzureOpenAI): An existing client to use. (Optional)
|
||||
instruction_role (str | None): The role to use for 'instruction' messages, for example, summarization
|
||||
prompts could use `developer` or `system`. (Optional)
|
||||
deployment_name: Name of the deployment.
|
||||
ai_model_type: The type of OpenAI model to deploy.
|
||||
endpoint: The specific endpoint URL for the deployment.
|
||||
base_url: The base URL for Azure services.
|
||||
api_version: Azure API version. Defaults to the defined DEFAULT_AZURE_API_VERSION.
|
||||
api_key: API key for Azure services.
|
||||
ad_token: Azure AD token for authentication.
|
||||
ad_token_provider: A callable or coroutine function providing Azure AD tokens.
|
||||
token_endpoint: Azure AD token endpoint use to get the token.
|
||||
ad_credential: Azure AD credential for authentication.
|
||||
default_headers: Default headers for HTTP requests.
|
||||
client: An existing client to use.
|
||||
instruction_role: The role to use for 'instruction' messages, for example, summarization
|
||||
prompts could use `developer` or `system`.
|
||||
kwargs: Additional keyword arguments.
|
||||
|
||||
"""
|
||||
@@ -211,8 +217,8 @@ class AzureOpenAIConfigBase(OpenAIHandler):
|
||||
# If the client is None, the api_key is none, the ad_token is none, and the ad_token_provider is none,
|
||||
# then we will attempt to get the ad_token using the default endpoint specified in the Azure OpenAI
|
||||
# settings.
|
||||
if not api_key and not ad_token_provider and not ad_token and token_endpoint:
|
||||
ad_token = get_entra_auth_token(token_endpoint)
|
||||
if not api_key and not ad_token_provider and not ad_token and token_endpoint and ad_credential:
|
||||
ad_token = get_entra_auth_token(ad_credential, token_endpoint)
|
||||
|
||||
if not api_key and not ad_token and not ad_token_provider:
|
||||
raise ServiceInitializationError(
|
||||
@@ -237,10 +243,8 @@ class AzureOpenAIConfigBase(OpenAIHandler):
|
||||
args["base_url"] = str(base_url)
|
||||
if endpoint and not base_url:
|
||||
args["azure_endpoint"] = str(endpoint)
|
||||
# TODO (eavanvalkenburg): Remove the check on model type when the package fixes: https://github.com/openai/openai-python/issues/2120
|
||||
if deployment_name and ai_model_type != OpenAIModelTypes.REALTIME:
|
||||
if deployment_name:
|
||||
args["azure_deployment"] = deployment_name
|
||||
|
||||
if "websocket_base_url" in kwargs:
|
||||
args["websocket_base_url"] = kwargs.pop("websocket_base_url")
|
||||
|
||||
@@ -248,7 +252,6 @@ class AzureOpenAIConfigBase(OpenAIHandler):
|
||||
args = {
|
||||
"ai_model_id": deployment_name,
|
||||
"client": client,
|
||||
"ai_model_type": ai_model_type,
|
||||
}
|
||||
if instruction_role:
|
||||
args["instruction_role"] = instruction_role
|
||||
@@ -271,7 +274,6 @@ class AzureOpenAIConfigBase(OpenAIHandler):
|
||||
"total_tokens",
|
||||
"api_type",
|
||||
"org_id",
|
||||
"ai_model_type",
|
||||
"service_id",
|
||||
"client",
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ from agent_framework import (
|
||||
TextContent,
|
||||
)
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from pydantic import Field
|
||||
|
||||
from agent_framework_azure import AzureAssistantsClient
|
||||
@@ -259,7 +260,7 @@ def get_weather(
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_client_get_response() -> None:
|
||||
"""Test Azure Assistants Client response."""
|
||||
async with AzureAssistantsClient() as azure_assistants_client:
|
||||
async with AzureAssistantsClient(ad_credential=DefaultAzureCredential()) as azure_assistants_client:
|
||||
assert isinstance(azure_assistants_client, ChatClient)
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
@@ -283,7 +284,7 @@ async def test_azure_assistants_client_get_response() -> None:
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_client_get_response_tools() -> None:
|
||||
"""Test Azure Assistants Client response with tools."""
|
||||
async with AzureAssistantsClient() as azure_assistants_client:
|
||||
async with AzureAssistantsClient(ad_credential=DefaultAzureCredential()) as azure_assistants_client:
|
||||
assert isinstance(azure_assistants_client, ChatClient)
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
@@ -304,7 +305,7 @@ async def test_azure_assistants_client_get_response_tools() -> None:
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_client_streaming() -> None:
|
||||
"""Test Azure Assistants Client streaming response."""
|
||||
async with AzureAssistantsClient() as azure_assistants_client:
|
||||
async with AzureAssistantsClient(ad_credential=DefaultAzureCredential()) as azure_assistants_client:
|
||||
assert isinstance(azure_assistants_client, ChatClient)
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
@@ -334,7 +335,7 @@ async def test_azure_assistants_client_streaming() -> None:
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_client_streaming_tools() -> None:
|
||||
"""Test Azure Assistants Client streaming response with tools."""
|
||||
async with AzureAssistantsClient() as azure_assistants_client:
|
||||
async with AzureAssistantsClient(ad_credential=DefaultAzureCredential()) as azure_assistants_client:
|
||||
assert isinstance(azure_assistants_client, ChatClient)
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
@@ -361,14 +362,16 @@ async def test_azure_assistants_client_streaming_tools() -> None:
|
||||
async def test_azure_assistants_client_with_existing_assistant() -> None:
|
||||
"""Test Azure Assistants Client with existing assistant ID."""
|
||||
# First create an assistant to use in the test
|
||||
async with AzureAssistantsClient() as temp_client:
|
||||
async with AzureAssistantsClient(ad_credential=DefaultAzureCredential()) as temp_client:
|
||||
# Get the assistant ID by triggering assistant creation
|
||||
messages = [ChatMessage(role="user", text="Hello")]
|
||||
await temp_client.get_response(messages=messages)
|
||||
assistant_id = temp_client.assistant_id
|
||||
|
||||
# Now test using the existing assistant
|
||||
async with AzureAssistantsClient(assistant_id=assistant_id) as azure_assistants_client:
|
||||
async with AzureAssistantsClient(
|
||||
assistant_id=assistant_id, ad_credential=DefaultAzureCredential()
|
||||
) as azure_assistants_client:
|
||||
assert isinstance(azure_assistants_client, ChatClient)
|
||||
assert azure_assistants_client.assistant_id == assistant_id
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ from agent_framework import (
|
||||
ChatMessage,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
TextContent,
|
||||
ai_function,
|
||||
)
|
||||
@@ -23,6 +21,7 @@ from agent_framework.openai import (
|
||||
OpenAIContentFilterException,
|
||||
)
|
||||
from agent_framework.telemetry import USER_AGENT_KEY
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from httpx import Request, Response
|
||||
from openai import AsyncAzureOpenAI, AsyncStream
|
||||
from openai.resources.chat.completions import AsyncCompletions as AsyncChatCompletions
|
||||
@@ -123,6 +122,7 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
"api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
|
||||
"api_version": azure_openai_unit_test_env["AZURE_OPENAI_API_VERSION"],
|
||||
"default_headers": default_headers,
|
||||
"env_file_path": "test.env",
|
||||
}
|
||||
|
||||
azure_chat_client = AzureChatClient.from_dict(settings)
|
||||
@@ -258,13 +258,15 @@ async def test_azure_on_your_data(
|
||||
content="test",
|
||||
role="assistant",
|
||||
context={ # type: ignore
|
||||
"citations": {
|
||||
"content": "test content",
|
||||
"title": "test title",
|
||||
"url": "test url",
|
||||
"filepath": "test filepath",
|
||||
"chunk_id": "test chunk_id",
|
||||
},
|
||||
"citations": [
|
||||
{
|
||||
"content": "test content",
|
||||
"title": "test title",
|
||||
"url": "test url",
|
||||
"filepath": "test filepath",
|
||||
"chunk_id": "test chunk_id",
|
||||
}
|
||||
],
|
||||
"intent": "query used",
|
||||
},
|
||||
),
|
||||
@@ -298,11 +300,11 @@ async def test_azure_on_your_data(
|
||||
additional_properties={"extra_body": expected_data_settings},
|
||||
)
|
||||
assert len(content.messages) == 1
|
||||
assert len(content.messages[0].contents) == 3
|
||||
assert isinstance(content.messages[0].contents[0], FunctionCallContent)
|
||||
assert isinstance(content.messages[0].contents[1], FunctionResultContent)
|
||||
assert isinstance(content.messages[0].contents[2], TextContent)
|
||||
assert content.messages[0].contents[2].text == "test"
|
||||
assert len(content.messages[0].contents) == 1
|
||||
assert isinstance(content.messages[0].contents[0], TextContent)
|
||||
assert len(content.messages[0].contents[0].annotations) == 1
|
||||
assert content.messages[0].contents[0].annotations[0].title == "test title"
|
||||
assert content.messages[0].contents[0].text == "test"
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
@@ -326,13 +328,15 @@ async def test_azure_on_your_data_string(
|
||||
content="test",
|
||||
role="assistant",
|
||||
context=json.dumps({ # type: ignore
|
||||
"citations": {
|
||||
"content": "test content",
|
||||
"title": "test title",
|
||||
"url": "test url",
|
||||
"filepath": "test filepath",
|
||||
"chunk_id": "test chunk_id",
|
||||
},
|
||||
"citations": [
|
||||
{
|
||||
"content": "test content",
|
||||
"title": "test title",
|
||||
"url": "test url",
|
||||
"filepath": "test filepath",
|
||||
"chunk_id": "test chunk_id",
|
||||
}
|
||||
],
|
||||
"intent": "query used",
|
||||
}),
|
||||
),
|
||||
@@ -366,11 +370,11 @@ async def test_azure_on_your_data_string(
|
||||
additional_properties={"extra_body": expected_data_settings},
|
||||
)
|
||||
assert len(content.messages) == 1
|
||||
assert len(content.messages[0].contents) == 3
|
||||
assert isinstance(content.messages[0].contents[0], FunctionCallContent)
|
||||
assert isinstance(content.messages[0].contents[1], FunctionResultContent)
|
||||
assert isinstance(content.messages[0].contents[2], TextContent)
|
||||
assert content.messages[0].contents[2].text == "test"
|
||||
assert len(content.messages[0].contents) == 1
|
||||
assert isinstance(content.messages[0].contents[0], TextContent)
|
||||
assert len(content.messages[0].contents[0].annotations) == 1
|
||||
assert content.messages[0].contents[0].annotations[0].title == "test title"
|
||||
assert content.messages[0].contents[0].text == "test"
|
||||
|
||||
mock_create.assert_awaited_once_with(
|
||||
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
@@ -437,55 +441,6 @@ async def test_azure_on_your_data_fail(
|
||||
)
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_azure_on_your_data_split_messages(
|
||||
mock_create: AsyncMock,
|
||||
azure_openai_unit_test_env: dict[str, str],
|
||||
chat_history: list[ChatMessage],
|
||||
mock_chat_completion_response: ChatCompletion,
|
||||
) -> None:
|
||||
mock_chat_completion_response.choices = [
|
||||
Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(
|
||||
content="test",
|
||||
role="assistant",
|
||||
context={ # type: ignore
|
||||
"citations": {
|
||||
"content": "test content",
|
||||
"title": "test title",
|
||||
"url": "test url",
|
||||
"filepath": "test filepath",
|
||||
"chunk_id": "test chunk_id",
|
||||
},
|
||||
"intent": "query used",
|
||||
},
|
||||
),
|
||||
finish_reason="stop",
|
||||
)
|
||||
]
|
||||
mock_create.return_value = mock_chat_completion_response
|
||||
prompt = "hello world"
|
||||
messages_in = chat_history
|
||||
messages_in.append(ChatMessage(text=prompt, role="user"))
|
||||
messages_out: list[ChatMessage] = []
|
||||
messages_out.append(ChatMessage(text=prompt, role="user"))
|
||||
|
||||
azure_chat_client = AzureChatClient()
|
||||
|
||||
content = await azure_chat_client.get_response(
|
||||
messages=messages_in,
|
||||
)
|
||||
message = azure_chat_client._split_message(content)
|
||||
assert len(content.messages) == 1
|
||||
assert len(content.messages[0].contents) == 3
|
||||
assert isinstance(content.messages[0].contents[0], FunctionCallContent)
|
||||
assert isinstance(content.messages[0].contents[1], FunctionResultContent)
|
||||
assert isinstance(content.messages[0].contents[2], TextContent)
|
||||
assert content.messages[0].contents[2].text == "test"
|
||||
assert message.messages[0].contents == [content.messages[0].contents[0]]
|
||||
|
||||
|
||||
CONTENT_FILTERED_ERROR_MESSAGE = (
|
||||
"The response was filtered due to the prompt triggering Azure OpenAI's content management policy. Please "
|
||||
"modify your prompt and retry. To learn more about our content filtering policies please read our "
|
||||
@@ -607,7 +562,7 @@ async def test_bad_request_non_content_filter(
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_streaming(
|
||||
async def test_get_streaming(
|
||||
mock_create: AsyncMock,
|
||||
azure_openai_unit_test_env: dict[str, str],
|
||||
chat_history: list[ChatMessage],
|
||||
@@ -646,7 +601,7 @@ def get_story_text() -> str:
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_response() -> None:
|
||||
"""Test Azure OpenAI chat completion responses."""
|
||||
azure_chat_client = AzureChatClient()
|
||||
azure_chat_client = AzureChatClient(ad_credential=DefaultAzureCredential())
|
||||
assert isinstance(azure_chat_client, ChatClient)
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
@@ -672,7 +627,7 @@ async def test_azure_openai_chat_client_response() -> None:
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_response_tools() -> None:
|
||||
"""Test AzureOpenAI chat completion responses."""
|
||||
azure_chat_client = AzureChatClient()
|
||||
azure_chat_client = AzureChatClient(ad_credential=DefaultAzureCredential())
|
||||
assert isinstance(azure_chat_client, ChatClient)
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
@@ -693,7 +648,7 @@ async def test_azure_openai_chat_client_response_tools() -> None:
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_streaming() -> None:
|
||||
"""Test Azure OpenAI chat completion responses."""
|
||||
azure_chat_client = AzureChatClient()
|
||||
azure_chat_client = AzureChatClient(ad_credential=DefaultAzureCredential())
|
||||
assert isinstance(azure_chat_client, ChatClient)
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
@@ -725,7 +680,7 @@ async def test_azure_openai_chat_client_streaming() -> None:
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_streaming_tools() -> None:
|
||||
"""Test AzureOpenAI chat completion responses."""
|
||||
azure_chat_client = AzureChatClient()
|
||||
azure_chat_client = AzureChatClient(ad_credential=DefaultAzureCredential())
|
||||
assert isinstance(azure_chat_client, ChatClient)
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
|
||||
@@ -6,7 +6,8 @@ from typing import Annotated
|
||||
import pytest
|
||||
from agent_framework import ChatClient, ChatMessage, ChatResponse, ChatResponseUpdate, TextContent, ai_function
|
||||
from agent_framework.azure import AzureResponsesClient
|
||||
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from pydantic import BaseModel
|
||||
|
||||
skip_if_azure_integration_tests_disabled = pytest.mark.skipif(
|
||||
@@ -104,7 +105,7 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_response() -> None:
|
||||
"""Test azure responses client responses."""
|
||||
azure_responses_client = AzureResponsesClient()
|
||||
azure_responses_client = AzureResponsesClient(ad_credential=DefaultAzureCredential())
|
||||
|
||||
assert isinstance(azure_responses_client, ChatClient)
|
||||
|
||||
@@ -147,7 +148,7 @@ async def test_azure_responses_client_response() -> None:
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_response_tools() -> None:
|
||||
"""Test azure responses client tools."""
|
||||
azure_responses_client = AzureResponsesClient()
|
||||
azure_responses_client = AzureResponsesClient(ad_credential=DefaultAzureCredential())
|
||||
|
||||
assert isinstance(azure_responses_client, ChatClient)
|
||||
|
||||
@@ -186,7 +187,7 @@ async def test_azure_responses_client_response_tools() -> None:
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_streaming() -> None:
|
||||
"""Test Azure azure responses client streaming responses."""
|
||||
azure_responses_client = AzureResponsesClient()
|
||||
azure_responses_client = AzureResponsesClient(ad_credential=DefaultAzureCredential())
|
||||
|
||||
assert isinstance(azure_responses_client, ChatClient)
|
||||
|
||||
@@ -219,29 +220,27 @@ async def test_azure_responses_client_streaming() -> None:
|
||||
messages.append(ChatMessage(role="user", text="The weather in Seattle is sunny"))
|
||||
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
|
||||
|
||||
# This is currently broken. See https://github.com/azure/azure-python/issues/2305
|
||||
with pytest.raises(ServiceResponseException):
|
||||
response = azure_responses_client.get_streaming_response(
|
||||
messages=messages,
|
||||
response_format=OutputStruct,
|
||||
)
|
||||
full_message = ""
|
||||
async for chunk in response:
|
||||
assert chunk is not None
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
for content in chunk.contents:
|
||||
if isinstance(content, TextContent) and content.text:
|
||||
full_message += content.text
|
||||
response = azure_responses_client.get_streaming_response(
|
||||
messages=messages,
|
||||
response_format=OutputStruct,
|
||||
)
|
||||
full_message = ""
|
||||
async for chunk in response:
|
||||
assert chunk is not None
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
for content in chunk.contents:
|
||||
if isinstance(content, TextContent) and content.text:
|
||||
full_message += content.text
|
||||
|
||||
output = OutputStruct.model_validate_json(full_message)
|
||||
assert "Seattle" in output.location
|
||||
assert "sunny" in output.weather.lower()
|
||||
output = OutputStruct.model_validate_json(full_message)
|
||||
assert "Seattle" in output.location
|
||||
assert "sunny" in output.weather.lower()
|
||||
|
||||
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_streaming_tools() -> None:
|
||||
"""Test azure responses client streaming tools."""
|
||||
azure_responses_client = AzureResponsesClient()
|
||||
azure_responses_client = AzureResponsesClient(ad_credential=DefaultAzureCredential())
|
||||
|
||||
assert isinstance(azure_responses_client, ChatClient)
|
||||
|
||||
@@ -266,22 +265,20 @@ async def test_azure_responses_client_streaming_tools() -> None:
|
||||
messages.clear()
|
||||
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
|
||||
|
||||
# This is currently broken. See https://github.com/azure/azure-python/issues/2305
|
||||
with pytest.raises(ServiceResponseException):
|
||||
response = azure_responses_client.get_streaming_response(
|
||||
messages=messages,
|
||||
tools=[get_weather],
|
||||
tool_choice="auto",
|
||||
response_format=OutputStruct,
|
||||
)
|
||||
full_message = ""
|
||||
async for chunk in response:
|
||||
assert chunk is not None
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
for content in chunk.contents:
|
||||
if isinstance(content, TextContent) and content.text:
|
||||
full_message += content.text
|
||||
response = azure_responses_client.get_streaming_response(
|
||||
messages=messages,
|
||||
tools=[get_weather],
|
||||
tool_choice="auto",
|
||||
response_format=OutputStruct,
|
||||
)
|
||||
full_message = ""
|
||||
async for chunk in response:
|
||||
assert chunk is not None
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
for content in chunk.contents:
|
||||
if isinstance(content, TextContent) and content.text:
|
||||
full_message += content.text
|
||||
|
||||
output = OutputStruct.model_validate_json(full_message)
|
||||
assert "Seattle" in output.location
|
||||
assert "sunny" in output.weather.lower()
|
||||
output = OutputStruct.model_validate_json(full_message)
|
||||
assert "Seattle" in output.location
|
||||
assert "sunny" in output.weather.lower()
|
||||
|
||||
@@ -4,7 +4,7 @@ import contextlib
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, MutableMapping, MutableSequence
|
||||
from typing import Any, ClassVar
|
||||
from typing import Any, ClassVar, TypeVar
|
||||
|
||||
from agent_framework import (
|
||||
AIContents,
|
||||
@@ -26,7 +26,6 @@ from agent_framework import (
|
||||
UsageDetails,
|
||||
use_tool_calling,
|
||||
)
|
||||
from agent_framework._clients import ai_function_to_json_schema_spec
|
||||
from agent_framework._pydantic import AFBaseSettings
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.telemetry import use_telemetry
|
||||
@@ -93,6 +92,9 @@ class FoundrySettings(AFBaseSettings):
|
||||
agent_name: str | None = "UnnamedAgent"
|
||||
|
||||
|
||||
TFoundryChatClient = TypeVar("TFoundryChatClient", bound="FoundryChatClient")
|
||||
|
||||
|
||||
@use_telemetry
|
||||
@use_tool_calling
|
||||
class FoundryChatClient(ChatClientBase):
|
||||
@@ -102,21 +104,22 @@ class FoundryChatClient(ChatClientBase):
|
||||
client: AIProjectClient = Field(...)
|
||||
credential: AsyncTokenCredential | None = Field(...)
|
||||
agent_id: str | None = Field(default=None)
|
||||
agent_name: str | None = Field(default=None)
|
||||
ai_model_deployment_name: str | None = Field(default=None)
|
||||
thread_id: str | None = Field(default=None)
|
||||
_should_delete_agent: bool = PrivateAttr(default=False) # Track whether we should delete the agent
|
||||
_should_close_client: bool = PrivateAttr(default=False) # Track whether we should close client connection
|
||||
_should_close_credential: bool = PrivateAttr(default=False) # Track whether we should close credential
|
||||
_foundry_settings: FoundrySettings = PrivateAttr()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: AIProjectClient | None = None,
|
||||
agent_id: str | None = None,
|
||||
agent_name: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
project_endpoint: str | None = None,
|
||||
model_deployment_name: str | None = None,
|
||||
credential: AsyncTokenCredential | None = None,
|
||||
async_ad_credential: AsyncTokenCredential | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
@@ -133,8 +136,7 @@ class FoundryChatClient(ChatClientBase):
|
||||
conversation_id property, when making a request.
|
||||
project_endpoint: The Azure AI Foundry project endpoint URL. Used if client is not provided.
|
||||
model_deployment_name: The model deployment name to use for agent creation.
|
||||
credential: Azure async credential to use for authentication. If not provided,
|
||||
DefaultAzureCredential will be used.
|
||||
async_ad_credential: Azure async credential to use for authentication.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
**kwargs: Additional keyword arguments passed to the parent class.
|
||||
@@ -152,7 +154,6 @@ class FoundryChatClient(ChatClientBase):
|
||||
|
||||
# If no client is provided, create one
|
||||
should_close_client = False
|
||||
should_close_credential = False
|
||||
if client is None:
|
||||
if not foundry_settings.project_endpoint:
|
||||
raise ServiceInitializationError("Project endpoint is required when client is not provided.")
|
||||
@@ -161,27 +162,20 @@ class FoundryChatClient(ChatClientBase):
|
||||
raise ServiceInitializationError("Model deployment name is required for agent creation.")
|
||||
|
||||
# Use provided credential or fallback to DefaultAzureCredential
|
||||
if credential is None:
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
|
||||
credential = DefaultAzureCredential()
|
||||
should_close_credential = True
|
||||
|
||||
client = AIProjectClient(endpoint=foundry_settings.project_endpoint, credential=credential)
|
||||
should_close_client = True
|
||||
if not async_ad_credential:
|
||||
raise ServiceInitializationError("Azure AD credential is required when client is not provided.")
|
||||
client = AIProjectClient(endpoint=foundry_settings.project_endpoint, credential=async_ad_credential)
|
||||
|
||||
super().__init__(
|
||||
client=client, # type: ignore[reportCallIssue]
|
||||
credential=credential, # type: ignore[reportCallIssue]
|
||||
credential=async_ad_credential, # type: ignore[reportCallIssue]
|
||||
agent_id=agent_id, # type: ignore[reportCallIssue]
|
||||
thread_id=thread_id, # type: ignore[reportCallIssue]
|
||||
agent_name=foundry_settings.agent_name, # type: ignore[reportCallIssue]
|
||||
ai_model_deployment_name=foundry_settings.model_deployment_name, # type: ignore[reportCallIssue]
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self._should_delete_agent = False
|
||||
self._should_close_client = should_close_client
|
||||
self._should_close_credential = should_close_credential
|
||||
self._foundry_settings = foundry_settings
|
||||
|
||||
async def __aenter__(self) -> "Self":
|
||||
"""Async context manager entry."""
|
||||
@@ -195,10 +189,9 @@ class FoundryChatClient(ChatClientBase):
|
||||
"""Close the client and clean up any agents we created."""
|
||||
await self._cleanup_agent_if_needed()
|
||||
await self._close_client_if_needed()
|
||||
await self._close_credential_if_needed()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, settings: dict[str, Any]) -> "FoundryChatClient":
|
||||
def from_dict(cls: type[TFoundryChatClient], settings: dict[str, Any]) -> TFoundryChatClient:
|
||||
"""Initialize a FoundryChatClient from a dictionary of settings.
|
||||
|
||||
Args:
|
||||
@@ -262,11 +255,11 @@ class FoundryChatClient(ChatClientBase):
|
||||
"""
|
||||
# If no agent_id is provided, create a temporary agent
|
||||
if self.agent_id is None:
|
||||
if not self._foundry_settings.model_deployment_name:
|
||||
if not self.ai_model_deployment_name:
|
||||
raise ServiceInitializationError("Model deployment name is required for agent creation.")
|
||||
|
||||
agent_name = self._foundry_settings.agent_name
|
||||
args = {"model": self._foundry_settings.model_deployment_name, "name": agent_name}
|
||||
agent_name = self.agent_name
|
||||
args = {"model": self.ai_model_deployment_name, "name": agent_name}
|
||||
if run_options:
|
||||
if "tools" in run_options:
|
||||
args["tools"] = run_options["tools"]
|
||||
@@ -459,11 +452,6 @@ class FoundryChatClient(ChatClientBase):
|
||||
|
||||
return contents
|
||||
|
||||
async def _close_credential_if_needed(self) -> None:
|
||||
"""Close credential if we created it."""
|
||||
if self._should_close_credential and self.credential is not None:
|
||||
await self.credential.close()
|
||||
|
||||
async def _close_client_if_needed(self) -> None:
|
||||
"""Close client session if we created it."""
|
||||
if self._should_close_client:
|
||||
@@ -496,7 +484,7 @@ class FoundryChatClient(ChatClientBase):
|
||||
if chat_options.tool_choice != "none" and chat_options.tools is not None:
|
||||
for tool in chat_options.tools:
|
||||
if isinstance(tool, AIFunction):
|
||||
tool_definitions.append(ai_function_to_json_schema_spec(tool)) # type: ignore[reportUnknownArgumentType]
|
||||
tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType]
|
||||
elif isinstance(tool, HostedCodeInterpreterTool):
|
||||
tool_definitions.append(CodeInterpreterToolDefinition())
|
||||
elif isinstance(tool, MutableMapping):
|
||||
@@ -605,3 +593,14 @@ class FoundryChatClient(ChatClientBase):
|
||||
tool_outputs.append(ToolOutput(tool_call_id=call_id, output=str(function_result_content.result)))
|
||||
|
||||
return run_id, tool_outputs
|
||||
|
||||
def _update_agent_name(self, agent_name: str | None) -> None:
|
||||
"""Update the agent name in the chat client.
|
||||
|
||||
Args:
|
||||
agent_name: The new name for the agent.
|
||||
"""
|
||||
# This is a no-op in the base class, but can be overridden by subclasses
|
||||
# to update the agent name in the client.
|
||||
if agent_name and not self.agent_name:
|
||||
self.agent_name = agent_name
|
||||
|
||||
@@ -15,6 +15,7 @@ from agent_framework import (
|
||||
TextContent,
|
||||
)
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
from pydantic import Field
|
||||
|
||||
from agent_framework_foundry import FoundryChatClient, FoundrySettings
|
||||
@@ -44,7 +45,8 @@ def create_test_foundry_chat_client(
|
||||
agent_id=agent_id,
|
||||
thread_id=thread_id,
|
||||
_should_delete_agent=should_delete_agent,
|
||||
_foundry_settings=foundry_settings,
|
||||
agent_name=foundry_settings.agent_name, # type: ignore[reportCallIssue]
|
||||
ai_model_deployment_name=foundry_settings.model_deployment_name, # type:
|
||||
credential=None,
|
||||
)
|
||||
|
||||
@@ -304,7 +306,7 @@ def get_weather(
|
||||
@skip_if_foundry_integration_tests_disabled
|
||||
async def test_foundry_chat_client_get_response() -> None:
|
||||
"""Test Foundry Chat Client response."""
|
||||
async with FoundryChatClient() as foundry_chat_client:
|
||||
async with FoundryChatClient(async_ad_credential=DefaultAzureCredential()) as foundry_chat_client:
|
||||
assert isinstance(foundry_chat_client, ChatClient)
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
@@ -328,7 +330,7 @@ async def test_foundry_chat_client_get_response() -> None:
|
||||
@skip_if_foundry_integration_tests_disabled
|
||||
async def test_foundry_chat_client_get_response_tools() -> None:
|
||||
"""Test Foundry Chat Client response with tools."""
|
||||
async with FoundryChatClient() as foundry_chat_client:
|
||||
async with FoundryChatClient(async_ad_credential=DefaultAzureCredential()) as foundry_chat_client:
|
||||
assert isinstance(foundry_chat_client, ChatClient)
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
@@ -349,7 +351,7 @@ async def test_foundry_chat_client_get_response_tools() -> None:
|
||||
@skip_if_foundry_integration_tests_disabled
|
||||
async def test_foundry_chat_client_streaming() -> None:
|
||||
"""Test Foundry Chat Client streaming response."""
|
||||
async with FoundryChatClient() as foundry_chat_client:
|
||||
async with FoundryChatClient(async_ad_credential=DefaultAzureCredential()) as foundry_chat_client:
|
||||
assert isinstance(foundry_chat_client, ChatClient)
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
@@ -379,7 +381,7 @@ async def test_foundry_chat_client_streaming() -> None:
|
||||
@skip_if_foundry_integration_tests_disabled
|
||||
async def test_foundry_chat_client_streaming_tools() -> None:
|
||||
"""Test Foundry Chat Client streaming response with tools."""
|
||||
async with FoundryChatClient() as foundry_chat_client:
|
||||
async with FoundryChatClient(async_ad_credential=DefaultAzureCredential()) as foundry_chat_client:
|
||||
assert isinstance(foundry_chat_client, ChatClient)
|
||||
|
||||
messages: list[ChatMessage] = []
|
||||
|
||||
@@ -381,6 +381,8 @@ class ChatClientAgent(AgentBase):
|
||||
kwargs: any additional keyword arguments.
|
||||
Unused, can be used by subclasses of this Agent.
|
||||
"""
|
||||
kwargs.update(additional_properties or {})
|
||||
|
||||
args: dict[str, Any] = {
|
||||
"chat_client": chat_client,
|
||||
"chat_options": ChatOptions(
|
||||
@@ -399,7 +401,7 @@ class ChatClientAgent(AgentBase):
|
||||
tools=tools, # type: ignore
|
||||
top_p=top_p,
|
||||
user=user,
|
||||
additional_properties=additional_properties or {},
|
||||
additional_properties=kwargs,
|
||||
),
|
||||
}
|
||||
if instructions is not None:
|
||||
@@ -412,6 +414,7 @@ class ChatClientAgent(AgentBase):
|
||||
args["id"] = id
|
||||
|
||||
super().__init__(**args)
|
||||
self._update_agent_name()
|
||||
|
||||
async def __aenter__(self) -> "Self":
|
||||
"""Async context manager entry.
|
||||
@@ -430,6 +433,16 @@ class ChatClientAgent(AgentBase):
|
||||
if isinstance(self.chat_client, AbstractAsyncContextManager):
|
||||
await self.chat_client.__aexit__(exc_type, exc_val, exc_tb) # type: ignore[reportUnknownMemberType]
|
||||
|
||||
def _update_agent_name(self) -> None:
|
||||
"""Update the agent name in a chat client.
|
||||
|
||||
Checks if there is a agent name, the implementation
|
||||
should check if there is already a agent name defined, and if not
|
||||
set it to this value.
|
||||
"""
|
||||
if hasattr(self.chat_client, "_update_agent_name") and callable(self.chat_client._update_agent_name): # type: ignore[reportAttributeAccessIssue]
|
||||
self.chat_client._update_agent_name(self.name) # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
async def run(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
|
||||
|
||||
@@ -73,18 +73,6 @@ async def _auto_invoke_function(
|
||||
)
|
||||
|
||||
|
||||
def ai_function_to_json_schema_spec(function: AIFunction[BaseModel, Any]) -> dict[str, Any]:
|
||||
"""Convert a AIFunction to the JSON Schema function specification format."""
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": function.name,
|
||||
"description": function.description,
|
||||
"parameters": function.parameters(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _tool_call_non_streaming(
|
||||
func: Callable[..., Awaitable["ChatResponse"]],
|
||||
) -> Callable[..., Awaitable["ChatResponse"]]:
|
||||
@@ -117,14 +105,15 @@ def _tool_call_non_streaming(
|
||||
_auto_invoke_function(
|
||||
function_call,
|
||||
custom_args=kwargs,
|
||||
tool_map={t.name: t for t in chat_options._ai_tools or [] if isinstance(t, AIFunction)}, # type: ignore[reportPrivateUsage]
|
||||
tool_map={t.name: t for t in chat_options.tools or [] if isinstance(t, AIFunction)}, # type: ignore[reportPrivateUsage]
|
||||
sequence_index=seq_idx,
|
||||
request_index=attempt_idx,
|
||||
)
|
||||
for seq_idx, function_call in enumerate(function_calls)
|
||||
])
|
||||
# add a single ChatMessage to the response with the results
|
||||
response.messages.append(ChatMessage(role="tool", contents=results))
|
||||
result_message = ChatMessage(role="tool", contents=results)
|
||||
response.messages.append(result_message)
|
||||
# response should contain 2 messages after this,
|
||||
# one with function call contents
|
||||
# and one with function result contents
|
||||
@@ -133,7 +122,11 @@ def _tool_call_non_streaming(
|
||||
# we need to keep track of all function call messages
|
||||
fcc_messages.extend(response.messages)
|
||||
# and add them as additional context to the messages
|
||||
messages.extend(response.messages)
|
||||
if chat_options.store:
|
||||
messages.clear()
|
||||
messages.append(result_message)
|
||||
else:
|
||||
messages.extend(response.messages)
|
||||
continue
|
||||
# If we reach this point, it means there were no function calls to handle,
|
||||
# we'll add the previous function call and responses
|
||||
@@ -146,7 +139,7 @@ def _tool_call_non_streaming(
|
||||
|
||||
# Failsafe: give up on tools, ask model for plain answer
|
||||
chat_options.tool_choice = "none"
|
||||
self._prepare_tools_and_tool_choice(chat_options=chat_options) # type: ignore[reportPrivateUsage]
|
||||
self._prepare_tool_choice(chat_options=chat_options) # type: ignore[reportPrivateUsage]
|
||||
response = await func(self, messages=messages, chat_options=chat_options)
|
||||
if fcc_messages:
|
||||
for msg in reversed(fcc_messages):
|
||||
@@ -202,7 +195,7 @@ def _tool_call_streaming(
|
||||
_auto_invoke_function(
|
||||
function_call,
|
||||
custom_args=kwargs,
|
||||
tool_map={t.name: t for t in chat_options._ai_tools or [] if isinstance(t, AIFunction)}, # type: ignore[reportPrivateUsage]
|
||||
tool_map={t.name: t for t in chat_options.tools or [] if isinstance(t, AIFunction)}, # type: ignore[reportPrivateUsage]
|
||||
sequence_index=seq_idx,
|
||||
request_index=attempt_idx,
|
||||
)
|
||||
@@ -216,7 +209,7 @@ def _tool_call_streaming(
|
||||
|
||||
# Failsafe: give up on tools, ask model for plain answer
|
||||
chat_options.tool_choice = "none"
|
||||
self._prepare_tools_and_tool_choice(chat_options=chat_options) # type: ignore[reportPrivateUsage]
|
||||
self._prepare_tool_choice(chat_options=chat_options) # type: ignore[reportPrivateUsage]
|
||||
async for update in func(self, messages=messages, chat_options=chat_options, **kwargs):
|
||||
yield update
|
||||
|
||||
@@ -529,7 +522,7 @@ class ChatClientBase(AFBaseModel, ABC):
|
||||
additional_properties=additional_properties or {},
|
||||
)
|
||||
prepped_messages = self._prepare_messages(messages)
|
||||
self._prepare_tools_and_tool_choice(chat_options=chat_options)
|
||||
self._prepare_tool_choice(chat_options=chat_options)
|
||||
return await self._inner_get_response(messages=prepped_messages, chat_options=chat_options, **kwargs)
|
||||
|
||||
async def get_streaming_response(
|
||||
@@ -610,13 +603,13 @@ class ChatClientBase(AFBaseModel, ABC):
|
||||
**kwargs,
|
||||
)
|
||||
prepped_messages = self._prepare_messages(messages)
|
||||
self._prepare_tools_and_tool_choice(chat_options=chat_options)
|
||||
self._prepare_tool_choice(chat_options=chat_options)
|
||||
async for update in self._inner_get_streaming_response(
|
||||
messages=prepped_messages, chat_options=chat_options, **kwargs
|
||||
):
|
||||
yield update
|
||||
|
||||
def _prepare_tools_and_tool_choice(self, chat_options: ChatOptions) -> None:
|
||||
def _prepare_tool_choice(self, chat_options: ChatOptions) -> None:
|
||||
"""Prepare the tools and tool choice for the chat options.
|
||||
|
||||
This function should be overridden by subclasses to customize tool handling.
|
||||
@@ -627,10 +620,6 @@ class ChatClientBase(AFBaseModel, ABC):
|
||||
chat_options.tools = None
|
||||
chat_options.tool_choice = ChatToolMode.NONE.mode
|
||||
return
|
||||
chat_options.tools = [
|
||||
(ai_function_to_json_schema_spec(t) if isinstance(t, AIFunction) else t) # type: ignore[reportUnknownArgumentType]
|
||||
for t in chat_options._ai_tools or [] # type: ignore[reportPrivateUsage]
|
||||
]
|
||||
if not chat_options.tools:
|
||||
chat_options.tool_choice = ChatToolMode.NONE.mode
|
||||
else:
|
||||
@@ -647,8 +636,8 @@ class ChatClientBase(AFBaseModel, ABC):
|
||||
def create_agent(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
instructions: str,
|
||||
name: str | None = None,
|
||||
instructions: str | None = None,
|
||||
tools: AITool
|
||||
| list[AITool]
|
||||
| Callable[..., Any]
|
||||
|
||||
@@ -7,9 +7,10 @@ from time import perf_counter
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Generic, Protocol, TypeVar, get_args, get_origin, runtime_checkable
|
||||
|
||||
from opentelemetry import metrics, trace
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
from pydantic import BaseModel, Field, PrivateAttr, create_model
|
||||
|
||||
from ._logging import get_logger
|
||||
from ._pydantic import AFBaseModel
|
||||
from .telemetry import GenAIAttributes, start_as_current_span
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -22,6 +23,48 @@ logger = get_logger()
|
||||
__all__ = ["AIFunction", "AITool", "HostedCodeInterpreterTool", "ai_function"]
|
||||
|
||||
|
||||
def _parse_inputs(
|
||||
inputs: "AIContents | dict[str, Any] | str | list[AIContents | dict[str, Any] | str] | None",
|
||||
) -> list["AIContents"]:
|
||||
"""Parse the inputs for a tool, ensuring they are of type AIContents."""
|
||||
if inputs is None:
|
||||
return []
|
||||
|
||||
from ._types import AIContent, DataContent, HostedFileContent, HostedVectorStoreContent, UriContent
|
||||
|
||||
parsed_inputs: list["AIContents"] = []
|
||||
if not isinstance(inputs, list):
|
||||
inputs = [inputs]
|
||||
for input_item in inputs:
|
||||
if isinstance(input_item, str):
|
||||
# If it's a string, we assume it's a URI or similar identifier.
|
||||
# Convert it to a UriContent or similar type as needed.
|
||||
parsed_inputs.append(UriContent(uri=input_item, media_type="text/plain"))
|
||||
elif isinstance(input_item, dict):
|
||||
# If it's a dict, we assume it contains properties for a specific content type.
|
||||
# we check if the required keys are present to determine the type.
|
||||
# for instance, if it has "uri" and "media_type", we treat it as UriContent.
|
||||
# if is only has uri, then we treat it as DataContent.
|
||||
# etc.
|
||||
if "uri" in input_item:
|
||||
parsed_inputs.append(
|
||||
UriContent(**input_item) if "media_type" in input_item else DataContent(**input_item)
|
||||
)
|
||||
elif "file_id" in input_item:
|
||||
parsed_inputs.append(HostedFileContent(**input_item))
|
||||
elif "vector_store_id" in input_item:
|
||||
parsed_inputs.append(HostedVectorStoreContent(**input_item))
|
||||
elif "data" in input_item:
|
||||
parsed_inputs.append(DataContent(**input_item))
|
||||
else:
|
||||
raise ValueError(f"Unsupported input type: {input_item}")
|
||||
elif isinstance(input_item, AIContent):
|
||||
parsed_inputs.append(input_item)
|
||||
else:
|
||||
raise TypeError(f"Unsupported input type: {type(input_item).__name__}. Expected AIContents or dict.")
|
||||
return parsed_inputs
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AITool(Protocol):
|
||||
"""Represents a generic tool that can be specified to an AI service.
|
||||
@@ -37,9 +80,9 @@ class AITool(Protocol):
|
||||
|
||||
name: str
|
||||
"""The name of the tool."""
|
||||
description: str | None = None
|
||||
description: str
|
||||
"""A description of the tool, suitable for use in describing the purpose to a model."""
|
||||
additional_properties: dict[str, Any] | None = None
|
||||
additional_properties: dict[str, Any] | None
|
||||
"""Additional properties associated with the tool."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
@@ -51,48 +94,96 @@ ArgsT = TypeVar("ArgsT", bound=BaseModel)
|
||||
ReturnT = TypeVar("ReturnT")
|
||||
|
||||
|
||||
class AIFunction(AITool, Generic[ArgsT, ReturnT]):
|
||||
"""A AITool that is callable as code."""
|
||||
class AIToolBase(AFBaseModel):
|
||||
"""Base class for AI tools, providing common attributes and methods.
|
||||
|
||||
Args:
|
||||
name: The name of the tool.
|
||||
description: A description of the tool.
|
||||
additional_properties: Additional properties associated with the tool.
|
||||
"""
|
||||
|
||||
name: str = Field(..., kw_only=False)
|
||||
description: str = ""
|
||||
additional_properties: dict[str, Any] | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return a string representation of the tool."""
|
||||
if self.description:
|
||||
return f"{self.__class__.__name__}(name={self.name}, description={self.description})"
|
||||
return f"{self.__class__.__name__}(name={self.name})"
|
||||
|
||||
|
||||
class HostedCodeInterpreterTool(AIToolBase):
|
||||
"""Represents a hosted tool that can be specified to an AI service to enable it to execute generated code.
|
||||
|
||||
This tool does not implement code interpretation itself. It serves as a marker to inform a service
|
||||
that it is allowed to execute generated code if the service is capable of doing so.
|
||||
"""
|
||||
|
||||
inputs: list[Any] = Field(default_factory=list)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
func: Callable[..., Awaitable[ReturnT] | ReturnT],
|
||||
name: str,
|
||||
description: str,
|
||||
input_model: type[ArgsT],
|
||||
*,
|
||||
inputs: "AIContents | dict[str, Any] | str | list[AIContents | dict[str, Any] | str] | None" = None,
|
||||
description: str | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""Initialize a FunctionTool.
|
||||
) -> None:
|
||||
"""Initialize the HostedCodeInterpreterTool.
|
||||
|
||||
Args:
|
||||
func: The function to wrap.
|
||||
name: The name of the tool.
|
||||
inputs: A list of contents that the tool can accept as input. Defaults to None.
|
||||
This should mostly be HostedFileContent or HostedVectorStoreContent.
|
||||
Can also be DataContent, depending on the service used.
|
||||
When supplying a list, it can contain:
|
||||
- AIContents instances
|
||||
- dicts with properties for AIContents (e.g., {"uri": "http://example.com", "media_type": "text/html"})
|
||||
- strings (which will be converted to UriContent with media_type "text/plain").
|
||||
If None, defaults to an empty list.
|
||||
description: A description of the tool.
|
||||
input_model: A Pydantic model that defines the input parameters for the function.
|
||||
**kwargs: Additional properties to set on the tool.
|
||||
stored in additional_properties.
|
||||
additional_properties: Additional properties associated with the tool.
|
||||
**kwargs: Additional keyword arguments to pass to the base class.
|
||||
"""
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.input_model = input_model
|
||||
self.additional_properties: dict[str, Any] | None = kwargs
|
||||
self._func = func
|
||||
self.invocation_duration_histogram = meter.create_histogram(
|
||||
"agent_framework.function.invocation.duration",
|
||||
args: dict[str, Any] = {
|
||||
"name": "code_interpreter",
|
||||
}
|
||||
if inputs:
|
||||
args["inputs"] = _parse_inputs(inputs)
|
||||
if description is not None:
|
||||
args["description"] = description
|
||||
if additional_properties is not None:
|
||||
args["additional_properties"] = additional_properties
|
||||
if "name" in kwargs:
|
||||
raise ValueError("The 'name' argument is reserved for the HostedCodeInterpreterTool and cannot be set.")
|
||||
super().__init__(**args, **kwargs)
|
||||
|
||||
|
||||
class AIFunction(AIToolBase, Generic[ArgsT, ReturnT]):
|
||||
"""A AITool that is callable as code.
|
||||
|
||||
Args:
|
||||
name: The name of the function.
|
||||
description: A description of the function.
|
||||
additional_properties: Additional properties to set on the function.
|
||||
func: The function to wrap. If None, returns a decorator.
|
||||
input_model: The Pydantic model that defines the input parameters for the function.
|
||||
"""
|
||||
|
||||
func: Callable[..., Awaitable[ReturnT] | ReturnT]
|
||||
input_model: type[ArgsT]
|
||||
_invocation_duration_histogram: metrics.Histogram = PrivateAttr(
|
||||
default_factory=lambda: meter.create_histogram(
|
||||
GenAIAttributes.MEASUREMENT_FUNCTION_INVOCATION_DURATION.value,
|
||||
unit="s",
|
||||
description="Measures the duration of a function's execution",
|
||||
)
|
||||
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
"""Return the parameter json schemas of the input model."""
|
||||
return self.input_model.model_json_schema()
|
||||
)
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> ReturnT | Awaitable[ReturnT]:
|
||||
"""Call the wrapped function with the provided arguments."""
|
||||
return self._func(*args, **kwargs)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"AIFunction(name={self.name}, description={self.description})"
|
||||
return self.func(*args, **kwargs)
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
@@ -136,9 +227,24 @@ class AIFunction(AITool, Generic[ArgsT, ReturnT]):
|
||||
raise
|
||||
finally:
|
||||
duration = perf_counter() - starting_time_stamp
|
||||
self.invocation_duration_histogram.record(duration, attributes=attributes)
|
||||
self._invocation_duration_histogram.record(duration, attributes=attributes)
|
||||
logger.info("Function completed. Duration: %fs", duration)
|
||||
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
"""Create the json schema of the parameters."""
|
||||
return self.input_model.model_json_schema()
|
||||
|
||||
def to_json_schema_spec(self) -> dict[str, Any]:
|
||||
"""Convert a AIFunction to the JSON Schema function specification format."""
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": self.parameters(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _parse_annotation(annotation: Any) -> Any:
|
||||
"""Parse a type annotation and return the corresponding type.
|
||||
@@ -207,91 +313,13 @@ def ai_function(
|
||||
raise TypeError(f"Input model for {tool_name} must be a subclass of BaseModel, got {input_model}")
|
||||
|
||||
return AIFunction[Any, ReturnT](
|
||||
func=f,
|
||||
name=tool_name,
|
||||
description=tool_desc,
|
||||
additional_properties=additional_properties or {},
|
||||
func=f,
|
||||
input_model=input_model,
|
||||
**(additional_properties if additional_properties is not None else {}),
|
||||
)
|
||||
|
||||
return wrapper(func)
|
||||
|
||||
return decorator(func) if func else decorator # type: ignore[reportReturnType, return-value]
|
||||
|
||||
|
||||
def _parse_inputs(
|
||||
inputs: "AIContents | dict[str, Any] | str | list[AIContents | dict[str, Any] | str] | None",
|
||||
) -> list["AIContents"]:
|
||||
"""Parse the inputs for a tool, ensuring they are of type AIContents."""
|
||||
if inputs is None:
|
||||
return []
|
||||
|
||||
from ._types import AIContent, DataContent, HostedFileContent, HostedVectorStoreContent, UriContent
|
||||
|
||||
parsed_inputs: list["AIContents"] = []
|
||||
if not isinstance(inputs, list):
|
||||
inputs = [inputs]
|
||||
for input_item in inputs:
|
||||
if isinstance(input_item, str):
|
||||
# If it's a string, we assume it's a URI or similar identifier.
|
||||
# Convert it to a UriContent or similar type as needed.
|
||||
parsed_inputs.append(UriContent(uri=input_item, media_type="text/plain"))
|
||||
elif isinstance(input_item, dict):
|
||||
# If it's a dict, we assume it contains properties for a specific content type.
|
||||
# we check if the required keys are present to determine the type.
|
||||
if "uri" in input_item:
|
||||
parsed_inputs.append(
|
||||
UriContent(**input_item) if "media_type" in input_item else DataContent(**input_item)
|
||||
)
|
||||
elif "file_id" in input_item:
|
||||
parsed_inputs.append(HostedFileContent(**input_item))
|
||||
elif "vector_store_id" in input_item:
|
||||
parsed_inputs.append(HostedVectorStoreContent(**input_item))
|
||||
elif "data" in input_item:
|
||||
parsed_inputs.append(DataContent(**input_item))
|
||||
else:
|
||||
raise ValueError(f"Unsupported input type: {input_item}")
|
||||
elif isinstance(input_item, AIContent):
|
||||
parsed_inputs.append(input_item)
|
||||
else:
|
||||
raise TypeError(f"Unsupported input type: {type(input_item).__name__}. Expected AIContents or dict.")
|
||||
return parsed_inputs
|
||||
|
||||
|
||||
class HostedCodeInterpreterTool(AITool):
|
||||
"""Represents a hosted tool that can be specified to an AI service to enable it to execute generated code.
|
||||
|
||||
This tool does not implement code interpretation itself. It serves as a marker to inform a service
|
||||
that it is allowed to execute generated code if the service is capable of doing so.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "code_interpreter",
|
||||
inputs: "AIContents | dict[str, Any] | str | list[AIContents | dict[str, Any] | str] | None" = None,
|
||||
description: str | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
):
|
||||
"""Initialize a HostedCodeInterpreterTool.
|
||||
|
||||
Args:
|
||||
name: The name of the tool. Defaults to "code_interpreter".
|
||||
inputs: A list of contents that the tool can accept as input. Defaults to None.
|
||||
This should mostly be HostedFileContent or HostedVectorStoreContent.
|
||||
Can also be DataContent, depending on the service used.
|
||||
When supplying a list, it can contain:
|
||||
- AIContents instances
|
||||
- dicts with properties for AIContents (e.g., {"uri": "http://example.com", "media_type": "text/html"})
|
||||
- strings (which will be converted to UriContent with media_type "text/plain").
|
||||
If None, defaults to an empty list.
|
||||
description: A description of the tool.
|
||||
additional_properties: Additional properties associated with the tool, specific to the service used.
|
||||
"""
|
||||
self.name = name
|
||||
self.inputs = _parse_inputs(inputs)
|
||||
self.description = description
|
||||
self.additional_properties = additional_properties
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return a string representation of the tool."""
|
||||
return f"HostedCodeInterpreterTool(name={self.name})"
|
||||
|
||||
@@ -21,11 +21,9 @@ from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
PrivateAttr,
|
||||
ValidationError,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from ._pydantic import AFBaseModel
|
||||
@@ -81,7 +79,6 @@ __all__ = [
|
||||
"AIAnnotations",
|
||||
"AIContent",
|
||||
"AIContents",
|
||||
"AITool",
|
||||
"AgentRunResponse",
|
||||
"AgentRunResponseUpdate",
|
||||
"AnnotatedRegion",
|
||||
@@ -159,6 +156,10 @@ class UsageDetails(AFBaseModel):
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns a string representation of the usage details."""
|
||||
return self.model_dump_json(indent=4, exclude_none=True)
|
||||
|
||||
@property
|
||||
def additional_counts(self) -> dict[str, int]:
|
||||
"""Represents well-known additional counts for usage. This is not an exhaustive list.
|
||||
@@ -173,6 +174,14 @@ class UsageDetails(AFBaseModel):
|
||||
"""
|
||||
return self.model_extra or {}
|
||||
|
||||
def __setitem__(self, key: str, value: int) -> None:
|
||||
"""Sets an additional count for the usage details."""
|
||||
if not isinstance(value, int):
|
||||
raise ValueError("Additional counts must be integers.")
|
||||
if self.model_extra is None:
|
||||
self.model_extra = {} # type: ignore[reportAttributeAccessIssue, misc]
|
||||
self.model_extra[key] = value
|
||||
|
||||
def __add__(self, other: "UsageDetails | None") -> "UsageDetails":
|
||||
"""Combines two `UsageDetails` instances."""
|
||||
if not other:
|
||||
@@ -394,7 +403,7 @@ class AIContent(AFBaseModel):
|
||||
type: Literal["ai"] = "ai"
|
||||
annotations: list[AIAnnotations] | None = None
|
||||
additional_properties: dict[str, Any] | None = None
|
||||
raw_representation: Any | None = Field(default=None, repr=False)
|
||||
raw_representation: Any | None = Field(default=None, repr=False, exclude=True)
|
||||
|
||||
|
||||
class TextContent(AIContent):
|
||||
@@ -1130,7 +1139,7 @@ class ChatRole(AFBaseModel):
|
||||
TOOL: The role that provides additional information and references in response to tool use requests.
|
||||
"""
|
||||
|
||||
value: str
|
||||
value: str = Field(..., kw_only=False)
|
||||
|
||||
SYSTEM: ClassVar[Self] # type: ignore[assignment]
|
||||
"""The role that instructs or sets the behaviour of the AI system."""
|
||||
@@ -1211,7 +1220,7 @@ class ChatMessage(AFBaseModel):
|
||||
"""The ID of the chat message."""
|
||||
additional_properties: dict[str, Any] | None = None
|
||||
"""Any additional properties associated with the chat message."""
|
||||
raw_representation: Any | None = None
|
||||
raw_representation: Any | None = Field(default=None, exclude=True)
|
||||
"""The raw representation of the chat message from an underlying implementation."""
|
||||
|
||||
@overload
|
||||
@@ -1760,13 +1769,6 @@ class ChatOptions(AFBaseModel):
|
||||
tools: list[AITool | MutableMapping[str, Any]] | None = None
|
||||
top_p: Annotated[float | None, Field(ge=0.0, le=1.0)] = None
|
||||
user: str | None = None
|
||||
_ai_tools: list[AITool | MutableMapping[str, Any]] | None = PrivateAttr(default=None)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _copy_to_ai_tools(self) -> Self:
|
||||
if self.tools and not self._ai_tools:
|
||||
self._ai_tools = self.tools
|
||||
return self
|
||||
|
||||
@field_validator("tools", mode="before")
|
||||
@classmethod
|
||||
@@ -1782,10 +1784,7 @@ class ChatOptions(AFBaseModel):
|
||||
| None
|
||||
),
|
||||
) -> list[AITool | MutableMapping[str, Any]] | None:
|
||||
"""Parse the tools field.
|
||||
|
||||
All tools are stored in both tools and _ai_tools.
|
||||
"""
|
||||
"""Parse the tools field."""
|
||||
if not tools:
|
||||
return None
|
||||
if not isinstance(tools, list):
|
||||
@@ -1849,22 +1848,22 @@ class ChatOptions(AFBaseModel):
|
||||
"""
|
||||
if not isinstance(other, ChatOptions):
|
||||
return self
|
||||
ai_tools = other._ai_tools
|
||||
updated_values = other.model_dump(exclude_none=True)
|
||||
updated_values.pop("tools", [])
|
||||
other_tools = other.tools
|
||||
updated_values = other.model_dump(exclude_none=True, exclude={"tools"})
|
||||
logit_bias = updated_values.pop("logit_bias", {})
|
||||
metadata = updated_values.pop("metadata", {})
|
||||
additional_properties = updated_values.pop("additional_properties", {})
|
||||
combined = self.model_copy(update=updated_values)
|
||||
if ai_tools:
|
||||
if not combined._ai_tools:
|
||||
combined._ai_tools = []
|
||||
for tool in ai_tools:
|
||||
if tool not in combined._ai_tools:
|
||||
combined._ai_tools.append(tool)
|
||||
combined.logit_bias = {**(combined.logit_bias or {}), **logit_bias}
|
||||
combined.metadata = {**(combined.metadata or {}), **metadata}
|
||||
combined.additional_properties = {**(combined.additional_properties or {}), **additional_properties}
|
||||
if other_tools:
|
||||
if not combined.tools:
|
||||
combined.tools = other_tools
|
||||
else:
|
||||
for tool in other_tools:
|
||||
if tool not in combined.tools:
|
||||
combined.tools.append(tool)
|
||||
return combined
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("agent_framework")
|
||||
|
||||
|
||||
class AgentFrameworkException(Exception):
|
||||
"""Base class for exceptions in the Agent Framework."""
|
||||
"""Base exceptions for the Agent Framework.
|
||||
|
||||
pass
|
||||
Automatically logs the message as debug.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, inner_exception: Exception | None = None, *args: Any, **kwargs: Any):
|
||||
"""Create an AgentFrameworkException.
|
||||
|
||||
This emits a debug log, with the inner_exception if provided.
|
||||
"""
|
||||
logger.debug(message, exc_info=inner_exception)
|
||||
super().__init__(message, *args, **kwargs) # type: ignore
|
||||
|
||||
|
||||
class AgentException(AgentFrameworkException):
|
||||
@@ -68,3 +82,15 @@ class ServiceInvalidResponseError(ServiceResponseException):
|
||||
"""An error occurred while validating the response from the service."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ToolException(AgentFrameworkException):
|
||||
"""An error occurred while executing a tool."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ToolExecutionException(ToolException):
|
||||
"""An error occurred while executing a tool."""
|
||||
|
||||
pass
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import AsyncIterable, Mapping, MutableMapping, MutableSequence
|
||||
from typing import Any, ClassVar
|
||||
from typing import Any
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
from openai.types.beta.threads import (
|
||||
@@ -20,7 +20,7 @@ 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 ChatClientBase, ai_function_to_json_schema_spec, use_tool_calling
|
||||
from .._clients import ChatClientBase, use_tool_calling
|
||||
from .._tools import AIFunction, HostedCodeInterpreterTool
|
||||
from .._types import (
|
||||
AIContents,
|
||||
@@ -54,7 +54,6 @@ __all__ = ["OpenAIAssistantsClient"]
|
||||
class OpenAIAssistantsClient(OpenAIConfigBase, ChatClientBase):
|
||||
"""OpenAI Assistants client."""
|
||||
|
||||
MODEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
assistant_id: str | None = Field(default=None)
|
||||
assistant_name: str | None = Field(default=None)
|
||||
thread_id: str | None = Field(default=None)
|
||||
@@ -362,7 +361,7 @@ class OpenAIAssistantsClient(OpenAIConfigBase, ChatClientBase):
|
||||
if chat_options.tool_choice != "none" and chat_options.tools is not None:
|
||||
for tool in chat_options.tools:
|
||||
if isinstance(tool, AIFunction):
|
||||
tool_definitions.append(ai_function_to_json_schema_spec(tool)) # type: ignore[reportUnknownArgumentType]
|
||||
tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType]
|
||||
elif isinstance(tool, HostedCodeInterpreterTool):
|
||||
tool_definitions.append({"type": "code_interpreter"})
|
||||
elif isinstance(tool, MutableMapping):
|
||||
@@ -467,3 +466,14 @@ class OpenAIAssistantsClient(OpenAIConfigBase, ChatClientBase):
|
||||
tool_outputs.append(ToolOutput(tool_call_id=call_id, output=str(function_result_content.result)))
|
||||
|
||||
return run_id, tool_outputs
|
||||
|
||||
def _update_agent_name(self, agent_name: str | None) -> None:
|
||||
"""Update the agent name in the chat client.
|
||||
|
||||
Args:
|
||||
agent_name: The new name for the agent.
|
||||
"""
|
||||
# This is a no-op in the base class, but can be overridden by subclasses
|
||||
# to update the agent name in the client.
|
||||
if agent_name and not self.assistant_name:
|
||||
self.assistant_name = agent_name
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterable, Mapping, MutableSequence, Sequence
|
||||
from collections.abc import AsyncIterable, Mapping, MutableMapping, MutableSequence, Sequence
|
||||
from datetime import datetime
|
||||
from itertools import chain
|
||||
from typing import Any, ClassVar, cast
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from openai import AsyncOpenAI, AsyncStream
|
||||
from openai import AsyncOpenAI, BadRequestError
|
||||
from openai.lib._parsing._completions import type_to_response_format_param
|
||||
from openai.types import CompletionUsage
|
||||
from openai.types.chat.chat_completion import ChatCompletion, Choice
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk, ChoiceDeltaToolCall
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
|
||||
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall
|
||||
from pydantic import SecretStr, ValidationError
|
||||
from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall
|
||||
from pydantic import BaseModel, SecretStr, ValidationError
|
||||
|
||||
from agent_framework import AIFunction, AITool, UsageContent
|
||||
|
||||
from .._clients import ChatClientBase, use_tool_calling
|
||||
from .._logging import get_logger
|
||||
from .._types import (
|
||||
AIContents,
|
||||
ChatFinishReason,
|
||||
@@ -28,12 +32,19 @@ from .._types import (
|
||||
TextContent,
|
||||
UsageDetails,
|
||||
)
|
||||
from ..exceptions import ServiceInitializationError, ServiceInvalidResponseError
|
||||
from ..exceptions import (
|
||||
ServiceInitializationError,
|
||||
ServiceInvalidRequestError,
|
||||
ServiceResponseException,
|
||||
)
|
||||
from ..telemetry import use_telemetry
|
||||
from ._shared import OpenAIConfigBase, OpenAIHandler, OpenAIModelTypes, OpenAISettings
|
||||
from ._exceptions import OpenAIContentFilterException
|
||||
from ._shared import OpenAIConfigBase, OpenAIHandler, OpenAISettings, prepare_function_call_results
|
||||
|
||||
__all__ = ["OpenAIChatClient"]
|
||||
|
||||
logger = get_logger("agent_framework.openai")
|
||||
|
||||
|
||||
# region Base Client
|
||||
@use_telemetry
|
||||
@@ -41,8 +52,6 @@ __all__ = ["OpenAIChatClient"]
|
||||
class OpenAIChatClientBase(OpenAIHandler, ChatClientBase):
|
||||
"""OpenAI Chat completion class."""
|
||||
|
||||
MODEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
|
||||
async def _inner_get_response(
|
||||
self,
|
||||
*,
|
||||
@@ -50,16 +59,24 @@ class OpenAIChatClientBase(OpenAIHandler, ChatClientBase):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
chat_options.additional_properties["stream"] = False
|
||||
if not chat_options.ai_model_id:
|
||||
chat_options.ai_model_id = self.ai_model_id
|
||||
|
||||
response = await self._send_request(chat_options, messages=self._prepare_chat_history_for_request(messages))
|
||||
assert isinstance(response, ChatCompletion) # nosec # noqa: S101
|
||||
response_metadata = self._get_metadata_from_chat_response(response)
|
||||
return next(
|
||||
self._create_chat_message_content(response, choice, response_metadata) for choice in response.choices
|
||||
)
|
||||
options_dict = self._prepare_options(messages, chat_options)
|
||||
try:
|
||||
return self._create_chat_response(await self.client.chat.completions.create(stream=False, **options_dict))
|
||||
except BadRequestError as ex:
|
||||
if ex.code == "content_filter":
|
||||
raise OpenAIContentFilterException(
|
||||
f"{type(self)} service encountered a content error",
|
||||
ex,
|
||||
) from ex
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to complete the prompt",
|
||||
ex,
|
||||
) from ex
|
||||
except Exception as ex:
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to complete the prompt",
|
||||
ex,
|
||||
) from ex
|
||||
|
||||
async def _inner_get_streaming_response(
|
||||
self,
|
||||
@@ -68,91 +85,138 @@ class OpenAIChatClientBase(OpenAIHandler, ChatClientBase):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[ChatResponseUpdate]:
|
||||
chat_options.additional_properties["stream"] = True
|
||||
chat_options.additional_properties["stream_options"] = {"include_usage": True}
|
||||
chat_options.ai_model_id = chat_options.ai_model_id or self.ai_model_id
|
||||
|
||||
response = await self._send_request(chat_options, messages=self._prepare_chat_history_for_request(messages))
|
||||
if not isinstance(response, AsyncStream):
|
||||
raise ServiceInvalidResponseError("Expected an AsyncStream[ChatCompletionChunk] response.")
|
||||
async for chunk in response:
|
||||
assert isinstance(chunk, ChatCompletionChunk) # nosec # noqa: S101
|
||||
if len(chunk.choices) == 0 and chunk.usage is None:
|
||||
continue
|
||||
|
||||
assert isinstance(chunk, ChatCompletionChunk) # nosec # noqa: S101
|
||||
chunk_metadata = self._get_metadata_from_streaming_chat_response(chunk)
|
||||
if chunk.usage is not None:
|
||||
# Usage is contained in the last chunk where the choices are empty
|
||||
# We are duplicating the usage metadata to all the choices in the response
|
||||
yield ChatResponseUpdate(
|
||||
role=ChatRole.ASSISTANT,
|
||||
contents=[],
|
||||
ai_model_id=chat_options.ai_model_id,
|
||||
additional_properties=chunk_metadata,
|
||||
)
|
||||
|
||||
else:
|
||||
yield next(
|
||||
self._create_streaming_chat_message_content(chunk, choice, chunk_metadata)
|
||||
for choice in chunk.choices
|
||||
)
|
||||
options_dict = self._prepare_options(messages, chat_options)
|
||||
options_dict["stream_options"] = {"include_usage": True}
|
||||
try:
|
||||
async for chunk in await self.client.chat.completions.create(stream=True, **options_dict):
|
||||
if len(chunk.choices) == 0 and chunk.usage is None:
|
||||
continue
|
||||
yield self._create_chat_response_update(chunk)
|
||||
except BadRequestError as ex:
|
||||
if ex.code == "content_filter":
|
||||
raise OpenAIContentFilterException(
|
||||
f"{type(self)} service encountered a content error",
|
||||
ex,
|
||||
) from ex
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to complete the prompt",
|
||||
ex,
|
||||
) from ex
|
||||
except Exception as ex:
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to complete the prompt",
|
||||
ex,
|
||||
) from ex
|
||||
|
||||
# region content creation
|
||||
|
||||
def _create_chat_message_content(
|
||||
self, response: ChatCompletion, choice: Choice, response_metadata: dict[str, Any]
|
||||
) -> "ChatResponse":
|
||||
"""Create a chat message content object from a choice."""
|
||||
metadata = self._get_metadata_from_chat_choice(choice)
|
||||
metadata.update(response_metadata)
|
||||
items: MutableSequence[ChatMessage] = []
|
||||
if parsed_tool_calls := [tool for tool in self._get_tool_calls_from_chat_choice(choice)]:
|
||||
items.append(ChatMessage(role="assistant", contents=parsed_tool_calls))
|
||||
if choice.message.content:
|
||||
items.append(ChatMessage(role="assistant", text=choice.message.content))
|
||||
elif hasattr(choice.message, "refusal") and choice.message.refusal:
|
||||
items.append(ChatMessage(role="assistant", text=choice.message.refusal))
|
||||
def _chat_to_tool_spec(self, tools: list[AITool | MutableMapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
chat_tools: list[dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, AITool):
|
||||
match tool:
|
||||
case AIFunction():
|
||||
chat_tools.append(tool.to_json_schema_spec())
|
||||
case _:
|
||||
logger.debug("Unsupported tool passed (type: %s), ignoring", type(tool))
|
||||
else:
|
||||
chat_tools.append(tool if isinstance(tool, dict) else dict(tool))
|
||||
return chat_tools
|
||||
|
||||
def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]:
|
||||
options_dict = chat_options.to_provider_settings()
|
||||
if messages and "messages" not in options_dict:
|
||||
options_dict["messages"] = self._prepare_chat_history_for_request(messages)
|
||||
if "messages" not in options_dict:
|
||||
raise ServiceInvalidRequestError("Messages are required for chat completions")
|
||||
if chat_options.tools is None:
|
||||
options_dict.pop("parallel_tool_calls", None)
|
||||
else:
|
||||
options_dict["tools"] = self._chat_to_tool_spec(chat_options.tools)
|
||||
if "model" not in options_dict:
|
||||
options_dict["model"] = self.ai_model_id
|
||||
if (
|
||||
chat_options.response_format
|
||||
and isinstance(chat_options.response_format, type)
|
||||
and issubclass(chat_options.response_format, BaseModel)
|
||||
):
|
||||
options_dict["response_format"] = type_to_response_format_param(chat_options.response_format)
|
||||
return options_dict
|
||||
|
||||
def _create_chat_response(self, response: ChatCompletion) -> "ChatResponse":
|
||||
"""Create a chat message content object from a choice."""
|
||||
response_metadata = self._get_metadata_from_chat_response(response)
|
||||
messages: list[ChatMessage] = []
|
||||
finish_reason: ChatFinishReason | None = None
|
||||
for choice in response.choices:
|
||||
response_metadata.update(self._get_metadata_from_chat_choice(choice))
|
||||
if choice.finish_reason:
|
||||
finish_reason = ChatFinishReason(value=choice.finish_reason)
|
||||
contents: list[AIContents] = []
|
||||
if parsed_tool_calls := [tool for tool in self._get_tool_calls_from_chat_choice(choice)]:
|
||||
contents.extend(parsed_tool_calls)
|
||||
if text_content := self._parse_text_from_choice(choice):
|
||||
contents.append(text_content)
|
||||
messages.append(ChatMessage(role="assistant", contents=contents))
|
||||
return ChatResponse(
|
||||
response_id=response.id,
|
||||
created_at=datetime.fromtimestamp(response.created).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
usage_details=self._usage_details_from_openai(response.usage) if response.usage else None,
|
||||
messages=items,
|
||||
model_id=self.ai_model_id,
|
||||
additional_properties=metadata,
|
||||
finish_reason=(ChatFinishReason(value=choice.finish_reason) if choice.finish_reason else None),
|
||||
messages=messages,
|
||||
model_id=response.model,
|
||||
additional_properties=response_metadata,
|
||||
finish_reason=finish_reason,
|
||||
)
|
||||
|
||||
def _create_streaming_chat_message_content(
|
||||
def _create_chat_response_update(
|
||||
self,
|
||||
chunk: ChatCompletionChunk,
|
||||
choice: ChunkChoice,
|
||||
chunk_metadata: dict[str, Any],
|
||||
) -> ChatResponseUpdate:
|
||||
"""Create a streaming chat message content object from a choice."""
|
||||
metadata = self._get_metadata_from_chat_choice(choice)
|
||||
metadata.update(chunk_metadata)
|
||||
chunk_metadata = self._get_metadata_from_streaming_chat_response(chunk)
|
||||
if chunk.usage:
|
||||
return ChatResponseUpdate(
|
||||
role=ChatRole.ASSISTANT,
|
||||
contents=[UsageContent(details=self._usage_details_from_openai(chunk.usage), raw_representation=chunk)],
|
||||
ai_model_id=chunk.model,
|
||||
additional_properties=chunk_metadata,
|
||||
)
|
||||
contents: list[AIContents] = []
|
||||
finish_reason: ChatFinishReason | None = None
|
||||
for choice in chunk.choices:
|
||||
chunk_metadata.update(self._get_metadata_from_chat_choice(choice))
|
||||
contents.extend(self._get_tool_calls_from_chat_choice(choice))
|
||||
if choice.finish_reason:
|
||||
finish_reason = ChatFinishReason(value=choice.finish_reason)
|
||||
|
||||
items: list[Any] = self._get_tool_calls_from_chat_choice(choice)
|
||||
if choice.delta and choice.delta.content is not None:
|
||||
items.append(TextContent(text=choice.delta.content))
|
||||
if text_content := self._parse_text_from_choice(choice):
|
||||
contents.append(text_content)
|
||||
return ChatResponseUpdate(
|
||||
created_at=datetime.fromtimestamp(chunk.created).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
contents=items,
|
||||
contents=contents,
|
||||
role=ChatRole.ASSISTANT,
|
||||
ai_model_id=self.ai_model_id,
|
||||
additional_properties=metadata,
|
||||
finish_reason=(ChatFinishReason(value=choice.finish_reason) if choice.finish_reason else None),
|
||||
ai_model_id=chunk.model,
|
||||
additional_properties=chunk_metadata,
|
||||
finish_reason=finish_reason,
|
||||
raw_representation=chunk,
|
||||
)
|
||||
|
||||
def _usage_details_from_openai(self, usage: CompletionUsage) -> UsageDetails | None:
|
||||
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,
|
||||
)
|
||||
|
||||
def _parse_text_from_choice(self, choice: Choice | ChunkChoice) -> TextContent | None:
|
||||
"""Parse the choice into a TextContent object."""
|
||||
message = choice.message if isinstance(choice, Choice) else choice.delta
|
||||
if message.content:
|
||||
return TextContent(text=message.content, raw_representation=choice)
|
||||
if hasattr(message, "refusal") and message.refusal:
|
||||
return TextContent(text=message.refusal, raw_representation=choice)
|
||||
return None
|
||||
|
||||
def _get_metadata_from_chat_response(self, response: ChatCompletion) -> dict[str, Any]:
|
||||
"""Get metadata from a chat response."""
|
||||
return {
|
||||
@@ -175,13 +239,15 @@ class OpenAIChatClientBase(OpenAIHandler, ChatClientBase):
|
||||
"""Get tool calls from a chat choice."""
|
||||
resp: list[AIContents] = []
|
||||
content = choice.message if isinstance(choice, Choice) else choice.delta
|
||||
if content and (tool_calls := getattr(content, "tool_calls", None)) is not None:
|
||||
for tool in cast(list[ChatCompletionMessageToolCall] | list[ChoiceDeltaToolCall], tool_calls):
|
||||
if tool.function: # type: ignore[reportAttributeAccessIssue, union-attr]
|
||||
if content and content.tool_calls:
|
||||
for tool in content.tool_calls:
|
||||
if not isinstance(tool, ChatCompletionMessageCustomToolCall) and tool.function:
|
||||
# ignoring tool.custom
|
||||
fcc = FunctionCallContent(
|
||||
call_id=tool.id if tool.id else "",
|
||||
name=tool.function.name if tool.function.name else "", # type: ignore
|
||||
arguments=tool.function.arguments if tool.function.arguments else "", # type: ignore
|
||||
name=tool.function.name if tool.function.name else "",
|
||||
arguments=tool.function.arguments if tool.function.arguments else "",
|
||||
raw_representation=tool.function,
|
||||
)
|
||||
resp.append(fcc)
|
||||
|
||||
@@ -236,7 +302,8 @@ class OpenAIChatClientBase(OpenAIHandler, ChatClientBase):
|
||||
args["tool_calls"] = [self._openai_content_parser(content)] # type: ignore
|
||||
case FunctionResultContent():
|
||||
args["tool_call_id"] = content.call_id
|
||||
args["content"] = content.result
|
||||
if content.result:
|
||||
args["content"] = prepare_function_call_results(content.result)
|
||||
case _:
|
||||
if "content" not in args:
|
||||
args["content"] = []
|
||||
@@ -275,6 +342,8 @@ class OpenAIChatClientBase(OpenAIHandler, ChatClientBase):
|
||||
|
||||
# region Public client
|
||||
|
||||
TOpenAIChatClient = TypeVar("TOpenAIChatClient", bound="OpenAIChatClient")
|
||||
|
||||
|
||||
class OpenAIChatClient(OpenAIConfigBase, OpenAIChatClientBase):
|
||||
"""OpenAI Chat completion class."""
|
||||
@@ -328,23 +397,19 @@ class OpenAIChatClient(OpenAIConfigBase, OpenAIChatClientBase):
|
||||
ai_model_id=openai_settings.chat_model_id,
|
||||
api_key=openai_settings.api_key.get_secret_value() if openai_settings.api_key else None,
|
||||
org_id=openai_settings.org_id,
|
||||
ai_model_type=OpenAIModelTypes.CHAT,
|
||||
default_headers=default_headers,
|
||||
client=async_client,
|
||||
instruction_role=instruction_role,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, settings: dict[str, Any]) -> "OpenAIChatClient":
|
||||
"""Initialize an Open AI service from a dictionary of settings.
|
||||
def from_dict(cls: type[TOpenAIChatClient], settings: dict[str, Any]) -> TOpenAIChatClient:
|
||||
"""Initialize an Open AI Chat Client from a dictionary of settings.
|
||||
|
||||
Args:
|
||||
settings: A dictionary of settings for the service.
|
||||
"""
|
||||
return OpenAIChatClient(
|
||||
ai_model_id=settings["ai_model_id"],
|
||||
default_headers=settings.get("default_headers"),
|
||||
)
|
||||
return cls(**settings)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -4,70 +4,87 @@ import sys
|
||||
from collections.abc import AsyncIterable, Callable, Mapping, MutableMapping, MutableSequence, Sequence
|
||||
from datetime import datetime
|
||||
from itertools import chain
|
||||
from typing import Any, ClassVar, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypeVar
|
||||
|
||||
from openai import AsyncOpenAI, AsyncStream
|
||||
from openai import AsyncOpenAI, BadRequestError
|
||||
from openai.types.responses.function_tool_param import FunctionToolParam
|
||||
from openai.types.responses.parsed_response import (
|
||||
ParsedResponse,
|
||||
)
|
||||
from openai.types.responses.response import Response as OpenAIResponse
|
||||
from openai.types.responses.response_code_interpreter_tool_call import ResponseCodeInterpreterToolCall
|
||||
from openai.types.responses.response_completed_event import ResponseCompletedEvent
|
||||
from openai.types.responses.response_content_part_added_event import ResponseContentPartAddedEvent
|
||||
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
|
||||
from openai.types.responses.response_includable import ResponseIncludable
|
||||
from openai.types.responses.response_output_item import ResponseOutputItem
|
||||
from openai.types.responses.response_output_message import ResponseOutputMessage
|
||||
from openai.types.responses.response_function_call_arguments_delta_event import ResponseFunctionCallArgumentsDeltaEvent
|
||||
from openai.types.responses.response_output_item_added_event import ResponseOutputItemAddedEvent
|
||||
from openai.types.responses.response_output_refusal import ResponseOutputRefusal
|
||||
from openai.types.responses.response_output_text import ResponseOutputText
|
||||
from openai.types.responses.response_stream_event import ResponseStreamEvent as OpenAIResponseStreamEvent
|
||||
from openai.types.responses.response_text_delta_event import ResponseTextDeltaEvent
|
||||
from openai.types.responses.response_usage import ResponseUsage
|
||||
from openai.types.responses.tool_param import (
|
||||
CodeInterpreter,
|
||||
CodeInterpreterContainerCodeInterpreterToolAuto,
|
||||
ToolParam,
|
||||
)
|
||||
from pydantic import BaseModel, SecretStr, ValidationError
|
||||
|
||||
from agent_framework import DataContent, TextReasoningContent, UriContent, UsageContent
|
||||
|
||||
from .._clients import ChatClientBase, use_tool_calling
|
||||
from .._tools import HostedCodeInterpreterTool
|
||||
from .._logging import get_logger
|
||||
from .._tools import AIFunction, AITool, HostedCodeInterpreterTool
|
||||
from .._types import (
|
||||
AIContents,
|
||||
AITool,
|
||||
ChatMessage,
|
||||
ChatOptions,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
ChatRole,
|
||||
ChatToolMode,
|
||||
CitationAnnotation,
|
||||
FunctionCallContent,
|
||||
FunctionResultContent,
|
||||
HostedFileContent,
|
||||
StructuredResponse,
|
||||
TextContent,
|
||||
TextSpanRegion,
|
||||
UsageDetails,
|
||||
)
|
||||
from ..exceptions import ServiceInitializationError, ServiceInvalidResponseError
|
||||
from ..exceptions import (
|
||||
ServiceInitializationError,
|
||||
ServiceInvalidRequestError,
|
||||
ServiceResponseException,
|
||||
)
|
||||
from ..telemetry import use_telemetry
|
||||
from ._shared import OpenAIConfigBase, OpenAIHandler, OpenAIModelTypes, OpenAISettings
|
||||
from ._exceptions import OpenAIContentFilterException
|
||||
from ._shared import OpenAIConfigBase, OpenAIHandler, 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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai.types.responses.response_includable import ResponseIncludable
|
||||
|
||||
from .._types import ChatToolMode
|
||||
|
||||
|
||||
logger = get_logger("agent_framework.openai")
|
||||
|
||||
__all__ = ["OpenAIResponsesClient"]
|
||||
|
||||
# region ResponsesClient
|
||||
|
||||
|
||||
class OpenAIResponsesClientBase(OpenAIHandler, ChatClientBase):
|
||||
def _filter_options(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Filter options for the responses call."""
|
||||
# The responses call does not support all the options that the chat completion call does.
|
||||
# We filter out the unsupported options.
|
||||
return {key: value for key, value in kwargs.items() if value is not None}
|
||||
"""Base class for all OpenAI Responses based API's."""
|
||||
|
||||
# The responses create call takes very different parameters than the chat completion call,
|
||||
# so we override the get_response method to handle the specific parameters for responses.
|
||||
@override
|
||||
async def get_response(
|
||||
self,
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage],
|
||||
*,
|
||||
# TODO(peterychang): enable this option. background: bool | None = None,
|
||||
include: list[ResponseIncludable] | None = None,
|
||||
include: list["ResponseIncludable"] | None = None,
|
||||
instruction: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
parallel_tool_calls: bool | None = None,
|
||||
@@ -79,7 +96,7 @@ class OpenAIResponsesClientBase(OpenAIHandler, ChatClientBase):
|
||||
seed: int | None = None,
|
||||
store: bool | None = None,
|
||||
temperature: float | None = None,
|
||||
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
|
||||
tool_choice: "ChatToolMode" | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
|
||||
tools: AITool
|
||||
| list[AITool]
|
||||
| Callable[..., Any]
|
||||
@@ -123,31 +140,27 @@ class OpenAIResponsesClientBase(OpenAIHandler, ChatClientBase):
|
||||
Returns:
|
||||
A chat response from the model.
|
||||
"""
|
||||
filtered_options = self._filter_options(
|
||||
background=False,
|
||||
return await super().get_response(
|
||||
messages=messages,
|
||||
include=include,
|
||||
instruction=instruction,
|
||||
max_tokens=max_tokens,
|
||||
parallel_tool_calls=parallel_tool_calls,
|
||||
model=model,
|
||||
previous_response_id=previous_response_id,
|
||||
reasoning=reasoning,
|
||||
service_tier=service_tier,
|
||||
truncation=truncation,
|
||||
timeout=timeout,
|
||||
)
|
||||
filtered_options.update(additional_properties or {})
|
||||
return await super().get_response(
|
||||
messages=messages,
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
response_format=response_format,
|
||||
seed=seed,
|
||||
store=store,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
tool_choice=tool_choice,
|
||||
tools=tools,
|
||||
top_p=top_p,
|
||||
user=user,
|
||||
additional_properties=filtered_options,
|
||||
truncation=truncation,
|
||||
timeout=timeout,
|
||||
additional_properties=additional_properties,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -157,7 +170,7 @@ class OpenAIResponsesClientBase(OpenAIHandler, ChatClientBase):
|
||||
messages: str | ChatMessage | list[str] | list[ChatMessage],
|
||||
*,
|
||||
# TODO(peterychang): enable this option. background: bool | None = None,
|
||||
include: list[ResponseIncludable] | None = None,
|
||||
include: list["ResponseIncludable"] | None = None,
|
||||
instruction: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
parallel_tool_calls: bool | None = None,
|
||||
@@ -169,7 +182,7 @@ class OpenAIResponsesClientBase(OpenAIHandler, ChatClientBase):
|
||||
seed: int | None = None,
|
||||
store: bool | None = None,
|
||||
temperature: float | None = None,
|
||||
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
|
||||
tool_choice: "ChatToolMode" | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
|
||||
tools: AITool
|
||||
| list[AITool]
|
||||
| Callable[..., Any]
|
||||
@@ -213,55 +226,32 @@ class OpenAIResponsesClientBase(OpenAIHandler, ChatClientBase):
|
||||
Returns:
|
||||
A stream representing the response(s) from the LLM.
|
||||
"""
|
||||
filtered_options = self._filter_options(
|
||||
background=False,
|
||||
async for update in super().get_streaming_response(
|
||||
messages=messages,
|
||||
include=include,
|
||||
instruction=instruction,
|
||||
max_tokens=max_tokens,
|
||||
parallel_tool_calls=parallel_tool_calls,
|
||||
model=model,
|
||||
previous_response_id=previous_response_id,
|
||||
reasoning=reasoning,
|
||||
service_tier=service_tier,
|
||||
truncation=truncation,
|
||||
timeout=timeout,
|
||||
)
|
||||
filtered_options.update(additional_properties or {})
|
||||
async for update in super().get_streaming_response(
|
||||
messages=messages,
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
response_format=response_format,
|
||||
seed=seed,
|
||||
store=store,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
tool_choice=tool_choice,
|
||||
tools=tools,
|
||||
top_p=top_p,
|
||||
user=user,
|
||||
additional_properties=filtered_options,
|
||||
truncation=truncation,
|
||||
timeout=timeout,
|
||||
additional_properties=additional_properties,
|
||||
**kwargs,
|
||||
):
|
||||
yield update
|
||||
|
||||
def _chat_to_response_tool_spec(self, tools: list[AITool | MutableMapping[str, Any]]) -> list[dict[str, Any]]:
|
||||
response_tools: list[dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, AITool):
|
||||
# TODO(peterychang): Support AITools
|
||||
if isinstance(tool, HostedCodeInterpreterTool):
|
||||
response_tools.append({"type": "code_interpreter", "container": {"type": "auto"}})
|
||||
continue
|
||||
if "function" not in tool:
|
||||
response_tools.append(tool if isinstance(tool, dict) else dict(tool))
|
||||
parameters = {"additionalProperties": False}
|
||||
parameters.update(tool.get("function", {}).get("parameters", {}))
|
||||
response_tools.append({
|
||||
"type": "function",
|
||||
"name": tool.get("function", {}).get("name", ""),
|
||||
"strict": True,
|
||||
"description": tool.get("function", {}).get("description", None),
|
||||
"parameters": parameters,
|
||||
})
|
||||
return response_tools
|
||||
# region Inner Methods
|
||||
|
||||
async def _inner_get_response(
|
||||
self,
|
||||
@@ -270,16 +260,39 @@ class OpenAIResponsesClientBase(OpenAIHandler, ChatClientBase):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> ChatResponse:
|
||||
chat_options.additional_properties["stream"] = False
|
||||
if not chat_options.ai_model_id:
|
||||
chat_options.ai_model_id = self.ai_model_id
|
||||
if chat_options.tools:
|
||||
chat_options.additional_properties.update({
|
||||
"response_tools": self._chat_to_response_tool_spec(chat_options.tools)
|
||||
})
|
||||
response = await self._send_request(chat_options, messages=self._prepare_chat_history_for_request(messages))
|
||||
assert isinstance(response, OpenAIResponse) # nosec # noqa: S101
|
||||
return next(self._create_response_content(response, item, store=chat_options.store) for item in response.output)
|
||||
options_dict = self._prepare_options(messages, chat_options)
|
||||
try:
|
||||
if not chat_options.response_format:
|
||||
response = await self.client.responses.create(
|
||||
stream=False,
|
||||
**options_dict,
|
||||
)
|
||||
chat_options.conversation_id = response.id if chat_options.store is True else None
|
||||
return self._create_response_content(response, chat_options=chat_options)
|
||||
# create call does not support response_format, so we need to handle it via parse call
|
||||
resp_format = chat_options.response_format
|
||||
parsed_response: ParsedResponse[BaseModel] = await self.client.responses.parse(
|
||||
text_format=resp_format,
|
||||
stream=False,
|
||||
**options_dict,
|
||||
)
|
||||
chat_options.conversation_id = parsed_response.id if chat_options.store is True else None
|
||||
return self._create_response_content(parsed_response, chat_options=chat_options)
|
||||
except BadRequestError as ex:
|
||||
if ex.code == "content_filter":
|
||||
raise OpenAIContentFilterException(
|
||||
f"{type(self)} service encountered a content error",
|
||||
inner_exception=ex,
|
||||
) from ex
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to complete the prompt",
|
||||
inner_exception=ex,
|
||||
) from ex
|
||||
except Exception as ex:
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to complete the prompt, with exception: {ex}",
|
||||
inner_exception=ex,
|
||||
) from ex
|
||||
|
||||
async def _inner_get_streaming_response(
|
||||
self,
|
||||
@@ -288,169 +301,113 @@ class OpenAIResponsesClientBase(OpenAIHandler, ChatClientBase):
|
||||
chat_options: ChatOptions,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterable[ChatResponseUpdate]:
|
||||
chat_options.additional_properties["stream"] = True
|
||||
chat_options.ai_model_id = chat_options.ai_model_id or self.ai_model_id
|
||||
options_dict = self._prepare_options(messages, chat_options)
|
||||
function_call_ids: dict[int, tuple[str, str]] = {} # output_index: (call_id, name)
|
||||
try:
|
||||
if not chat_options.response_format:
|
||||
response = await self.client.responses.create(
|
||||
stream=True,
|
||||
**options_dict,
|
||||
)
|
||||
async for chunk in response:
|
||||
update = self._create_streaming_response_content(
|
||||
chunk, chat_options=chat_options, function_call_ids=function_call_ids
|
||||
)
|
||||
yield update
|
||||
return
|
||||
# create call does not support response_format, so we need to handle it via stream call
|
||||
async with self.client.responses.stream(
|
||||
text_format=chat_options.response_format,
|
||||
**options_dict,
|
||||
) as response:
|
||||
async for chunk in response:
|
||||
update = self._create_streaming_response_content(
|
||||
chunk, chat_options=chat_options, function_call_ids=function_call_ids
|
||||
)
|
||||
yield update
|
||||
except BadRequestError as ex:
|
||||
if ex.code == "content_filter":
|
||||
raise OpenAIContentFilterException(
|
||||
f"{type(self)} service encountered a content error",
|
||||
inner_exception=ex,
|
||||
) from ex
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to complete the prompt",
|
||||
inner_exception=ex,
|
||||
) from ex
|
||||
except Exception as ex:
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to complete the prompt",
|
||||
inner_exception=ex,
|
||||
) from ex
|
||||
|
||||
if chat_options.tools:
|
||||
chat_options.additional_properties.update({
|
||||
"response_tools": self._chat_to_response_tool_spec(chat_options.tools)
|
||||
})
|
||||
response = await self._send_request(chat_options, messages=self._prepare_chat_history_for_request(messages))
|
||||
if not isinstance(response, AsyncStream):
|
||||
raise ServiceInvalidResponseError("Expected an AsyncStream[ResponseStreamEvent] response.")
|
||||
async for chunk in response:
|
||||
update = self._create_streaming_response_content(chunk, store=chat_options.store) # type: ignore
|
||||
if not update:
|
||||
continue
|
||||
yield update
|
||||
# region Prep methods
|
||||
|
||||
def _create_response_content(
|
||||
self, response: OpenAIResponse, item: ResponseOutputItem, store: bool | None
|
||||
) -> "ChatResponse":
|
||||
"""Create a chat message content object from a choice."""
|
||||
items: MutableSequence[ChatMessage] = []
|
||||
metadata: dict[str, Any] = response.metadata or {}
|
||||
# TODO(peterychang): Add support for other content types
|
||||
if parsed_tool_calls := [tool for tool in self._get_tool_calls_from_response(response)]:
|
||||
items.append(ChatMessage(role="assistant", contents=parsed_tool_calls))
|
||||
if isinstance(item, ResponseOutputMessage):
|
||||
for content in item.content:
|
||||
# TODO(peterychang): Add annotations when available
|
||||
if isinstance(content, ResponseOutputText):
|
||||
items.append(ChatMessage(role=item.role, text=content.text))
|
||||
metadata.update(self._get_metadata_from_response(content))
|
||||
elif isinstance(content, ResponseOutputRefusal):
|
||||
items.append(ChatMessage(role=item.role, text=content.refusal))
|
||||
if isinstance(item, ResponseCodeInterpreterToolCall):
|
||||
items.append(ChatMessage(role=ChatRole.ASSISTANT, text=response.output_text))
|
||||
return ChatResponse(
|
||||
response_id=response.id,
|
||||
conversation_id=response.id if store is True else None,
|
||||
created_at=datetime.fromtimestamp(response.created_at).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
usage_details=self._usage_details_from_openai(response.usage) if response.usage else None,
|
||||
messages=items,
|
||||
model_id=self.ai_model_id,
|
||||
additional_properties=metadata,
|
||||
raw_representation=response,
|
||||
)
|
||||
def _chat_to_response_tool_spec(
|
||||
self, tools: list[AITool | MutableMapping[str, Any]]
|
||||
) -> list[ToolParam | dict[str, Any]]:
|
||||
response_tools: list[ToolParam | dict[str, Any]] = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, AITool):
|
||||
match tool:
|
||||
case HostedCodeInterpreterTool():
|
||||
tool_args: dict[str, Any] = {"type": "auto"}
|
||||
if tool.inputs:
|
||||
tool_args["file_ids"] = []
|
||||
for tool_input in tool.inputs:
|
||||
if isinstance(tool_input, HostedFileContent):
|
||||
tool_args["file_ids"].append(tool_input.file_id)
|
||||
if not tool_args["file_ids"]:
|
||||
tool_args.pop("file_ids")
|
||||
response_tools.append(
|
||||
CodeInterpreter(
|
||||
type="code_interpreter",
|
||||
container=CodeInterpreterContainerCodeInterpreterToolAuto(**tool_args), # type: ignore[typeddict-item]
|
||||
)
|
||||
)
|
||||
case AIFunction():
|
||||
params = tool.parameters()
|
||||
params["additionalProperties"] = False
|
||||
response_tools.append(
|
||||
FunctionToolParam(
|
||||
name=tool.name,
|
||||
parameters=params,
|
||||
strict=False,
|
||||
type="function",
|
||||
description=tool.description,
|
||||
)
|
||||
)
|
||||
case _:
|
||||
logger.debug("Unsupported tool passed (type: %s)", type(tool))
|
||||
else:
|
||||
response_tools.append(tool if isinstance(tool, dict) else dict(tool))
|
||||
return response_tools
|
||||
|
||||
def _create_streaming_response_content(
|
||||
self, event: OpenAIResponseStreamEvent, store: bool | None
|
||||
) -> ChatResponseUpdate | None:
|
||||
"""Create a streaming chat message content object from a choice."""
|
||||
metadata: dict[str, Any] = {}
|
||||
items: list[AIContents] = []
|
||||
conversation_id: str | None = None
|
||||
# TODO(peterychang): Add support for other content types
|
||||
if isinstance(event, ResponseContentPartAddedEvent):
|
||||
if isinstance(event.part, ResponseOutputText):
|
||||
items.append(TextContent(text=event.part.text))
|
||||
metadata.update(self._get_metadata_from_response(event.part))
|
||||
elif isinstance(event.part, ResponseOutputRefusal):
|
||||
items.append(TextContent(text=event.part.refusal))
|
||||
elif isinstance(event, ResponseTextDeltaEvent):
|
||||
items.append(TextContent(text=event.delta))
|
||||
metadata.update(self._get_metadata_from_response(event))
|
||||
elif isinstance(event, ResponseCompletedEvent):
|
||||
conversation_id = event.response.id if store is True else None
|
||||
# Tool calls are available in the completed event
|
||||
if parsed_tool_calls := [tool for tool in self._get_tool_calls_from_response(event.response)]:
|
||||
items.extend(parsed_tool_calls)
|
||||
def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]:
|
||||
"""Take ChatOptions and create the specific options for Responses."""
|
||||
options_dict = chat_options.to_provider_settings(exclude={"response_format"})
|
||||
# messages
|
||||
request_input = self._prepare_chat_messages_for_request(messages)
|
||||
if not request_input:
|
||||
raise ServiceInvalidRequestError("Messages are required for chat completions")
|
||||
options_dict["input"] = request_input
|
||||
# tools
|
||||
if chat_options.tools is None:
|
||||
options_dict.pop("parallel_tool_calls", None)
|
||||
else:
|
||||
return None
|
||||
return ChatResponseUpdate(
|
||||
contents=items,
|
||||
conversation_id=conversation_id,
|
||||
role=ChatRole.ASSISTANT,
|
||||
ai_model_id=self.ai_model_id,
|
||||
additional_properties=metadata,
|
||||
raw_representation=event,
|
||||
)
|
||||
options_dict["tools"] = self._chat_to_response_tool_spec(chat_options.tools)
|
||||
# other settings
|
||||
if "store" not in options_dict:
|
||||
options_dict["store"] = False
|
||||
if "conversation_id" in options_dict:
|
||||
options_dict["previous_response_id"] = options_dict["conversation_id"]
|
||||
options_dict.pop("conversation_id")
|
||||
if "model" not in options_dict:
|
||||
options_dict["model"] = self.ai_model_id
|
||||
return options_dict
|
||||
|
||||
def _get_tool_calls_from_response(self, response: OpenAIResponse) -> list[AIContents]:
|
||||
resp: list[AIContents] = []
|
||||
# TODO(peterychang): Support the other tool calls
|
||||
for item in (i for i in response.output if isinstance(i, ResponseFunctionToolCall)):
|
||||
fcc = FunctionCallContent(
|
||||
call_id=item.id if item.id else "",
|
||||
name=item.name,
|
||||
arguments=item.arguments,
|
||||
additional_properties={"call_id": item.call_id},
|
||||
)
|
||||
resp.append(fcc)
|
||||
|
||||
return resp
|
||||
|
||||
def _usage_details_from_openai(self, usage: ResponseUsage) -> UsageDetails | None:
|
||||
return UsageDetails(
|
||||
prompt_tokens=usage.input_tokens,
|
||||
completion_tokens=usage.output_tokens,
|
||||
total_tokens=usage.total_tokens,
|
||||
)
|
||||
|
||||
def _openai_chat_message_parser(
|
||||
self,
|
||||
message: ChatMessage,
|
||||
tool_id_to_call_id: dict[str, str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Parse a chat message into the openai format."""
|
||||
all_messages: list[dict[str, Any]] = []
|
||||
args: dict[str, Any] = {
|
||||
"role": message.role.value if isinstance(message.role, ChatRole) else message.role,
|
||||
}
|
||||
if message.additional_properties:
|
||||
args["metadata"] = message.additional_properties
|
||||
for content in message.contents:
|
||||
match content:
|
||||
case FunctionResultContent():
|
||||
new_args: dict[str, Any] = {}
|
||||
new_args.update(self._openai_content_parser(message.role, content, tool_id_to_call_id))
|
||||
all_messages.append(new_args)
|
||||
case FunctionCallContent():
|
||||
function_call = self._openai_content_parser(message.role, content, tool_id_to_call_id)
|
||||
all_messages.append(function_call) # type: ignore
|
||||
case _:
|
||||
if "content" not in args:
|
||||
args["content"] = []
|
||||
args["content"].append(self._openai_content_parser(message.role, content, tool_id_to_call_id)) # type: ignore
|
||||
if "content" in args or "tool_calls" in args:
|
||||
all_messages.append(args)
|
||||
return all_messages
|
||||
|
||||
def _openai_content_parser(
|
||||
self,
|
||||
role: ChatRole,
|
||||
content: AIContents,
|
||||
tool_id_to_call_id: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
"""Parse contents into the openai format."""
|
||||
match content:
|
||||
case FunctionCallContent():
|
||||
return {
|
||||
"id": content.call_id,
|
||||
"call_id": tool_id_to_call_id[content.call_id],
|
||||
"type": "function_call",
|
||||
"name": content.name,
|
||||
"arguments": content.arguments,
|
||||
}
|
||||
case FunctionResultContent():
|
||||
# call_id for the result needs to be the same as the call_id for the function call
|
||||
return {
|
||||
"call_id": tool_id_to_call_id[content.call_id],
|
||||
"type": "function_call_output",
|
||||
"output": content.result,
|
||||
}
|
||||
case TextContent():
|
||||
return {
|
||||
"type": "output_text" if role == ChatRole.ASSISTANT else "input_text",
|
||||
"text": content.text,
|
||||
}
|
||||
# TODO(peterychang): We'll probably need to specialize the other content types as well
|
||||
case _:
|
||||
return content.model_dump(exclude_none=True)
|
||||
|
||||
def _prepare_chat_history_for_request(self, chat_messages: Sequence[ChatMessage]) -> list[dict[str, Any]]:
|
||||
"""Prepare the chat history for a request.
|
||||
def _prepare_chat_messages_for_request(self, chat_messages: Sequence[ChatMessage]) -> list[dict[str, Any]]:
|
||||
"""Prepare the chat messages for a request.
|
||||
|
||||
Allowing customization of the key names for role/author, and optionally overriding the role.
|
||||
|
||||
@@ -464,24 +421,336 @@ class OpenAIResponsesClientBase(OpenAIHandler, ChatClientBase):
|
||||
chat_messages: The chat history to prepare.
|
||||
|
||||
Returns:
|
||||
prepared_chat_history (Any): The prepared chat history for a request.
|
||||
The prepared chat messages for a request.
|
||||
"""
|
||||
tool_id_to_call_id: dict[str, str] = {}
|
||||
call_id_to_id: dict[str, str] = {}
|
||||
for message in chat_messages:
|
||||
for content in message.contents:
|
||||
if isinstance(content, FunctionCallContent):
|
||||
assert content.additional_properties and "call_id" in content.additional_properties # nosec # noqa: S101
|
||||
call_id = content.additional_properties["call_id"]
|
||||
tool_id_to_call_id[content.call_id] = call_id
|
||||
list_of_list = [self._openai_chat_message_parser(message, tool_id_to_call_id) for message in chat_messages]
|
||||
if (
|
||||
isinstance(content, FunctionCallContent)
|
||||
and content.additional_properties
|
||||
and "fc_id" in content.additional_properties
|
||||
):
|
||||
call_id_to_id[content.call_id] = content.additional_properties["fc_id"]
|
||||
list_of_list = [self._openai_chat_message_parser(message, call_id_to_id) for message in chat_messages]
|
||||
# Flatten the list of lists into a single list
|
||||
return list(chain.from_iterable(list_of_list))
|
||||
|
||||
# region Response creation methods
|
||||
|
||||
def _create_response_content(
|
||||
self,
|
||||
response: OpenAIResponse | ParsedResponse[BaseModel],
|
||||
chat_options: ChatOptions,
|
||||
) -> "ChatResponse":
|
||||
"""Create a chat message content object from a choice."""
|
||||
structured_response: BaseModel | None = response.output_parsed if isinstance(response, ParsedResponse) else None # type: ignore[reportUnknownMemberType]
|
||||
|
||||
metadata: dict[str, Any] = response.metadata or {}
|
||||
contents: list[AIContents] = []
|
||||
for item in response.output: # type: ignore[reportUnknownMemberType]
|
||||
match item.type:
|
||||
# types:
|
||||
# ParsedResponseOutputMessage[Unknown] |
|
||||
# ParsedResponseFunctionToolCall |
|
||||
# ResponseFileSearchToolCall |
|
||||
# ResponseFunctionWebSearch |
|
||||
# ResponseComputerToolCall |
|
||||
# ResponseReasoningItem |
|
||||
# McpCall |
|
||||
# McpApprovalRequest |
|
||||
# ImageGenerationCall |
|
||||
# LocalShellCall |
|
||||
# LocalShellCallAction |
|
||||
# McpListTools |
|
||||
# ResponseCodeInterpreterToolCall |
|
||||
# ResponseCustomToolCall |
|
||||
# ParsedResponseOutputMessage[BaseModel] |
|
||||
# ResponseOutputMessage |
|
||||
# ResponseFunctionToolCall
|
||||
case "message": # ResponseOutputMessage
|
||||
for message_content in item.content: # type: ignore[reportMissingTypeArgument]
|
||||
match message_content.type:
|
||||
case "output_text":
|
||||
text_content = TextContent(
|
||||
text=message_content.text, raw_representation=message_content
|
||||
)
|
||||
metadata.update(self._get_metadata_from_response(message_content))
|
||||
if message_content.annotations:
|
||||
text_content.annotations = []
|
||||
for annotation in message_content.annotations:
|
||||
match annotation.type:
|
||||
case "file_path":
|
||||
text_content.annotations.append(
|
||||
CitationAnnotation(
|
||||
file_id=annotation.file_id,
|
||||
additional_properties={
|
||||
"index": annotation.index,
|
||||
},
|
||||
raw_representation=annotation,
|
||||
)
|
||||
)
|
||||
case "file_citation":
|
||||
text_content.annotations.append(
|
||||
CitationAnnotation(
|
||||
url=annotation.filename,
|
||||
file_id=annotation.file_id,
|
||||
raw_representation=annotation,
|
||||
additional_properties={
|
||||
"index": annotation.index,
|
||||
},
|
||||
)
|
||||
)
|
||||
case "url_citation":
|
||||
text_content.annotations.append(
|
||||
CitationAnnotation(
|
||||
title=annotation.title,
|
||||
url=annotation.url,
|
||||
annotated_regions=[
|
||||
TextSpanRegion(
|
||||
start_index=annotation.start_index,
|
||||
end_index=annotation.end_index,
|
||||
)
|
||||
],
|
||||
raw_representation=annotation,
|
||||
)
|
||||
)
|
||||
case "container_file_citation":
|
||||
text_content.annotations.append(
|
||||
CitationAnnotation(
|
||||
file_id=annotation.file_id,
|
||||
url=annotation.filename,
|
||||
additional_properties={
|
||||
"container_id": annotation.container_id,
|
||||
},
|
||||
annotated_regions=[
|
||||
TextSpanRegion(
|
||||
start_index=annotation.start_index,
|
||||
end_index=annotation.end_index,
|
||||
)
|
||||
],
|
||||
raw_representation=annotation,
|
||||
)
|
||||
)
|
||||
case _:
|
||||
logger.debug("Unparsed annotation type: %s", annotation.type)
|
||||
contents.append(text_content)
|
||||
case "refusal":
|
||||
contents.append(
|
||||
TextContent(text=message_content.refusal, raw_representation=message_content)
|
||||
)
|
||||
case "reasoning": # ResponseOutputReasoning
|
||||
if item.content:
|
||||
for index, reasoning_content in enumerate(item.content):
|
||||
additional_properties = None
|
||||
if item.summary and index < len(item.summary):
|
||||
additional_properties = {"summary": item.summary[index]}
|
||||
contents.append(
|
||||
TextReasoningContent(
|
||||
text=reasoning_content.text,
|
||||
raw_representation=reasoning_content,
|
||||
additional_properties=additional_properties,
|
||||
)
|
||||
)
|
||||
case "code_interpreter_call": # ResponseOutputCodeInterpreterCall
|
||||
if item.outputs:
|
||||
for code_output in item.outputs:
|
||||
if code_output.type == "logs":
|
||||
contents.append(TextContent(text=code_output.logs, raw_representation=item))
|
||||
if code_output.type == "image":
|
||||
contents.append(
|
||||
UriContent(
|
||||
uri=code_output.url,
|
||||
raw_representation=item,
|
||||
# no more specific media type then this can be inferred
|
||||
media_type="image",
|
||||
)
|
||||
)
|
||||
elif item.code:
|
||||
# fallback if no output was returned is the code:
|
||||
contents.append(TextContent(text=item.code, raw_representation=item))
|
||||
case "function_call": # ResponseOutputFunctionCall
|
||||
contents.append(
|
||||
FunctionCallContent(
|
||||
call_id=item.call_id if item.call_id else "",
|
||||
name=item.name,
|
||||
arguments=item.arguments,
|
||||
additional_properties={"fc_id": item.id},
|
||||
raw_representation=item,
|
||||
)
|
||||
)
|
||||
case "image_generation_call": # ResponseOutputImageGenerationCall
|
||||
if item.result:
|
||||
contents.append(
|
||||
DataContent(
|
||||
uri=item.result,
|
||||
raw_representation=item,
|
||||
)
|
||||
)
|
||||
# TODO(peterychang): Add support for other content types
|
||||
case _:
|
||||
logger.debug("Unparsed content of type: %s: %s", item.type, item)
|
||||
response_message = ChatMessage(role="assistant", contents=contents)
|
||||
args: dict[str, Any] = {
|
||||
"response_id": response.id,
|
||||
"created_at": datetime.fromtimestamp(response.created_at).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
||||
"messages": response_message,
|
||||
"model_id": response.model,
|
||||
"additional_properties": metadata,
|
||||
"raw_representation": response,
|
||||
}
|
||||
if chat_options.store:
|
||||
args["conversation_id"] = response.id
|
||||
if response.usage and (usage_details := self._usage_details_from_openai(response.usage)):
|
||||
args["usage_details"] = usage_details
|
||||
if structured_response:
|
||||
args["value"] = structured_response
|
||||
return StructuredResponse(**args)
|
||||
return ChatResponse(**args)
|
||||
|
||||
def _create_streaming_response_content(
|
||||
self,
|
||||
event: OpenAIResponseStreamEvent,
|
||||
chat_options: ChatOptions,
|
||||
function_call_ids: dict[int, tuple[str, str]],
|
||||
) -> ChatResponseUpdate:
|
||||
"""Create a streaming chat message content object from a choice."""
|
||||
metadata: dict[str, Any] = {}
|
||||
items: list[AIContents] = []
|
||||
conversation_id: str | None = None
|
||||
model = self.ai_model_id
|
||||
# TODO(peterychang): Add support for other content types
|
||||
match event:
|
||||
case ResponseContentPartAddedEvent():
|
||||
match event.part:
|
||||
case ResponseOutputText():
|
||||
items.append(TextContent(text=event.part.text, raw_representation=event))
|
||||
metadata.update(self._get_metadata_from_response(event.part))
|
||||
case ResponseOutputRefusal():
|
||||
items.append(TextContent(text=event.part.refusal, raw_representation=event))
|
||||
case ResponseTextDeltaEvent():
|
||||
items.append(TextContent(text=event.delta, raw_representation=event))
|
||||
metadata.update(self._get_metadata_from_response(event))
|
||||
case ResponseCompletedEvent():
|
||||
conversation_id = event.response.id if chat_options.store is True else None
|
||||
model = event.response.model
|
||||
if event.response.usage:
|
||||
usage = self._usage_details_from_openai(event.response.usage)
|
||||
if usage:
|
||||
items.append(UsageContent(details=usage, raw_representation=event))
|
||||
case ResponseOutputItemAddedEvent():
|
||||
if event.item.type == "function_call":
|
||||
function_call_ids[event.output_index] = (event.item.call_id, event.item.name)
|
||||
case ResponseFunctionCallArgumentsDeltaEvent():
|
||||
call_id, name = function_call_ids.get(event.output_index, (None, None))
|
||||
if call_id and name:
|
||||
items.append(
|
||||
FunctionCallContent(
|
||||
call_id=call_id,
|
||||
name=name,
|
||||
arguments=event.delta,
|
||||
additional_properties={"output_index": event.output_index, "fc_id": event.item_id},
|
||||
raw_representation=event,
|
||||
)
|
||||
)
|
||||
case _:
|
||||
logger.debug("Unparsed event: %s", event)
|
||||
|
||||
return ChatResponseUpdate(
|
||||
contents=items,
|
||||
conversation_id=conversation_id,
|
||||
role=ChatRole.ASSISTANT,
|
||||
ai_model_id=model,
|
||||
additional_properties=metadata,
|
||||
raw_representation=event,
|
||||
)
|
||||
|
||||
def _usage_details_from_openai(self, usage: ResponseUsage) -> UsageDetails | None:
|
||||
details = UsageDetails(
|
||||
input_token_count=usage.input_tokens,
|
||||
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
|
||||
if usage.output_tokens_details and usage.output_tokens_details.reasoning_tokens:
|
||||
details["openai.reasoning_tokens"] = usage.output_tokens_details.reasoning_tokens
|
||||
return details
|
||||
|
||||
def _openai_chat_message_parser(
|
||||
self,
|
||||
message: ChatMessage,
|
||||
call_id_to_id: dict[str, str],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Parse a chat message into the openai format."""
|
||||
all_messages: list[dict[str, Any]] = []
|
||||
args: dict[str, Any] = {
|
||||
"role": message.role.value if isinstance(message.role, ChatRole) else message.role,
|
||||
}
|
||||
if message.additional_properties:
|
||||
args["metadata"] = message.additional_properties
|
||||
for content in message.contents:
|
||||
match content:
|
||||
case FunctionResultContent():
|
||||
new_args: dict[str, Any] = {}
|
||||
new_args.update(self._openai_content_parser(message.role, content, call_id_to_id))
|
||||
all_messages.append(new_args)
|
||||
case FunctionCallContent():
|
||||
function_call = self._openai_content_parser(message.role, content, call_id_to_id)
|
||||
all_messages.append(function_call) # type: ignore
|
||||
case _:
|
||||
if "content" not in args:
|
||||
args["content"] = []
|
||||
args["content"].append(self._openai_content_parser(message.role, content, call_id_to_id)) # type: ignore
|
||||
if "content" in args or "tool_calls" in args:
|
||||
all_messages.append(args)
|
||||
return all_messages
|
||||
|
||||
def _openai_content_parser(
|
||||
self,
|
||||
role: ChatRole,
|
||||
content: AIContents,
|
||||
call_id_to_id: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
"""Parse contents into the openai format."""
|
||||
match content:
|
||||
case FunctionCallContent():
|
||||
return {
|
||||
"call_id": content.call_id,
|
||||
"id": call_id_to_id[content.call_id],
|
||||
"type": "function_call",
|
||||
"name": content.name,
|
||||
"arguments": content.arguments,
|
||||
}
|
||||
case FunctionResultContent():
|
||||
# call_id for the result needs to be the same as the call_id for the function call
|
||||
args: dict[str, Any] = {
|
||||
"call_id": content.call_id,
|
||||
"id": call_id_to_id.get(content.call_id),
|
||||
"type": "function_call_output",
|
||||
}
|
||||
if content.result:
|
||||
args["output"] = prepare_function_call_results(content.result)
|
||||
return args
|
||||
case TextContent():
|
||||
return {
|
||||
"type": "output_text" if role == ChatRole.ASSISTANT else "input_text",
|
||||
"text": content.text,
|
||||
}
|
||||
# TODO(peterychang): We'll probably need to specialize the other content types as well
|
||||
case _:
|
||||
return content.model_dump(exclude_none=True)
|
||||
|
||||
def _get_metadata_from_response(self, output: Any) -> dict[str, Any]:
|
||||
"""Get metadata from a chat choice."""
|
||||
return {
|
||||
"logprobs": getattr(output, "logprobs", None),
|
||||
}
|
||||
if logprobs := getattr(output, "logprobs", None):
|
||||
return {
|
||||
"logprobs": logprobs,
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
TOpenAIResponsesClient = TypeVar("TOpenAIResponsesClient", bound="OpenAIResponsesClient")
|
||||
|
||||
|
||||
@use_telemetry
|
||||
@@ -489,8 +758,6 @@ class OpenAIResponsesClientBase(OpenAIHandler, ChatClientBase):
|
||||
class OpenAIResponsesClient(OpenAIConfigBase, OpenAIResponsesClientBase):
|
||||
"""OpenAI Responses client class."""
|
||||
|
||||
MODEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ai_model_id: str | None = None,
|
||||
@@ -540,25 +807,19 @@ class OpenAIResponsesClient(OpenAIConfigBase, OpenAIResponsesClientBase):
|
||||
ai_model_id=openai_settings.responses_model_id,
|
||||
api_key=openai_settings.api_key.get_secret_value() if openai_settings.api_key else None,
|
||||
org_id=openai_settings.org_id,
|
||||
ai_model_type=OpenAIModelTypes.RESPONSE,
|
||||
default_headers=default_headers,
|
||||
client=async_client,
|
||||
instruction_role=instruction_role,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, settings: dict[str, Any]) -> "OpenAIResponsesClient":
|
||||
def from_dict(cls: type[TOpenAIResponsesClient], settings: dict[str, Any]) -> TOpenAIResponsesClient:
|
||||
"""Initialize an Open AI service from a dictionary of settings.
|
||||
|
||||
Args:
|
||||
settings: A dictionary of settings for the service.
|
||||
"""
|
||||
return OpenAIResponsesClient(
|
||||
ai_model_id=settings["ai_model_id"],
|
||||
default_headers=settings.get("default_headers"),
|
||||
api_key=settings.get("api_key"),
|
||||
org_id=settings.get("org_id"),
|
||||
)
|
||||
return cls(**settings)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from abc import ABC
|
||||
from collections.abc import Mapping
|
||||
from copy import copy
|
||||
from enum import Enum
|
||||
from typing import Annotated, Any, ClassVar, Union
|
||||
|
||||
from openai import (
|
||||
AsyncOpenAI,
|
||||
AsyncStream,
|
||||
BadRequestError,
|
||||
_legacy_response, # type: ignore
|
||||
)
|
||||
from openai.lib._parsing._completions import type_to_response_format_param
|
||||
from openai.types import Completion
|
||||
from openai.types.audio import Transcription
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionChunk
|
||||
@@ -25,10 +22,9 @@ from pydantic.types import StringConstraints
|
||||
|
||||
from .._logging import get_logger
|
||||
from .._pydantic import AFBaseModel, AFBaseSettings
|
||||
from .._types import ChatOptions, SpeechToTextOptions, TextToSpeechOptions
|
||||
from ..exceptions import ServiceInitializationError, ServiceInvalidRequestError, ServiceResponseException
|
||||
from .._types import AIContents, ChatOptions, SpeechToTextOptions, TextToSpeechOptions
|
||||
from ..exceptions import ServiceInitializationError
|
||||
from ..telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent
|
||||
from ._exceptions import OpenAIContentFilterException
|
||||
|
||||
logger: logging.Logger = get_logger("agent_framework.openai")
|
||||
|
||||
@@ -46,11 +42,7 @@ RESPONSE_TYPE = Union[
|
||||
_legacy_response.HttpxBinaryResponseContent,
|
||||
]
|
||||
|
||||
OPTION_TYPE = Union[
|
||||
ChatOptions,
|
||||
SpeechToTextOptions,
|
||||
TextToSpeechOptions,
|
||||
]
|
||||
OPTION_TYPE = Union[ChatOptions, SpeechToTextOptions, TextToSpeechOptions, dict[str, Any]]
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -58,6 +50,23 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def prepare_function_call_results(content: AIContents | Any | list[AIContents | Any]) -> str | list[str]:
|
||||
"""Prepare the values of the function call results."""
|
||||
if isinstance(content, list):
|
||||
results: list[str] = []
|
||||
for item in content:
|
||||
res = prepare_function_call_results(item)
|
||||
if isinstance(res, list):
|
||||
results.extend(res)
|
||||
else:
|
||||
results.append(res)
|
||||
return results[0] if len(results) == 1 else results
|
||||
if isinstance(content, BaseModel):
|
||||
return content.model_dump_json(exclude_none=True, exclude={"raw_representation", "additional_properties"})
|
||||
# fallback
|
||||
return json.dumps(content)
|
||||
|
||||
|
||||
class OpenAISettings(AFBaseSettings):
|
||||
"""OpenAI environment settings.
|
||||
|
||||
@@ -108,177 +117,23 @@ class OpenAISettings(AFBaseSettings):
|
||||
realtime_model_id: str | None = None
|
||||
|
||||
|
||||
class OpenAIModelTypes(Enum):
|
||||
"""OpenAI model types, can be text, chat or embedding."""
|
||||
|
||||
CHAT = "chat"
|
||||
EMBEDDING = "embedding"
|
||||
TEXT_TO_IMAGE = "text-to-image"
|
||||
SPEECH_TO_TEXT = "speech-to-text"
|
||||
TEXT_TO_SPEECH = "text-to-speech"
|
||||
REALTIME = "realtime"
|
||||
RESPONSE = "response"
|
||||
|
||||
|
||||
class OpenAIHandler(AFBaseModel, ABC):
|
||||
"""Internal class for calls to OpenAI API's."""
|
||||
class OpenAIHandler(AFBaseModel):
|
||||
"""Base class for OpenAI Clients."""
|
||||
|
||||
client: AsyncOpenAI
|
||||
ai_model_id: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
ai_model_type: OpenAIModelTypes = OpenAIModelTypes.CHAT
|
||||
|
||||
async def _send_request(self, options: OPTION_TYPE, messages: list[dict[str, Any]] | None = None) -> RESPONSE_TYPE:
|
||||
"""Send a request to the OpenAI API."""
|
||||
if self.ai_model_type == OpenAIModelTypes.CHAT:
|
||||
assert isinstance(options, ChatOptions) # nosec # noqa: S101
|
||||
return await self._send_completion_request(options, messages)
|
||||
# TODO(evmattso): move other PromptExecutionSettings to a common options class
|
||||
if self.ai_model_type == OpenAIModelTypes.EMBEDDING:
|
||||
raise NotImplementedError("Embedding generation is not yet implemented in OpenAIHandler")
|
||||
if self.ai_model_type == OpenAIModelTypes.TEXT_TO_IMAGE:
|
||||
raise NotImplementedError("Text to image generation is not yet implemented in OpenAIHandler")
|
||||
if self.ai_model_type == OpenAIModelTypes.SPEECH_TO_TEXT:
|
||||
assert isinstance(options, SpeechToTextOptions) # nosec # noqa: S101
|
||||
return await self._send_audio_to_text_request(options)
|
||||
if self.ai_model_type == OpenAIModelTypes.TEXT_TO_SPEECH:
|
||||
assert isinstance(options, TextToSpeechOptions) # nosec # noqa: S101
|
||||
return await self._send_text_to_audio_request(options)
|
||||
if self.ai_model_type == OpenAIModelTypes.RESPONSE:
|
||||
assert isinstance(options, ChatOptions) # nosec # noqa: S101
|
||||
return await self._send_response_request(options, messages)
|
||||
|
||||
raise NotImplementedError(f"Model type {self.ai_model_type} is not supported")
|
||||
|
||||
async def _send_completion_request(
|
||||
self,
|
||||
chat_options: "ChatOptions",
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
) -> ChatCompletion | AsyncStream[ChatCompletionChunk]:
|
||||
"""Execute the appropriate call to OpenAI models."""
|
||||
try:
|
||||
options_dict = chat_options.to_provider_settings()
|
||||
if messages and "messages" not in options_dict:
|
||||
options_dict["messages"] = messages
|
||||
if "messages" not in options_dict:
|
||||
raise ServiceInvalidRequestError("Messages are required for chat completions")
|
||||
self._handle_structured_outputs(chat_options, options_dict)
|
||||
if chat_options.tools is None:
|
||||
options_dict.pop("parallel_tool_calls", None)
|
||||
return await self.client.chat.completions.create(**options_dict) # type: ignore
|
||||
except BadRequestError as ex:
|
||||
if ex.code == "content_filter":
|
||||
raise OpenAIContentFilterException(
|
||||
f"{type(self)} service encountered a content error",
|
||||
ex,
|
||||
) from ex
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to complete the prompt",
|
||||
ex,
|
||||
) from ex
|
||||
except Exception as ex:
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to complete the prompt",
|
||||
ex,
|
||||
) from ex
|
||||
|
||||
async def _send_audio_to_text_request(self, options: SpeechToTextOptions) -> Transcription:
|
||||
"""Send a request to the OpenAI audio to text endpoint."""
|
||||
if not options.additional_properties["filename"]:
|
||||
raise ServiceInvalidRequestError("Audio file is required for audio to text service")
|
||||
|
||||
try:
|
||||
# TODO(peterychang): open isn't async safe
|
||||
with open(options.additional_properties["filename"], "rb") as audio_file: # noqa: ASYNC230
|
||||
return await self.client.audio.transcriptions.create( # type: ignore
|
||||
file=audio_file,
|
||||
**options.to_provider_settings(exclude={"filename"}),
|
||||
)
|
||||
except Exception as ex:
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to transcribe audio",
|
||||
ex,
|
||||
) from ex
|
||||
|
||||
async def _send_text_to_audio_request(
|
||||
self, options: TextToSpeechOptions
|
||||
) -> _legacy_response.HttpxBinaryResponseContent:
|
||||
"""Send a request to the OpenAI text to audio endpoint.
|
||||
|
||||
The OpenAI API returns the content of the generated audio file.
|
||||
"""
|
||||
try:
|
||||
return await self.client.audio.speech.create(
|
||||
**options.to_provider_settings(),
|
||||
)
|
||||
except Exception as ex:
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to generate audio",
|
||||
ex,
|
||||
) from ex
|
||||
|
||||
async def _send_response_request(
|
||||
self,
|
||||
chat_options: "ChatOptions",
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
) -> Response | AsyncStream[ResponseStreamEvent]:
|
||||
try:
|
||||
options_dict = chat_options.to_provider_settings()
|
||||
if messages and "input" not in options_dict:
|
||||
options_dict["input"] = messages
|
||||
if "input" not in options_dict:
|
||||
raise ServiceInvalidRequestError("Messages are required for chat completions")
|
||||
if chat_options.tools is None:
|
||||
options_dict.pop("parallel_tool_calls", None)
|
||||
else:
|
||||
options_dict["tools"] = options_dict["response_tools"]
|
||||
options_dict.pop("response_tools", None)
|
||||
if chat_options.response_format:
|
||||
# create call does not support response_format, so we need to handle it via parse call
|
||||
resp_format = options_dict.pop("response_format", None)
|
||||
return await self.client.responses.parse(
|
||||
**options_dict,
|
||||
text_format=resp_format,
|
||||
)
|
||||
if "store" not in options_dict:
|
||||
options_dict["store"] = False
|
||||
if "conversation_id" in options_dict:
|
||||
options_dict["previous_response_id"] = options_dict["conversation_id"]
|
||||
options_dict.pop("conversation_id")
|
||||
return await self.client.responses.create(**options_dict) # type: ignore
|
||||
except BadRequestError as ex:
|
||||
if ex.code == "content_filter":
|
||||
raise OpenAIContentFilterException(
|
||||
f"{type(self)} service encountered a content error",
|
||||
ex,
|
||||
) from ex
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to complete the prompt",
|
||||
ex,
|
||||
) from ex
|
||||
except Exception as ex:
|
||||
raise ServiceResponseException(
|
||||
f"{type(self)} service failed to complete the prompt",
|
||||
ex,
|
||||
) from ex
|
||||
|
||||
def _handle_structured_outputs(self, chat_options: "ChatOptions", options_dict: dict[str, Any]) -> None:
|
||||
if (
|
||||
chat_options.response_format
|
||||
and isinstance(chat_options.response_format, type)
|
||||
and issubclass(chat_options.response_format, BaseModel)
|
||||
):
|
||||
options_dict["response_format"] = type_to_response_format_param(chat_options.response_format)
|
||||
|
||||
|
||||
class OpenAIConfigBase(OpenAIHandler):
|
||||
"""Internal class for configuring a connection to an OpenAI service."""
|
||||
|
||||
MODEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
def __init__(
|
||||
self,
|
||||
ai_model_id: str = Field(min_length=1),
|
||||
api_key: str | None = Field(min_length=1),
|
||||
ai_model_type: OpenAIModelTypes | None = OpenAIModelTypes.CHAT,
|
||||
org_id: str | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
client: AsyncOpenAI | None = None,
|
||||
@@ -295,8 +150,6 @@ class OpenAIConfigBase(OpenAIHandler):
|
||||
Default to a preset value.
|
||||
api_key (str): OpenAI API key for authentication.
|
||||
Must be non-empty. (Optional)
|
||||
ai_model_type (OpenAIModelTypes): The type of OpenAI
|
||||
model to interact with. Defaults to CHAT.
|
||||
org_id (str): OpenAI organization ID. This is optional
|
||||
unless the account belongs to multiple organizations.
|
||||
default_headers (Mapping[str, str]): Default headers
|
||||
@@ -324,7 +177,6 @@ class OpenAIConfigBase(OpenAIHandler):
|
||||
args = {
|
||||
"ai_model_id": ai_model_id,
|
||||
"client": client,
|
||||
"ai_model_type": ai_model_type,
|
||||
}
|
||||
if instruction_role:
|
||||
args["instruction_role"] = instruction_role
|
||||
@@ -344,7 +196,6 @@ class OpenAIConfigBase(OpenAIHandler):
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
"api_type",
|
||||
"ai_model_type",
|
||||
"client",
|
||||
},
|
||||
by_alias=True,
|
||||
|
||||
@@ -138,6 +138,7 @@ class GenAIAttributes(str, Enum):
|
||||
|
||||
# Agent Framework specific attributes
|
||||
MEASUREMENT_FUNCTION_TAG_NAME = "agent_framework.function.name"
|
||||
MEASUREMENT_FUNCTION_INVOCATION_DURATION = "agent_framework.function.invocation.duration"
|
||||
AGENT_FRAMEWORK_GEN_AI_SYSTEM = "microsoft.agent_framework"
|
||||
|
||||
|
||||
|
||||
@@ -305,48 +305,3 @@ async def test_base_client_with_streaming_function_calling_disabled(chat_client_
|
||||
updates.append(update)
|
||||
assert len(updates) == 1
|
||||
assert exec_counter == 0
|
||||
|
||||
|
||||
def test_chat_options_parsing_tools(chat_client_base, ai_function_tool) -> None:
|
||||
"""Test that chat options can parse tools correctly."""
|
||||
|
||||
def echo() -> str:
|
||||
"""Echo the input."""
|
||||
return "Echo"
|
||||
|
||||
dict_function = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Retrieves current weather for the given location.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string", "description": "City and country e.g. Bogotá, Colombia"},
|
||||
"units": {
|
||||
"type": "string",
|
||||
"enum": ["celsius", "fahrenheit"],
|
||||
"description": "Units the temperature will be returned in.",
|
||||
},
|
||||
},
|
||||
"required": ["location", "units"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"strict": True,
|
||||
},
|
||||
}
|
||||
|
||||
options = ChatOptions(tools=[ai_function_tool, echo, dict_function], tool_choice="auto")
|
||||
assert len(options.tools) == 3
|
||||
assert options.tools[0] == ai_function_tool
|
||||
assert options.tools[1] != echo
|
||||
assert options.tools[2] == dict_function
|
||||
# after prepare, the tools should be represented as dicts
|
||||
# while ai_tools is still the same.
|
||||
chat_client_base._prepare_tools_and_tool_choice(chat_options=options)
|
||||
assert options._ai_tools[0] == ai_function_tool
|
||||
assert options._ai_tools[2] == dict_function
|
||||
assert len(options.tools) == 3
|
||||
assert options.tools[0]["function"]["name"] == "simple_function"
|
||||
assert options.tools[1]["function"]["name"] == "echo"
|
||||
assert options.tools[2]["function"]["name"] == "get_weather"
|
||||
|
||||
@@ -95,7 +95,7 @@ async def test_ai_function_invoke_telemetry_enabled():
|
||||
|
||||
# Mock the histogram
|
||||
mock_histogram = Mock()
|
||||
telemetry_test_tool.invocation_duration_histogram = mock_histogram
|
||||
telemetry_test_tool._invocation_duration_histogram = mock_histogram
|
||||
|
||||
# Call invoke
|
||||
result = await telemetry_test_tool.invoke(x=1, y=2, tool_call_id="test_call_id")
|
||||
@@ -139,7 +139,7 @@ async def test_ai_function_invoke_telemetry_with_pydantic_args():
|
||||
mock_start_span.return_value = mock_context_manager
|
||||
|
||||
mock_histogram = Mock()
|
||||
pydantic_test_tool.invocation_duration_histogram = mock_histogram
|
||||
pydantic_test_tool._invocation_duration_histogram = mock_histogram
|
||||
|
||||
# Call invoke with Pydantic model
|
||||
result = await pydantic_test_tool.invoke(arguments=args_model, tool_call_id="pydantic_call")
|
||||
@@ -172,7 +172,7 @@ async def test_ai_function_invoke_telemetry_with_exception():
|
||||
mock_start_span.return_value = mock_context_manager
|
||||
|
||||
mock_histogram = Mock()
|
||||
exception_test_tool.invocation_duration_histogram = mock_histogram
|
||||
exception_test_tool._invocation_duration_histogram = mock_histogram
|
||||
|
||||
# Call invoke and expect exception
|
||||
with pytest.raises(ValueError, match="Test exception for telemetry"):
|
||||
@@ -212,7 +212,7 @@ async def test_ai_function_invoke_telemetry_async_function():
|
||||
mock_start_span.return_value = mock_context_manager
|
||||
|
||||
mock_histogram = Mock()
|
||||
async_telemetry_test.invocation_duration_histogram = mock_histogram
|
||||
async_telemetry_test._invocation_duration_histogram = mock_histogram
|
||||
|
||||
# Call invoke
|
||||
result = await async_telemetry_test.invoke(x=3, y=4, tool_call_id="async_call")
|
||||
@@ -251,7 +251,7 @@ async def test_ai_function_invoke_telemetry_no_tool_call_id():
|
||||
mock_start_span.return_value = mock_context_manager
|
||||
|
||||
mock_histogram = Mock()
|
||||
no_id_test_tool.invocation_duration_histogram = mock_histogram
|
||||
no_id_test_tool._invocation_duration_histogram = mock_histogram
|
||||
|
||||
# Call invoke without tool_call_id
|
||||
result = await no_id_test_tool.invoke(x=5)
|
||||
@@ -300,29 +300,19 @@ def test_hosted_code_interpreter_tool_default():
|
||||
|
||||
assert tool.name == "code_interpreter"
|
||||
assert tool.inputs == []
|
||||
assert tool.description is None
|
||||
assert tool.description == ""
|
||||
assert tool.additional_properties is None
|
||||
assert str(tool) == "HostedCodeInterpreterTool(name=code_interpreter)"
|
||||
|
||||
|
||||
def test_hosted_code_interpreter_tool_custom_name():
|
||||
"""Test HostedCodeInterpreterTool with custom name."""
|
||||
tool = HostedCodeInterpreterTool(name="custom_interpreter")
|
||||
|
||||
assert tool.name == "custom_interpreter"
|
||||
assert tool.inputs == []
|
||||
assert str(tool) == "HostedCodeInterpreterTool(name=custom_interpreter)"
|
||||
|
||||
|
||||
def test_hosted_code_interpreter_tool_with_description():
|
||||
"""Test HostedCodeInterpreterTool with description and additional properties."""
|
||||
tool = HostedCodeInterpreterTool(
|
||||
name="test_interpreter",
|
||||
description="A test code interpreter",
|
||||
additional_properties={"version": "1.0", "language": "python"},
|
||||
)
|
||||
|
||||
assert tool.name == "test_interpreter"
|
||||
assert tool.name == "code_interpreter"
|
||||
assert tool.description == "A test code interpreter"
|
||||
assert tool.additional_properties == {"version": "1.0", "language": "python"}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from agent_framework import (
|
||||
AIAnnotation,
|
||||
AIContent,
|
||||
AIContents,
|
||||
AIFunction,
|
||||
AITool,
|
||||
AnnotatedRegion,
|
||||
ChatFinishReason,
|
||||
@@ -723,11 +724,12 @@ def test_chat_options_init_with_args(ai_function_tool, ai_tool) -> None:
|
||||
assert options.presence_penalty == 0.0
|
||||
assert options.frequency_penalty == 0.0
|
||||
assert options.user == "user-123"
|
||||
for tool in options._ai_tools:
|
||||
for tool in options.tools:
|
||||
assert isinstance(tool, AITool)
|
||||
assert tool.name is not None
|
||||
assert tool.description is not None
|
||||
assert tool.parameters() is not None
|
||||
if isinstance(tool, AIFunction):
|
||||
assert tool.parameters() is not None
|
||||
|
||||
settings = options.to_provider_settings()
|
||||
assert settings["model"] == "gpt-4" # uses alias
|
||||
@@ -754,8 +756,6 @@ def test_chat_options_and(ai_function_tool, ai_tool) -> None:
|
||||
options3 = options1 & options2
|
||||
|
||||
assert options3.ai_model_id == "gpt-4.1"
|
||||
assert len(options3._ai_tools) == 2
|
||||
assert options3._ai_tools == [ai_function_tool, ai_tool]
|
||||
assert options3.tools == [ai_function_tool, ai_tool]
|
||||
assert options3.logit_bias == {"x": 1}
|
||||
assert options3.metadata == {"a": "b"}
|
||||
|
||||
@@ -15,7 +15,6 @@ from pydantic import BaseModel
|
||||
|
||||
from agent_framework import ChatMessage, ChatResponseUpdate
|
||||
from agent_framework.exceptions import (
|
||||
ServiceInvalidResponseError,
|
||||
ServiceResponseException,
|
||||
)
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
@@ -192,7 +191,7 @@ async def test_cmc_general_exception(
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_scmc(
|
||||
async def test_get_streaming(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[ChatMessage],
|
||||
openai_unit_test_env: dict[str, str],
|
||||
@@ -231,7 +230,7 @@ async def test_scmc(
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_scmc_singular(
|
||||
async def test_get_streaming_singular(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[ChatMessage],
|
||||
openai_unit_test_env: dict[str, str],
|
||||
@@ -270,7 +269,7 @@ async def test_scmc_singular(
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_scmc_structured_output_no_fcc(
|
||||
async def test_get_streaming_structured_output_no_fcc(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[ChatMessage],
|
||||
openai_unit_test_env: dict[str, str],
|
||||
@@ -308,7 +307,7 @@ async def test_scmc_structured_output_no_fcc(
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_scmc_no_fcc_in_response(
|
||||
async def test_get_streaming_no_fcc_in_response(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[ChatMessage],
|
||||
mock_streaming_chat_completion_response: ChatCompletion,
|
||||
@@ -334,7 +333,7 @@ async def test_scmc_no_fcc_in_response(
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_scmc_no_stream(
|
||||
async def test_get_streaming_no_stream(
|
||||
mock_create: AsyncMock,
|
||||
chat_history: list[ChatMessage],
|
||||
openai_unit_test_env: dict[str, str],
|
||||
@@ -344,7 +343,7 @@ async def test_scmc_no_stream(
|
||||
chat_history.append(ChatMessage(role="user", text="hello world"))
|
||||
|
||||
openai_chat_completion = OpenAIChatClient()
|
||||
with pytest.raises(ServiceInvalidResponseError):
|
||||
with pytest.raises(ServiceResponseException):
|
||||
[
|
||||
msg
|
||||
async for msg in openai_chat_completion.get_streaming_response(
|
||||
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework import ChatClient, ChatMessage, ChatResponse, ChatResponseUpdate, TextContent, ai_function
|
||||
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
|
||||
skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
|
||||
@@ -247,23 +247,21 @@ async def test_openai_responses_client_streaming() -> None:
|
||||
messages.append(ChatMessage(role="user", text="The weather in Seattle is sunny"))
|
||||
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
|
||||
|
||||
# This is currently broken. See https://github.com/openai/openai-python/issues/2305
|
||||
with pytest.raises(ServiceResponseException):
|
||||
response = openai_responses_client.get_streaming_response(
|
||||
messages=messages,
|
||||
response_format=OutputStruct,
|
||||
)
|
||||
full_message = ""
|
||||
async for chunk in response:
|
||||
assert chunk is not None
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
for content in chunk.contents:
|
||||
if isinstance(content, TextContent) and content.text:
|
||||
full_message += content.text
|
||||
response = openai_responses_client.get_streaming_response(
|
||||
messages=messages,
|
||||
response_format=OutputStruct,
|
||||
)
|
||||
full_message = ""
|
||||
async for chunk in response:
|
||||
assert chunk is not None
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
for content in chunk.contents:
|
||||
if isinstance(content, TextContent) and content.text:
|
||||
full_message += content.text
|
||||
|
||||
output = OutputStruct.model_validate_json(full_message)
|
||||
assert "Seattle" in output.location
|
||||
assert "sunny" in output.weather
|
||||
output = OutputStruct.model_validate_json(full_message)
|
||||
assert "Seattle" in output.location
|
||||
assert "sunny" in output.weather
|
||||
|
||||
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
@@ -294,22 +292,20 @@ async def test_openai_responses_client_streaming_tools() -> None:
|
||||
messages.clear()
|
||||
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
|
||||
|
||||
# This is currently broken. See https://github.com/openai/openai-python/issues/2305
|
||||
with pytest.raises(ServiceResponseException):
|
||||
response = openai_responses_client.get_streaming_response(
|
||||
messages=messages,
|
||||
tools=[get_weather],
|
||||
tool_choice="auto",
|
||||
response_format=OutputStruct,
|
||||
)
|
||||
full_message = ""
|
||||
async for chunk in response:
|
||||
assert chunk is not None
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
for content in chunk.contents:
|
||||
if isinstance(content, TextContent) and content.text:
|
||||
full_message += content.text
|
||||
response = openai_responses_client.get_streaming_response(
|
||||
messages=messages,
|
||||
tools=[get_weather],
|
||||
tool_choice="auto",
|
||||
response_format=OutputStruct,
|
||||
)
|
||||
full_message = ""
|
||||
async for chunk in response:
|
||||
assert chunk is not None
|
||||
assert isinstance(chunk, ChatResponseUpdate)
|
||||
for content in chunk.contents:
|
||||
if isinstance(content, TextContent) and content.text:
|
||||
full_message += content.text
|
||||
|
||||
output = OutputStruct.model_validate_json(full_message)
|
||||
assert "Seattle" in output.location
|
||||
assert "sunny" in output.weather
|
||||
output = OutputStruct.model_validate_json(full_message)
|
||||
assert "Seattle" in output.location
|
||||
assert "sunny" in output.weather
|
||||
|
||||
Reference in New Issue
Block a user