Python: follow on work on OpenAI (#169)

* updated openai, fcc works, with sample

* reduced files in openai
This commit is contained in:
Eduard van Valkenburg
2025-07-12 07:15:51 +02:00
committed by GitHub
Unverified
parent 80c1e2ee0a
commit 407ed6de70
32 changed files with 693 additions and 668 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather for a given location.",
"parameters": {
"properties": {
"location": {
"title": "Location",
"type": "string"
}
},
"required": [
"location"
],
"title": "get_weather_input",
"type": "object"
}
}
}
+2 -4
View File
@@ -1,8 +1,4 @@
{
"editor.formatOnType": true,
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.defaultFormatter": "charliermarsh.ruff",
"cSpell.languageSettings": [
{
"languageId": "py",
@@ -16,6 +12,8 @@
"source.fixAll": "explicit"
},
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.formatOnType": true,
"editor.defaultFormatter": "charliermarsh.ruff"
},
"python.analysis.autoFormatStrings": true,
-1
View File
@@ -24,7 +24,6 @@ classifiers = [
]
dependencies = [
"agent-framework",
"agent-framework-openai"
]
[tool.pytest.ini_options]
@@ -19,6 +19,7 @@ _IMPORTS = {
"AgentThread": "._agents",
"AITool": "._tools",
"ai_function": "._tools",
"AIFunction": "._tools",
"AIContent": "._types",
"AIContents": "._types",
"ChatClientAgent": "._agents",
@@ -5,7 +5,7 @@ from ._agents import Agent, AgentThread, ChatClientAgent, ChatClientAgentThread,
from ._clients import ChatClient, ChatClientBase, EmbeddingGenerator, use_tool_calling
from ._logging import get_logger
from ._pydantic import AFBaseModel, AFBaseSettings
from ._tools import AITool, ai_function
from ._tools import AIFunction, AITool, ai_function
from ._types import (
AgentRunResponse,
AgentRunResponseUpdate,
@@ -39,6 +39,7 @@ __all__ = [
"AFBaseSettings",
"AIContent",
"AIContents",
"AIFunction",
"AITool",
"Agent",
"AgentRunResponse",
@@ -108,7 +108,10 @@ def _prepare_tools_and_tool_choice(chat_options: ChatOptions) -> None:
chat_options.tools = [
(_tool_to_json_schema_spec(t) if isinstance(t, AITool) else t) for t in chat_options.tools or []
]
chat_options.tool_choice = chat_tool_mode.mode
if not chat_options.tools:
chat_options.tool_choice = ChatToolMode.NONE.mode
else:
chat_options.tool_choice = chat_tool_mode.mode
def _tool_call_non_streaming(func: TInnerGetResponse) -> TInnerGetResponse:
@@ -208,8 +211,9 @@ def _tool_call_streaming(func: TInnerGetStreamingResponse) -> TInnerGetStreaming
# the full completion depending on the prompt, the message may contain both function call
# content and others
response: ChatResponse = ChatResponse.from_chat_response_updates(all_messages)
function_calls = [item for item in response.messages[0].contents if isinstance(item, FunctionCallContent)]
# add the single assistant response message to the history
messages.append(response.messages[0])
function_calls = [item for item in response.messages[0].contents if isinstance(item, FunctionCallContent)]
if function_calls:
# Run all function calls concurrently
@@ -224,8 +228,9 @@ def _tool_call_streaming(func: TInnerGetStreamingResponse) -> TInnerGetStreaming
for seq_idx, function_call in enumerate(function_calls)
])
yield ChatResponseUpdate(contents=results, role="tool")
response.messages.append(ChatMessage(role="tool", contents=results))
messages.extend(response.messages)
function_result_msg = ChatMessage(role="tool", contents=results)
response.messages.append(function_result_msg)
messages.append(function_result_msg)
continue
# Failsafe: give up on tools, ask model for plain answer
@@ -238,11 +243,17 @@ def _tool_call_streaming(func: TInnerGetStreamingResponse) -> TInnerGetStreaming
def use_tool_calling(cls: type[TChatClientBase]) -> type[TChatClientBase]:
inner_response = getattr(cls, "_inner_get_response", None)
if inner_response is not None:
"""Class decorator that enables tool calling for a chat client.
Remarks:
This only works on classes that derive from ChatClientBase
and have the _tool_map attribute as well as the _inner_get_response
and _inner_get_streaming_response methods.
"""
if inner_response := getattr(cls, "_inner_get_response", None):
cls._inner_get_response = _tool_call_non_streaming(inner_response) # type: ignore
inner_streaming_response = getattr(cls, "_inner_get_streaming_response", None)
if inner_streaming_response is not None:
if inner_streaming_response := getattr(cls, "_inner_get_streaming_response", None):
cls._inner_get_streaming_response = _tool_call_streaming(inner_streaming_response) # type: ignore
return cls
@@ -303,6 +314,17 @@ class ChatClientBase(AFBaseModel, ABC):
maximum_iterations_per_request: int = 10
_tool_map: dict[str, AIFunction[BaseModel, Any]] = PrivateAttr(default_factory=dict) # type: ignore
def _prepare_messages(self, messages: str | ChatMessage | list[str | ChatMessage]) -> MutableSequence[ChatMessage]:
"""Turn the allowed input into a list of chat messages."""
if isinstance(messages, str):
messages = [ChatMessage(role="user", text=messages)]
if isinstance(messages, ChatMessage):
messages = [messages]
for i, msg in enumerate(messages):
if isinstance(msg, str):
messages[i] = ChatMessage(role="user", text=msg)
return messages # type: ignore[return-value]
# region Internal methods to be implemented by the derived classes
@abstractmethod
@@ -355,14 +377,14 @@ class ChatClientBase(AFBaseModel, ABC):
async def get_response(
self,
messages: str | ChatMessage | list[ChatMessage],
messages: str | ChatMessage | list[str | ChatMessage],
*,
model: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
top_p: float | None = None,
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: Sequence[AITool] | None = None,
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tools: AITool | Sequence[AITool] | None = None,
response_format: type[BaseModel] | None = None,
user: str | None = None,
stop: str | Sequence[str] | None = None,
@@ -402,6 +424,8 @@ class ChatClientBase(AFBaseModel, ABC):
A chat response from the model.
"""
if tools is not None:
if not isinstance(tools, Sequence):
tools = [tools]
self._tool_map = {tool.name: tool for tool in tools if isinstance(tool, AIFunction)}
chat_options = ChatOptions(
ai_model_id=model,
@@ -421,23 +445,20 @@ class ChatClientBase(AFBaseModel, ABC):
metadata=metadata,
additional_properties=additional_properties or {},
)
if isinstance(messages, str):
messages = [ChatMessage(role="user", text=messages)]
if isinstance(messages, ChatMessage):
messages = [messages]
prepped_messages = self._prepare_messages(messages)
_prepare_tools_and_tool_choice(chat_options=chat_options)
return await self._inner_get_response(messages=messages, chat_options=chat_options, **kwargs)
return await self._inner_get_response(messages=prepped_messages, chat_options=chat_options, **kwargs)
async def get_streaming_response(
self,
messages: str | ChatMessage | list[ChatMessage],
messages: str | ChatMessage | list[str | ChatMessage],
*,
model: str | None = None,
max_tokens: int | None = None,
temperature: float | None = None,
top_p: float | None = None,
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: Sequence[AITool] | None = None,
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tools: AITool | Sequence[AITool] | None = None,
response_format: type[BaseModel] | None = None,
user: str | None = None,
stop: str | Sequence[str] | None = None,
@@ -476,6 +497,8 @@ class ChatClientBase(AFBaseModel, ABC):
A stream representing the response(s) from the LLM.
"""
if tools is not None:
if not isinstance(tools, Sequence):
tools = [tools]
self._tool_map = {tool.name: tool for tool in tools if isinstance(tool, AIFunction)}
chat_options = ChatOptions(
ai_model_id=model,
@@ -496,12 +519,11 @@ class ChatClientBase(AFBaseModel, ABC):
additional_properties=additional_properties or {},
**kwargs,
)
if isinstance(messages, str):
messages = [ChatMessage(role="user", text=messages)]
if isinstance(messages, ChatMessage):
messages = [messages]
prepped_messages = self._prepare_messages(messages)
_prepare_tools_and_tool_choice(chat_options=chat_options)
async for update in self._inner_get_streaming_response(messages=messages, chat_options=chat_options, **kwargs):
async for update in self._inner_get_streaming_response(
messages=prepped_messages, chat_options=chat_options, **kwargs
):
yield update
+37 -26
View File
@@ -1,8 +1,8 @@
# Copyright (c) Microsoft. All rights reserved.
import functools
import inspect
from collections.abc import Awaitable, Callable, Mapping
from functools import wraps
from typing import Any, Generic, Protocol, TypeVar, runtime_checkable
from pydantic import BaseModel, create_model
@@ -10,7 +10,7 @@ from pydantic import BaseModel, create_model
@runtime_checkable
class AITool(Protocol):
"""Represents a tool that can be specified to an AI service."""
"""Represents a generic tool that can be specified to an AI service."""
name: str
"""The name of the tool."""
@@ -33,7 +33,7 @@ ReturnT = TypeVar("ReturnT")
class AIFunction(AITool, Generic[ArgsT, ReturnT]):
"""A tool that represents a function that can be called by an AI service."""
"""A AITool that is callable as code."""
def __init__(
self,
@@ -98,8 +98,18 @@ def ai_function(
name: str | None = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
) -> AIFunction[Any, ReturnT] | Callable[[Callable[..., ReturnT | Awaitable[ReturnT]]], AIFunction[Any, ReturnT]]:
"""Decorate a function to turn it into a AIFunction that can be passed to models.
) -> AIFunction[Any, ReturnT]:
"""Decorate a function to turn it into a AIFunction that can be passed to models and executed automatically.
Remarks:
In order to add descriptions to parameters, use:
```python
from typing import Annotated
from pydantic import Field
arg: Annotated[<type>, Field(description="<description>")]
```
Args:
func: The function to wrap. If None, returns a decorator.
@@ -109,31 +119,32 @@ def ai_function(
"""
def wrapper(f: Callable[..., ReturnT | Awaitable[ReturnT]]) -> AIFunction[Any, ReturnT]:
tool_name: str = name or getattr(f, "__name__", "unknown_function") # type: ignore[assignment]
tool_desc: str = description or (f.__doc__ or "")
sig = inspect.signature(f)
fields = {
pname: (
param.annotation if param.annotation is not inspect.Parameter.empty else str,
param.default if param.default is not inspect.Parameter.empty else ...,
)
for pname, param in sig.parameters.items()
if pname not in {"self", "cls"}
}
input_model: Any = create_model(f"{tool_name}_input", **fields) # type: ignore[call-overload]
if not issubclass(input_model, BaseModel):
raise TypeError(f"Input model for {tool_name} must be a subclass of BaseModel, got {input_model}")
def decorator(func: Callable[..., ReturnT | Awaitable[ReturnT]]) -> AIFunction[Any, ReturnT]:
@wraps(func)
def wrapper(f: Callable[..., ReturnT | Awaitable[ReturnT]]) -> AIFunction[Any, ReturnT]:
tool_name: str = name or getattr(f, "__name__", "unknown_function") # type: ignore[assignment]
tool_desc: str = description or (f.__doc__ or "")
sig = inspect.signature(f)
fields = {
pname: (
param.annotation if param.annotation is not inspect.Parameter.empty else str,
param.default if param.default is not inspect.Parameter.empty else ...,
)
for pname, param in sig.parameters.items()
if pname not in {"self", "cls"}
}
input_model: Any = create_model(f"{tool_name}_input", **fields) # type: ignore[call-overload]
if not issubclass(input_model, BaseModel):
raise TypeError(f"Input model for {tool_name} must be a subclass of BaseModel, got {input_model}")
return functools.update_wrapper( # type: ignore[return-value]
AIFunction[Any, ReturnT](
return AIFunction[Any, ReturnT](
func=f,
name=tool_name,
description=tool_desc,
input_model=input_model,
**(additional_properties if additional_properties is not None else {}),
),
f,
)
)
return wrapper(func) if func else wrapper
return wrapper(func)
return decorator(func) if func else decorator # type: ignore[reportReturnType, return-value]
+34 -23
View File
@@ -4,15 +4,14 @@ import base64
import json
import re
import sys
from collections.abc import AsyncIterable, Iterable, Iterator, Mapping, MutableSequence, Sequence
from collections.abc import AsyncIterable, Iterable, Iterator, Mapping, MutableMapping, MutableSequence, Sequence
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, overload
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
from agent_framework.exceptions import AgentFrameworkException
from ._pydantic import AFBaseModel
from ._tools import AITool
from .exceptions import AgentFrameworkException
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
@@ -224,7 +223,7 @@ def _coalesce_text_content(
first_new_content = i
else:
if first_new_content is not None:
new_content = type_(text="\n".join(current_texts))
new_content = type_(text=" ".join(current_texts))
new_content.raw_representation = contents[first_new_content].raw_representation
new_content.additional_properties = contents[first_new_content].additional_properties
# Store the replacement node. We inherit the properties of the first text node. We don't
@@ -235,7 +234,7 @@ def _coalesce_text_content(
first_new_content = None
coalesced_contents.append(content)
if current_texts:
coalesced_contents.append(type_(text="\n".join(current_texts)))
coalesced_contents.append(type_(text=" ".join(current_texts)))
contents.clear()
contents.extend(coalesced_contents)
@@ -647,7 +646,7 @@ class FunctionCallContent(AIContent):
def __add__(self, other: "FunctionCallContent") -> "FunctionCallContent":
if not isinstance(other, FunctionCallContent):
raise TypeError("Incompatible type")
if self.call_id != other.call_id:
if other.call_id and self.call_id != other.call_id:
raise AgentFrameworkException("Incompatible function call contents")
if not self.arguments:
arguments = other.arguments
@@ -880,15 +879,6 @@ class ChatMessage(AFBaseModel):
raw_representation: Any | None = None
"""The raw representation of the chat message from an underlying implementation."""
@property
def text(self) -> str:
"""Returns the text content of the message.
Remarks:
This property concatenates the text of all TextContent objects in Contents.
"""
return "\n".join(content.text for content in self.contents if isinstance(content, TextContent))
@overload
def __init__(
self,
@@ -916,7 +906,7 @@ class ChatMessage(AFBaseModel):
self,
role: ChatRole | Literal["system", "user", "assistant", "tool"],
*,
contents: list[AIContents],
contents: MutableSequence[AIContents],
author_name: str | None = None,
message_id: str | None = None,
additional_properties: dict[str, Any] | None = None,
@@ -938,7 +928,7 @@ class ChatMessage(AFBaseModel):
role: ChatRole | Literal["system", "user", "assistant", "tool"],
*,
text: str | None = None,
contents: list[AIContents] | None = None,
contents: MutableSequence[AIContents] | None = None,
author_name: str | None = None,
message_id: str | None = None,
additional_properties: dict[str, Any] | None = None,
@@ -959,6 +949,15 @@ class ChatMessage(AFBaseModel):
raw_representation=raw_representation, # type: ignore[reportCallIssue]
)
@property
def text(self) -> str:
"""Returns the text content of the message.
Remarks:
This property concatenates the text of all TextContent objects in Contents.
"""
return " ".join(content.text for content in self.contents if isinstance(content, TextContent))
# region: ChatResponse
@@ -1123,7 +1122,10 @@ class ChatResponse(AFBaseModel):
@property
def text(self) -> str:
"""Returns the concatenated text of all messages in the response."""
return "\n".join(message.text for message in self.messages if isinstance(message, ChatMessage))
return ("\n".join(message.text for message in self.messages if isinstance(message, ChatMessage))).strip()
def __str__(self) -> str:
return self.text
class StructuredResponse(ChatResponse, Generic[TValue]):
@@ -1343,7 +1345,10 @@ class ChatResponseUpdate(AFBaseModel):
@property
def text(self) -> str:
"""Returns the concatenated text of all contents in the update."""
return "\n".join(content.text for content in self.contents if isinstance(content, TextContent))
return "".join(content.text for content in self.contents if isinstance(content, TextContent))
def __str__(self) -> str:
return self.text
def with_(self, contents: list[AIContent] | None = None, message_id: str | None = None) -> Self:
"""Returns a new instance with the specified contents and message_id."""
@@ -1398,19 +1403,19 @@ class ChatOptions(AFBaseModel):
temperature: Annotated[float | None, Field(ge=0.0, le=2.0)] = None
top_p: Annotated[float | None, Field(ge=0.0, le=1.0)] = None
tool_choice: ChatToolMode | Literal["auto", "required", "none"] | Mapping[str, Any] | None = None
tools: Sequence[AITool] | Sequence[Mapping[str, Any]] | None = None
tools: Sequence[AITool] | Sequence[MutableMapping[str, Any]] | None = None
response_format: type[BaseModel] | None = Field(
default=None, description="Structured output response format schema. Must be a valid Pydantic model."
)
user: str | None = None
stop: str | Sequence[str] | None = None
frequency_penalty: Annotated[float | None, Field(ge=-2.0, le=2.0)] = None
logit_bias: Mapping[str | int, float] | None = None
logit_bias: MutableMapping[str | int, float] | None = None
presence_penalty: Annotated[float | None, Field(ge=-2.0, le=2.0)] = None
seed: int | None = None
store: bool | None = None
metadata: Mapping[str, str] | None = None
additional_properties: Mapping[str, Any] = Field(
metadata: MutableMapping[str, str] | None = None
additional_properties: MutableMapping[str, Any] = Field(
default_factory=dict, description="Provider-specific additional properties."
)
@@ -1745,3 +1750,9 @@ class TextToSpeechOptions(AFBaseModel):
for key in merged_exclude:
settings.pop(key, None)
return settings
# endregion
# endregion
@@ -1,5 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Final
USER_AGENT: Final[str] = "User-Agent"
@@ -0,0 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.
from ._chat_client import OpenAIChatClient
from ._shared import OpenAISettings
__all__ = [
"OpenAIChatClient",
"OpenAISettings",
]
@@ -3,13 +3,20 @@
import json
from collections.abc import AsyncIterable, Mapping, MutableSequence, Sequence
from datetime import datetime
from itertools import chain
from typing import Any, ClassVar, cast
from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidResponseError
from openai import AsyncOpenAI, AsyncStream
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 Choice as ChunkChoice
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall
from pydantic import SecretStr, ValidationError
from agent_framework import (
ChatClientBase,
from .._clients import ChatClientBase, use_tool_calling
from .._types import (
AIContents,
ChatFinishReason,
ChatMessage,
ChatOptions,
@@ -17,24 +24,17 @@ from agent_framework import (
ChatResponseUpdate,
ChatRole,
FunctionCallContent,
FunctionResultContent,
TextContent,
UsageDetails,
)
from openai import AsyncOpenAI, AsyncStream
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 Choice as ChunkChoice
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall
from ._openai_config_base import OpenAIConfigBase
from ._openai_handler import OpenAIHandler
from ._openai_model_types import OpenAIModelTypes
from ._openai_settings import OpenAISettings
from ..exceptions import ServiceInitializationError, ServiceInvalidResponseError
from ._shared import OpenAIConfigBase, OpenAIHandler, OpenAIModelTypes, OpenAISettings
# Implements agent_framework.ChatClient protocol
class OpenAIChatCompletionBase(OpenAIHandler, ChatClientBase):
# Implements agent_framework.ChatClient protocol, through ChatClientBase
@use_tool_calling
class OpenAIChatClientBase(OpenAIHandler, ChatClientBase):
"""OpenAI Chat completion class."""
MODEL_PROVIDER_NAME: ClassVar[str] = "openai"
@@ -50,10 +50,9 @@ class OpenAIChatCompletionBase(OpenAIHandler, ChatClientBase):
chat_options: ChatOptions,
**kwargs: Any,
) -> ChatResponse:
# TODO(peterychang): Is there a better way to handle this?
chat_options.additional_properties = dict(chat_options.additional_properties)
chat_options.additional_properties.update({"stream": False})
chat_options.ai_model_id = chat_options.ai_model_id or self.ai_model_id
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
@@ -70,9 +69,8 @@ class OpenAIChatCompletionBase(OpenAIHandler, ChatClientBase):
chat_options: ChatOptions,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
# TODO(peterychang): Is there a better way to handle this?
chat_options.additional_properties = dict(chat_options.additional_properties)
chat_options.additional_properties.update({"stream": True, "stream_options": {"include_usage": True}})
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))
@@ -110,10 +108,9 @@ class OpenAIChatCompletionBase(OpenAIHandler, ChatClientBase):
"""Create a chat message content object from a choice."""
metadata = self._get_metadata_from_chat_choice(choice)
metadata.update(response_metadata)
items: list[ChatMessage] = [
ChatMessage(role="assistant", contents=[tool]) for tool in self._get_tool_calls_from_chat_choice(choice)
]
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:
@@ -176,19 +173,17 @@ class OpenAIChatCompletionBase(OpenAIHandler, ChatClientBase):
"logprobs": getattr(choice, "logprobs", None),
}
def _get_tool_calls_from_chat_choice(self, choice: Choice | ChunkChoice) -> list[FunctionCallContent]:
def _get_tool_calls_from_chat_choice(self, choice: Choice | ChunkChoice) -> list[AIContents]:
"""Get tool calls from a chat choice."""
resp: list[FunctionCallContent] = []
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:
fcc = FunctionCallContent(
call_id=tool.id if tool.id else "",
name=tool.function.name if tool.function and tool.function.name else "",
arguments=json.loads(tool.function.arguments)
if tool.function and tool.function.arguments
else {},
name=tool.function.name if tool.function.name else "",
arguments=tool.function.arguments if tool.function.arguments else "",
)
resp.append(fcc)
@@ -197,7 +192,7 @@ class OpenAIChatCompletionBase(OpenAIHandler, ChatClientBase):
def _prepare_chat_history_for_request(
self,
chat_history: ChatMessage | Sequence[ChatMessage],
chat_messages: Sequence[ChatMessage],
role_key: str = "role",
content_key: str = "content",
) -> list[dict[str, Any]]:
@@ -212,30 +207,67 @@ class OpenAIChatCompletionBase(OpenAIHandler, ChatClientBase):
Override this method to customize the formatting of the chat history for a request.
Args:
chat_history (list[ChatMessage]): The chat history to prepare.
role_key (str): The key name for the role/author.
content_key (str): The key name for the content/message.
chat_messages: The chat history to prepare.
role_key: The key name for the role/author.
content_key: The key name for the content/message.
Returns:
prepared_chat_history (Any): The prepared chat history for a request.
"""
# TODO(peterychang): Chat history type is not finalized yet
if not isinstance(chat_history, Sequence):
chat_history = [chat_history]
# TODO(peterychang): This is the bare minimum to get the chat history into a format that OpenAI expects.
return [
{
"role": message.role.value if isinstance(message.role, ChatRole) else message.role,
"content": [content.model_dump() for content in message.contents],
"metadata": message.additional_properties or {},
}
for message in chat_history
]
list_of_list = [self._openai_chat_message_parser(message) for message in chat_messages]
# Flatten the list of lists into a single list
return list(chain.from_iterable(list_of_list))
# endregion
def _openai_chat_message_parser(self, message: ChatMessage) -> 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 = args.copy()
new_args["tool_call_id"] = content.call_id
new_args["content"] = content.result
all_messages.append(new_args)
case FunctionCallContent():
function_call = self._openai_content_parser(content)
if "tool_calls" not in args:
args["tool_calls"] = []
args["tool_calls"].append(function_call)
case _:
if "content" not in args:
args["content"] = []
args["content"].append(self._openai_content_parser(content))
if "content" in args or "tool_calls" in args:
all_messages.append(args)
return all_messages
class OpenAIChatCompletion(OpenAIConfigBase, OpenAIChatCompletionBase):
def _openai_content_parser(self, content: AIContents) -> dict[str, Any]:
"""Parse contents into the openai format."""
match content:
case FunctionCallContent():
args = json.dumps(content.arguments) if isinstance(content.arguments, Mapping) else content.arguments
return {
"id": content.call_id,
"type": "function",
"function": {"name": content.name, "arguments": args},
}
case FunctionResultContent():
return {
"tool_call_id": content.call_id,
"content": content.result,
}
case _:
return content.model_dump(exclude_none=True)
class OpenAIChatClient(OpenAIConfigBase, OpenAIChatClientBase):
"""OpenAI Chat completion class."""
def __init__(
@@ -301,13 +333,13 @@ class OpenAIChatCompletion(OpenAIConfigBase, OpenAIChatCompletionBase):
)
@classmethod
def from_dict(cls, settings: dict[str, Any]) -> "OpenAIChatCompletion":
def from_dict(cls, settings: dict[str, Any]) -> "OpenAIChatClient":
"""Initialize an Open AI service from a dictionary of settings.
Args:
settings: A dictionary of settings for the service.
"""
return OpenAIChatCompletion(
return OpenAIChatClient(
ai_model_id=settings["ai_model_id"],
default_headers=settings.get("default_headers"),
)
@@ -0,0 +1,294 @@
# Copyright (c) Microsoft. All rights reserved.
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
from openai.types.images_response import ImagesResponse
from pydantic import BaseModel, ConfigDict, Field, SecretStr, validate_call
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 ..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")
RESPONSE_TYPE = Union[
ChatCompletion,
Completion,
AsyncStream[ChatCompletionChunk],
AsyncStream[Completion],
list[Any],
ImagesResponse,
Transcription,
_legacy_response.HttpxBinaryResponseContent,
]
OPTION_TYPE = Union[
ChatOptions,
SpeechToTextOptions,
TextToSpeechOptions,
]
class OpenAISettings(AFBaseSettings):
"""OpenAI model settings.
The settings are first loaded from environment variables with the prefix 'OPENAI_'.
If the environment variables are not found, the settings can be loaded from a .env file with the
encoding 'utf-8'. If the settings are not found in the .env file, the settings are ignored;
however, validation will fail alerting that the settings are missing.
Optional settings for prefix 'OPENAI_' are:
- api_key: SecretStr - OpenAI API key, see https://platform.openai.com/account/api-keys
(Env var OPENAI_API_KEY)
- org_id: str | None - This is usually optional unless your account belongs to multiple organizations.
(Env var OPENAI_ORG_ID)
- chat_model_id: str | None - The OpenAI chat model ID to use, for example, gpt-3.5-turbo or gpt-4.
(Env var OPENAI_CHAT_MODEL_ID)
- responses_model_id: str | None - The OpenAI responses model ID to use, for example, gpt-4o or o1.
(Env var OPENAI_RESPONSES_MODEL_ID)
- text_model_id: str | None - The OpenAI text model ID to use, for example, gpt-3.5-turbo-instruct.
(Env var OPENAI_TEXT_MODEL_ID)
- embedding_model_id: str | None - The OpenAI embedding model ID to use, for example, text-embedding-ada-002.
(Env var OPENAI_EMBEDDING_MODEL_ID)
- text_to_image_model_id: str | None - The OpenAI text to image model ID to use, for example, dall-e-3.
(Env var OPENAI_TEXT_TO_IMAGE_MODEL_ID)
- audio_to_text_model_id: str | None - The OpenAI audio to text model ID to use, for example, whisper-1.
(Env var OPENAI_AUDIO_TO_TEXT_MODEL_ID)
- text_to_audio_model_id: str | None - The OpenAI text to audio model ID to use, for example, jukebox-1.
(Env var OPENAI_TEXT_TO_AUDIO_MODEL_ID)
- realtime_model_id: str | None - The OpenAI realtime model ID to use,
for example, gpt-4o-realtime-preview-2024-12-17.
(Env var OPENAI_REALTIME_MODEL_ID)
- env_file_path: str | None - if provided, the .env settings are read from this file path location
"""
env_prefix: ClassVar[str] = "OPENAI_"
api_key: SecretStr | None = None
org_id: str | None = None
chat_model_id: str | None = None
responses_model_id: str | None = None
text_model_id: str | None = None
embedding_model_id: str | None = None
text_to_image_model_id: str | None = None
audio_to_text_model_id: str | None = None
text_to_audio_model_id: str | None = None
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."""
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)
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
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."""
@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,
instruction_role: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize a client for OpenAI services.
This constructor sets up a client to interact with OpenAI's API, allowing for
different types of AI model interactions, like chat or text completion.
Args:
ai_model_id (str): OpenAI model identifier. Must be non-empty.
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
for HTTP requests. (Optional)
client (AsyncOpenAI): An existing OpenAI client, optional.
instruction_role (str): The role to use for 'instruction'
messages, for example, summarization prompts could use `developer` or `system`. (Optional)
kwargs: Additional keyword arguments.
"""
# Merge APP_INFO into the headers if it exists
merged_headers = dict(copy(default_headers)) if default_headers else {}
if APP_INFO:
merged_headers.update(APP_INFO)
merged_headers = prepend_agent_framework_to_user_agent(merged_headers)
if not client:
if not api_key:
raise ServiceInitializationError("Please provide an api_key")
client = AsyncOpenAI(
api_key=api_key,
organization=org_id,
default_headers=merged_headers,
)
args = {
"ai_model_id": ai_model_id,
"client": client,
"ai_model_type": ai_model_type,
}
if instruction_role:
args["instruction_role"] = instruction_role
super().__init__(**args, **kwargs)
def to_dict(self) -> dict[str, Any]:
"""Create a dict of the service settings."""
client_settings = {
"api_key": self.client.api_key,
"default_headers": {k: v for k, v in self.client.default_headers.items() if k != USER_AGENT_KEY},
}
if self.client.organization:
client_settings["org_id"] = self.client.organization
base = self.model_dump(
exclude={
"prompt_tokens",
"completion_tokens",
"total_tokens",
"api_type",
"ai_model_type",
"client",
},
by_alias=True,
exclude_none=True,
)
base.update(client_settings)
return base
@@ -4,10 +4,10 @@ from dataclasses import dataclass
from enum import Enum
from typing import Any
from agent_framework.exceptions import ServiceContentFilterException
from openai import BadRequestError
from ..exceptions import ServiceContentFilterException
class ContentFilterResultSeverity(Enum):
"""The severity of the content filter result."""
@@ -0,0 +1,46 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from importlib.metadata import PackageNotFoundError, version
from typing import Any, Final
try:
version_info = version("agent-framework")
except PackageNotFoundError:
version_info = "dev"
# Note that if this environment variable does not exist, telemetry is enabled.
TELEMETRY_DISABLED_ENV_VAR = "AZURE_TELEMETRY_DISABLED"
IS_TELEMETRY_ENABLED = os.environ.get(TELEMETRY_DISABLED_ENV_VAR, "false").lower() not in ["true", "1"]
APP_INFO = (
{
"agent-framework-version": f"python/{version_info}",
}
if IS_TELEMETRY_ENABLED
else None
)
USER_AGENT_KEY: Final[str] = "User-Agent"
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}"
def prepend_agent_framework_to_user_agent(headers: dict[str, Any]) -> dict[str, Any]:
"""Prepend "agent-framework" to the User-Agent in the headers.
Args:
headers: The existing headers dictionary.
Returns:
The modified headers dictionary with "agent-framework-python/{version}" prepended to the User-Agent.
"""
headers[USER_AGENT_KEY] = (
f"{AGENT_FRAMEWORK_USER_AGENT} {headers[USER_AGENT_KEY]}"
if USER_AGENT_KEY in headers
else AGENT_FRAMEWORK_USER_AGENT
)
return headers
__all__ = ["AGENT_FRAMEWORK_USER_AGENT", "APP_INFO", "USER_AGENT_KEY", "prepend_agent_framework_to_user_agent"]
+7 -4
View File
@@ -23,12 +23,16 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-azure",
"agent-framework-openai",
"openai>=1.94.0",
"pydantic>=2.11.7",
"typing-extensions>=4.14.0",
]
[project.optional-dependencies]
azure = [
"agent-framework-azure"
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
dev-dependencies = [
@@ -115,8 +119,7 @@ include = "../../shared_tasks.toml"
[tool.uv.build-backend]
module-name = "agent_framework"
module-root = ""
namespace = true
[build-system]
requires = ["uv_build>=0.7.19,<0.8.0"]
build-backend = "uv_build"
build-backend = "uv_build"
+24 -1
View File
@@ -1,6 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework import AITool, ai_function
from agent_framework import AIFunction, AITool, ai_function
def test_ai_function_decorator():
@@ -12,6 +12,7 @@ def test_ai_function_decorator():
return x + y
assert isinstance(test_tool, AITool)
assert isinstance(test_tool, AIFunction)
assert test_tool.name == "test_tool"
assert test_tool.description == "A test tool"
assert test_tool.parameters() == {
@@ -23,6 +24,27 @@ def test_ai_function_decorator():
assert test_tool(1, 2) == 3
def test_ai_function_decorator_without_args():
"""Test the ai_function decorator."""
@ai_function
def test_tool(x: int, y: int) -> int:
"""A simple function that adds two numbers."""
return x + y
assert isinstance(test_tool, AITool)
assert isinstance(test_tool, AIFunction)
assert test_tool.name == "test_tool"
assert test_tool.description == "A simple function that adds two numbers."
assert test_tool.parameters() == {
"properties": {"x": {"title": "X", "type": "integer"}, "y": {"title": "Y", "type": "integer"}},
"required": ["x", "y"],
"title": "test_tool_input",
"type": "object",
}
assert test_tool(1, 2) == 3
async def test_ai_function_decorator_with_async():
"""Test the ai_function decorator with an async function."""
@@ -32,6 +54,7 @@ async def test_ai_function_decorator_with_async():
return x + y
assert isinstance(async_test_tool, AITool)
assert isinstance(async_test_tool, AIFunction)
assert async_test_tool.name == "async_test_tool"
assert async_test_tool.description == "An async test tool"
assert async_test_tool.parameters() == {
+13 -5
View File
@@ -274,7 +274,7 @@ def test_chat_message_contents():
assert isinstance(message.contents[1], TextContent)
assert message.contents[0].text == "Hello, how are you?"
assert message.contents[1].text == "I'm fine, thank you!"
assert message.text == "Hello, how are you?\nI'm fine, thank you!"
assert message.text == "Hello, how are you? I'm fine, thank you!"
# region: ChatResponse
@@ -348,7 +348,7 @@ def test_chat_response_updates_to_chat_response_one():
# Check the type and content
assert len(chat_response.messages) == 1
assert chat_response.text == "I'm doing well, \nthank you!"
assert chat_response.text == "I'm doing well, thank you!"
assert isinstance(chat_response.messages[0], ChatMessage)
assert len(chat_response.messages[0].contents) == 1
assert chat_response.messages[0].message_id == "1"
@@ -396,7 +396,7 @@ def test_chat_response_updates_to_chat_response_multiple():
# Check the type and content
assert len(chat_response.messages) == 1
assert chat_response.text == "I'm doing well, \nthank you!"
assert chat_response.text == "I'm doing well, thank you!"
assert isinstance(chat_response.messages[0], ChatMessage)
assert len(chat_response.messages[0].contents) == 3
assert chat_response.messages[0].message_id == "1"
@@ -422,11 +422,19 @@ def test_chat_response_updates_to_chat_response_multiple_multiple():
# Check the type and content
assert len(chat_response.messages) == 1
assert chat_response.text == "I'm doing well, \nthank you!\nMore context\nFinal part"
assert isinstance(chat_response.messages[0], ChatMessage)
assert len(chat_response.messages[0].contents) == 3
assert chat_response.messages[0].message_id == "1"
assert len(chat_response.messages[0].contents) == 3
assert isinstance(chat_response.messages[0].contents[0], TextContent)
assert chat_response.messages[0].contents[0].text == "I'm doing well, thank you!"
assert isinstance(chat_response.messages[0].contents[1], TextReasoningContent)
assert chat_response.messages[0].contents[1].text == "Additional context"
assert isinstance(chat_response.messages[0].contents[2], TextContent)
assert chat_response.messages[0].contents[2].text == "More context Final part"
assert chat_response.text == "I'm doing well, thank you! More context Final part"
# region: ChatToolMode
View File
@@ -1,16 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._chat_completion import OpenAIChatCompletion, OpenAIChatCompletionBase
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"OpenAIChatCompletion",
"OpenAIChatCompletionBase",
"__version__",
]
@@ -1,97 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from collections.abc import Mapping
from copy import copy
from typing import Any
from agent_framework.exceptions import ServiceInitializationError
from pydantic import ConfigDict, Field, validate_call
from openai import AsyncOpenAI
from ._openai_handler import OpenAIHandler
from ._openai_model_types import OpenAIModelTypes
logger: logging.Logger = logging.getLogger(__name__)
class OpenAIConfigBase(OpenAIHandler):
"""Internal class for configuring a connection to an OpenAI service."""
@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,
instruction_role: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize a client for OpenAI services.
This constructor sets up a client to interact with OpenAI's API, allowing for
different types of AI model interactions, like chat or text completion.
Args:
ai_model_id (str): OpenAI model identifier. Must be non-empty.
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
for HTTP requests. (Optional)
client (AsyncOpenAI): An existing OpenAI client, optional.
instruction_role (str): The role to use for 'instruction'
messages, for example, summarization prompts could use `developer` or `system`. (Optional)
kwargs: Additional keyword arguments.
"""
# Merge APP_INFO into the headers if it exists
merged_headers = dict(copy(default_headers)) if default_headers else {}
if not client:
if not api_key:
raise ServiceInitializationError("Please provide an api_key")
client = AsyncOpenAI(
api_key=api_key,
organization=org_id,
default_headers=merged_headers,
)
args = {
"ai_model_id": ai_model_id,
"client": client,
"ai_model_type": ai_model_type,
}
if instruction_role:
args["instruction_role"] = instruction_role
super().__init__(**args, **kwargs)
def to_dict(self) -> dict[str, Any]:
"""Create a dict of the service settings."""
client_settings = {
"api_key": self.client.api_key,
"default_headers": {k: v for k, v in self.client.default_headers.items() if k != "User-Agent"},
}
if self.client.organization:
client_settings["org_id"] = self.client.organization
base = self.model_dump(
exclude={
"prompt_tokens",
"completion_tokens",
"total_tokens",
"api_type",
"ai_model_type",
"client",
},
by_alias=True,
exclude_none=True,
)
base.update(client_settings)
return base
@@ -1,148 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from abc import ABC
from typing import Annotated, Any, Union
from agent_framework.exceptions import ServiceInvalidRequestError, ServiceResponseException
from pydantic import BaseModel
from pydantic.types import StringConstraints
from agent_framework import AFBaseModel, ChatOptions, SpeechToTextOptions, TextToSpeechOptions
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
from openai.types.images_response import ImagesResponse
from ._openai_model_types import OpenAIModelTypes
from .exceptions import OpenAIContentFilterException
logger: logging.Logger = logging.getLogger(__name__)
RESPONSE_TYPE = Union[
ChatCompletion,
Completion,
AsyncStream[ChatCompletionChunk],
AsyncStream[Completion],
list[Any],
ImagesResponse,
Transcription,
_legacy_response.HttpxBinaryResponseContent,
]
# TODO(evmattso): update with proper Options types to move away from ExecutionSettings
OPTION_TYPE = Union[
ChatOptions,
SpeechToTextOptions,
TextToSpeechOptions,
]
class OpenAIHandler(AFBaseModel, ABC):
"""Internal class for calls to OpenAI API's."""
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)
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 | Completion | AsyncStream[ChatCompletionChunk] | AsyncStream[Completion]:
"""Execute the appropriate call to OpenAI models."""
try:
options_dict = chat_options.to_provider_settings()
if messages is not None:
options_dict["messages"] = messages
if self.ai_model_type == OpenAIModelTypes.CHAT:
self._handle_structured_outputs(chat_options, options_dict)
if chat_options.tools is None:
options_dict.pop("parallel_tool_calls", None)
response = await self.client.chat.completions.create(**options_dict) # type: ignore
else:
response = await self.client.completions.create(**options_dict) # type: ignore
assert isinstance(response, (ChatCompletion, Completion, AsyncStream)) # nosec # noqa: S101
return response # 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(
file=audio_file,
**options.to_provider_settings(exclude={"filename"}),
) # type: ignore
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
def _handle_structured_outputs(self, chat_options: "ChatOptions", options_dict: dict[str, Any]) -> None:
response_format = getattr(chat_options, "response_format", None)
if response_format and isinstance(response_format, type) and issubclass(response_format, BaseModel):
options_dict["response_format"] = type_to_response_format_param(response_format)
@@ -1,15 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import Enum
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"
@@ -1,54 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import ClassVar
from pydantic import SecretStr
from agent_framework import AFBaseSettings
class OpenAISettings(AFBaseSettings):
"""OpenAI model settings.
The settings are first loaded from environment variables with the prefix 'OPENAI_'.
If the environment variables are not found, the settings can be loaded from a .env file with the
encoding 'utf-8'. If the settings are not found in the .env file, the settings are ignored;
however, validation will fail alerting that the settings are missing.
Optional settings for prefix 'OPENAI_' are:
- api_key: SecretStr - OpenAI API key, see https://platform.openai.com/account/api-keys
(Env var OPENAI_API_KEY)
- org_id: str | None - This is usually optional unless your account belongs to multiple organizations.
(Env var OPENAI_ORG_ID)
- chat_model_id: str | None - The OpenAI chat model ID to use, for example, gpt-3.5-turbo or gpt-4.
(Env var OPENAI_CHAT_MODEL_ID)
- responses_model_id: str | None - The OpenAI responses model ID to use, for example, gpt-4o or o1.
(Env var OPENAI_RESPONSES_MODEL_ID)
- text_model_id: str | None - The OpenAI text model ID to use, for example, gpt-3.5-turbo-instruct.
(Env var OPENAI_TEXT_MODEL_ID)
- embedding_model_id: str | None - The OpenAI embedding model ID to use, for example, text-embedding-ada-002.
(Env var OPENAI_EMBEDDING_MODEL_ID)
- text_to_image_model_id: str | None - The OpenAI text to image model ID to use, for example, dall-e-3.
(Env var OPENAI_TEXT_TO_IMAGE_MODEL_ID)
- audio_to_text_model_id: str | None - The OpenAI audio to text model ID to use, for example, whisper-1.
(Env var OPENAI_AUDIO_TO_TEXT_MODEL_ID)
- text_to_audio_model_id: str | None - The OpenAI text to audio model ID to use, for example, jukebox-1.
(Env var OPENAI_TEXT_TO_AUDIO_MODEL_ID)
- realtime_model_id: str | None - The OpenAI realtime model ID to use,
for example, gpt-4o-realtime-preview-2024-12-17.
(Env var OPENAI_REALTIME_MODEL_ID)
- env_file_path: str | None - if provided, the .env settings are read from this file path location
"""
env_prefix: ClassVar[str] = "OPENAI_"
api_key: SecretStr | None = None
org_id: str | None = None
chat_model_id: str | None = None
responses_model_id: str | None = None
text_model_id: str | None = None
embedding_model_id: str | None = None
text_to_image_model_id: str | None = None
audio_to_text_model_id: str | None = None
text_to_audio_model_id: str | None = None
realtime_model_id: str | None = None
-74
View File
@@ -1,74 +0,0 @@
[project]
name = "agent-framework-openai"
description = "OpenAI integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "SK-Support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "0.1.0b1"
license = {file = "../../LICENSE"}
urls.homepage = "https://learn.microsoft.com/en-us/semantic-kernel/overview/"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Framework :: Pydantic :: 2",
"Typing :: Typed",
]
dependencies = [
"agent-framework",
"openai>=1.93.0",
]
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
[tool.ruff]
extend = "../../pyproject.toml"
[tool.pyright]
extend = "../../pyproject.toml"
exclude = ['tests', ".venv"]
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
disallow_any_unimported = true
[tool.bandit]
targets = ["agent_framework"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.uv.build-backend]
module-name = "agent_framework.openai"
module-root = ""
[build-system]
requires = ["uv_build>=0.7.19,<0.8.0"]
build-backend = "uv_build"
-57
View File
@@ -1,57 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any
from pytest import fixture
from agent_framework import ChatMessage
# region: Connector Settings fixtures
@fixture
def exclude_list(request: Any) -> list[str]:
"""Fixture that returns a list of environment variables to exclude."""
return request.param if hasattr(request, "param") else []
@fixture
def override_env_param_dict(request: Any) -> dict[str, str]:
"""Fixture that returns a dict of environment variables to override."""
return request.param if hasattr(request, "param") else {}
@fixture()
def openai_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
"""Fixture to set environment variables for OpenAISettings."""
if exclude_list is None:
exclude_list = []
if override_env_param_dict is None:
override_env_param_dict = {}
env_vars = {
"OPENAI_API_KEY": "test_api_key",
"OPENAI_ORG_ID": "test_org_id",
"OPENAI_RESPONSES_MODEL_ID": "test_responses_model_id",
"OPENAI_CHAT_MODEL_ID": "test_chat_model_id",
"OPENAI_TEXT_MODEL_ID": "test_text_model_id",
"OPENAI_EMBEDDING_MODEL_ID": "test_embedding_model_id",
"OPENAI_TEXT_TO_IMAGE_MODEL_ID": "test_text_to_image_model_id",
"OPENAI_AUDIO_TO_TEXT_MODEL_ID": "test_audio_to_text_model_id",
"OPENAI_TEXT_TO_AUDIO_MODEL_ID": "test_text_to_audio_model_id",
"OPENAI_REALTIME_MODEL_ID": "test_realtime_model_id",
}
env_vars.update(override_env_param_dict) # type: ignore
for key, value in env_vars.items():
if key not in exclude_list:
monkeypatch.setenv(key, value) # type: ignore
else:
monkeypatch.delenv(key, raising=False) # type: ignore
return env_vars
@fixture(scope="function")
def chat_history() -> list["ChatMessage"]:
return []
@@ -1,24 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from pytest import mark
@mark.xfail(reason="Not solved")
def test_self():
try:
from agent_framework.openai import __version__
except ImportError:
__version__ = None
assert __version__ is not None
def test_agent_framework():
try:
from agent_framework import TextContent
except ImportError:
TextContent = None
assert TextContent is not None
text = TextContent("Hello, world!")
assert text is not None
+11 -6
View File
@@ -2,6 +2,11 @@
name = "agent-framework-project"
description = "Microsoft Agent Framework for building AI Agents with Python."
version = "0.0.0"
requires-python = ">=3.10"
dependencies = [
"agent-framework",
"agent-framework-azure",
]
[dependency-groups]
dev = [
@@ -21,6 +26,7 @@ dev = [
"tomli",
"tomli-w",
"markdownify",
# Documentation
"myst-nb==1.1.2",
"pydata-sphinx-theme==0.16.0",
@@ -49,12 +55,11 @@ environments = [
]
[tool.uv.workspace]
members = ["packages/*"]
members = [ "packages/*" ]
[tool.uv.sources]
agent-framework = { workspace = true }
agent-framework-openai = { workspace = true }
agent-framework-azure = { workspace = true }
agent-framework = { workspace = true }
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
@@ -104,7 +109,7 @@ ignore = [
[tool.ruff.lint.per-file-ignores]
# Ignore all directories named `tests` and `samples`.
"**/tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"]
"samples/**" = ["D", "INP", "ERA001", "RUF", "S"]
"samples/**" = ["D", "INP", "ERA001", "RUF", "S", "T201"]
"*.ipynb" = ["CPY", "E501"]
[tool.ruff.format]
@@ -141,7 +146,7 @@ disallow_any_unimported = true
[tool.bandit]
targets = ["agent_framework"]
exclude_dirs = ["tests", "./run_tasks_in_packages_if_exists.py", "./check_md_code_blocks.py", "docs"]
exclude_dirs = ["tests", "./run_tasks_in_packages_if_exists.py", "./check_md_code_blocks.py", "docs", "samples"]
[tool.poe]
executor.type = "uv"
@@ -155,7 +160,7 @@ docs-serve = "sphinx-autobuild --watch docs/agent-framework docs/build --port 80
docs-check = "sphinx-build --fail-on-warning docs/agent-framework docs/build"
docs-check-examples = "sphinx-build -b code_lint docs/agent-framework docs/build"
pre-commit-install = "uv run pre-commit install --install-hooks --overwrite"
install = "uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit"
install = "uv sync --all-packages --dev -U --prerelease=if-necessary-or-explicit"
test = "python run_tasks_in_packages_if_exists.py test"
fmt = "python run_tasks_in_packages_if_exists.py fmt"
format.ref = "fmt"
@@ -0,0 +1,38 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from random import randint
from typing import Annotated
from agent_framework import ai_function
from agent_framework.openai import OpenAIChatClient
from pydantic import Field
@ai_function
def get_weather(
location: Annotated[str, Field(description="The location to get the weather for.")],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
async def main():
client = OpenAIChatClient()
message = "What's the weather in Amsterdam and in Paris?"
stream = False
print(f"User: {message}")
if stream:
print("Assistant: ", end="")
async for chunk in client.get_streaming_response(message, tools=get_weather):
if str(chunk):
print(str(chunk), end="")
print("")
else:
response = await client.get_response(message, tools=get_weather)
print(f"Assistant: {response}")
if __name__ == "__main__":
asyncio.run(main())
+22 -29
View File
@@ -19,7 +19,6 @@ supported-markers = [
members = [
"agent-framework",
"agent-framework-azure",
"agent-framework-openai",
"agent-framework-project",
]
@@ -40,12 +39,16 @@ name = "agent-framework"
version = "0.1.0b1"
source = { editable = "packages/main" }
dependencies = [
{ name = "agent-framework-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.optional-dependencies]
azure = [
{ name = "agent-framework-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.dev-dependencies]
dev = [
{ name = "autodoc-pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -81,11 +84,12 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "agent-framework-azure", editable = "packages/azure" },
{ name = "agent-framework-openai", editable = "packages/openai" },
{ name = "agent-framework-azure", marker = "extra == 'azure'", editable = "packages/azure" },
{ name = "openai", specifier = ">=1.94.0" },
{ name = "pydantic", specifier = ">=2.11.7" },
{ name = "typing-extensions", specifier = ">=4.14.0" },
]
provides-extras = ["azure"]
[package.metadata.requires-dev]
dev = [
@@ -125,34 +129,19 @@ version = "0.1.0b1"
source = { editable = "packages/azure" }
dependencies = [
{ name = "agent-framework", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.metadata]
requires-dist = [
{ name = "agent-framework", editable = "packages/main" },
{ name = "agent-framework-openai", editable = "packages/openai" },
]
[[package]]
name = "agent-framework-openai"
version = "0.1.0b1"
source = { editable = "packages/openai" }
dependencies = [
{ name = "agent-framework", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.metadata]
requires-dist = [
{ name = "agent-framework", editable = "packages/main" },
{ name = "openai", specifier = ">=1.93.0" },
]
requires-dist = [{ name = "agent-framework", editable = "packages/main" }]
[[package]]
name = "agent-framework-project"
version = "0.0.0"
source = { virtual = "." }
dependencies = [
{ name = "agent-framework", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "agent-framework-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
[package.dev-dependencies]
dev = [
@@ -188,6 +177,10 @@ dev = [
]
[package.metadata]
requires-dist = [
{ name = "agent-framework", editable = "packages/main" },
{ name = "agent-framework-azure", editable = "packages/azure" },
]
[package.metadata.requires-dev]
dev = [
@@ -639,7 +632,7 @@ name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
@@ -1353,7 +1346,7 @@ wheels = [
[[package]]
name = "openai"
version = "1.94.0"
version = "1.95.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -1365,9 +1358,9 @@ dependencies = [
{ name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/7e/2e36eb5d2e9a18028ee66f2e553c6392ae1775ef9f6aa11f15f1074c7e98/openai-1.94.0.tar.gz", hash = "sha256:31c6c213cc80365d54632296c4aef7cda1800003ca5c784ac50a05d6bc05c197", size = 487682, upload-time = "2025-07-10T14:21:08.686Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/2f/0c6f509a1585545962bfa6e201d7fb658eb2a6f52fb8c26765632d91706c/openai-1.95.0.tar.gz", hash = "sha256:54bc42df9f7142312647dd485d34cca5df20af825fa64a30ca55164be2cf4cc9", size = 488144, upload-time = "2025-07-10T18:35:49.946Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/93/a20d43aa9c6d8b1b1f2a9262da6180b4420ff71fae2e5d14e496022cfe66/openai-1.94.0-py3-none-any.whl", hash = "sha256:159c43b811669abe9bb4aafdc57a049966dfde2eac94b151aac3eb63bf9825b4", size = 755167, upload-time = "2025-07-10T14:21:06.974Z" },
{ url = "https://files.pythonhosted.org/packages/19/a5/57d0bb58b938a3e3f352ff26e645da1660436402a6ad1b29780d261cc5a5/openai-1.95.0-py3-none-any.whl", hash = "sha256:a7afc9dca7e7d616371842af8ea6dbfbcb739a85d183f5f664ab1cc311b9ef18", size = 755572, upload-time = "2025-07-10T18:35:47.507Z" },
]
[[package]]