mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: follow on work on OpenAI (#169)
* updated openai, fcc works, with sample * reduced files in openai
This commit is contained in:
committed by
GitHub
Unverified
parent
80c1e2ee0a
commit
407ed6de70
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+2
-4
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
+84
-52
@@ -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
|
||||
+2
-2
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() == {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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())
|
||||
Generated
+22
-29
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user