diff --git a/python/.user/function.json b/python/.user/function.json new file mode 100644 index 0000000000..8cecdcf07c --- /dev/null +++ b/python/.user/function.json @@ -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" + } + } +} \ No newline at end of file diff --git a/python/.vscode/settings.json b/python/.vscode/settings.json index e52f2e3825..63f100a9bc 100644 --- a/python/.vscode/settings.json +++ b/python/.vscode/settings.json @@ -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, diff --git a/python/packages/azure/pyproject.toml b/python/packages/azure/pyproject.toml index d06c525295..4274789b62 100644 --- a/python/packages/azure/pyproject.toml +++ b/python/packages/azure/pyproject.toml @@ -24,7 +24,6 @@ classifiers = [ ] dependencies = [ "agent-framework", - "agent-framework-openai" ] [tool.pytest.ini_options] diff --git a/python/packages/main/agent_framework/__init__.py b/python/packages/main/agent_framework/__init__.py index 822b65795f..80ebd3bd56 100644 --- a/python/packages/main/agent_framework/__init__.py +++ b/python/packages/main/agent_framework/__init__.py @@ -19,6 +19,7 @@ _IMPORTS = { "AgentThread": "._agents", "AITool": "._tools", "ai_function": "._tools", + "AIFunction": "._tools", "AIContent": "._types", "AIContents": "._types", "ChatClientAgent": "._agents", diff --git a/python/packages/main/agent_framework/__init__.pyi b/python/packages/main/agent_framework/__init__.pyi index 963940e966..587d87c85c 100644 --- a/python/packages/main/agent_framework/__init__.pyi +++ b/python/packages/main/agent_framework/__init__.pyi @@ -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", diff --git a/python/packages/main/agent_framework/_clients.py b/python/packages/main/agent_framework/_clients.py index c143cf2fa1..04cc7ad892 100644 --- a/python/packages/main/agent_framework/_clients.py +++ b/python/packages/main/agent_framework/_clients.py @@ -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 diff --git a/python/packages/main/agent_framework/_tools.py b/python/packages/main/agent_framework/_tools.py index 53c1338dc1..0df9e68b24 100644 --- a/python/packages/main/agent_framework/_tools.py +++ b/python/packages/main/agent_framework/_tools.py @@ -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[, Field(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] diff --git a/python/packages/main/agent_framework/_types.py b/python/packages/main/agent_framework/_types.py index d1ce0234a3..95e9bdc9db 100644 --- a/python/packages/main/agent_framework/_types.py +++ b/python/packages/main/agent_framework/_types.py @@ -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 diff --git a/python/packages/main/agent_framework/const.py b/python/packages/main/agent_framework/const.py deleted file mode 100644 index 40a6ba8164..0000000000 --- a/python/packages/main/agent_framework/const.py +++ /dev/null @@ -1,5 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from typing import Final - -USER_AGENT: Final[str] = "User-Agent" diff --git a/python/packages/main/agent_framework/openai/__init__.py b/python/packages/main/agent_framework/openai/__init__.py new file mode 100644 index 0000000000..8a31422f4f --- /dev/null +++ b/python/packages/main/agent_framework/openai/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft. All rights reserved. + + +from ._chat_client import OpenAIChatClient +from ._shared import OpenAISettings + +__all__ = [ + "OpenAIChatClient", + "OpenAISettings", +] diff --git a/python/packages/openai/agent_framework/openai/_chat_completion.py b/python/packages/main/agent_framework/openai/_chat_client.py similarity index 74% rename from python/packages/openai/agent_framework/openai/_chat_completion.py rename to python/packages/main/agent_framework/openai/_chat_client.py index 5608ee6715..64aa9bf8b7 100644 --- a/python/packages/openai/agent_framework/openai/_chat_completion.py +++ b/python/packages/main/agent_framework/openai/_chat_client.py @@ -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"), ) diff --git a/python/packages/main/agent_framework/openai/_shared.py b/python/packages/main/agent_framework/openai/_shared.py new file mode 100644 index 0000000000..50990ef2eb --- /dev/null +++ b/python/packages/main/agent_framework/openai/_shared.py @@ -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 diff --git a/python/packages/openai/agent_framework/openai/exceptions.py b/python/packages/main/agent_framework/openai/exceptions.py similarity index 97% rename from python/packages/openai/agent_framework/openai/exceptions.py rename to python/packages/main/agent_framework/openai/exceptions.py index 81395cb238..155009e0e8 100644 --- a/python/packages/openai/agent_framework/openai/exceptions.py +++ b/python/packages/main/agent_framework/openai/exceptions.py @@ -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.""" diff --git a/python/packages/main/agent_framework/telemetry.py b/python/packages/main/agent_framework/telemetry.py index e69de29bb2..e3d2947a43 100644 --- a/python/packages/main/agent_framework/telemetry.py +++ b/python/packages/main/agent_framework/telemetry.py @@ -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"] diff --git a/python/packages/main/pyproject.toml b/python/packages/main/pyproject.toml index d90139e2d0..5f214a0ed2 100644 --- a/python/packages/main/pyproject.toml +++ b/python/packages/main/pyproject.toml @@ -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" \ No newline at end of file +build-backend = "uv_build" diff --git a/python/packages/main/tests/unit/test_tool.py b/python/packages/main/tests/unit/test_tool.py index 2d8c011362..bfc5eeeb74 100644 --- a/python/packages/main/tests/unit/test_tool.py +++ b/python/packages/main/tests/unit/test_tool.py @@ -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() == { diff --git a/python/packages/main/tests/unit/test_types.py b/python/packages/main/tests/unit/test_types.py index a16fb02df9..82378753e0 100644 --- a/python/packages/main/tests/unit/test_types.py +++ b/python/packages/main/tests/unit/test_types.py @@ -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 diff --git a/python/packages/openai/README.md b/python/packages/openai/README.md deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/python/packages/openai/agent_framework/openai/__init__.py b/python/packages/openai/agent_framework/openai/__init__.py deleted file mode 100644 index e1e5fd8602..0000000000 --- a/python/packages/openai/agent_framework/openai/__init__.py +++ /dev/null @@ -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__", -] diff --git a/python/packages/openai/agent_framework/openai/_openai_config_base.py b/python/packages/openai/agent_framework/openai/_openai_config_base.py deleted file mode 100644 index 0963ae7dba..0000000000 --- a/python/packages/openai/agent_framework/openai/_openai_config_base.py +++ /dev/null @@ -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 diff --git a/python/packages/openai/agent_framework/openai/_openai_handler.py b/python/packages/openai/agent_framework/openai/_openai_handler.py deleted file mode 100644 index 0b97ff9d7e..0000000000 --- a/python/packages/openai/agent_framework/openai/_openai_handler.py +++ /dev/null @@ -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) diff --git a/python/packages/openai/agent_framework/openai/_openai_model_types.py b/python/packages/openai/agent_framework/openai/_openai_model_types.py deleted file mode 100644 index a6a6cb4c39..0000000000 --- a/python/packages/openai/agent_framework/openai/_openai_model_types.py +++ /dev/null @@ -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" diff --git a/python/packages/openai/agent_framework/openai/_openai_settings.py b/python/packages/openai/agent_framework/openai/_openai_settings.py deleted file mode 100644 index 0f09d88387..0000000000 --- a/python/packages/openai/agent_framework/openai/_openai_settings.py +++ /dev/null @@ -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 diff --git a/python/packages/openai/agent_framework/openai/py.typed b/python/packages/openai/agent_framework/openai/py.typed deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml deleted file mode 100644 index 9e08fa23a6..0000000000 --- a/python/packages/openai/pyproject.toml +++ /dev/null @@ -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" \ No newline at end of file diff --git a/python/packages/openai/tests/__init__.py b/python/packages/openai/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/python/packages/openai/tests/conftest.py b/python/packages/openai/tests/conftest.py deleted file mode 100644 index 2e62e07ba5..0000000000 --- a/python/packages/openai/tests/conftest.py +++ /dev/null @@ -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 [] diff --git a/python/packages/openai/tests/unit/__init__.py b/python/packages/openai/tests/unit/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/python/packages/openai/tests/unit/test_cross_package.py b/python/packages/openai/tests/unit/test_cross_package.py deleted file mode 100644 index 4a14ad6b88..0000000000 --- a/python/packages/openai/tests/unit/test_cross_package.py +++ /dev/null @@ -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 diff --git a/python/pyproject.toml b/python/pyproject.toml index 95e1498820..bffb29fc49 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -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" diff --git a/python/samples/getting_started/chat_client/openai_chat_client.py b/python/samples/getting_started/chat_client/openai_chat_client.py new file mode 100644 index 0000000000..e89715d99d --- /dev/null +++ b/python/samples/getting_started/chat_client/openai_chat_client.py @@ -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()) diff --git a/python/uv.lock b/python/uv.lock index 19979ae884..7dd2b4d5b2 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -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]]