mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [BREAKING] added SerializationMixin and applied to contents, agents, chat client… (#1012)
* added SerializationMixin and applied to contents, agents, chat clients, removed AFBaseModel * fix annotations type * mypy fixes * fix tests * fix serializable subvalues and added large docstring * updated indents in code block * fixed exported urls
This commit is contained in:
committed by
GitHub
Unverified
parent
3eb26632ce
commit
54ad135914
@@ -5,7 +5,7 @@ import json
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Sequence
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
from a2a.client import Client, ClientConfig, ClientFactory, minimal_agent_card
|
||||
@@ -307,7 +307,7 @@ class A2AAgent(BaseAgent):
|
||||
role=A2ARole("user"),
|
||||
parts=parts,
|
||||
message_id=message.message_id or uuid.uuid4().hex,
|
||||
metadata=message.additional_properties,
|
||||
metadata=cast(dict[str, Any], message.additional_properties),
|
||||
)
|
||||
|
||||
def _a2a_parts_to_contents(self, parts: Sequence[A2APart]) -> list[Contents]:
|
||||
|
||||
@@ -84,7 +84,7 @@ from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.ai.projects.models import ConnectionType
|
||||
from azure.core.credentials_async import AsyncTokenCredential
|
||||
from azure.core.exceptions import HttpResponseError, ResourceNotFoundError
|
||||
from pydantic import BaseModel, Field, PrivateAttr, ValidationError
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from typing import Self # pragma: no cover
|
||||
@@ -128,14 +128,6 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
"""Azure AI Agent Chat client."""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
project_client: AIProjectClient = Field(...)
|
||||
credential: AsyncTokenCredential | None = Field(...)
|
||||
agent_id: str | None = Field(default=None)
|
||||
agent_name: str | None = Field(default=None)
|
||||
ai_model_id: str | None = Field(default=None)
|
||||
thread_id: str | None = Field(default=None)
|
||||
_should_delete_agent: bool = PrivateAttr(default=False) # Track whether we should delete the agent
|
||||
_should_close_client: bool = PrivateAttr(default=False) # Track whether we should close client connection
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -205,16 +197,18 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
)
|
||||
should_close_client = True
|
||||
|
||||
super().__init__(
|
||||
project_client=project_client, # type: ignore[reportCallIssue]
|
||||
credential=async_credential, # type: ignore[reportCallIssue]
|
||||
agent_id=agent_id, # type: ignore[reportCallIssue]
|
||||
thread_id=thread_id, # type: ignore[reportCallIssue]
|
||||
agent_name=agent_name, # type: ignore[reportCallIssue]
|
||||
ai_model_id=azure_ai_settings.model_deployment_name, # type: ignore[reportCallIssue]
|
||||
**kwargs,
|
||||
)
|
||||
self._should_close_client = should_close_client
|
||||
# Initialize parent
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# Initialize instance variables
|
||||
self.project_client = project_client
|
||||
self.credential = async_credential
|
||||
self.agent_id = agent_id
|
||||
self.agent_name = agent_name
|
||||
self.model_id = azure_ai_settings.model_deployment_name
|
||||
self.thread_id = thread_id
|
||||
self._should_delete_agent = False # Track whether we should delete the agent
|
||||
self._should_close_client = should_close_client # Track whether we should close client connection
|
||||
|
||||
async def setup_azure_ai_observability(self, enable_sensitive_data: bool | None = None) -> None:
|
||||
"""Use this method to setup tracing in your Azure AI Project.
|
||||
@@ -251,7 +245,7 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
await self._close_client_if_needed()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type[TAzureAIAgentClient], settings: dict[str, Any]) -> TAzureAIAgentClient:
|
||||
def from_settings(cls: type[TAzureAIAgentClient], settings: dict[str, Any]) -> TAzureAIAgentClient:
|
||||
"""Initialize a AzureAIAgentClient from a dictionary of settings.
|
||||
|
||||
Args:
|
||||
@@ -317,11 +311,11 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
"""
|
||||
# If no agent_id is provided, create a temporary agent
|
||||
if self.agent_id is None:
|
||||
if not self.ai_model_id:
|
||||
if not self.model_id:
|
||||
raise ServiceInitializationError("Model deployment name is required for agent creation.")
|
||||
|
||||
agent_name: str = self.agent_name or "UnnamedAgent"
|
||||
args: dict[str, Any] = {"model": self.ai_model_id, "name": agent_name}
|
||||
args: dict[str, Any] = {"model": self.model_id, "name": agent_name}
|
||||
if run_options:
|
||||
if "tools" in run_options:
|
||||
args["tools"] = run_options["tools"]
|
||||
@@ -866,8 +860,8 @@ class AzureAIAgentClient(BaseChatClient):
|
||||
if isinstance(content, FunctionResultContent):
|
||||
if tool_outputs is None:
|
||||
tool_outputs = []
|
||||
result_contents: list[Any] = ( # type: ignore
|
||||
content.result if isinstance(content.result, list) else [content.result] # type: ignore
|
||||
result_contents: list[Any] = (
|
||||
content.result if isinstance(content.result, list) else [content.result]
|
||||
)
|
||||
results: list[Any] = []
|
||||
for item in result_contents:
|
||||
|
||||
@@ -50,17 +50,26 @@ def create_test_azure_ai_chat_client(
|
||||
azure_ai_settings: AzureAISettings | None = None,
|
||||
should_delete_agent: bool = False,
|
||||
) -> AzureAIAgentClient:
|
||||
"""Helper function to create AzureAIAgentClient instances for testing, bypassing Pydantic validation."""
|
||||
"""Helper function to create AzureAIAgentClient instances for testing, bypassing normal validation."""
|
||||
if azure_ai_settings is None:
|
||||
azure_ai_settings = AzureAISettings(env_file_path="test.env")
|
||||
|
||||
return AzureAIAgentClient.model_construct(
|
||||
project_client=mock_ai_project_client,
|
||||
agent_id=agent_id,
|
||||
thread_id=thread_id,
|
||||
_should_delete_agent=should_delete_agent,
|
||||
ai_model_id=azure_ai_settings.model_deployment_name,
|
||||
)
|
||||
# Create client instance directly
|
||||
client = object.__new__(AzureAIAgentClient)
|
||||
|
||||
# Set attributes directly
|
||||
client.project_client = mock_ai_project_client
|
||||
client.credential = None
|
||||
client.agent_id = agent_id
|
||||
client.agent_name = None
|
||||
client.model_id = azure_ai_settings.model_deployment_name
|
||||
client.thread_id = thread_id
|
||||
client._should_delete_agent = should_delete_agent
|
||||
client._should_close_client = False
|
||||
client.additional_properties = {}
|
||||
client.middleware = None
|
||||
|
||||
return client
|
||||
|
||||
|
||||
def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None:
|
||||
@@ -101,14 +110,19 @@ def test_azure_ai_chat_client_init_auto_create_client(
|
||||
) -> None:
|
||||
"""Test AzureAIAgentClient initialization with auto-created project_client."""
|
||||
azure_ai_settings = AzureAISettings(**azure_ai_unit_test_env) # type: ignore
|
||||
chat_client = AzureAIAgentClient.model_construct(
|
||||
project_client=mock_ai_project_client,
|
||||
agent_id=None,
|
||||
thread_id=None,
|
||||
_should_delete_agent=False,
|
||||
_azure_ai_settings=azure_ai_settings,
|
||||
credential=None,
|
||||
)
|
||||
|
||||
# Create client instance directly
|
||||
chat_client = object.__new__(AzureAIAgentClient)
|
||||
chat_client.project_client = mock_ai_project_client
|
||||
chat_client.agent_id = None
|
||||
chat_client.thread_id = None
|
||||
chat_client._should_delete_agent = False
|
||||
chat_client._should_close_client = False
|
||||
chat_client.credential = None
|
||||
chat_client.model_id = azure_ai_settings.model_deployment_name
|
||||
chat_client.agent_name = None
|
||||
chat_client.additional_properties = {}
|
||||
chat_client.middleware = None
|
||||
|
||||
assert chat_client.project_client is mock_ai_project_client
|
||||
assert chat_client.agent_id is None
|
||||
@@ -169,7 +183,6 @@ def test_azure_ai_chat_client_from_dict(mock_ai_project_client: MagicMock) -> No
|
||||
azure_ai_settings = AzureAISettings(
|
||||
project_endpoint=settings["project_endpoint"],
|
||||
model_deployment_name=settings["model_deployment_name"],
|
||||
agent_name=settings["agent_name"],
|
||||
)
|
||||
|
||||
chat_client: AzureAIAgentClient = create_test_azure_ai_chat_client(
|
||||
@@ -229,9 +242,7 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_create_new(
|
||||
azure_ai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test _get_agent_id_or_create when creating a new agent."""
|
||||
azure_ai_settings = AzureAISettings(
|
||||
model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], agent_name="TestAgent"
|
||||
)
|
||||
azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"])
|
||||
chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, azure_ai_settings=azure_ai_settings)
|
||||
|
||||
agent_id = await chat_client._get_agent_id_or_create() # type: ignore
|
||||
@@ -519,9 +530,7 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_run_options(
|
||||
mock_ai_project_client: MagicMock, azure_ai_unit_test_env: dict[str, str]
|
||||
) -> None:
|
||||
"""Test _get_agent_id_or_create with run_options containing tools and instructions."""
|
||||
azure_ai_settings = AzureAISettings(
|
||||
model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], agent_name="TestAgent"
|
||||
)
|
||||
azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"])
|
||||
chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, azure_ai_settings=azure_ai_settings)
|
||||
|
||||
run_options = {
|
||||
@@ -618,6 +627,7 @@ def get_weather(
|
||||
return f"The weather in {location} is sunny with a high of 25°C."
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_get_response() -> None:
|
||||
"""Test Azure AI Chat Client response."""
|
||||
@@ -642,6 +652,7 @@ async def test_azure_ai_chat_client_get_response() -> None:
|
||||
assert any(word in response.text.lower() for word in ["sunny", "25"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_get_response_tools() -> None:
|
||||
"""Test Azure AI Chat Client response with tools."""
|
||||
@@ -663,6 +674,7 @@ async def test_azure_ai_chat_client_get_response_tools() -> None:
|
||||
assert any(word in response.text.lower() for word in ["sunny", "25"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_streaming() -> None:
|
||||
"""Test Azure AI Chat Client streaming response."""
|
||||
@@ -693,6 +705,7 @@ async def test_azure_ai_chat_client_streaming() -> None:
|
||||
assert any(word in full_message.lower() for word in ["sunny", "25"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_streaming_tools() -> None:
|
||||
"""Test Azure AI Chat Client streaming response with tools."""
|
||||
@@ -719,6 +732,7 @@ async def test_azure_ai_chat_client_streaming_tools() -> None:
|
||||
assert any(word in full_message.lower() for word in ["sunny", "25"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_basic_run() -> None:
|
||||
"""Test ChatAgent basic run functionality with AzureAIAgentClient."""
|
||||
@@ -735,6 +749,7 @@ async def test_azure_ai_chat_client_agent_basic_run() -> None:
|
||||
assert "Hello World" in response.text
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_basic_run_streaming() -> None:
|
||||
"""Test ChatAgent basic streaming functionality with AzureAIAgentClient."""
|
||||
@@ -754,6 +769,7 @@ async def test_azure_ai_chat_client_agent_basic_run_streaming() -> None:
|
||||
assert "streaming response test" in full_message.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_thread_persistence() -> None:
|
||||
"""Test ChatAgent thread persistence across runs with AzureAIAgentClient."""
|
||||
@@ -779,6 +795,7 @@ async def test_azure_ai_chat_client_agent_thread_persistence() -> None:
|
||||
assert "42" in second_response.text
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_existing_thread_id() -> None:
|
||||
"""Test ChatAgent existing thread ID functionality with AzureAIAgentClient."""
|
||||
@@ -816,6 +833,7 @@ async def test_azure_ai_chat_client_agent_existing_thread_id() -> None:
|
||||
assert "alice" in response2.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_code_interpreter():
|
||||
"""Test ChatAgent with code interpreter through AzureAIAgentClient."""
|
||||
@@ -835,6 +853,7 @@ async def test_azure_ai_chat_client_agent_code_interpreter():
|
||||
assert "120" in response.text or "factorial" in response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_with_mcp_tools() -> None:
|
||||
"""Test MCP tools defined at agent creation with AzureAIAgentClient."""
|
||||
@@ -856,6 +875,7 @@ async def test_azure_ai_chat_client_agent_with_mcp_tools() -> None:
|
||||
assert any(term in response.text.lower() for term in ["app service", "azure", "web", "application"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_ai_integration_tests_disabled
|
||||
async def test_azure_ai_chat_client_agent_level_tool_persistence():
|
||||
"""Test that agent-level tools persist across multiple runs with AzureAIAgentClient."""
|
||||
|
||||
@@ -268,7 +268,6 @@ class CopilotStudioAgent(BaseAgent):
|
||||
yield AgentRunResponseUpdate(
|
||||
role=message.role,
|
||||
contents=message.contents,
|
||||
additional_properties=message.additional_properties,
|
||||
author_name=message.author_name,
|
||||
raw_representation=message.raw_representation,
|
||||
response_id=message.message_id,
|
||||
|
||||
@@ -16,6 +16,7 @@ from ._logging import get_logger
|
||||
from ._mcp import MCPTool
|
||||
from ._memory import AggregateContextProvider, Context, ContextProvider
|
||||
from ._middleware import Middleware, use_agent_middleware
|
||||
from ._serialization import SerializationMixin
|
||||
from ._threads import AgentThread, ChatMessageStoreProtocol
|
||||
from ._tools import FUNCTION_INVOKING_CHAT_CLIENT_MARKER, AIFunction, ToolProtocol
|
||||
from ._types import (
|
||||
@@ -135,26 +136,20 @@ class AgentProtocol(Protocol):
|
||||
# region BaseAgent
|
||||
|
||||
|
||||
class BaseAgent:
|
||||
"""Base class for all Agent Framework agents.
|
||||
class BaseAgent(SerializationMixin):
|
||||
"""Base class for all Agent Framework agents."""
|
||||
|
||||
Attributes:
|
||||
id: The unique identifier of the agent If no id is provided,
|
||||
a new UUID will be generated.
|
||||
name: The name of the agent, can be None.
|
||||
description: The description of the agent.
|
||||
display_name: The display name of the agent, which is either the name or id.
|
||||
context_providers: The collection of multiple context providers to include during agent invocation.
|
||||
middleware: List of middleware to intercept agent and function invocations.
|
||||
"""
|
||||
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
id: str | None = None,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
context_providers: ContextProvider | Sequence[ContextProvider] | None = None,
|
||||
middleware: Middleware | Sequence[Middleware] | None = None,
|
||||
additional_properties: MutableMapping[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Base class for all Agent Framework agents.
|
||||
@@ -167,7 +162,8 @@ class BaseAgent:
|
||||
display_name: The display name of the agent, which is either the name or id.
|
||||
context_providers: The collection of multiple context providers to include during agent invocation.
|
||||
middleware: List of middleware to intercept agent and function invocations.
|
||||
kwargs: will be stored in `additional_properties`
|
||||
additional_properties: Additional properties set on the agent.
|
||||
kwargs: Additional keyword arguments (merged into additional_properties).
|
||||
"""
|
||||
if id is None:
|
||||
id = str(uuid4())
|
||||
@@ -179,7 +175,10 @@ class BaseAgent:
|
||||
self.middleware: list[Middleware] | None = cast(list[Middleware], middleware) if middleware else None
|
||||
else:
|
||||
self.middleware = [middleware]
|
||||
self.additional_properties = kwargs
|
||||
|
||||
# Merge kwargs into additional_properties
|
||||
self.additional_properties: dict[str, Any] = cast(dict[str, Any], additional_properties or {})
|
||||
self.additional_properties.update(kwargs)
|
||||
|
||||
async def _notify_thread_of_new_messages(
|
||||
self,
|
||||
@@ -213,7 +212,7 @@ class BaseAgent:
|
||||
async def deserialize_thread(self, serialized_thread: Any, **kwargs: Any) -> AgentThread:
|
||||
"""Deserializes the thread."""
|
||||
thread: AgentThread = self.get_new_thread()
|
||||
await thread.deserialize(serialized_thread, **kwargs)
|
||||
await thread.update_from_thread_state(serialized_thread, **kwargs)
|
||||
return thread
|
||||
|
||||
def as_tool(
|
||||
@@ -700,7 +699,7 @@ class ChatAgent(BaseAgent):
|
||||
store=store,
|
||||
temperature=temperature,
|
||||
tool_choice=tool_choice,
|
||||
tools=final_tools, # type: ignore[reportArgumentType]
|
||||
tools=final_tools,
|
||||
top_p=top_p,
|
||||
user=user,
|
||||
additional_properties=additional_properties or {},
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterable, Callable, MutableMapping, MutableSequence, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, runtime_checkable
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Protocol, TypeVar, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._logging import get_logger
|
||||
from ._mcp import MCPTool
|
||||
@@ -17,7 +17,7 @@ from ._middleware import (
|
||||
FunctionMiddlewareCallable,
|
||||
Middleware,
|
||||
)
|
||||
from ._pydantic import AFBaseModel
|
||||
from ._serialization import SerializationMixin
|
||||
from ._threads import ChatMessageStoreProtocol
|
||||
from ._tools import ToolProtocol
|
||||
from ._types import (
|
||||
@@ -189,21 +189,61 @@ def prepare_messages(messages: str | ChatMessage | list[str] | list[ChatMessage]
|
||||
return return_messages
|
||||
|
||||
|
||||
class BaseChatClient(AFBaseModel, ABC):
|
||||
class BaseChatClient(SerializationMixin, ABC):
|
||||
"""Base class for chat clients."""
|
||||
|
||||
additional_properties: dict[str, Any] = Field(default_factory=dict)
|
||||
middleware: (
|
||||
ChatMiddleware
|
||||
| ChatMiddlewareCallable
|
||||
| FunctionMiddleware
|
||||
| FunctionMiddlewareCallable
|
||||
| list[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable]
|
||||
| None
|
||||
) = None
|
||||
OTEL_PROVIDER_NAME: str = "unknown"
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "unknown"
|
||||
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"}
|
||||
# This is used for OTel setup, should be overridden in subclasses
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
middleware: (
|
||||
ChatMiddleware
|
||||
| ChatMiddlewareCallable
|
||||
| FunctionMiddleware
|
||||
| FunctionMiddlewareCallable
|
||||
| list[ChatMiddleware | ChatMiddlewareCallable | FunctionMiddleware | FunctionMiddlewareCallable]
|
||||
| None
|
||||
) = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize BaseChatClient.
|
||||
|
||||
Args:
|
||||
additional_properties: Additional properties for the client.
|
||||
middleware: Middleware for the client.
|
||||
**kwargs: Additional keyword arguments (merged into additional_properties).
|
||||
"""
|
||||
# Merge kwargs into additional_properties
|
||||
self.additional_properties = additional_properties or {}
|
||||
self.additional_properties.update(kwargs)
|
||||
|
||||
self.middleware = middleware
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Convert the instance to a dictionary.
|
||||
|
||||
Extracts additional_properties fields to the root level.
|
||||
|
||||
Args:
|
||||
exclude: Set of field names to exclude from serialization.
|
||||
exclude_none: Whether to exclude None values from the output. Defaults to True.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the instance.
|
||||
"""
|
||||
# Get the base dict from SerializationMixin
|
||||
result = super().to_dict(exclude=exclude, exclude_none=exclude_none)
|
||||
|
||||
# Extract additional_properties to root level
|
||||
if self.additional_properties:
|
||||
result.update(self.additional_properties)
|
||||
|
||||
return result
|
||||
|
||||
def prepare_messages(
|
||||
self, messages: str | ChatMessage | list[str] | list[ChatMessage], chat_options: ChatOptions
|
||||
) -> MutableSequence[ChatMessage]:
|
||||
|
||||
@@ -60,7 +60,7 @@ def _mcp_prompt_message_to_chat_message(
|
||||
"""Convert a MCP container type to a Agent Framework type."""
|
||||
return ChatMessage(
|
||||
role=Role(value=mcp_type.role),
|
||||
contents=[_mcp_type_to_ai_content(mcp_type.content)], # type: ignore[call-arg]
|
||||
contents=[_mcp_type_to_ai_content(mcp_type.content)],
|
||||
raw_representation=mcp_type,
|
||||
)
|
||||
|
||||
|
||||
@@ -3,19 +3,13 @@
|
||||
|
||||
from typing import Annotated, Any, ClassVar, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, UrlConstraints
|
||||
from pydantic import Field, UrlConstraints
|
||||
from pydantic.networks import AnyUrl
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
HTTPsUrl = Annotated[AnyUrl, UrlConstraints(max_length=2083, allowed_schemes=["https"])]
|
||||
|
||||
__all__ = ["AFBaseModel", "AFBaseSettings", "HTTPsUrl"]
|
||||
|
||||
|
||||
class AFBaseModel(BaseModel):
|
||||
"""Base class for all pydantic models in the Agent Framework."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True, validate_assignment=True)
|
||||
__all__ = ["AFBaseSettings", "HTTPsUrl"]
|
||||
|
||||
|
||||
TSettings = TypeVar("TSettings", bound="AFBaseSettings")
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import MutableMapping
|
||||
from typing import Any, ClassVar, Protocol, TypeVar, runtime_checkable
|
||||
|
||||
from ._logging import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
TClass = TypeVar("TClass", bound="SerializationMixin")
|
||||
TProtocol = TypeVar("TProtocol", bound="SerializationProtocol")
|
||||
|
||||
# Regex pattern for converting CamelCase to snake_case
|
||||
_CAMEL_TO_SNAKE_PATTERN = re.compile(r"(?<!^)(?=[A-Z])")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SerializationProtocol(Protocol):
|
||||
"""Protocol for objects that support serialization and deserialization."""
|
||||
|
||||
def to_dict(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Convert the instance to a dictionary."""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type[TProtocol], value: MutableMapping[str, Any], /, **kwargs: Any) -> TProtocol:
|
||||
"""Create an instance from a dictionary."""
|
||||
...
|
||||
|
||||
|
||||
def is_serializable(value: Any) -> bool:
|
||||
"""Check if a value is JSON serializable."""
|
||||
return isinstance(value, (str, int, float, bool, type(None), list, dict))
|
||||
|
||||
|
||||
class SerializationMixin:
|
||||
"""Mixin class providing serialization and deserialization capabilities.
|
||||
|
||||
Classes using this mixin should handle MutableMapping inputs in their __init__ method
|
||||
for any parameters that expect SerializationMixin/SerializationProtocol instances.
|
||||
The __init__ should check if the value is a MutableMapping and call from_dict() to convert it.
|
||||
|
||||
So take the two classes below as an example. The first purely uses base types, strings in this case.
|
||||
The second has a param that is of the type of the first class.
|
||||
Because we setup the __init__ method to handle MutableMapping,
|
||||
we can pass in a dict to the second class and it will convert it to an instance of the first class.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class SerializableMixinType(SerializationMixin):
|
||||
def __init__(self, param1: str, param2: int) -> None:
|
||||
self.param1 = param1
|
||||
self.param2 = param2
|
||||
|
||||
|
||||
class MyClass(SerializationMixin):
|
||||
def __init__(
|
||||
self,
|
||||
regular_param: str,
|
||||
param: SerializableMixinType | MutableMapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
if isinstance(param, MutableMapping):
|
||||
self.param = self.from_dict(param)
|
||||
else:
|
||||
self.param = param
|
||||
self.regular_param = regular_param
|
||||
|
||||
|
||||
instance = MyClass.from_dict({"regular_param": "value", "param": {"param1": "value1", "param2": 42}})
|
||||
|
||||
A more complex use case involves a injectable dependency that is not serialized.
|
||||
In this case, the dependency is passed in via the dependencies parameter to from_dict/from_json.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from libary import Client
|
||||
|
||||
|
||||
class MyClass(SerializationMixin):
|
||||
INJECTABLE = {"client"}
|
||||
|
||||
def __init__(self, regular_param: str, client: Client) -> None:
|
||||
self.client = client
|
||||
self.regular_param = regular_param
|
||||
|
||||
|
||||
json_of_class = MyClass(regular_param="value", client=Client()).to_json()
|
||||
# this looks like: {"type": "my_class", "regular_param": "value"}
|
||||
|
||||
instance = MyClass.from_dict(json_of_class, dependencies={"my_class.client": Client()})
|
||||
|
||||
During serialization, the field listed as INJECTABLE (and also DEFAULT_EXCLUDE) will be excluded from the output.
|
||||
Then in deserialization,
|
||||
the dependencies dict is checked for any keys matching the formats:
|
||||
- "<type>.<parameter>"
|
||||
- "<type>.<dict-parameter>.<key>"
|
||||
where <type> is the type identifier for the class (either the value of the 'type' class variable or
|
||||
the snake_cased class name if 'type' is not present),
|
||||
<parameter> is the name of the parameter in the __init__ method,
|
||||
<dict-parameter> is the name of a parameter that is a dict,
|
||||
and <key> is a key in that dict parameter.
|
||||
"""
|
||||
|
||||
DEFAULT_EXCLUDE: ClassVar[set[str]] = set()
|
||||
INJECTABLE: ClassVar[set[str]] = set()
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Convert the instance and any nested objects to a dictionary.
|
||||
|
||||
Args:
|
||||
exclude: Set of field names to exclude from serialization.
|
||||
exclude_none: Whether to exclude None values from the output. Defaults to True.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the instance.
|
||||
"""
|
||||
# Combine exclude sets
|
||||
combined_exclude = set(self.DEFAULT_EXCLUDE)
|
||||
if exclude:
|
||||
combined_exclude.update(exclude)
|
||||
combined_exclude.update(self.INJECTABLE)
|
||||
|
||||
# Get all instance attributes
|
||||
result: dict[str, Any] = {"type": self._get_type_identifier()}
|
||||
for key, value in self.__dict__.items():
|
||||
if key not in combined_exclude and not key.startswith("_"):
|
||||
if exclude_none and value is None:
|
||||
continue
|
||||
# Recursively serialize SerializationProtocol objects
|
||||
if isinstance(value, SerializationProtocol):
|
||||
result[key] = value.to_dict(exclude=exclude, exclude_none=exclude_none)
|
||||
continue
|
||||
# Handle lists containing SerializationProtocol objects
|
||||
if isinstance(value, list):
|
||||
value_as_list: list[Any] = []
|
||||
for item in value:
|
||||
if isinstance(item, SerializationProtocol):
|
||||
value_as_list.append(item.to_dict(exclude=exclude, exclude_none=exclude_none))
|
||||
continue
|
||||
if is_serializable(item):
|
||||
value_as_list.append(item)
|
||||
continue
|
||||
logger.debug(
|
||||
f"Skipping non-serializable item in list attribute '{key}' of type {type(item).__name__}"
|
||||
)
|
||||
result[key] = value_as_list
|
||||
continue
|
||||
# Handle dicts containing SerializationProtocol values
|
||||
if isinstance(value, dict):
|
||||
serialized_dict: dict[str, Any] = {}
|
||||
for k, v in value.items():
|
||||
if isinstance(v, SerializationProtocol):
|
||||
serialized_dict[k] = v.to_dict(exclude=exclude, exclude_none=exclude_none)
|
||||
continue
|
||||
# Check if the value is JSON serializable
|
||||
if is_serializable(v):
|
||||
serialized_dict[k] = v
|
||||
continue
|
||||
logger.debug(
|
||||
f"Skipping non-serializable value for key '{k}' in dict attribute '{key}' "
|
||||
f"of type {type(v).__name__}"
|
||||
)
|
||||
result[key] = serialized_dict
|
||||
continue
|
||||
# Directly include JSON serializable values
|
||||
if is_serializable(value):
|
||||
result[key] = value
|
||||
continue
|
||||
logger.debug(f"Skipping non-serializable attribute '{key}' of type {type(value).__name__}")
|
||||
|
||||
return result
|
||||
|
||||
def to_json(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> str:
|
||||
"""Convert the instance to a JSON string.
|
||||
|
||||
Args:
|
||||
exclude: Set of field names to exclude from serialization.
|
||||
exclude_none: Whether to exclude None values from the output. Defaults to True.
|
||||
|
||||
Returns:
|
||||
JSON string representation of the instance.
|
||||
"""
|
||||
return json.dumps(self.to_dict(exclude=exclude, exclude_none=exclude_none))
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls: type[TClass], value: MutableMapping[str, Any], /, dependencies: MutableMapping[str, Any] | None = None
|
||||
) -> TClass:
|
||||
"""Create an instance from a dictionary.
|
||||
|
||||
Args:
|
||||
value: Dictionary containing the instance data (positional-only).
|
||||
dependencies: Dictionary mapping dependency keys to values.
|
||||
Keys should be in format "<type>.<parameter>" or "<type>.<dict-parameter>.<key>".
|
||||
|
||||
Returns:
|
||||
New instance of the class.
|
||||
"""
|
||||
if dependencies is None:
|
||||
dependencies = {}
|
||||
|
||||
# Get the type identifier
|
||||
type_id = cls._get_type_identifier()
|
||||
|
||||
# Create a copy of the value dict to work with, filtering out the 'type' key
|
||||
kwargs = {k: v for k, v in value.items() if k != "type"}
|
||||
|
||||
# Process dependencies
|
||||
for dep_key, dep_value in dependencies.items():
|
||||
parts = dep_key.split(".")
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
|
||||
dep_type = parts[0]
|
||||
if dep_type != type_id:
|
||||
continue
|
||||
|
||||
param_name = parts[1]
|
||||
|
||||
# Log debug message if dependency is not in INJECTABLE
|
||||
if param_name not in cls.INJECTABLE:
|
||||
logger.debug(
|
||||
f"Dependency '{param_name}' for type '{type_id}' is not in INJECTABLE set. "
|
||||
f"Available injectable parameters: {cls.INJECTABLE}"
|
||||
)
|
||||
|
||||
if len(parts) == 2:
|
||||
# Simple parameter: <type>.<parameter>
|
||||
kwargs[param_name] = dep_value
|
||||
elif len(parts) == 3:
|
||||
# Dict parameter: <type>.<dict-parameter>.<key>
|
||||
dict_param_name = parts[1]
|
||||
key = parts[2]
|
||||
if dict_param_name not in kwargs:
|
||||
kwargs[dict_param_name] = {}
|
||||
kwargs[dict_param_name][key] = dep_value
|
||||
|
||||
return cls(**kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls: type[TClass], value: str, /, dependencies: MutableMapping[str, Any] | None = None) -> TClass:
|
||||
"""Create an instance from a JSON string.
|
||||
|
||||
Args:
|
||||
value: JSON string containing the instance data (positional-only).
|
||||
dependencies: Dictionary mapping dependency keys to values.
|
||||
Keys should be in format "<type>.<parameter>" or "<type>.<dict-parameter>.<key>".
|
||||
|
||||
Returns:
|
||||
New instance of the class.
|
||||
"""
|
||||
data = json.loads(value)
|
||||
return cls.from_dict(data, dependencies=dependencies)
|
||||
|
||||
@classmethod
|
||||
def _get_type_identifier(cls) -> str:
|
||||
"""Get the type identifier for this class.
|
||||
|
||||
Returns the value of the 'type' class variable if present,
|
||||
otherwise returns a snake_cased version of the class name.
|
||||
|
||||
Returns:
|
||||
Type identifier string.
|
||||
"""
|
||||
if (type_ := getattr(cls, "type", None)) and isinstance(type_, str):
|
||||
return type_ # type:ignore[no-any-return]
|
||||
|
||||
# Convert class name to snake_case
|
||||
return _CAMEL_TO_SNAKE_PATTERN.sub("_", cls.__name__).lower()
|
||||
@@ -3,10 +3,9 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Protocol, TypeVar
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
from ._memory import AggregateContextProvider
|
||||
from ._pydantic import AFBaseModel
|
||||
from ._types import ChatMessage
|
||||
from .exceptions import AgentThreadException
|
||||
|
||||
@@ -64,7 +63,19 @@ class ChatMessageStoreProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
class AgentThreadState(AFBaseModel):
|
||||
class ChatMessageStoreState(BaseModel):
|
||||
"""State model for serializing and deserializing chat message store data.
|
||||
|
||||
Attributes:
|
||||
messages: List of chat messages stored in the message store.
|
||||
"""
|
||||
|
||||
messages: list[ChatMessage]
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
class AgentThreadState(BaseModel):
|
||||
"""State model for serializing and deserializing thread information.
|
||||
|
||||
Attributes:
|
||||
@@ -73,7 +84,9 @@ class AgentThreadState(AFBaseModel):
|
||||
"""
|
||||
|
||||
service_thread_id: str | None = None
|
||||
chat_message_store_state: Any | None = None
|
||||
chat_message_store_state: ChatMessageStoreState | None = None
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
@model_validator(mode="before")
|
||||
def validate_only_one(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -86,16 +99,6 @@ class AgentThreadState(AFBaseModel):
|
||||
return values
|
||||
|
||||
|
||||
class ChatMessageStoreState(AFBaseModel):
|
||||
"""State model for serializing and deserializing chat message store data.
|
||||
|
||||
Attributes:
|
||||
messages: List of chat messages stored in the message store.
|
||||
"""
|
||||
|
||||
messages: list[ChatMessage]
|
||||
|
||||
|
||||
TChatMessageStore = TypeVar("TChatMessageStore", bound="ChatMessageStore")
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import (
|
||||
TYPE_CHECKING,
|
||||
Annotated,
|
||||
Any,
|
||||
ClassVar,
|
||||
Final,
|
||||
Generic,
|
||||
Literal,
|
||||
@@ -22,10 +23,10 @@ from typing import (
|
||||
)
|
||||
|
||||
from opentelemetry.metrics import Histogram
|
||||
from pydantic import AnyUrl, BaseModel, Field, PrivateAttr, ValidationError, create_model, field_validator
|
||||
from pydantic import AnyUrl, BaseModel, Field, ValidationError, create_model
|
||||
|
||||
from ._logging import get_logger
|
||||
from ._pydantic import AFBaseModel
|
||||
from ._serialization import SerializationMixin
|
||||
from .exceptions import ChatClientInitializationError, ToolException
|
||||
from .observability import (
|
||||
OPERATION_DURATION_BUCKET_BOUNDARIES,
|
||||
@@ -150,7 +151,7 @@ class ToolProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
class BaseTool(AFBaseModel):
|
||||
class BaseTool(SerializationMixin):
|
||||
"""Base class for AI tools, providing common attributes and methods.
|
||||
|
||||
Args:
|
||||
@@ -159,9 +160,29 @@ class BaseTool(AFBaseModel):
|
||||
additional_properties: Additional properties associated with the tool.
|
||||
"""
|
||||
|
||||
name: str = Field(..., kw_only=False)
|
||||
description: str = ""
|
||||
additional_properties: dict[str, Any] | None = None
|
||||
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"additional_properties"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
description: str = "",
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the BaseTool.
|
||||
|
||||
Args:
|
||||
name: The name of the tool.
|
||||
description: A description of the tool.
|
||||
additional_properties: Additional properties associated with the tool.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.additional_properties = additional_properties
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return a string representation of the tool."""
|
||||
@@ -177,8 +198,6 @@ class HostedCodeInterpreterTool(BaseTool):
|
||||
that it is allowed to execute generated code if the service is capable of doing so.
|
||||
"""
|
||||
|
||||
inputs: list[Any] = Field(default_factory=list)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -202,18 +221,17 @@ class HostedCodeInterpreterTool(BaseTool):
|
||||
additional_properties: Additional properties associated with the tool.
|
||||
**kwargs: Additional keyword arguments to pass to the base class.
|
||||
"""
|
||||
args: dict[str, Any] = {
|
||||
"name": "code_interpreter",
|
||||
}
|
||||
if inputs:
|
||||
args["inputs"] = _parse_inputs(inputs)
|
||||
if description is not None:
|
||||
args["description"] = description
|
||||
if additional_properties is not None:
|
||||
args["additional_properties"] = additional_properties
|
||||
if "name" in kwargs:
|
||||
raise ValueError("The 'name' argument is reserved for the HostedCodeInterpreterTool and cannot be set.")
|
||||
super().__init__(**args, **kwargs)
|
||||
|
||||
self.inputs = _parse_inputs(inputs) if inputs else []
|
||||
|
||||
super().__init__(
|
||||
name="code_interpreter",
|
||||
description=description or "",
|
||||
additional_properties=additional_properties,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
class HostedWebSearchTool(BaseTool):
|
||||
@@ -263,11 +281,6 @@ class HostedMCPSpecificApproval(TypedDict, total=False):
|
||||
class HostedMCPTool(BaseTool):
|
||||
"""Represents a MCP tool that is managed and executed by the service."""
|
||||
|
||||
url: AnyUrl
|
||||
approval_mode: Literal["always_require", "never_require"] | HostedMCPSpecificApproval | None = None
|
||||
allowed_tools: set[str] | None = None
|
||||
headers: dict[str, str] | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -296,43 +309,45 @@ class HostedMCPTool(BaseTool):
|
||||
additional_properties: Additional properties to include in the tool definition.
|
||||
**kwargs: Additional keyword arguments to pass to the base class.
|
||||
"""
|
||||
args: dict[str, Any] = {
|
||||
"name": name,
|
||||
"url": url,
|
||||
}
|
||||
if allowed_tools is not None:
|
||||
args["allowed_tools"] = allowed_tools
|
||||
if approval_mode is not None:
|
||||
args["approval_mode"] = approval_mode
|
||||
if headers is not None:
|
||||
args["headers"] = headers
|
||||
if description is not None:
|
||||
args["description"] = description
|
||||
if additional_properties is not None:
|
||||
args["additional_properties"] = additional_properties
|
||||
try:
|
||||
super().__init__(**args, **kwargs)
|
||||
except ValidationError as err:
|
||||
raise ToolException(f"Error initializing HostedMCPTool: {err}", inner_exception=err) from err
|
||||
# Validate approval_mode
|
||||
if approval_mode is not None:
|
||||
if isinstance(approval_mode, str):
|
||||
if approval_mode not in ("always_require", "never_require"):
|
||||
raise ValueError(
|
||||
f"Invalid approval_mode: {approval_mode}. "
|
||||
"Must be 'always_require', 'never_require', or a dict with 'always_require_approval' "
|
||||
"or 'never_require_approval' keys."
|
||||
)
|
||||
elif isinstance(approval_mode, dict):
|
||||
# Validate that the dict has sets
|
||||
for key, value in approval_mode.items():
|
||||
if not isinstance(value, set):
|
||||
approval_mode[key] = set(value) # type: ignore
|
||||
|
||||
@field_validator("approval_mode")
|
||||
def validate_approval_mode(cls, approval_mode: str | dict[str, Any] | None) -> str | dict[str, Any] | None:
|
||||
"""Validate the approval_mode field to ensure it is one of the accepted values."""
|
||||
if approval_mode is None or not isinstance(approval_mode, dict):
|
||||
return approval_mode
|
||||
# Validate that the dict has sets
|
||||
for key, value in approval_mode.items():
|
||||
if not isinstance(value, set):
|
||||
approval_mode[key] = set(value) # Convert to set if it's a list or other collection
|
||||
return approval_mode
|
||||
# Validate allowed_tools
|
||||
if allowed_tools is not None and isinstance(allowed_tools, dict):
|
||||
raise TypeError(
|
||||
f"allowed_tools must be a sequence of strings, not a dict. Got: {type(allowed_tools).__name__}"
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
name=name,
|
||||
description=description or "",
|
||||
additional_properties=additional_properties,
|
||||
**kwargs,
|
||||
)
|
||||
self.url = url if isinstance(url, AnyUrl) else AnyUrl(url)
|
||||
self.approval_mode = approval_mode
|
||||
self.allowed_tools = set(allowed_tools) if allowed_tools else None
|
||||
self.headers = headers
|
||||
except (ValidationError, ValueError, TypeError) as err:
|
||||
raise ToolException(f"Error initializing HostedMCPTool: {err}", inner_exception=err) from err
|
||||
|
||||
|
||||
class HostedFileSearchTool(BaseTool):
|
||||
"""Represents a file search tool that can be specified to an AI service to enable it to perform file searches."""
|
||||
|
||||
inputs: list[Any] | None = None
|
||||
max_results: int | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
inputs: "Contents | dict[str, Any] | str | list[Contents | dict[str, Any] | str] | None" = None,
|
||||
@@ -357,20 +372,18 @@ class HostedFileSearchTool(BaseTool):
|
||||
additional_properties: Additional properties associated with the tool.
|
||||
**kwargs: Additional keyword arguments to pass to the base class.
|
||||
"""
|
||||
args: dict[str, Any] = {
|
||||
"name": "file_search",
|
||||
}
|
||||
if inputs:
|
||||
args["inputs"] = _parse_inputs(inputs)
|
||||
if max_results:
|
||||
args["max_results"] = max_results
|
||||
if description is not None:
|
||||
args["description"] = description
|
||||
if additional_properties is not None:
|
||||
args["additional_properties"] = additional_properties
|
||||
if "name" in kwargs:
|
||||
raise ValueError("The 'name' argument is reserved for the HostedFileSearchTool and cannot be set.")
|
||||
super().__init__(**args, **kwargs)
|
||||
|
||||
self.inputs = _parse_inputs(inputs) if inputs else None
|
||||
self.max_results = max_results
|
||||
|
||||
super().__init__(
|
||||
name="file_search",
|
||||
description=description or "",
|
||||
additional_properties=additional_properties,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _default_histogram() -> Histogram:
|
||||
@@ -406,9 +419,38 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
|
||||
input_model: The Pydantic model that defines the input parameters for the function.
|
||||
"""
|
||||
|
||||
func: Callable[..., Awaitable[ReturnT] | ReturnT]
|
||||
input_model: type[ArgsT]
|
||||
_invocation_duration_histogram: Histogram = PrivateAttr(default_factory=_default_histogram)
|
||||
INJECTABLE: ClassVar[set[str]] = {"func"}
|
||||
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"input_model", "_invocation_duration_histogram"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
description: str = "",
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
func: Callable[..., Awaitable[ReturnT] | ReturnT],
|
||||
input_model: type[ArgsT],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize the AIFunction.
|
||||
|
||||
Args:
|
||||
name: The name of the function.
|
||||
description: A description of the function.
|
||||
additional_properties: Additional properties to set on the function.
|
||||
func: The function to wrap.
|
||||
input_model: The Pydantic model that defines the input parameters for the function.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
super().__init__(
|
||||
name=name,
|
||||
description=description,
|
||||
additional_properties=additional_properties,
|
||||
**kwargs,
|
||||
)
|
||||
self.func = func
|
||||
self.input_model = input_model
|
||||
self._invocation_duration_histogram = _default_histogram()
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> ReturnT | Awaitable[ReturnT]:
|
||||
"""Call the wrapped function with the provided arguments."""
|
||||
@@ -668,7 +710,7 @@ def _get_tool_map(
|
||||
tools: "ToolProtocol \
|
||||
| Callable[..., Any] \
|
||||
| MutableMapping[str, Any] \
|
||||
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]",
|
||||
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]",
|
||||
) -> dict[str, AIFunction[Any, Any]]:
|
||||
ai_function_list: dict[str, AIFunction[Any, Any]] = {}
|
||||
for tool in tools if isinstance(tools, list) else [tools]:
|
||||
@@ -689,7 +731,7 @@ async def execute_function_calls(
|
||||
tools: "ToolProtocol \
|
||||
| Callable[..., Any] \
|
||||
| MutableMapping[str, Any] \
|
||||
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]",
|
||||
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]",
|
||||
middleware_pipeline: Any = None, # Optional MiddlewarePipeline to avoid circular imports
|
||||
) -> list["Contents"]:
|
||||
tool_map = _get_tool_map(tools)
|
||||
@@ -778,7 +820,7 @@ def _handle_function_calls_response(
|
||||
# Use the stored middleware pipeline instead of extracting from kwargs
|
||||
# because kwargs may have been modified by the underlying function
|
||||
middleware_pipeline = stored_middleware_pipeline
|
||||
function_results = await execute_function_calls(
|
||||
function_call_results: list[Contents] = await execute_function_calls(
|
||||
custom_args=kwargs,
|
||||
attempt_idx=attempt_idx,
|
||||
function_calls=function_calls,
|
||||
@@ -786,7 +828,7 @@ def _handle_function_calls_response(
|
||||
middleware_pipeline=middleware_pipeline,
|
||||
)
|
||||
# add a single ChatMessage to the response with the results
|
||||
result_message = ChatMessage(role="tool", contents=function_results) # type: ignore[call-overload]
|
||||
result_message = ChatMessage(role="tool", contents=function_call_results)
|
||||
response.messages.append(result_message)
|
||||
# response should contain 2 messages after this,
|
||||
# one with function call contents
|
||||
@@ -891,7 +933,7 @@ def _handle_function_calls_streaming_response(
|
||||
update_conversation_id(kwargs, response.conversation_id)
|
||||
prepped_messages = []
|
||||
|
||||
tools = kwargs.get("tools")
|
||||
tools: Sequence[ToolProtocol | MutableMapping[str, Any]] | None = kwargs.get("tools")
|
||||
if not tools and (chat_options := kwargs.get("chat_options")) and isinstance(chat_options, ChatOptions):
|
||||
tools = chat_options.tools
|
||||
|
||||
@@ -903,7 +945,7 @@ def _handle_function_calls_streaming_response(
|
||||
custom_args=kwargs,
|
||||
attempt_idx=attempt_idx,
|
||||
function_calls=function_calls,
|
||||
tools=tools, # type: ignore[reportArgumentType]
|
||||
tools=tools,
|
||||
middleware_pipeline=middleware_pipeline,
|
||||
)
|
||||
function_result_msg = ChatMessage(role="tool", contents=function_results)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -127,9 +127,10 @@ class AzureOpenAIAssistantsClient(OpenAIAssistantsClient):
|
||||
async_client = AsyncAzureOpenAI(**client_params)
|
||||
|
||||
super().__init__(
|
||||
ai_model_id=azure_openai_settings.chat_deployment_name,
|
||||
model_id=azure_openai_settings.chat_deployment_name,
|
||||
assistant_id=assistant_id,
|
||||
assistant_name=assistant_name,
|
||||
thread_id=thread_id,
|
||||
async_client=async_client, # type: ignore[reportArgumentType]
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
@@ -48,6 +48,7 @@ class AzureOpenAIChatClient(AzureOpenAIConfigMixin, OpenAIBaseChatClient):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
deployment_name: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
@@ -62,6 +63,7 @@ class AzureOpenAIChatClient(AzureOpenAIConfigMixin, OpenAIBaseChatClient):
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
instruction_role: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize an AzureChatCompletion service.
|
||||
|
||||
@@ -87,6 +89,7 @@ class AzureOpenAIChatClient(AzureOpenAIConfigMixin, OpenAIBaseChatClient):
|
||||
env_file_encoding: The encoding of the environment settings file, defaults to 'utf-8'.
|
||||
instruction_role: The role to use for 'instruction' messages, for example, summarization
|
||||
prompts could use `developer` or `system`. (Optional)
|
||||
kwargs: Other keyword parameters.
|
||||
"""
|
||||
try:
|
||||
# Filter out any None values from the arguments
|
||||
@@ -123,27 +126,7 @@ class AzureOpenAIChatClient(AzureOpenAIConfigMixin, OpenAIBaseChatClient):
|
||||
default_headers=default_headers,
|
||||
client=async_client,
|
||||
instruction_role=instruction_role,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type[TAzureOpenAIChatClient], settings: dict[str, Any]) -> TAzureOpenAIChatClient:
|
||||
"""Initialize an Azure OpenAI service from a dictionary of settings.
|
||||
|
||||
Args:
|
||||
settings: A dictionary of settings for the service.
|
||||
should contain keys: service_id, and optionally:
|
||||
ad_auth, ad_token_provider, default_headers
|
||||
"""
|
||||
return cls(
|
||||
api_key=settings.get("api_key"),
|
||||
deployment_name=settings.get("deployment_name"),
|
||||
endpoint=settings.get("endpoint"),
|
||||
base_url=settings.get("base_url"),
|
||||
api_version=settings.get("api_version"),
|
||||
ad_token=settings.get("ad_token"),
|
||||
ad_token_provider=settings.get("ad_token_provider"),
|
||||
default_headers=settings.get("default_headers"),
|
||||
env_file_path=settings.get("env_file_path"),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@override
|
||||
|
||||
@@ -43,6 +43,7 @@ class AzureOpenAIResponsesClient(AzureOpenAIConfigMixin, OpenAIBaseResponsesClie
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
instruction_role: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize an AzureResponses service.
|
||||
|
||||
@@ -68,7 +69,10 @@ class AzureOpenAIResponsesClient(AzureOpenAIConfigMixin, OpenAIBaseResponsesClie
|
||||
env_file_encoding: The encoding of the environment settings file, defaults to 'utf-8'.
|
||||
instruction_role: The role to use for 'instruction' messages, for example, summarization
|
||||
prompts could use `developer` or `system`. (Optional)
|
||||
kwargs: Additional keyword arguments.
|
||||
"""
|
||||
if model_id := kwargs.pop("model_id", None) and not deployment_name:
|
||||
deployment_name = str(model_id)
|
||||
try:
|
||||
azure_openai_settings = AzureOpenAISettings(
|
||||
# pydantic settings will see if there is a value, if not, will try the env var or .env file
|
||||
@@ -115,22 +119,3 @@ class AzureOpenAIResponsesClient(AzureOpenAIConfigMixin, OpenAIBaseResponsesClie
|
||||
client=async_client,
|
||||
instruction_role=instruction_role,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type[TAzureOpenAIResponsesClient], settings: dict[str, Any]) -> TAzureOpenAIResponsesClient:
|
||||
"""Initialize an Open AI service from a dictionary of settings.
|
||||
|
||||
Args:
|
||||
settings: A dictionary of settings for the service.
|
||||
"""
|
||||
return cls(
|
||||
api_key=settings.get("api_key"),
|
||||
deployment_name=settings.get("deployment_name"),
|
||||
endpoint=settings.get("endpoint"),
|
||||
base_url=settings.get("base_url"),
|
||||
api_version=settings.get("api_version"),
|
||||
ad_token=settings.get("ad_token"),
|
||||
ad_token_provider=settings.get("ad_token_provider"),
|
||||
default_headers=settings.get("default_headers"),
|
||||
env_file_path=settings.get("env_file_path"),
|
||||
)
|
||||
|
||||
@@ -8,10 +8,10 @@ from typing import Any, ClassVar, Final
|
||||
|
||||
from azure.core.credentials import TokenCredential
|
||||
from openai.lib.azure import AsyncAzureOpenAI
|
||||
from pydantic import ConfigDict, SecretStr, model_validator, validate_call
|
||||
from pydantic import SecretStr, model_validator
|
||||
|
||||
from .._pydantic import AFBaseSettings, HTTPsUrl
|
||||
from .._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent
|
||||
from .._telemetry import APP_INFO, prepend_agent_framework_to_user_agent
|
||||
from ..exceptions import ServiceInitializationError
|
||||
from ..openai._shared import OpenAIBase
|
||||
from ._entra_id_authentication import get_entra_auth_token
|
||||
@@ -124,9 +124,9 @@ class AzureOpenAISettings(AFBaseSettings):
|
||||
class AzureOpenAIConfigMixin(OpenAIBase):
|
||||
"""Internal class for configuring a connection to an Azure OpenAI service."""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "azure_openai" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.openai"
|
||||
# Note: INJECTABLE = {"client"} is inherited from OpenAIBase
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
def __init__(
|
||||
self,
|
||||
deployment_name: str,
|
||||
@@ -207,36 +207,20 @@ class AzureOpenAIConfigMixin(OpenAIBase):
|
||||
args["websocket_base_url"] = kwargs.pop("websocket_base_url")
|
||||
|
||||
client = AsyncAzureOpenAI(**args)
|
||||
args = {
|
||||
"ai_model_id": deployment_name,
|
||||
"client": client,
|
||||
}
|
||||
if instruction_role:
|
||||
args["instruction_role"] = instruction_role
|
||||
super().__init__(**args, **kwargs)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert the configuration to a dictionary."""
|
||||
client_settings = {
|
||||
"base_url": str(self.client.base_url),
|
||||
"api_version": self.client._custom_query["api-version"], # type: ignore
|
||||
"api_key": self.client.api_key,
|
||||
"ad_token": getattr(self.client, "_azure_ad_token", None),
|
||||
"ad_token_provider": getattr(self.client, "_azure_ad_token_provider", None),
|
||||
"default_headers": {k: v for k, v in self.client.default_headers.items() if k != USER_AGENT_KEY},
|
||||
}
|
||||
base = self.model_dump(
|
||||
exclude={
|
||||
"prompt_tokens",
|
||||
"completion_tokens",
|
||||
"total_tokens",
|
||||
"api_type",
|
||||
"org_id",
|
||||
"service_id",
|
||||
"client",
|
||||
},
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
)
|
||||
base.update(client_settings)
|
||||
return base
|
||||
# Store configuration as instance attributes for serialization
|
||||
self.endpoint = str(endpoint)
|
||||
self.base_url = str(base_url)
|
||||
self.api_version = api_version
|
||||
self.deployment_name = deployment_name
|
||||
self.instruction_role = instruction_role
|
||||
# Store default_headers but filter out USER_AGENT_KEY for serialization
|
||||
if default_headers:
|
||||
from .._telemetry import USER_AGENT_KEY
|
||||
|
||||
def_headers = {k: v for k, v in default_headers.items() if k != USER_AGENT_KEY}
|
||||
else:
|
||||
def_headers = None
|
||||
self.default_headers = def_headers
|
||||
|
||||
super().__init__(model_id=deployment_name, client=client)
|
||||
|
||||
@@ -18,7 +18,7 @@ from openai.types.beta.threads import (
|
||||
from openai.types.beta.threads.run_create_params import AdditionalMessage
|
||||
from openai.types.beta.threads.run_submit_tool_outputs_params import ToolOutput
|
||||
from openai.types.beta.threads.runs import RunStep
|
||||
from pydantic import Field, PrivateAttr, SecretStr, ValidationError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from .._clients import BaseChatClient
|
||||
from .._middleware import use_chat_middleware
|
||||
@@ -57,28 +57,25 @@ __all__ = ["OpenAIAssistantsClient"]
|
||||
class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
"""OpenAI Assistants client."""
|
||||
|
||||
assistant_id: str | None = Field(default=None)
|
||||
assistant_name: str | None = Field(default=None)
|
||||
thread_id: str | None = Field(default=None)
|
||||
_should_delete_assistant: bool = PrivateAttr(default=False) # Track whether we should delete the assistant
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ai_model_id: str | None = None,
|
||||
model_id: str | None = None,
|
||||
assistant_id: str | None = None,
|
||||
assistant_name: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
api_key: str | None = None,
|
||||
org_id: str | None = None,
|
||||
base_url: str | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
async_client: AsyncOpenAI | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize an OpenAI Assistants client.
|
||||
|
||||
Args:
|
||||
ai_model_id: OpenAI model name, see
|
||||
model_id: OpenAI model name, see
|
||||
https://platform.openai.com/docs/models
|
||||
assistant_id: The ID of an OpenAI assistant to use.
|
||||
If not provided, a new assistant will be created (and deleted after the request).
|
||||
@@ -90,18 +87,21 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
the env vars or .env file value.
|
||||
org_id: The optional org ID to use. If provided will override,
|
||||
the env vars or .env file value.
|
||||
base_url: The optional base URL to use. If provided will override,
|
||||
default_headers: The default headers mapping of string keys to
|
||||
string values for HTTP requests. (Optional)
|
||||
async_client: An existing client to use. (Optional)
|
||||
env_file_path: Use the environment settings file as a fallback
|
||||
to environment variables. (Optional)
|
||||
env_file_encoding: The encoding of the environment settings file. (Optional)
|
||||
kwargs: Other keyword parameters.
|
||||
"""
|
||||
try:
|
||||
openai_settings = OpenAISettings(
|
||||
api_key=SecretStr(api_key) if api_key else None,
|
||||
api_key=api_key, # type: ignore[reportArgumentType]
|
||||
base_url=base_url,
|
||||
org_id=org_id,
|
||||
chat_model_id=ai_model_id,
|
||||
chat_model_id=model_id,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
@@ -119,15 +119,17 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
ai_model_id=openai_settings.chat_model_id,
|
||||
assistant_id=assistant_id, # type: ignore[reportCallIssue]
|
||||
assistant_name=assistant_name, # type: ignore[reportCallIssue]
|
||||
thread_id=thread_id, # type: ignore[reportCallIssue]
|
||||
model_id=openai_settings.chat_model_id,
|
||||
api_key=openai_settings.api_key.get_secret_value() if openai_settings.api_key else None,
|
||||
org_id=openai_settings.org_id,
|
||||
default_headers=default_headers,
|
||||
client=async_client,
|
||||
base_url=openai_settings.base_url,
|
||||
)
|
||||
self.assistant_id: str | None = assistant_id
|
||||
self.assistant_name: str | None = assistant_name
|
||||
self.thread_id: str | None = thread_id
|
||||
self._should_delete_assistant: bool = False
|
||||
|
||||
async def __aenter__(self) -> "Self":
|
||||
"""Async context manager entry."""
|
||||
@@ -141,8 +143,8 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
"""Clean up any assistants we created."""
|
||||
if self._should_delete_assistant and self.assistant_id is not None:
|
||||
await self.client.beta.assistants.delete(self.assistant_id)
|
||||
self.assistant_id = None
|
||||
self._should_delete_assistant = False
|
||||
object.__setattr__(self, "assistant_id", None)
|
||||
object.__setattr__(self, "_should_delete_assistant", False)
|
||||
|
||||
async def _inner_get_response(
|
||||
self,
|
||||
@@ -194,10 +196,7 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
"""
|
||||
# If no assistant is provided, create a temporary assistant
|
||||
if self.assistant_id is None:
|
||||
created_assistant = await self.client.beta.assistants.create(
|
||||
name=self.assistant_name, model=self.ai_model_id
|
||||
)
|
||||
|
||||
created_assistant = await self.client.beta.assistants.create(name=self.assistant_name, model=self.model_id)
|
||||
self.assistant_id = created_assistant.id
|
||||
self._should_delete_assistant = True
|
||||
|
||||
@@ -495,4 +494,4 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
# This is a no-op in the base class, but can be overridden by subclasses
|
||||
# to update the agent name in the client.
|
||||
if agent_name and not self.assistant_name:
|
||||
self.assistant_name = agent_name
|
||||
object.__setattr__(self, "assistant_name", agent_name)
|
||||
|
||||
@@ -14,7 +14,7 @@ from openai.types.chat.chat_completion import ChatCompletion, Choice
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
|
||||
from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall
|
||||
from pydantic import BaseModel, SecretStr, ValidationError
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from .._clients import BaseChatClient
|
||||
from .._logging import get_logger
|
||||
@@ -172,7 +172,7 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
|
||||
options_dict.pop("tool_choice", None)
|
||||
|
||||
if "model" not in options_dict:
|
||||
options_dict["model"] = self.ai_model_id
|
||||
options_dict["model"] = self.model_id
|
||||
if (
|
||||
chat_options.response_format
|
||||
and isinstance(chat_options.response_format, type)
|
||||
@@ -464,7 +464,7 @@ class OpenAIChatClient(OpenAIConfigMixin, OpenAIBaseChatClient):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ai_model_id: str | None = None,
|
||||
model_id: str | None = None,
|
||||
api_key: str | None = None,
|
||||
org_id: str | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
@@ -477,7 +477,7 @@ class OpenAIChatClient(OpenAIConfigMixin, OpenAIBaseChatClient):
|
||||
"""Initialize an OpenAIChatCompletion service.
|
||||
|
||||
Args:
|
||||
ai_model_id: OpenAI model name, see
|
||||
model_id: OpenAI model name, see
|
||||
https://platform.openai.com/docs/models
|
||||
api_key: The optional API key to use. If provided will override,
|
||||
the env vars or .env file value.
|
||||
@@ -497,10 +497,10 @@ class OpenAIChatClient(OpenAIConfigMixin, OpenAIBaseChatClient):
|
||||
"""
|
||||
try:
|
||||
openai_settings = OpenAISettings(
|
||||
api_key=SecretStr(api_key) if api_key else None,
|
||||
api_key=api_key, # type: ignore[reportArgumentType]
|
||||
base_url=base_url,
|
||||
org_id=org_id,
|
||||
chat_model_id=ai_model_id,
|
||||
chat_model_id=model_id,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
@@ -514,11 +514,11 @@ class OpenAIChatClient(OpenAIConfigMixin, OpenAIBaseChatClient):
|
||||
if not openai_settings.chat_model_id:
|
||||
raise ServiceInitializationError(
|
||||
"OpenAI model ID is required. "
|
||||
"Set via 'ai_model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable."
|
||||
"Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable."
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
ai_model_id=openai_settings.chat_model_id,
|
||||
model_id=openai_settings.chat_model_id,
|
||||
api_key=openai_settings.api_key.get_secret_value() if openai_settings.api_key else None,
|
||||
base_url=openai_settings.base_url if openai_settings.base_url else None,
|
||||
org_id=openai_settings.org_id,
|
||||
@@ -526,15 +526,3 @@ class OpenAIChatClient(OpenAIConfigMixin, OpenAIBaseChatClient):
|
||||
client=async_client,
|
||||
instruction_role=instruction_role,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type[TOpenAIChatClient], settings: dict[str, Any]) -> TOpenAIChatClient:
|
||||
"""Initialize an Open AI Chat Client from a dictionary of settings.
|
||||
|
||||
Args:
|
||||
settings: A dictionary of settings for the service.
|
||||
"""
|
||||
return cls(**settings)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -22,7 +22,7 @@ from openai.types.responses.tool_param import (
|
||||
)
|
||||
from openai.types.responses.web_search_tool_param import UserLocation as WebSearchUserLocation
|
||||
from openai.types.responses.web_search_tool_param import WebSearchToolParam
|
||||
from pydantic import BaseModel, SecretStr, ValidationError
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from .._clients import BaseChatClient
|
||||
from .._logging import get_logger
|
||||
@@ -333,7 +333,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
if chat_options.conversation_id:
|
||||
options_dict["previous_response_id"] = chat_options.conversation_id
|
||||
if "model" not in options_dict:
|
||||
options_dict["model"] = self.ai_model_id
|
||||
options_dict["model"] = self.model_id
|
||||
return options_dict
|
||||
|
||||
def _prepare_chat_messages_for_request(self, chat_messages: Sequence[ChatMessage]) -> list[dict[str, Any]]:
|
||||
@@ -731,7 +731,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
|
||||
metadata: dict[str, Any] = {}
|
||||
contents: list[Contents] = []
|
||||
conversation_id: str | None = None
|
||||
model = self.ai_model_id
|
||||
model = self.model_id
|
||||
# TODO(peterychang): Add support for other content types
|
||||
match event.type:
|
||||
# types:
|
||||
@@ -942,24 +942,27 @@ class OpenAIResponsesClient(OpenAIConfigMixin, OpenAIBaseResponsesClient):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ai_model_id: str | None = None,
|
||||
model_id: str | None = None,
|
||||
api_key: str | None = None,
|
||||
org_id: str | None = None,
|
||||
base_url: str | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
async_client: AsyncOpenAI | None = None,
|
||||
instruction_role: str | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Initialize an OpenAIChatCompletion service.
|
||||
|
||||
Args:
|
||||
ai_model_id: OpenAI model name, see
|
||||
model_id: OpenAI model name, see
|
||||
https://platform.openai.com/docs/models
|
||||
api_key: The optional API key to use. If provided will override,
|
||||
the env vars or .env file value.
|
||||
org_id: The optional org ID to use. If provided will override,
|
||||
the env vars or .env file value.
|
||||
base_url: The optional base URL to use. If provided will override,
|
||||
default_headers: The default headers mapping of string keys to
|
||||
string values for HTTP requests. (Optional)
|
||||
async_client: An existing client to use. (Optional)
|
||||
@@ -968,12 +971,14 @@ class OpenAIResponsesClient(OpenAIConfigMixin, OpenAIBaseResponsesClient):
|
||||
env_file_path: Use the environment settings file as a fallback
|
||||
to environment variables. (Optional)
|
||||
env_file_encoding: The encoding of the environment settings file. (Optional)
|
||||
kwargs: Other keyword parameters.
|
||||
"""
|
||||
try:
|
||||
openai_settings = OpenAISettings(
|
||||
api_key=SecretStr(api_key) if api_key else None,
|
||||
api_key=api_key, # type: ignore[reportArgumentType]
|
||||
org_id=org_id,
|
||||
responses_model_id=ai_model_id,
|
||||
base_url=base_url,
|
||||
responses_model_id=model_id,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
@@ -987,26 +992,15 @@ class OpenAIResponsesClient(OpenAIConfigMixin, OpenAIBaseResponsesClient):
|
||||
if not openai_settings.responses_model_id:
|
||||
raise ServiceInitializationError(
|
||||
"OpenAI model ID is required. "
|
||||
"Set via 'ai_model_id' parameter or 'OPENAI_RESPONSES_MODEL_ID' environment variable."
|
||||
"Set via 'model_id' parameter or 'OPENAI_RESPONSES_MODEL_ID' environment variable."
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
ai_model_id=openai_settings.responses_model_id,
|
||||
model_id=openai_settings.responses_model_id,
|
||||
api_key=openai_settings.api_key.get_secret_value() if openai_settings.api_key else None,
|
||||
org_id=openai_settings.org_id,
|
||||
default_headers=default_headers,
|
||||
client=async_client,
|
||||
instruction_role=instruction_role,
|
||||
base_url=openai_settings.base_url,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: type[TOpenAIResponsesClient], settings: dict[str, Any]) -> TOpenAIResponsesClient:
|
||||
"""Initialize an Open AI service from a dictionary of settings.
|
||||
|
||||
Args:
|
||||
settings: A dictionary of settings for the service.
|
||||
"""
|
||||
return cls(**settings)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from copy import copy
|
||||
from typing import Annotated, Any, ClassVar, Union
|
||||
from typing import Any, ClassVar, Union
|
||||
|
||||
from openai import (
|
||||
AsyncOpenAI,
|
||||
@@ -17,11 +17,11 @@ from openai.types.chat import ChatCompletion, ChatCompletionChunk
|
||||
from openai.types.images_response import ImagesResponse
|
||||
from openai.types.responses.response import Response
|
||||
from openai.types.responses.response_stream_event import ResponseStreamEvent
|
||||
from pydantic import ConfigDict, Field, SecretStr, validate_call
|
||||
from pydantic.types import StringConstraints
|
||||
from pydantic import SecretStr
|
||||
|
||||
from .._logging import get_logger
|
||||
from .._pydantic import AFBaseModel, AFBaseSettings
|
||||
from .._pydantic import AFBaseSettings
|
||||
from .._serialization import SerializationMixin
|
||||
from .._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent
|
||||
from .._types import ChatOptions, Contents
|
||||
from ..exceptions import ServiceInitializationError
|
||||
@@ -106,11 +106,45 @@ class OpenAISettings(AFBaseSettings):
|
||||
responses_model_id: str | None = None
|
||||
|
||||
|
||||
class OpenAIBase(AFBaseModel):
|
||||
class OpenAIBase(SerializationMixin):
|
||||
"""Base class for OpenAI Clients."""
|
||||
|
||||
client: AsyncOpenAI
|
||||
ai_model_id: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||
INJECTABLE: ClassVar[set[str]] = {"client"}
|
||||
|
||||
def __init__(self, *, client: AsyncOpenAI, model_id: str, **kwargs: Any) -> None:
|
||||
"""Initialize OpenAIBase.
|
||||
|
||||
Args:
|
||||
client: The AsyncOpenAI client instance.
|
||||
model_id: The AI model ID to use (non-empty, whitespace stripped).
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
if not model_id or not model_id.strip():
|
||||
raise ValueError("model_id must be a non-empty string")
|
||||
self.client = client
|
||||
self.model_id = model_id.strip()
|
||||
|
||||
# Call super().__init__() to continue MRO chain (e.g., BaseChatClient)
|
||||
# Extract known kwargs that belong to other base classes
|
||||
additional_properties = kwargs.pop("additional_properties", None)
|
||||
middleware = kwargs.pop("middleware", None)
|
||||
instruction_role = kwargs.pop("instruction_role", None)
|
||||
|
||||
# Build super().__init__() args
|
||||
super_kwargs = {}
|
||||
if additional_properties is not None:
|
||||
super_kwargs["additional_properties"] = additional_properties
|
||||
if middleware is not None:
|
||||
super_kwargs["middleware"] = middleware
|
||||
|
||||
# Call super().__init__() with filtered kwargs
|
||||
super().__init__(**super_kwargs)
|
||||
|
||||
# Store instruction_role and any remaining kwargs as instance attributes
|
||||
if instruction_role is not None:
|
||||
self.instruction_role = instruction_role
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
class OpenAIConfigMixin(OpenAIBase):
|
||||
@@ -118,11 +152,10 @@ class OpenAIConfigMixin(OpenAIBase):
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "openai" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
|
||||
@validate_call(config=ConfigDict(arbitrary_types_allowed=True))
|
||||
def __init__(
|
||||
self,
|
||||
ai_model_id: str = Field(min_length=1),
|
||||
api_key: str | None = Field(min_length=1),
|
||||
model_id: str,
|
||||
api_key: str | None = None,
|
||||
org_id: str | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
client: AsyncOpenAI | None = None,
|
||||
@@ -136,7 +169,7 @@ class OpenAIConfigMixin(OpenAIBase):
|
||||
different types of AI model interactions, like chat or text completion.
|
||||
|
||||
Args:
|
||||
ai_model_id: OpenAI model identifier. Must be non-empty.
|
||||
model_id: OpenAI model identifier. Must be non-empty.
|
||||
Default to a preset value.
|
||||
api_key: OpenAI API key for authentication.
|
||||
Must be non-empty. (Optional)
|
||||
@@ -167,32 +200,25 @@ class OpenAIConfigMixin(OpenAIBase):
|
||||
if base_url:
|
||||
args["base_url"] = base_url
|
||||
client = AsyncOpenAI(**args)
|
||||
|
||||
# Store configuration as instance attributes for serialization
|
||||
self.org_id = org_id
|
||||
self.base_url = str(base_url)
|
||||
# Store default_headers but filter out USER_AGENT_KEY for serialization
|
||||
if default_headers:
|
||||
self.default_headers: dict[str, Any] | None = {
|
||||
k: v for k, v in default_headers.items() if k != USER_AGENT_KEY
|
||||
}
|
||||
else:
|
||||
self.default_headers = None
|
||||
|
||||
args = {
|
||||
"ai_model_id": ai_model_id,
|
||||
"model_id": model_id,
|
||||
"client": client,
|
||||
}
|
||||
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",
|
||||
"client",
|
||||
},
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
)
|
||||
base.update(client_settings)
|
||||
return base
|
||||
# Ensure additional_properties and middleware are passed through kwargs to BaseChatClient
|
||||
# These are consumed by BaseChatClient.__init__ via kwargs
|
||||
super().__init__(**args, **kwargs)
|
||||
|
||||
@@ -40,17 +40,20 @@ def create_test_azure_assistants_client(
|
||||
thread_id: str | None = None,
|
||||
should_delete_assistant: bool = False,
|
||||
) -> AzureOpenAIAssistantsClient:
|
||||
"""Helper function to create AzureOpenAIAssistantsClient instances for testing, bypassing Pydantic validation."""
|
||||
return AzureOpenAIAssistantsClient.model_construct(
|
||||
ai_model_id=deployment_name or "test_chat_deployment",
|
||||
"""Helper function to create AzureOpenAIAssistantsClient instances for testing."""
|
||||
client = AzureOpenAIAssistantsClient(
|
||||
deployment_name=deployment_name or "test_chat_deployment",
|
||||
assistant_id=assistant_id,
|
||||
assistant_name=assistant_name,
|
||||
thread_id=thread_id,
|
||||
api_key="test-api-key",
|
||||
endpoint="https://test-endpoint.com",
|
||||
client=mock_async_azure_openai,
|
||||
_should_delete_assistant=should_delete_assistant,
|
||||
async_client=mock_async_azure_openai,
|
||||
)
|
||||
# Set the _should_delete_assistant flag directly if needed
|
||||
if should_delete_assistant:
|
||||
object.__setattr__(client, "_should_delete_assistant", True)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -88,7 +91,7 @@ def test_azure_assistants_client_init_with_client(mock_async_azure_openai: Magic
|
||||
)
|
||||
|
||||
assert chat_client.client is mock_async_azure_openai
|
||||
assert chat_client.ai_model_id == "test_chat_deployment"
|
||||
assert chat_client.model_id == "test_chat_deployment"
|
||||
assert chat_client.assistant_id == "existing-assistant-id"
|
||||
assert chat_client.thread_id == "test-thread-id"
|
||||
assert not chat_client._should_delete_assistant # type: ignore
|
||||
@@ -100,19 +103,16 @@ def test_azure_assistants_client_init_auto_create_client(
|
||||
mock_async_azure_openai: MagicMock,
|
||||
) -> None:
|
||||
"""Test AzureOpenAIAssistantsClient initialization with auto-created client."""
|
||||
chat_client = AzureOpenAIAssistantsClient.model_construct(
|
||||
ai_model_id=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
assistant_id=None,
|
||||
chat_client = AzureOpenAIAssistantsClient(
|
||||
deployment_name=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
|
||||
assistant_name="TestAssistant",
|
||||
thread_id=None,
|
||||
api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
|
||||
endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"],
|
||||
client=mock_async_azure_openai,
|
||||
_should_delete_assistant=False,
|
||||
async_client=mock_async_azure_openai,
|
||||
)
|
||||
|
||||
assert chat_client.client is mock_async_azure_openai
|
||||
assert chat_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
|
||||
assert chat_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
|
||||
assert chat_client.assistant_id is None
|
||||
assert chat_client.assistant_name == "TestAssistant"
|
||||
assert not chat_client._should_delete_assistant # type: ignore
|
||||
@@ -145,7 +145,7 @@ def test_azure_assistants_client_init_with_default_headers(azure_openai_unit_tes
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
assert chat_client.ai_model_id == "test_chat_deployment"
|
||||
assert chat_client.model_id == "test_chat_deployment"
|
||||
assert isinstance(chat_client, ChatClientProtocol)
|
||||
|
||||
# Assert that the default header we added is present in the client's default headers
|
||||
@@ -241,11 +241,10 @@ def test_azure_assistants_client_serialize(azure_openai_unit_test_env: dict[str,
|
||||
|
||||
dumped_settings = chat_client.to_dict()
|
||||
|
||||
assert dumped_settings["ai_model_id"] == "test_chat_deployment"
|
||||
assert dumped_settings["model_id"] == "test_chat_deployment"
|
||||
assert dumped_settings["assistant_id"] == "test-assistant-id"
|
||||
assert dumped_settings["assistant_name"] == "TestAssistant"
|
||||
assert dumped_settings["thread_id"] == "test-thread-id"
|
||||
assert dumped_settings["api_key"] == azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"]
|
||||
|
||||
# Assert that the default header we added is present in the dumped_settings default headers
|
||||
for key, value in default_headers.items():
|
||||
@@ -262,6 +261,7 @@ def get_weather(
|
||||
return f"The weather in {location} is sunny with a high of 25°C."
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_client_get_response() -> None:
|
||||
"""Test Azure Assistants Client response."""
|
||||
@@ -286,6 +286,7 @@ async def test_azure_assistants_client_get_response() -> None:
|
||||
assert any(word in response.text.lower() for word in ["sunny", "25", "weather", "seattle"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_client_get_response_tools() -> None:
|
||||
"""Test Azure Assistants Client response with tools."""
|
||||
@@ -307,6 +308,7 @@ async def test_azure_assistants_client_get_response_tools() -> None:
|
||||
assert any(word in response.text.lower() for word in ["sunny", "25", "weather"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_client_streaming() -> None:
|
||||
"""Test Azure Assistants Client streaming response."""
|
||||
@@ -337,6 +339,7 @@ async def test_azure_assistants_client_streaming() -> None:
|
||||
assert any(word in full_message.lower() for word in ["sunny", "25", "weather", "seattle"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_client_streaming_tools() -> None:
|
||||
"""Test Azure Assistants Client streaming response with tools."""
|
||||
@@ -363,6 +366,7 @@ async def test_azure_assistants_client_streaming_tools() -> None:
|
||||
assert any(word in full_message.lower() for word in ["sunny", "25", "weather"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_client_with_existing_assistant() -> None:
|
||||
"""Test Azure Assistants Client with existing assistant ID."""
|
||||
@@ -390,6 +394,7 @@ async def test_azure_assistants_client_with_existing_assistant() -> None:
|
||||
assert len(response.text) > 0
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_agent_basic_run():
|
||||
"""Test ChatAgent basic run functionality with AzureOpenAIAssistantsClient."""
|
||||
@@ -406,6 +411,7 @@ async def test_azure_assistants_agent_basic_run():
|
||||
assert "Hello World" in response.text
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_agent_basic_run_streaming():
|
||||
"""Test ChatAgent basic streaming functionality with AzureOpenAIAssistantsClient."""
|
||||
@@ -425,6 +431,7 @@ async def test_azure_assistants_agent_basic_run_streaming():
|
||||
assert "streaming response test" in full_message.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_agent_thread_persistence():
|
||||
"""Test ChatAgent thread persistence across runs with AzureOpenAIAssistantsClient."""
|
||||
@@ -453,6 +460,7 @@ async def test_azure_assistants_agent_thread_persistence():
|
||||
assert thread.service_thread_id is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_agent_existing_thread_id():
|
||||
"""Test ChatAgent with existing thread ID to continue conversations across agent instances."""
|
||||
@@ -497,6 +505,7 @@ async def test_azure_assistants_agent_existing_thread_id():
|
||||
assert "paris" in response2.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_agent_code_interpreter():
|
||||
"""Test ChatAgent with code interpreter through AzureOpenAIAssistantsClient."""
|
||||
@@ -516,6 +525,7 @@ async def test_azure_assistants_agent_code_interpreter():
|
||||
assert "120" in response.text or "factorial" in response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_assistants_client_agent_level_tool_persistence():
|
||||
"""Test that agent-level tools persist across multiple runs with Azure Assistants Client."""
|
||||
|
||||
@@ -53,7 +53,7 @@ def test_init(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
|
||||
assert azure_chat_client.client is not None
|
||||
assert isinstance(azure_chat_client.client, AsyncAzureOpenAI)
|
||||
assert azure_chat_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
|
||||
assert azure_chat_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_chat_client, BaseChatClient)
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ def test_init_base_url(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
|
||||
assert azure_chat_client.client is not None
|
||||
assert isinstance(azure_chat_client.client, AsyncAzureOpenAI)
|
||||
assert azure_chat_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
|
||||
assert azure_chat_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_chat_client, BaseChatClient)
|
||||
for key, value in default_headers.items():
|
||||
assert key in azure_chat_client.client.default_headers
|
||||
@@ -89,7 +89,7 @@ def test_init_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
|
||||
assert azure_chat_client.client is not None
|
||||
assert isinstance(azure_chat_client.client, AsyncAzureOpenAI)
|
||||
assert azure_chat_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
|
||||
assert azure_chat_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_chat_client, BaseChatClient)
|
||||
|
||||
|
||||
@@ -130,10 +130,11 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
|
||||
azure_chat_client = AzureOpenAIChatClient.from_dict(settings)
|
||||
dumped_settings = azure_chat_client.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == settings["deployment_name"]
|
||||
assert str(settings["deployment_name"]) in str(dumped_settings["base_url"])
|
||||
assert settings["api_key"] == dumped_settings["api_key"]
|
||||
assert dumped_settings["model_id"] == settings["deployment_name"]
|
||||
assert str(settings["endpoint"]) in str(dumped_settings["endpoint"])
|
||||
assert str(settings["deployment_name"]) == str(dumped_settings["deployment_name"])
|
||||
assert settings["api_version"] == dumped_settings["api_version"]
|
||||
assert "api_key" not in dumped_settings
|
||||
|
||||
# Assert that the default header we added is present in the dumped_settings default headers
|
||||
for key, value in default_headers.items():
|
||||
@@ -608,6 +609,7 @@ def get_weather(location: str) -> str:
|
||||
return f"The weather in {location} is sunny and 72°F."
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_response() -> None:
|
||||
"""Test Azure OpenAI chat completion responses."""
|
||||
@@ -637,6 +639,7 @@ async def test_azure_openai_chat_client_response() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_response_tools() -> None:
|
||||
"""Test AzureOpenAI chat completion responses."""
|
||||
@@ -658,6 +661,7 @@ async def test_azure_openai_chat_client_response_tools() -> None:
|
||||
assert "scientists" in response.text
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_streaming() -> None:
|
||||
"""Test Azure OpenAI chat completion responses."""
|
||||
@@ -692,6 +696,7 @@ async def test_azure_openai_chat_client_streaming() -> None:
|
||||
assert "scientists" in full_message
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_streaming_tools() -> None:
|
||||
"""Test AzureOpenAI chat completion responses."""
|
||||
@@ -718,6 +723,7 @@ async def test_azure_openai_chat_client_streaming_tools() -> None:
|
||||
assert "scientists" in full_message
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_agent_basic_run():
|
||||
"""Test Azure OpenAI chat client agent basic run functionality with AzureOpenAIChatClient."""
|
||||
@@ -733,6 +739,7 @@ async def test_azure_openai_chat_client_agent_basic_run():
|
||||
assert "hello world" in response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_agent_basic_run_streaming():
|
||||
"""Test Azure OpenAI chat client agent basic streaming functionality with AzureOpenAIChatClient."""
|
||||
@@ -750,6 +757,7 @@ async def test_azure_openai_chat_client_agent_basic_run_streaming():
|
||||
assert "streaming response test" in full_text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_agent_thread_persistence():
|
||||
"""Test Azure OpenAI chat client agent thread persistence across runs with AzureOpenAIChatClient."""
|
||||
@@ -774,6 +782,7 @@ async def test_azure_openai_chat_client_agent_thread_persistence():
|
||||
assert "alice" in response2.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_openai_chat_client_agent_existing_thread():
|
||||
"""Test Azure OpenAI chat client agent with existing thread to continue conversations across agent instances."""
|
||||
@@ -808,6 +817,7 @@ async def test_azure_openai_chat_client_agent_existing_thread():
|
||||
assert "alice" in second_response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_chat_client_agent_level_tool_persistence():
|
||||
"""Test that agent-level tools persist across multiple runs with Azure Chat Client."""
|
||||
|
||||
@@ -76,7 +76,7 @@ def test_init(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
# Test successful initialization
|
||||
azure_responses_client = AzureOpenAIResponsesClient()
|
||||
|
||||
assert azure_responses_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
|
||||
assert azure_responses_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_responses_client, ChatClientProtocol)
|
||||
|
||||
|
||||
@@ -86,12 +86,12 @@ def test_init_validation_fail() -> None:
|
||||
AzureOpenAIResponsesClient(api_key="34523", deployment_name={"test": "dict"}) # type: ignore
|
||||
|
||||
|
||||
def test_init_ai_model_id_constructor(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_init_model_id_constructor(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
# Test successful initialization
|
||||
ai_model_id = "test_model_id"
|
||||
azure_responses_client = AzureOpenAIResponsesClient(deployment_name=ai_model_id)
|
||||
model_id = "test_model_id"
|
||||
azure_responses_client = AzureOpenAIResponsesClient(deployment_name=model_id)
|
||||
|
||||
assert azure_responses_client.ai_model_id == ai_model_id
|
||||
assert azure_responses_client.model_id == model_id
|
||||
assert isinstance(azure_responses_client, ChatClientProtocol)
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ def test_init_with_default_header(azure_openai_unit_test_env: dict[str, str]) ->
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
assert azure_responses_client.ai_model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
|
||||
assert azure_responses_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
|
||||
assert isinstance(azure_responses_client, ChatClientProtocol)
|
||||
|
||||
# Assert that the default header we added is present in the client's default headers
|
||||
@@ -124,15 +124,15 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
settings = {
|
||||
"ai_model_id": azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"],
|
||||
"deployment_name": azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"],
|
||||
"api_key": azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
|
||||
azure_responses_client = AzureOpenAIResponsesClient.from_dict(settings)
|
||||
dumped_settings = azure_responses_client.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
|
||||
assert dumped_settings["api_key"] == azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"]
|
||||
assert dumped_settings["deployment_name"] == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
|
||||
assert "api_key" not in dumped_settings
|
||||
# Assert that the default header we added is present in the dumped_settings default headers
|
||||
for key, value in default_headers.items():
|
||||
assert key in dumped_settings["default_headers"]
|
||||
@@ -141,6 +141,7 @@ def test_serialize(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
assert "User-Agent" not in dumped_settings["default_headers"]
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_response() -> None:
|
||||
"""Test azure responses client responses."""
|
||||
@@ -184,6 +185,7 @@ async def test_azure_responses_client_response() -> None:
|
||||
assert "sunny" in structured_response.value.weather.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_response_tools() -> None:
|
||||
"""Test azure responses client tools."""
|
||||
@@ -223,6 +225,7 @@ async def test_azure_responses_client_response_tools() -> None:
|
||||
assert "sunny" in structured_response.value.weather.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_streaming() -> None:
|
||||
"""Test Azure azure responses client streaming responses."""
|
||||
@@ -273,6 +276,7 @@ async def test_azure_responses_client_streaming() -> None:
|
||||
assert "sunny" in structured_response.value.weather.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_streaming_tools() -> None:
|
||||
"""Test azure responses client streaming tools."""
|
||||
@@ -320,6 +324,7 @@ async def test_azure_responses_client_streaming_tools() -> None:
|
||||
assert "sunny" in output.weather.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_agent_basic_run():
|
||||
"""Test Azure Responses Client agent basic run functionality with AzureOpenAIResponsesClient."""
|
||||
@@ -336,6 +341,7 @@ async def test_azure_responses_client_agent_basic_run():
|
||||
assert "hello world" in response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_agent_basic_run_streaming():
|
||||
"""Test Azure Responses Client agent basic streaming functionality with AzureOpenAIResponsesClient."""
|
||||
@@ -353,6 +359,7 @@ async def test_azure_responses_client_agent_basic_run_streaming():
|
||||
assert "streaming response test" in full_text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_agent_thread_persistence():
|
||||
"""Test Azure Responses Client agent thread persistence across runs with AzureOpenAIResponsesClient."""
|
||||
@@ -376,6 +383,7 @@ async def test_azure_responses_client_agent_thread_persistence():
|
||||
assert second_response.text is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_agent_thread_storage_with_store_true():
|
||||
"""Test Azure Responses Client agent with store=True to verify service_thread_id is returned."""
|
||||
@@ -407,6 +415,7 @@ async def test_azure_responses_client_agent_thread_storage_with_store_true():
|
||||
assert len(thread.service_thread_id) > 0
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_agent_existing_thread():
|
||||
"""Test Azure Responses Client agent with existing thread to continue conversations across agent instances."""
|
||||
@@ -441,6 +450,7 @@ async def test_azure_responses_client_agent_existing_thread():
|
||||
assert "photography" in second_response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_agent_hosted_code_interpreter_tool():
|
||||
"""Test Azure Responses Client agent with HostedCodeInterpreterTool through AzureOpenAIResponsesClient."""
|
||||
@@ -462,6 +472,7 @@ async def test_azure_responses_client_agent_hosted_code_interpreter_tool():
|
||||
assert contains_relevant_content or len(response.text.strip()) > 10
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_agent_level_tool_persistence():
|
||||
"""Test that agent-level tools persist across multiple runs with Azure Responses Client."""
|
||||
@@ -488,6 +499,7 @@ async def test_azure_responses_client_agent_level_tool_persistence():
|
||||
assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_agent_chat_options_run_level() -> None:
|
||||
"""Integration test for comprehensive ChatOptions parameter coverage with Azure Response Agent."""
|
||||
@@ -511,6 +523,7 @@ async def test_azure_responses_client_agent_chat_options_run_level() -> None:
|
||||
assert len(response.text) > 0
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_agent_chat_options_agent_level() -> None:
|
||||
"""Integration test for comprehensive ChatOptions parameter coverage with Azure Response Agent."""
|
||||
@@ -534,6 +547,7 @@ async def test_azure_responses_client_agent_chat_options_agent_level() -> None:
|
||||
assert len(response.text) > 0
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
async def test_azure_responses_client_agent_hosted_mcp_tool() -> None:
|
||||
"""Integration test for HostedMCPTool with Azure Response Agent using Microsoft Learn MCP."""
|
||||
@@ -562,6 +576,7 @@ async def test_azure_responses_client_agent_hosted_mcp_tool() -> None:
|
||||
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
@pytest.mark.skip(reason="File search requires API key auth, subscription only allows token auth")
|
||||
async def test_azure_responses_client_file_search() -> None:
|
||||
@@ -588,6 +603,7 @@ async def test_azure_responses_client_file_search() -> None:
|
||||
assert "75" in response.text
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_azure_integration_tests_disabled
|
||||
@pytest.mark.skip(reason="File search requires API key auth, subscription only allows token auth")
|
||||
async def test_azure_responses_client_file_search_streaming() -> None:
|
||||
|
||||
+6
-4
@@ -8,7 +8,7 @@ from typing import Any
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
from pytest import fixture
|
||||
|
||||
from agent_framework import (
|
||||
@@ -116,9 +116,11 @@ class MockChatClient:
|
||||
class MockBaseChatClient(BaseChatClient):
|
||||
"""Mock implementation of the BaseChatClient."""
|
||||
|
||||
run_responses: list[ChatResponse] = Field(default_factory=list)
|
||||
streaming_responses: list[list[ChatResponseUpdate]] = Field(default_factory=list)
|
||||
call_count: int = Field(default=0)
|
||||
def __init__(self, **kwargs: Any):
|
||||
super().__init__(**kwargs)
|
||||
self.run_responses: list[ChatResponse] = []
|
||||
self.streaming_responses: list[list[ChatResponseUpdate]] = []
|
||||
self.call_count: int = 0
|
||||
|
||||
@override
|
||||
async def _inner_get_response(
|
||||
+1
@@ -510,6 +510,7 @@ def test_local_mcp_streamable_http_tool_init():
|
||||
|
||||
|
||||
# Integration test
|
||||
@pytest.mark.flaky
|
||||
@skip_if_mcp_integration_tests_disabled
|
||||
async def test_streamable_http_integration():
|
||||
"""Test MCP StreamableHTTP integration."""
|
||||
@@ -0,0 +1,190 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for SerializationMixin functionality."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent_framework._serialization import SerializationMixin
|
||||
|
||||
|
||||
class TestSerializationMixin:
|
||||
"""Test SerializationMixin serialization, deserialization, and dependency injection."""
|
||||
|
||||
def test_basic_serialization(self):
|
||||
"""Test basic to_dict and from_dict functionality."""
|
||||
|
||||
class TestClass(SerializationMixin):
|
||||
def __init__(self, value: str, number: int):
|
||||
self.value = value
|
||||
self.number = number
|
||||
|
||||
obj = TestClass(value="test", number=42)
|
||||
data = obj.to_dict()
|
||||
|
||||
assert data["type"] == "test_class"
|
||||
assert data["value"] == "test"
|
||||
assert data["number"] == 42
|
||||
|
||||
restored = TestClass.from_dict(data)
|
||||
assert restored.value == "test"
|
||||
assert restored.number == 42
|
||||
|
||||
def test_injectable_dependency_no_warning(self, caplog):
|
||||
"""Test that injectable dependencies don't trigger debug logging."""
|
||||
|
||||
class TestClass(SerializationMixin):
|
||||
INJECTABLE = {"client"}
|
||||
|
||||
def __init__(self, value: str, client: Any = None):
|
||||
self.value = value
|
||||
self.client = client
|
||||
|
||||
mock_client = "mock_client_instance"
|
||||
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
obj = TestClass.from_dict(
|
||||
{"type": "test_class", "value": "test"},
|
||||
dependencies={"test_class.client": mock_client},
|
||||
)
|
||||
|
||||
assert obj.value == "test"
|
||||
assert obj.client == mock_client
|
||||
# No debug message should be logged for injectable dependency
|
||||
assert not any("is not in INJECTABLE set" in record.message for record in caplog.records)
|
||||
|
||||
def test_non_injectable_dependency_logs_debug(self, caplog):
|
||||
"""Test that non-injectable dependencies trigger debug logging."""
|
||||
|
||||
class TestClass(SerializationMixin):
|
||||
INJECTABLE = {"client"}
|
||||
|
||||
def __init__(self, value: str, other: Any = None):
|
||||
self.value = value
|
||||
self.other = other
|
||||
|
||||
mock_other = "mock_other_instance"
|
||||
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
obj = TestClass.from_dict(
|
||||
{"type": "test_class", "value": "test"},
|
||||
dependencies={"test_class.other": mock_other},
|
||||
)
|
||||
|
||||
assert obj.value == "test"
|
||||
assert obj.other == mock_other
|
||||
# Debug message should be logged for non-injectable dependency
|
||||
debug_messages = [record.message for record in caplog.records if record.levelname == "DEBUG"]
|
||||
assert any("is not in INJECTABLE set" in msg for msg in debug_messages)
|
||||
assert any("other" in msg for msg in debug_messages)
|
||||
assert any("client" in msg for msg in debug_messages) # Should mention available injectable
|
||||
|
||||
def test_multiple_dependencies_mixed_injectable(self, caplog):
|
||||
"""Test with both injectable and non-injectable dependencies."""
|
||||
|
||||
class TestClass(SerializationMixin):
|
||||
INJECTABLE = {"client", "logger"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
value: str,
|
||||
client: Any = None,
|
||||
logger: Any = None,
|
||||
other: Any = None,
|
||||
):
|
||||
self.value = value
|
||||
self.client = client
|
||||
self.logger = logger
|
||||
self.other = other
|
||||
|
||||
mock_client = "mock_client"
|
||||
mock_logger = "mock_logger"
|
||||
mock_other = "mock_other"
|
||||
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
obj = TestClass.from_dict(
|
||||
{"type": "test_class", "value": "test"},
|
||||
dependencies={
|
||||
"test_class.client": mock_client,
|
||||
"test_class.logger": mock_logger,
|
||||
"test_class.other": mock_other,
|
||||
},
|
||||
)
|
||||
|
||||
assert obj.value == "test"
|
||||
assert obj.client == mock_client
|
||||
assert obj.logger == mock_logger
|
||||
assert obj.other == mock_other
|
||||
|
||||
# Only 'other' should trigger debug logging
|
||||
debug_messages = [record.message for record in caplog.records if record.levelname == "DEBUG"]
|
||||
assert any("other" in msg and "is not in INJECTABLE set" in msg for msg in debug_messages)
|
||||
# 'client' and 'logger' should not be mentioned as non-injectable dependencies
|
||||
assert not any("Dependency 'client'" in msg and "is not in INJECTABLE set" in msg for msg in debug_messages)
|
||||
assert not any("Dependency 'logger'" in msg and "is not in INJECTABLE set" in msg for msg in debug_messages)
|
||||
|
||||
def test_no_injectable_set_defined(self, caplog):
|
||||
"""Test behavior when INJECTABLE is not defined (empty set default)."""
|
||||
|
||||
class TestClass(SerializationMixin):
|
||||
def __init__(self, value: str, client: Any = None):
|
||||
self.value = value
|
||||
self.client = client
|
||||
|
||||
mock_client = "mock_client"
|
||||
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
obj = TestClass.from_dict(
|
||||
{"type": "test_class", "value": "test"},
|
||||
dependencies={"test_class.client": mock_client},
|
||||
)
|
||||
|
||||
assert obj.value == "test"
|
||||
assert obj.client == mock_client
|
||||
# Should log debug message since INJECTABLE is empty by default
|
||||
debug_messages = [record.message for record in caplog.records if record.levelname == "DEBUG"]
|
||||
assert any("client" in msg and "is not in INJECTABLE set" in msg for msg in debug_messages)
|
||||
|
||||
def test_default_exclude_serialization(self):
|
||||
"""Test that DEFAULT_EXCLUDE fields are not included in to_dict()."""
|
||||
|
||||
class TestClass(SerializationMixin):
|
||||
DEFAULT_EXCLUDE = {"secret"}
|
||||
|
||||
def __init__(self, value: str, secret: str):
|
||||
self.value = value
|
||||
self.secret = secret
|
||||
|
||||
obj = TestClass(value="test", secret="hidden")
|
||||
data = obj.to_dict()
|
||||
|
||||
assert "value" in data
|
||||
assert "secret" not in data
|
||||
assert data["value"] == "test"
|
||||
|
||||
def test_roundtrip_with_injectable_dependency(self):
|
||||
"""Test full roundtrip serialization/deserialization with injectable dependency."""
|
||||
|
||||
class TestClass(SerializationMixin):
|
||||
INJECTABLE = {"client"}
|
||||
DEFAULT_EXCLUDE = {"client"}
|
||||
|
||||
def __init__(self, value: str, number: int, client: Any = None):
|
||||
self.value = value
|
||||
self.number = number
|
||||
self.client = client
|
||||
|
||||
mock_client = "mock_client"
|
||||
obj = TestClass(value="test", number=42, client=mock_client)
|
||||
|
||||
# Serialize
|
||||
data = obj.to_dict()
|
||||
assert data["value"] == "test"
|
||||
assert data["number"] == 42
|
||||
assert "client" not in data # Excluded from serialization
|
||||
|
||||
# Deserialize with dependency injection
|
||||
restored = TestClass.from_dict(data, dependencies={"test_class.client": mock_client})
|
||||
assert restored.value == "test"
|
||||
assert restored.number == 42
|
||||
assert restored.client == mock_client
|
||||
+2
-2
@@ -377,10 +377,10 @@ class TestThreadState:
|
||||
def test_init_with_chat_message_store_state(self) -> None:
|
||||
"""Test AgentThreadState initialization with chat_message_store_state."""
|
||||
store_data: dict[str, Any] = {"messages": []}
|
||||
state = AgentThreadState(chat_message_store_state=store_data)
|
||||
state = AgentThreadState.model_validate({"chat_message_store_state": store_data})
|
||||
|
||||
assert state.service_thread_id is None
|
||||
assert state.chat_message_store_state == store_data
|
||||
assert state.chat_message_store_state.messages == []
|
||||
|
||||
def test_init_with_both(self) -> None:
|
||||
"""Test AgentThreadState initialization with both parameters."""
|
||||
+431
-54
@@ -3,6 +3,7 @@
|
||||
from collections.abc import AsyncIterable
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pytest import fixture, mark, raises
|
||||
|
||||
@@ -10,7 +11,6 @@ from agent_framework import (
|
||||
AgentRunResponse,
|
||||
AgentRunResponseUpdate,
|
||||
AIFunction,
|
||||
BaseAnnotation,
|
||||
BaseContent,
|
||||
ChatMessage,
|
||||
ChatOptions,
|
||||
@@ -209,7 +209,7 @@ def test_hosted_file_content_minimal():
|
||||
# Check the type and content
|
||||
assert content.type == "hosted_file"
|
||||
assert content.file_id == "file-456"
|
||||
assert content.additional_properties is None
|
||||
assert content.additional_properties == {}
|
||||
assert content.raw_representation is None
|
||||
|
||||
# Ensure the instance is of type BaseContent
|
||||
@@ -240,7 +240,7 @@ def test_hosted_vector_store_content_minimal():
|
||||
# Check the type and content
|
||||
assert content.type == "hosted_vector_store"
|
||||
assert content.vector_store_id == "vs-101112"
|
||||
assert content.additional_properties is None
|
||||
assert content.additional_properties == {}
|
||||
assert content.raw_representation is None
|
||||
|
||||
# Ensure the instance is of type BaseContent
|
||||
@@ -1204,33 +1204,6 @@ def test_text_content_add_comprehensive_coverage():
|
||||
assert result.raw_representation == ["raw1", "raw2", "raw3"]
|
||||
|
||||
|
||||
def test_text_content_add_annotations_coverage():
|
||||
"""Test TextContent __add__ method with annotation combinations to improve coverage."""
|
||||
|
||||
ann1 = BaseAnnotation()
|
||||
ann2 = BaseAnnotation()
|
||||
|
||||
# Test first has annotations, second has None
|
||||
t1 = TextContent("Hello", annotations=[ann1])
|
||||
t2 = TextContent(" World", annotations=None)
|
||||
result = t1 + t2
|
||||
assert result.annotations == [ann1]
|
||||
|
||||
# Test first has None, second has annotations
|
||||
t1 = TextContent("Hello", annotations=None)
|
||||
t2 = TextContent(" World", annotations=[ann2])
|
||||
result = t1 + t2
|
||||
assert result.annotations == [ann2]
|
||||
|
||||
# Test both have annotations
|
||||
t1 = TextContent("Hello", annotations=[ann1])
|
||||
t2 = TextContent(" World", annotations=[ann2])
|
||||
result = t1 + t2
|
||||
assert len(result.annotations) == 2
|
||||
assert ann1 in result.annotations
|
||||
assert ann2 in result.annotations
|
||||
|
||||
|
||||
def test_text_content_iadd_coverage():
|
||||
"""Test TextContent __iadd__ method for better coverage."""
|
||||
|
||||
@@ -1277,7 +1250,7 @@ def test_comprehensive_to_dict_exclude_options():
|
||||
text_content = TextContent("Hello", raw_representation=None, additional_properties={"prop": "val"})
|
||||
text_dict = text_content.to_dict(exclude_none=True)
|
||||
assert "raw_representation" not in text_dict
|
||||
assert text_dict["additional_properties"] == {"prop": "val"}
|
||||
assert text_dict["prop"] == "val"
|
||||
|
||||
# Test with custom exclude set
|
||||
text_dict_exclude = text_content.to_dict(exclude={"additional_properties"})
|
||||
@@ -1328,8 +1301,6 @@ def test_chat_message_from_dict_with_mixed_content():
|
||||
{"type": "text", "text": "Hello"},
|
||||
{"type": "function_call", "call_id": "call1", "name": "func", "arguments": {"arg": "val"}},
|
||||
{"type": "function_result", "call_id": "call1", "result": "success"},
|
||||
# Test with unknown type that falls back to BaseContent
|
||||
{"type": "unknown_type", "raw_representation": "something"},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1383,7 +1354,7 @@ def test_comprehensive_serialization_methods():
|
||||
text_data = {
|
||||
"text": "Hello world",
|
||||
"raw_representation": {"key": "value"},
|
||||
"additional_properties": {"prop": "val"},
|
||||
"prop": "val",
|
||||
"annotations": None,
|
||||
}
|
||||
text_content = TextContent.from_dict(text_data)
|
||||
@@ -1394,7 +1365,7 @@ def test_comprehensive_serialization_methods():
|
||||
# Test round-trip
|
||||
text_dict = text_content.to_dict()
|
||||
assert text_dict["text"] == "Hello world"
|
||||
assert text_dict["additional_properties"] == {"prop": "val"}
|
||||
assert text_dict["prop"] == "val"
|
||||
# Note: raw_representation is always excluded from to_dict() output
|
||||
|
||||
# Test with exclude_none
|
||||
@@ -1464,18 +1435,19 @@ def test_usage_content_serialization_with_details():
|
||||
|
||||
# Test from_dict with details as dict
|
||||
usage_data = {
|
||||
"details": {"input_token_count": 10, "output_token_count": 20, "total_token_count": 30},
|
||||
"annotations": [
|
||||
{"type": "citation", "start": 0, "end": 5, "citation": "source1"},
|
||||
{"type": "unknown", "custom_field": "value"}, # Tests fallback to BaseAnnotation
|
||||
],
|
||||
"type": "usage",
|
||||
"details": {
|
||||
"type": "usage_details",
|
||||
"input_token_count": 10,
|
||||
"output_token_count": 20,
|
||||
"total_token_count": 30,
|
||||
"custom_count": 5,
|
||||
},
|
||||
}
|
||||
usage_content = UsageContent.from_dict(usage_data)
|
||||
assert isinstance(usage_content.details, UsageDetails)
|
||||
assert usage_content.details.input_token_count == 10
|
||||
assert len(usage_content.annotations) == 2
|
||||
assert isinstance(usage_content.annotations[0], CitationAnnotation)
|
||||
assert isinstance(usage_content.annotations[1], BaseAnnotation)
|
||||
assert usage_content.details.additional_counts["custom_count"] == 5
|
||||
|
||||
# Test to_dict with UsageDetails object
|
||||
usage_dict = usage_content.to_dict()
|
||||
@@ -1488,9 +1460,15 @@ def test_function_approval_response_content_serialization():
|
||||
|
||||
# Test from_dict with function_call as dict
|
||||
response_data = {
|
||||
"type": "function_approval_response",
|
||||
"id": "response123",
|
||||
"approved": True,
|
||||
"function_call": {"call_id": "call123", "name": "test_func", "arguments": {"param": "value"}},
|
||||
"function_call": {
|
||||
"type": "function_call",
|
||||
"call_id": "call123",
|
||||
"name": "test_func",
|
||||
"arguments": {"param": "value"},
|
||||
},
|
||||
}
|
||||
response_content = FunctionApprovalResponseContent.from_dict(response_data)
|
||||
assert isinstance(response_content.function_call, FunctionCallContent)
|
||||
@@ -1512,7 +1490,12 @@ def test_chat_response_complex_serialization():
|
||||
{"role": "assistant", "contents": [{"type": "text", "text": "Hi there"}]},
|
||||
],
|
||||
"finish_reason": {"value": "stop"},
|
||||
"usage_details": {"input_token_count": 5, "output_token_count": 8, "total_token_count": 13},
|
||||
"usage_details": {
|
||||
"type": "usage_details",
|
||||
"input_token_count": 5,
|
||||
"output_token_count": 8,
|
||||
"total_token_count": 13,
|
||||
},
|
||||
"model_id": "gpt-4", # Test alias handling
|
||||
}
|
||||
|
||||
@@ -1543,22 +1526,21 @@ def test_chat_response_update_all_content_types():
|
||||
{"type": "error", "error": "An error occurred"},
|
||||
{"type": "function_call", "call_id": "call1", "name": "func", "arguments": {}},
|
||||
{"type": "function_result", "call_id": "call1", "result": "success"},
|
||||
{"type": "usage", "details": {"input_token_count": 1}},
|
||||
{"type": "usage", "details": {"type": "usage_details", "input_token_count": 1}},
|
||||
{"type": "hosted_file", "file_id": "file123"},
|
||||
{"type": "hosted_vector_store", "vector_store_id": "vs123"},
|
||||
{
|
||||
"type": "function_approval_request",
|
||||
"id": "req1",
|
||||
"function_call": {"call_id": "call1", "name": "func", "arguments": {}},
|
||||
"function_call": {"type": "function_call", "call_id": "call1", "name": "func", "arguments": {}},
|
||||
},
|
||||
{
|
||||
"type": "function_approval_response",
|
||||
"id": "resp1",
|
||||
"approved": True,
|
||||
"function_call": {"call_id": "call1", "name": "func", "arguments": {}},
|
||||
"function_call": {"type": "function_call", "call_id": "call1", "name": "func", "arguments": {}},
|
||||
},
|
||||
{"type": "text_reasoning", "text": "reasoning"},
|
||||
{"type": "unknown_type", "custom_field": "value"}, # Tests fallback
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1586,7 +1568,12 @@ def test_agent_run_response_complex_serialization():
|
||||
{"role": "user", "contents": [{"type": "text", "text": "Hello"}]},
|
||||
{"role": "assistant", "contents": [{"type": "text", "text": "Hi"}]},
|
||||
],
|
||||
"usage_details": {"input_token_count": 3, "output_token_count": 2, "total_token_count": 5},
|
||||
"usage_details": {
|
||||
"type": "usage_details",
|
||||
"input_token_count": 3,
|
||||
"output_token_count": 2,
|
||||
"total_token_count": 5,
|
||||
},
|
||||
}
|
||||
|
||||
response = AgentRunResponse.from_dict(response_data)
|
||||
@@ -1612,22 +1599,21 @@ def test_agent_run_response_update_all_content_types():
|
||||
{"type": "error", "error": "An error occurred"},
|
||||
{"type": "function_call", "call_id": "call1", "name": "func", "arguments": {}},
|
||||
{"type": "function_result", "call_id": "call1", "result": "success"},
|
||||
{"type": "usage", "details": {"input_token_count": 1}},
|
||||
{"type": "usage", "details": {"type": "usage_details", "input_token_count": 1}},
|
||||
{"type": "hosted_file", "file_id": "file123"},
|
||||
{"type": "hosted_vector_store", "vector_store_id": "vs123"},
|
||||
{
|
||||
"type": "function_approval_request",
|
||||
"id": "req1",
|
||||
"function_call": {"call_id": "call1", "name": "func", "arguments": {}},
|
||||
"function_call": {"type": "function_call", "call_id": "call1", "name": "func", "arguments": {}},
|
||||
},
|
||||
{
|
||||
"type": "function_approval_response",
|
||||
"id": "resp1",
|
||||
"approved": True,
|
||||
"function_call": {"call_id": "call1", "name": "func", "arguments": {}},
|
||||
"function_call": {"type": "function_call", "call_id": "call1", "name": "func", "arguments": {}},
|
||||
},
|
||||
{"type": "text_reasoning", "text": "reasoning"},
|
||||
{"type": "unknown_type", "custom_field": "value"}, # Tests fallback
|
||||
],
|
||||
"role": {"value": "assistant"}, # Test role as dict
|
||||
}
|
||||
@@ -1648,3 +1634,394 @@ def test_agent_run_response_update_all_content_types():
|
||||
update_str = AgentRunResponseUpdate.from_dict(update_data_str_role)
|
||||
assert isinstance(update_str.role, Role)
|
||||
assert update_str.role.value == "user"
|
||||
|
||||
|
||||
# region Serialization
|
||||
|
||||
|
||||
@mark.parametrize(
|
||||
"content_class,init_kwargs",
|
||||
[
|
||||
pytest.param(
|
||||
TextContent,
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Hello world",
|
||||
"raw_representation": "raw",
|
||||
},
|
||||
id="text_content",
|
||||
),
|
||||
pytest.param(
|
||||
TextReasoningContent,
|
||||
{
|
||||
"type": "text_reasoning",
|
||||
"text": "Reasoning text",
|
||||
"raw_representation": "raw",
|
||||
},
|
||||
id="text_reasoning_content",
|
||||
),
|
||||
pytest.param(
|
||||
DataContent,
|
||||
{
|
||||
"type": "data",
|
||||
"uri": "data:text/plain;base64,dGVzdCBkYXRh",
|
||||
},
|
||||
id="data_content_with_uri",
|
||||
),
|
||||
pytest.param(
|
||||
DataContent,
|
||||
{
|
||||
"type": "data",
|
||||
"data": b"test data",
|
||||
"media_type": "text/plain",
|
||||
},
|
||||
id="data_content_with_bytes",
|
||||
),
|
||||
pytest.param(
|
||||
UriContent,
|
||||
{
|
||||
"type": "uri",
|
||||
"uri": "http://example.com",
|
||||
"media_type": "text/html",
|
||||
},
|
||||
id="uri_content",
|
||||
),
|
||||
pytest.param(
|
||||
HostedFileContent,
|
||||
{"type": "hosted_file", "file_id": "file-123"},
|
||||
id="hosted_file_content",
|
||||
),
|
||||
pytest.param(
|
||||
HostedVectorStoreContent,
|
||||
{
|
||||
"type": "hosted_vector_store",
|
||||
"vector_store_id": "vs-789",
|
||||
},
|
||||
id="hosted_vector_store_content",
|
||||
),
|
||||
pytest.param(
|
||||
FunctionCallContent,
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call-1",
|
||||
"name": "test_func",
|
||||
"arguments": {"arg": "val"},
|
||||
},
|
||||
id="function_call_content",
|
||||
),
|
||||
pytest.param(
|
||||
FunctionResultContent,
|
||||
{
|
||||
"type": "function_result",
|
||||
"call_id": "call-1",
|
||||
"result": "success",
|
||||
},
|
||||
id="function_result_content",
|
||||
),
|
||||
pytest.param(
|
||||
ErrorContent,
|
||||
{
|
||||
"type": "error",
|
||||
"message": "Error occurred",
|
||||
"error_code": "E001",
|
||||
},
|
||||
id="error_content",
|
||||
),
|
||||
pytest.param(
|
||||
UsageContent,
|
||||
{
|
||||
"type": "usage",
|
||||
"details": {
|
||||
"type": "usage_details",
|
||||
"input_token_count": 10,
|
||||
"output_token_count": 20,
|
||||
"reasoning_tokens": 5,
|
||||
},
|
||||
},
|
||||
id="usage_content",
|
||||
),
|
||||
pytest.param(
|
||||
FunctionApprovalRequestContent,
|
||||
{
|
||||
"type": "function_approval_request",
|
||||
"id": "req-1",
|
||||
"function_call": {"type": "function_call", "call_id": "call-1", "name": "test_func", "arguments": {}},
|
||||
},
|
||||
id="function_approval_request",
|
||||
),
|
||||
pytest.param(
|
||||
FunctionApprovalResponseContent,
|
||||
{
|
||||
"type": "function_approval_response",
|
||||
"id": "resp-1",
|
||||
"approved": True,
|
||||
"function_call": {"type": "function_call", "call_id": "call-1", "name": "test_func", "arguments": {}},
|
||||
},
|
||||
id="function_approval_response",
|
||||
),
|
||||
pytest.param(
|
||||
ChatMessage,
|
||||
{
|
||||
"role": {"type": "role", "value": "user"},
|
||||
"contents": [
|
||||
{"type": "text", "text": "Hello"},
|
||||
{"type": "function_call", "call_id": "call-1", "name": "test_func", "arguments": {}},
|
||||
],
|
||||
"message_id": "msg-123",
|
||||
"author_name": "User",
|
||||
},
|
||||
id="chat_message",
|
||||
),
|
||||
pytest.param(
|
||||
ChatResponse,
|
||||
{
|
||||
"type": "chat_response",
|
||||
"messages": [
|
||||
{
|
||||
"type": "chat_message",
|
||||
"role": {"type": "role", "value": "user"},
|
||||
"contents": [{"type": "text", "text": "Hello"}],
|
||||
},
|
||||
{
|
||||
"type": "chat_message",
|
||||
"role": {"type": "role", "value": "assistant"},
|
||||
"contents": [{"type": "text", "text": "Hi there"}],
|
||||
},
|
||||
],
|
||||
"finish_reason": {"type": "finish_reason", "value": "stop"},
|
||||
"usage_details": {
|
||||
"type": "usage_details",
|
||||
"input_token_count": 10,
|
||||
"output_token_count": 20,
|
||||
"total_token_count": 30,
|
||||
},
|
||||
"response_id": "resp-123",
|
||||
"model_id": "gpt-4",
|
||||
},
|
||||
id="chat_response",
|
||||
),
|
||||
pytest.param(
|
||||
ChatResponseUpdate,
|
||||
{
|
||||
"contents": [
|
||||
{"type": "text", "text": "Hello"},
|
||||
{"type": "function_call", "call_id": "call-1", "name": "test_func", "arguments": {}},
|
||||
],
|
||||
"role": {"type": "role", "value": "assistant"},
|
||||
"finish_reason": {"type": "finish_reason", "value": "stop"},
|
||||
"message_id": "msg-123",
|
||||
"response_id": "resp-123",
|
||||
},
|
||||
id="chat_response_update",
|
||||
),
|
||||
pytest.param(
|
||||
AgentRunResponse,
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
"role": {"type": "role", "value": "user"},
|
||||
"contents": [{"type": "text", "text": "Question"}],
|
||||
},
|
||||
{
|
||||
"role": {"type": "role", "value": "assistant"},
|
||||
"contents": [{"type": "text", "text": "Answer"}],
|
||||
},
|
||||
],
|
||||
"response_id": "run-123",
|
||||
"usage_details": {
|
||||
"type": "usage_details",
|
||||
"input_token_count": 5,
|
||||
"output_token_count": 3,
|
||||
"total_token_count": 8,
|
||||
},
|
||||
},
|
||||
id="agent_run_response",
|
||||
),
|
||||
pytest.param(
|
||||
AgentRunResponseUpdate,
|
||||
{
|
||||
"contents": [
|
||||
{"type": "text", "text": "Streaming"},
|
||||
{"type": "function_call", "call_id": "call-1", "name": "test_func", "arguments": {}},
|
||||
],
|
||||
"role": {"type": "role", "value": "assistant"},
|
||||
"message_id": "msg-123",
|
||||
"response_id": "run-123",
|
||||
"author_name": "Agent",
|
||||
},
|
||||
id="agent_run_response_update",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_content_roundtrip_serialization(content_class: type[BaseContent], init_kwargs: dict[str, Any]):
|
||||
"""Test to_dict/from_dict roundtrip for all content types."""
|
||||
# Create instance
|
||||
content = content_class(**init_kwargs)
|
||||
|
||||
# Serialize to dict
|
||||
content_dict = content.to_dict()
|
||||
|
||||
# Verify type key is in serialized dict
|
||||
assert "type" in content_dict
|
||||
if hasattr(content, "type"):
|
||||
assert content_dict["type"] == content.type # type: ignore[attr-defined]
|
||||
|
||||
# Deserialize from dict
|
||||
reconstructed = content_class.from_dict(content_dict)
|
||||
|
||||
# Verify type
|
||||
assert isinstance(reconstructed, content_class)
|
||||
# Check type attribute dynamically
|
||||
if hasattr(content, "type"):
|
||||
assert reconstructed.type == content.type # type: ignore[attr-defined]
|
||||
|
||||
# Verify key attributes (excluding raw_representation which is not serialized)
|
||||
for key, value in init_kwargs.items():
|
||||
if key == "type":
|
||||
continue
|
||||
if key == "raw_representation":
|
||||
# raw_representation is intentionally excluded from serialization
|
||||
continue
|
||||
|
||||
# Special handling for DataContent created with 'data' parameter
|
||||
if content_class == DataContent and key == "data":
|
||||
# DataContent converts 'data' to 'uri', so we skip checking 'data' attribute
|
||||
# Instead we verify that uri and media_type are set correctly
|
||||
assert hasattr(reconstructed, "uri")
|
||||
assert hasattr(reconstructed, "media_type")
|
||||
assert reconstructed.media_type == init_kwargs.get("media_type")
|
||||
# Verify the uri contains the encoded data
|
||||
assert reconstructed.uri.startswith(f"data:{init_kwargs.get('media_type')};base64,")
|
||||
continue
|
||||
|
||||
reconstructed_value = getattr(reconstructed, key)
|
||||
|
||||
# Special handling for nested SerializationMixin objects
|
||||
if hasattr(value, "to_dict"):
|
||||
# Compare the serialized forms
|
||||
assert reconstructed_value.to_dict() == value.to_dict()
|
||||
# Special handling for lists that may contain dicts converted to objects
|
||||
elif isinstance(value, list) and value and isinstance(reconstructed_value, list):
|
||||
# Check if this is a list of objects that were created from dicts
|
||||
if isinstance(value[0], dict) and hasattr(reconstructed_value[0], "to_dict"):
|
||||
# Compare each item by serializing the reconstructed object
|
||||
assert len(reconstructed_value) == len(value)
|
||||
|
||||
else:
|
||||
assert reconstructed_value == value
|
||||
# Special handling for dicts that get converted to objects (like UsageDetails, FunctionCallContent)
|
||||
elif isinstance(value, dict) and hasattr(reconstructed_value, "to_dict"):
|
||||
# Compare the dict with the serialized form of the object, excluding 'type' key
|
||||
reconstructed_dict = reconstructed_value.to_dict()
|
||||
assert len(reconstructed_dict) == len(value)
|
||||
else:
|
||||
assert reconstructed_value == value
|
||||
|
||||
|
||||
def test_text_content_with_annotations_serialization():
|
||||
"""Test TextContent with CitationAnnotation and TextSpanRegion roundtrip serialization."""
|
||||
# Create TextSpanRegion
|
||||
region = TextSpanRegion(start_index=0, end_index=5)
|
||||
|
||||
# Create CitationAnnotation with region
|
||||
citation = CitationAnnotation(
|
||||
title="Test Citation",
|
||||
url="http://example.com/citation",
|
||||
file_id="file-123",
|
||||
tool_name="test_tool",
|
||||
snippet="This is a test snippet",
|
||||
annotated_regions=[region],
|
||||
additional_properties={"custom": "value"},
|
||||
)
|
||||
|
||||
# Create TextContent with annotation
|
||||
content = TextContent(
|
||||
text="Hello world", annotations=[citation], additional_properties={"content_key": "content_val"}
|
||||
)
|
||||
|
||||
# Serialize to dict
|
||||
content_dict = content.to_dict()
|
||||
|
||||
# Verify structure
|
||||
assert content_dict["type"] == "text"
|
||||
assert content_dict["text"] == "Hello world"
|
||||
assert content_dict["content_key"] == "content_val"
|
||||
assert len(content_dict["annotations"]) == 1
|
||||
|
||||
# Verify annotation structure
|
||||
annotation_dict = content_dict["annotations"][0]
|
||||
assert annotation_dict["type"] == "citation"
|
||||
assert annotation_dict["title"] == "Test Citation"
|
||||
assert annotation_dict["url"] == "http://example.com/citation"
|
||||
assert annotation_dict["file_id"] == "file-123"
|
||||
assert annotation_dict["tool_name"] == "test_tool"
|
||||
assert annotation_dict["snippet"] == "This is a test snippet"
|
||||
assert annotation_dict["custom"] == "value"
|
||||
|
||||
# Verify region structure
|
||||
assert len(annotation_dict["annotated_regions"]) == 1
|
||||
region_dict = annotation_dict["annotated_regions"][0]
|
||||
assert region_dict["type"] == "text_span"
|
||||
assert region_dict["start_index"] == 0
|
||||
assert region_dict["end_index"] == 5
|
||||
|
||||
# Deserialize from dict
|
||||
reconstructed = TextContent.from_dict(content_dict)
|
||||
|
||||
# Verify reconstructed content
|
||||
assert isinstance(reconstructed, TextContent)
|
||||
assert reconstructed.text == "Hello world"
|
||||
assert reconstructed.type == "text"
|
||||
assert reconstructed.additional_properties == {"content_key": "content_val"}
|
||||
|
||||
# Verify reconstructed annotation
|
||||
assert len(reconstructed.annotations) == 1 # type: ignore[arg-type]
|
||||
recon_annotation = reconstructed.annotations[0] # type: ignore[index]
|
||||
assert isinstance(recon_annotation, CitationAnnotation)
|
||||
assert recon_annotation.title == "Test Citation"
|
||||
assert recon_annotation.url == "http://example.com/citation"
|
||||
assert recon_annotation.file_id == "file-123"
|
||||
assert recon_annotation.tool_name == "test_tool"
|
||||
assert recon_annotation.snippet == "This is a test snippet"
|
||||
assert recon_annotation.additional_properties == {"custom": "value"}
|
||||
|
||||
# Verify reconstructed region
|
||||
assert len(recon_annotation.annotated_regions) == 1 # type: ignore[arg-type]
|
||||
recon_region = recon_annotation.annotated_regions[0] # type: ignore[index]
|
||||
assert isinstance(recon_region, TextSpanRegion)
|
||||
assert recon_region.start_index == 0
|
||||
assert recon_region.end_index == 5
|
||||
assert recon_region.type == "text_span"
|
||||
|
||||
|
||||
def test_text_content_with_multiple_annotations_serialization():
|
||||
"""Test TextContent with multiple annotations roundtrip serialization."""
|
||||
# Create multiple regions
|
||||
region1 = TextSpanRegion(start_index=0, end_index=5)
|
||||
region2 = TextSpanRegion(start_index=6, end_index=11)
|
||||
|
||||
# Create multiple citations
|
||||
citation1 = CitationAnnotation(title="Citation 1", url="http://example.com/1", annotated_regions=[region1])
|
||||
|
||||
citation2 = CitationAnnotation(title="Citation 2", url="http://example.com/2", annotated_regions=[region2])
|
||||
|
||||
# Create TextContent with multiple annotations
|
||||
content = TextContent(text="Hello world", annotations=[citation1, citation2])
|
||||
|
||||
# Serialize
|
||||
content_dict = content.to_dict()
|
||||
|
||||
# Verify we have 2 annotations
|
||||
assert len(content_dict["annotations"]) == 2
|
||||
assert content_dict["annotations"][0]["title"] == "Citation 1"
|
||||
assert content_dict["annotations"][1]["title"] == "Citation 2"
|
||||
|
||||
# Deserialize
|
||||
reconstructed = TextContent.from_dict(content_dict)
|
||||
|
||||
# Verify reconstruction
|
||||
assert len(reconstructed.annotations) == 2
|
||||
assert all(isinstance(ann, CitationAnnotation) for ann in reconstructed.annotations)
|
||||
assert reconstructed.annotations[0].title == "Citation 1"
|
||||
assert reconstructed.annotations[1].title == "Citation 2"
|
||||
assert all(isinstance(ann.annotated_regions[0], TextSpanRegion) for ann in reconstructed.annotations)
|
||||
@@ -46,23 +46,26 @@ skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
|
||||
|
||||
def create_test_openai_assistants_client(
|
||||
mock_async_openai: MagicMock,
|
||||
ai_model_id: str | None = None,
|
||||
model_id: str | None = None,
|
||||
assistant_id: str | None = None,
|
||||
assistant_name: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
should_delete_assistant: bool = False,
|
||||
) -> OpenAIAssistantsClient:
|
||||
"""Helper function to create OpenAIAssistantsClient instances for testing, bypassing Pydantic validation."""
|
||||
return OpenAIAssistantsClient.model_construct(
|
||||
ai_model_id=ai_model_id or "gpt-4",
|
||||
"""Helper function to create OpenAIAssistantsClient instances for testing."""
|
||||
client = OpenAIAssistantsClient(
|
||||
model_id=model_id or "gpt-4",
|
||||
assistant_id=assistant_id,
|
||||
assistant_name=assistant_name,
|
||||
thread_id=thread_id,
|
||||
api_key="test-api-key",
|
||||
org_id="test-org-id",
|
||||
client=mock_async_openai,
|
||||
_should_delete_assistant=should_delete_assistant,
|
||||
async_client=mock_async_openai,
|
||||
)
|
||||
# Set the _should_delete_assistant flag directly if needed
|
||||
if should_delete_assistant:
|
||||
object.__setattr__(client, "_should_delete_assistant", True)
|
||||
return client
|
||||
|
||||
|
||||
async def create_vector_store(client: OpenAIAssistantsClient) -> tuple[str, HostedVectorStoreContent]:
|
||||
@@ -117,11 +120,11 @@ def mock_async_openai() -> MagicMock:
|
||||
def test_openai_assistants_client_init_with_client(mock_async_openai: MagicMock) -> None:
|
||||
"""Test OpenAIAssistantsClient initialization with existing client."""
|
||||
chat_client = create_test_openai_assistants_client(
|
||||
mock_async_openai, ai_model_id="gpt-4", assistant_id="existing-assistant-id", thread_id="test-thread-id"
|
||||
mock_async_openai, model_id="gpt-4", assistant_id="existing-assistant-id", thread_id="test-thread-id"
|
||||
)
|
||||
|
||||
assert chat_client.client is mock_async_openai
|
||||
assert chat_client.ai_model_id == "gpt-4"
|
||||
assert chat_client.model_id == "gpt-4"
|
||||
assert chat_client.assistant_id == "existing-assistant-id"
|
||||
assert chat_client.thread_id == "test-thread-id"
|
||||
assert not chat_client._should_delete_assistant # type: ignore
|
||||
@@ -133,19 +136,16 @@ def test_openai_assistants_client_init_auto_create_client(
|
||||
mock_async_openai: MagicMock,
|
||||
) -> None:
|
||||
"""Test OpenAIAssistantsClient initialization with auto-created client."""
|
||||
chat_client = OpenAIAssistantsClient.model_construct(
|
||||
ai_model_id=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
assistant_id=None,
|
||||
chat_client = OpenAIAssistantsClient(
|
||||
model_id=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
assistant_name="TestAssistant",
|
||||
thread_id=None,
|
||||
api_key=openai_unit_test_env["OPENAI_API_KEY"],
|
||||
org_id=openai_unit_test_env["OPENAI_ORG_ID"],
|
||||
client=mock_async_openai,
|
||||
_should_delete_assistant=False,
|
||||
async_client=mock_async_openai,
|
||||
)
|
||||
|
||||
assert chat_client.client is mock_async_openai
|
||||
assert chat_client.ai_model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
assert chat_client.model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
assert chat_client.assistant_id is None
|
||||
assert chat_client.assistant_name == "TestAssistant"
|
||||
assert not chat_client._should_delete_assistant # type: ignore
|
||||
@@ -155,7 +155,7 @@ def test_openai_assistants_client_init_validation_fail() -> None:
|
||||
"""Test OpenAIAssistantsClient initialization with validation failure."""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
# Force failure by providing invalid model ID type - this should cause validation to fail
|
||||
OpenAIAssistantsClient(ai_model_id=123, api_key="valid-key") # type: ignore
|
||||
OpenAIAssistantsClient(model_id=123, api_key="valid-key") # type: ignore
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_CHAT_MODEL_ID"]], indirect=True)
|
||||
@@ -171,7 +171,7 @@ def test_openai_assistants_client_init_missing_model_id(openai_unit_test_env: di
|
||||
def test_openai_assistants_client_init_missing_api_key(openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test OpenAIAssistantsClient initialization with missing API key."""
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAIAssistantsClient(ai_model_id="gpt-4", env_file_path="nonexistent.env")
|
||||
OpenAIAssistantsClient(model_id="gpt-4", env_file_path="nonexistent.env")
|
||||
|
||||
|
||||
def test_openai_assistants_client_init_with_default_headers(openai_unit_test_env: dict[str, str]) -> None:
|
||||
@@ -179,12 +179,12 @@ def test_openai_assistants_client_init_with_default_headers(openai_unit_test_env
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
chat_client = OpenAIAssistantsClient(
|
||||
ai_model_id="gpt-4",
|
||||
model_id="gpt-4",
|
||||
api_key=openai_unit_test_env["OPENAI_API_KEY"],
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
assert chat_client.ai_model_id == "gpt-4"
|
||||
assert chat_client.model_id == "gpt-4"
|
||||
assert isinstance(chat_client, ChatClientProtocol)
|
||||
|
||||
# Assert that the default header we added is present in the client's default headers
|
||||
@@ -211,7 +211,7 @@ async def test_openai_assistants_client_get_assistant_id_or_create_create_new(
|
||||
) -> None:
|
||||
"""Test _get_assistant_id_or_create when creating a new assistant."""
|
||||
chat_client = create_test_openai_assistants_client(
|
||||
mock_async_openai, ai_model_id="gpt-4", assistant_name="TestAssistant"
|
||||
mock_async_openai, model_id="gpt-4", assistant_name="TestAssistant"
|
||||
)
|
||||
|
||||
assistant_id = await chat_client._get_assistant_id_or_create() # type: ignore
|
||||
@@ -269,7 +269,7 @@ def test_openai_assistants_client_serialize(openai_unit_test_env: dict[str, str]
|
||||
|
||||
# Test basic initialization and to_dict
|
||||
chat_client = OpenAIAssistantsClient(
|
||||
ai_model_id="gpt-4",
|
||||
model_id="gpt-4",
|
||||
assistant_id="test-assistant-id",
|
||||
assistant_name="TestAssistant",
|
||||
thread_id="test-thread-id",
|
||||
@@ -280,11 +280,10 @@ def test_openai_assistants_client_serialize(openai_unit_test_env: dict[str, str]
|
||||
|
||||
dumped_settings = chat_client.to_dict()
|
||||
|
||||
assert dumped_settings["ai_model_id"] == "gpt-4"
|
||||
assert dumped_settings["model_id"] == "gpt-4"
|
||||
assert dumped_settings["assistant_id"] == "test-assistant-id"
|
||||
assert dumped_settings["assistant_name"] == "TestAssistant"
|
||||
assert dumped_settings["thread_id"] == "test-thread-id"
|
||||
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
|
||||
assert dumped_settings["org_id"] == openai_unit_test_env["OPENAI_ORG_ID"]
|
||||
|
||||
# Assert that the default header we added is present in the dumped_settings default headers
|
||||
@@ -915,6 +914,7 @@ def get_weather(
|
||||
return f"The weather in {location} is sunny with a high of 25°C."
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_assistants_client_get_response() -> None:
|
||||
"""Test OpenAI Assistants Client response."""
|
||||
@@ -939,6 +939,7 @@ async def test_openai_assistants_client_get_response() -> None:
|
||||
assert any(word in response.text.lower() for word in ["sunny", "25", "weather", "seattle"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_assistants_client_get_response_tools() -> None:
|
||||
"""Test OpenAI Assistants Client response with tools."""
|
||||
@@ -960,6 +961,7 @@ async def test_openai_assistants_client_get_response_tools() -> None:
|
||||
assert any(word in response.text.lower() for word in ["sunny", "25", "weather"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_assistants_client_streaming() -> None:
|
||||
"""Test OpenAI Assistants Client streaming response."""
|
||||
@@ -990,6 +992,7 @@ async def test_openai_assistants_client_streaming() -> None:
|
||||
assert any(word in full_message.lower() for word in ["sunny", "25", "weather", "seattle"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_assistants_client_streaming_tools() -> None:
|
||||
"""Test OpenAI Assistants Client streaming response with tools."""
|
||||
@@ -1016,6 +1019,7 @@ async def test_openai_assistants_client_streaming_tools() -> None:
|
||||
assert any(word in full_message.lower() for word in ["sunny", "25", "weather"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_assistants_client_with_existing_assistant() -> None:
|
||||
"""Test OpenAI Assistants Client with existing assistant ID."""
|
||||
@@ -1028,7 +1032,7 @@ async def test_openai_assistants_client_with_existing_assistant() -> None:
|
||||
|
||||
# Now test using the existing assistant
|
||||
async with OpenAIAssistantsClient(
|
||||
ai_model_id="gpt-4o-mini", assistant_id=assistant_id
|
||||
model_id="gpt-4o-mini", assistant_id=assistant_id
|
||||
) as openai_assistants_client:
|
||||
assert isinstance(openai_assistants_client, ChatClientProtocol)
|
||||
assert openai_assistants_client.assistant_id == assistant_id
|
||||
@@ -1043,6 +1047,7 @@ async def test_openai_assistants_client_with_existing_assistant() -> None:
|
||||
assert len(response.text) > 0
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
@pytest.mark.skip(reason="OpenAI file search functionality is currently broken - tracked in GitHub issue")
|
||||
async def test_openai_assistants_client_file_search() -> None:
|
||||
@@ -1066,6 +1071,7 @@ async def test_openai_assistants_client_file_search() -> None:
|
||||
assert any(word in response.text.lower() for word in ["sunny", "25", "weather"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
@pytest.mark.skip(reason="OpenAI file search functionality is currently broken - tracked in GitHub issue")
|
||||
async def test_openai_assistants_client_file_search_streaming() -> None:
|
||||
@@ -1096,6 +1102,7 @@ async def test_openai_assistants_client_file_search_streaming() -> None:
|
||||
assert any(word in full_message.lower() for word in ["sunny", "25", "weather"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_assistants_agent_basic_run():
|
||||
"""Test ChatAgent basic run functionality with OpenAIAssistantsClient."""
|
||||
@@ -1112,6 +1119,7 @@ async def test_openai_assistants_agent_basic_run():
|
||||
assert "Hello World" in response.text
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_assistants_agent_basic_run_streaming():
|
||||
"""Test ChatAgent basic streaming functionality with OpenAIAssistantsClient."""
|
||||
@@ -1131,6 +1139,7 @@ async def test_openai_assistants_agent_basic_run_streaming():
|
||||
assert "streaming response test" in full_message.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_assistants_agent_thread_persistence():
|
||||
"""Test ChatAgent thread persistence across runs with OpenAIAssistantsClient."""
|
||||
@@ -1159,6 +1168,7 @@ async def test_openai_assistants_agent_thread_persistence():
|
||||
assert thread.service_thread_id is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_assistants_agent_existing_thread_id():
|
||||
"""Test ChatAgent with existing thread ID to continue conversations across agent instances."""
|
||||
@@ -1203,6 +1213,7 @@ async def test_openai_assistants_agent_existing_thread_id():
|
||||
assert "paris" in response2.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_assistants_agent_code_interpreter():
|
||||
"""Test ChatAgent with code interpreter through OpenAIAssistantsClient."""
|
||||
@@ -1222,6 +1233,7 @@ async def test_openai_assistants_agent_code_interpreter():
|
||||
assert "120" in response.text or "factorial" in response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_assistants_client_agent_level_tool_persistence():
|
||||
"""Test that agent-level tools persist across multiple runs with OpenAI Assistants Client."""
|
||||
|
||||
@@ -41,22 +41,22 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None:
|
||||
# Test successful initialization
|
||||
open_ai_chat_completion = OpenAIChatClient()
|
||||
|
||||
assert open_ai_chat_completion.ai_model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
assert open_ai_chat_completion.model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
assert isinstance(open_ai_chat_completion, ChatClientProtocol)
|
||||
|
||||
|
||||
def test_init_validation_fail() -> None:
|
||||
# Test successful initialization
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAIChatClient(api_key="34523", ai_model_id={"test": "dict"}) # type: ignore
|
||||
OpenAIChatClient(api_key="34523", model_id={"test": "dict"}) # type: ignore
|
||||
|
||||
|
||||
def test_init_ai_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_init_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None:
|
||||
# Test successful initialization
|
||||
ai_model_id = "test_model_id"
|
||||
open_ai_chat_completion = OpenAIChatClient(ai_model_id=ai_model_id)
|
||||
model_id = "test_model_id"
|
||||
open_ai_chat_completion = OpenAIChatClient(model_id=model_id)
|
||||
|
||||
assert open_ai_chat_completion.ai_model_id == ai_model_id
|
||||
assert open_ai_chat_completion.model_id == model_id
|
||||
assert isinstance(open_ai_chat_completion, ChatClientProtocol)
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None:
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
assert open_ai_chat_completion.ai_model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
assert open_ai_chat_completion.model_id == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
assert isinstance(open_ai_chat_completion, ChatClientProtocol)
|
||||
|
||||
# Assert that the default header we added is present in the client's default headers
|
||||
@@ -95,7 +95,7 @@ def test_init_base_url_from_settings_env() -> None:
|
||||
},
|
||||
):
|
||||
client = OpenAIChatClient()
|
||||
assert client.ai_model_id == "gpt-5"
|
||||
assert client.model_id == "gpt-5"
|
||||
assert str(client.client.base_url) == "https://custom-openai-endpoint.com/v1/"
|
||||
|
||||
|
||||
@@ -109,11 +109,11 @@ def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None:
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
|
||||
def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None:
|
||||
ai_model_id = "test_model_id"
|
||||
model_id = "test_model_id"
|
||||
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAIChatClient(
|
||||
ai_model_id=ai_model_id,
|
||||
model_id=model_id,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
@@ -122,15 +122,14 @@ def test_serialize(openai_unit_test_env: dict[str, str]) -> None:
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
settings = {
|
||||
"ai_model_id": openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
"model_id": openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
|
||||
open_ai_chat_completion = OpenAIChatClient.from_dict(settings)
|
||||
dumped_settings = open_ai_chat_completion.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
|
||||
assert dumped_settings["model_id"] == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
# Assert that the default header we added is present in the dumped_settings default headers
|
||||
for key, value in default_headers.items():
|
||||
assert key in dumped_settings["default_headers"]
|
||||
@@ -141,18 +140,17 @@ def test_serialize(openai_unit_test_env: dict[str, str]) -> None:
|
||||
|
||||
def test_serialize_with_org_id(openai_unit_test_env: dict[str, str]) -> None:
|
||||
settings = {
|
||||
"ai_model_id": openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
"model_id": openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
|
||||
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
|
||||
"org_id": openai_unit_test_env["OPENAI_ORG_ID"],
|
||||
}
|
||||
|
||||
open_ai_chat_completion = OpenAIChatClient.from_dict(settings)
|
||||
dumped_settings = open_ai_chat_completion.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
|
||||
assert dumped_settings["model_id"] == openai_unit_test_env["OPENAI_CHAT_MODEL_ID"]
|
||||
assert dumped_settings["org_id"] == openai_unit_test_env["OPENAI_ORG_ID"]
|
||||
# Assert that the 'User-Agent' header is not present in the dumped_settings default headers
|
||||
assert "User-Agent" not in dumped_settings["default_headers"]
|
||||
assert "User-Agent" not in dumped_settings.get("default_headers", {})
|
||||
|
||||
|
||||
async def test_content_filter_exception_handling(openai_unit_test_env: dict[str, str]) -> None:
|
||||
@@ -210,6 +208,7 @@ def get_weather(location: str) -> str:
|
||||
return f"The weather in {location} is sunny and 72°F."
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_chat_completion_response() -> None:
|
||||
"""Test OpenAI chat completion responses."""
|
||||
@@ -237,6 +236,7 @@ async def test_openai_chat_completion_response() -> None:
|
||||
assert "scientists" in response.text
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_chat_completion_response_tools() -> None:
|
||||
"""Test OpenAI chat completion responses."""
|
||||
@@ -259,6 +259,7 @@ async def test_openai_chat_completion_response_tools() -> None:
|
||||
assert "scientists" in response.text
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_chat_client_streaming() -> None:
|
||||
"""Test Azure OpenAI chat completion responses."""
|
||||
@@ -294,6 +295,7 @@ async def test_openai_chat_client_streaming() -> None:
|
||||
assert "scientists" in full_message
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_chat_client_streaming_tools() -> None:
|
||||
"""Test AzureOpenAI chat completion responses."""
|
||||
@@ -321,10 +323,11 @@ async def test_openai_chat_client_streaming_tools() -> None:
|
||||
assert "scientists" in full_message
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_chat_client_web_search() -> None:
|
||||
# Currently only a select few models support web search tool calls
|
||||
openai_chat_client = OpenAIChatClient(ai_model_id="gpt-4o-search-preview")
|
||||
openai_chat_client = OpenAIChatClient(model_id="gpt-4o-search-preview")
|
||||
|
||||
assert isinstance(openai_chat_client, ChatClientProtocol)
|
||||
|
||||
@@ -361,9 +364,10 @@ async def test_openai_chat_client_web_search() -> None:
|
||||
assert response.text is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_chat_client_web_search_streaming() -> None:
|
||||
openai_chat_client = OpenAIChatClient(ai_model_id="gpt-4o-search-preview")
|
||||
openai_chat_client = OpenAIChatClient(model_id="gpt-4o-search-preview")
|
||||
|
||||
assert isinstance(openai_chat_client, ChatClientProtocol)
|
||||
|
||||
@@ -414,11 +418,12 @@ async def test_openai_chat_client_web_search_streaming() -> None:
|
||||
assert full_message is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_chat_client_agent_basic_run():
|
||||
"""Test OpenAI chat client agent basic run functionality with OpenAIChatClient."""
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-search-preview"),
|
||||
chat_client=OpenAIChatClient(model_id="gpt-4o-search-preview"),
|
||||
) as agent:
|
||||
# Test basic run
|
||||
response = await agent.run("Hello! Please respond with 'Hello World' exactly.")
|
||||
@@ -429,11 +434,12 @@ async def test_openai_chat_client_agent_basic_run():
|
||||
assert "hello world" in response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_chat_client_agent_basic_run_streaming():
|
||||
"""Test OpenAI chat client agent basic streaming functionality with OpenAIChatClient."""
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-search-preview"),
|
||||
chat_client=OpenAIChatClient(model_id="gpt-4o-search-preview"),
|
||||
) as agent:
|
||||
# Test streaming run
|
||||
full_text = ""
|
||||
@@ -446,11 +452,12 @@ async def test_openai_chat_client_agent_basic_run_streaming():
|
||||
assert "streaming response test" in full_text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_chat_client_agent_thread_persistence():
|
||||
"""Test OpenAI chat client agent thread persistence across runs with OpenAIChatClient."""
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-search-preview"),
|
||||
chat_client=OpenAIChatClient(model_id="gpt-4o-search-preview"),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as agent:
|
||||
# Create a new thread that will be reused
|
||||
@@ -470,6 +477,7 @@ async def test_openai_chat_client_agent_thread_persistence():
|
||||
assert "alice" in response2.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_chat_client_agent_existing_thread():
|
||||
"""Test OpenAI chat client agent with existing thread to continue conversations across agent instances."""
|
||||
@@ -477,7 +485,7 @@ async def test_openai_chat_client_agent_existing_thread():
|
||||
preserved_thread = None
|
||||
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-search-preview"),
|
||||
chat_client=OpenAIChatClient(model_id="gpt-4o-search-preview"),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as first_agent:
|
||||
# Start a conversation and capture the thread
|
||||
@@ -493,7 +501,7 @@ async def test_openai_chat_client_agent_existing_thread():
|
||||
# Second conversation - reuse the thread in a new agent instance
|
||||
if preserved_thread:
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-search-preview"),
|
||||
chat_client=OpenAIChatClient(model_id="gpt-4o-search-preview"),
|
||||
instructions="You are a helpful assistant with good memory.",
|
||||
) as second_agent:
|
||||
# Reuse the preserved thread
|
||||
@@ -504,12 +512,13 @@ async def test_openai_chat_client_agent_existing_thread():
|
||||
assert "alice" in second_response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_chat_client_agent_level_tool_persistence():
|
||||
"""Test that agent-level tools persist across multiple runs with OpenAI Chat Client."""
|
||||
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIChatClient(ai_model_id="gpt-4.1"),
|
||||
chat_client=OpenAIChatClient(model_id="gpt-4.1"),
|
||||
instructions="You are a helpful assistant that uses available tools.",
|
||||
tools=[get_weather], # Agent-level tool
|
||||
) as agent:
|
||||
@@ -530,6 +539,7 @@ async def test_openai_chat_client_agent_level_tool_persistence():
|
||||
assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_chat_client_run_level_tool_isolation():
|
||||
"""Test that run-level tools are isolated to specific runs and don't persist with OpenAI Chat Client."""
|
||||
@@ -544,7 +554,7 @@ async def test_openai_chat_client_run_level_tool_isolation():
|
||||
return f"The weather in {location} is sunny and 72°F."
|
||||
|
||||
async with ChatAgent(
|
||||
chat_client=OpenAIChatClient(ai_model_id="gpt-4.1"),
|
||||
chat_client=OpenAIChatClient(model_id="gpt-4.1"),
|
||||
instructions="You are a helpful assistant.",
|
||||
) as agent:
|
||||
# First run - use run-level tool
|
||||
@@ -571,7 +581,7 @@ async def test_openai_chat_client_run_level_tool_isolation():
|
||||
|
||||
async def test_exception_message_includes_original_error_details() -> None:
|
||||
"""Test that exception messages include original error details in the new format."""
|
||||
client = OpenAIChatClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIChatClient(model_id="test-model", api_key="test-key")
|
||||
messages = [ChatMessage(role="user", text="test message")]
|
||||
|
||||
mock_response = MagicMock()
|
||||
|
||||
@@ -96,22 +96,22 @@ def test_init(openai_unit_test_env: dict[str, str]) -> None:
|
||||
# Test successful initialization
|
||||
openai_responses_client = OpenAIResponsesClient()
|
||||
|
||||
assert openai_responses_client.ai_model_id == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"]
|
||||
assert openai_responses_client.model_id == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"]
|
||||
assert isinstance(openai_responses_client, ChatClientProtocol)
|
||||
|
||||
|
||||
def test_init_validation_fail() -> None:
|
||||
# Test successful initialization
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAIResponsesClient(api_key="34523", ai_model_id={"test": "dict"}) # type: ignore
|
||||
OpenAIResponsesClient(api_key="34523", model_id={"test": "dict"}) # type: ignore
|
||||
|
||||
|
||||
def test_init_ai_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None:
|
||||
def test_init_model_id_constructor(openai_unit_test_env: dict[str, str]) -> None:
|
||||
# Test successful initialization
|
||||
ai_model_id = "test_model_id"
|
||||
openai_responses_client = OpenAIResponsesClient(ai_model_id=ai_model_id)
|
||||
model_id = "test_model_id"
|
||||
openai_responses_client = OpenAIResponsesClient(model_id=model_id)
|
||||
|
||||
assert openai_responses_client.ai_model_id == ai_model_id
|
||||
assert openai_responses_client.model_id == model_id
|
||||
assert isinstance(openai_responses_client, ChatClientProtocol)
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ def test_init_with_default_header(openai_unit_test_env: dict[str, str]) -> None:
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
assert openai_responses_client.ai_model_id == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"]
|
||||
assert openai_responses_client.model_id == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"]
|
||||
assert isinstance(openai_responses_client, ChatClientProtocol)
|
||||
|
||||
# Assert that the default header we added is present in the client's default headers
|
||||
@@ -142,11 +142,11 @@ def test_init_with_empty_model_id(openai_unit_test_env: dict[str, str]) -> None:
|
||||
|
||||
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
|
||||
def test_init_with_empty_api_key(openai_unit_test_env: dict[str, str]) -> None:
|
||||
ai_model_id = "test_model_id"
|
||||
model_id = "test_model_id"
|
||||
|
||||
with pytest.raises(ServiceInitializationError):
|
||||
OpenAIResponsesClient(
|
||||
ai_model_id=ai_model_id,
|
||||
model_id=model_id,
|
||||
env_file_path="test.env",
|
||||
)
|
||||
|
||||
@@ -155,15 +155,14 @@ def test_serialize(openai_unit_test_env: dict[str, str]) -> None:
|
||||
default_headers = {"X-Unit-Test": "test-guid"}
|
||||
|
||||
settings = {
|
||||
"ai_model_id": openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"],
|
||||
"model_id": openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"],
|
||||
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
|
||||
"default_headers": default_headers,
|
||||
}
|
||||
|
||||
openai_responses_client = OpenAIResponsesClient.from_dict(settings)
|
||||
dumped_settings = openai_responses_client.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"]
|
||||
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
|
||||
assert dumped_settings["model_id"] == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"]
|
||||
# Assert that the default header we added is present in the dumped_settings default headers
|
||||
for key, value in default_headers.items():
|
||||
assert key in dumped_settings["default_headers"]
|
||||
@@ -174,24 +173,23 @@ def test_serialize(openai_unit_test_env: dict[str, str]) -> None:
|
||||
|
||||
def test_serialize_with_org_id(openai_unit_test_env: dict[str, str]) -> None:
|
||||
settings = {
|
||||
"ai_model_id": openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"],
|
||||
"model_id": openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"],
|
||||
"api_key": openai_unit_test_env["OPENAI_API_KEY"],
|
||||
"org_id": openai_unit_test_env["OPENAI_ORG_ID"],
|
||||
}
|
||||
|
||||
openai_responses_client = OpenAIResponsesClient.from_dict(settings)
|
||||
dumped_settings = openai_responses_client.to_dict()
|
||||
assert dumped_settings["ai_model_id"] == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"]
|
||||
assert dumped_settings["api_key"] == openai_unit_test_env["OPENAI_API_KEY"]
|
||||
assert dumped_settings["model_id"] == openai_unit_test_env["OPENAI_RESPONSES_MODEL_ID"]
|
||||
assert dumped_settings["org_id"] == openai_unit_test_env["OPENAI_ORG_ID"]
|
||||
# Assert that the 'User-Agent' header is not present in the dumped_settings default headers
|
||||
assert "User-Agent" not in dumped_settings["default_headers"]
|
||||
assert "User-Agent" not in dumped_settings.get("default_headers", {})
|
||||
|
||||
|
||||
def test_get_response_with_invalid_input() -> None:
|
||||
"""Test get_response with invalid inputs to trigger exception handling."""
|
||||
|
||||
client = OpenAIResponsesClient(ai_model_id="invalid-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="invalid-model", api_key="test-key")
|
||||
|
||||
# Test with empty messages which should trigger ServiceInvalidRequestError
|
||||
with pytest.raises(ServiceInvalidRequestError, match="Messages are required"):
|
||||
@@ -200,7 +198,7 @@ def test_get_response_with_invalid_input() -> None:
|
||||
|
||||
def test_get_response_with_all_parameters() -> None:
|
||||
"""Test get_response with all possible parameters to cover parameter handling logic."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test with comprehensive parameter set - should fail due to invalid API key
|
||||
with pytest.raises(ServiceResponseException):
|
||||
@@ -232,7 +230,7 @@ def test_get_response_with_all_parameters() -> None:
|
||||
|
||||
def test_web_search_tool_with_location() -> None:
|
||||
"""Test HostedWebSearchTool with location parameters."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test web search tool with location
|
||||
web_search_tool = HostedWebSearchTool(
|
||||
@@ -254,7 +252,7 @@ def test_web_search_tool_with_location() -> None:
|
||||
|
||||
def test_file_search_tool_with_invalid_inputs() -> None:
|
||||
"""Test HostedFileSearchTool with invalid vector store inputs."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test with invalid inputs type (should trigger ValueError)
|
||||
file_search_tool = HostedFileSearchTool(inputs=[HostedFileContent(file_id="invalid")])
|
||||
@@ -268,7 +266,7 @@ def test_file_search_tool_with_invalid_inputs() -> None:
|
||||
|
||||
def test_code_interpreter_tool_variations() -> None:
|
||||
"""Test HostedCodeInterpreterTool with and without file inputs."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test code interpreter without files
|
||||
code_tool_empty = HostedCodeInterpreterTool()
|
||||
@@ -293,7 +291,7 @@ def test_code_interpreter_tool_variations() -> None:
|
||||
|
||||
def test_content_filter_exception() -> None:
|
||||
"""Test that content filter errors in get_response are properly handled."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Mock a BadRequestError with content_filter code
|
||||
mock_error = BadRequestError(
|
||||
@@ -313,7 +311,7 @@ def test_content_filter_exception() -> None:
|
||||
def test_hosted_file_search_tool_validation() -> None:
|
||||
"""Test get_response HostedFileSearchTool validation."""
|
||||
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test HostedFileSearchTool without inputs (should raise ValueError)
|
||||
empty_file_search_tool = HostedFileSearchTool()
|
||||
@@ -326,7 +324,7 @@ def test_hosted_file_search_tool_validation() -> None:
|
||||
|
||||
def test_chat_message_parsing_with_function_calls() -> None:
|
||||
"""Test get_response message preparation with function call and result content types in conversation flow."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Create messages with function call and result content
|
||||
function_call = FunctionCallContent(
|
||||
@@ -351,7 +349,7 @@ def test_chat_message_parsing_with_function_calls() -> None:
|
||||
|
||||
async def test_response_format_parse_path() -> None:
|
||||
"""Test get_response response_format parsing path."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Mock successful parse response
|
||||
mock_parsed_response = MagicMock()
|
||||
@@ -375,7 +373,7 @@ async def test_response_format_parse_path() -> None:
|
||||
|
||||
async def test_bad_request_error_non_content_filter() -> None:
|
||||
"""Test get_response BadRequestError without content_filter."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Mock a BadRequestError without content_filter code
|
||||
mock_error = BadRequestError(
|
||||
@@ -396,7 +394,7 @@ async def test_bad_request_error_non_content_filter() -> None:
|
||||
|
||||
async def test_streaming_content_filter_exception_handling() -> None:
|
||||
"""Test that content filter errors in get_streaming_response are properly handled."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Mock the OpenAI client to raise a BadRequestError with content_filter code
|
||||
with patch.object(client.client.responses, "create") as mock_create:
|
||||
@@ -413,10 +411,11 @@ async def test_streaming_content_filter_exception_handling() -> None:
|
||||
break
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_get_streaming_response_with_all_parameters() -> None:
|
||||
"""Test get_streaming_response with all possible parameters."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Should fail due to invalid API key
|
||||
with pytest.raises(ServiceResponseException):
|
||||
@@ -449,7 +448,7 @@ async def test_get_streaming_response_with_all_parameters() -> None:
|
||||
|
||||
def test_response_content_creation_with_annotations() -> None:
|
||||
"""Test _create_response_content with different annotation types."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Create a mock response with annotated text content
|
||||
mock_response = MagicMock()
|
||||
@@ -489,7 +488,7 @@ def test_response_content_creation_with_annotations() -> None:
|
||||
|
||||
def test_response_content_creation_with_refusal() -> None:
|
||||
"""Test _create_response_content with refusal content."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Create a mock response with refusal content
|
||||
mock_response = MagicMock()
|
||||
@@ -519,7 +518,7 @@ def test_response_content_creation_with_refusal() -> None:
|
||||
|
||||
def test_response_content_creation_with_reasoning() -> None:
|
||||
"""Test _create_response_content with reasoning content."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Create a mock response with reasoning content
|
||||
mock_response = MagicMock()
|
||||
@@ -550,7 +549,7 @@ def test_response_content_creation_with_reasoning() -> None:
|
||||
def test_response_content_creation_with_code_interpreter() -> None:
|
||||
"""Test _create_response_content with code interpreter outputs."""
|
||||
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Create a mock response with code interpreter outputs
|
||||
mock_response = MagicMock()
|
||||
@@ -588,7 +587,7 @@ def test_response_content_creation_with_code_interpreter() -> None:
|
||||
|
||||
def test_response_content_creation_with_function_call() -> None:
|
||||
"""Test _create_response_content with function call content."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Create a mock response with function call
|
||||
mock_response = MagicMock()
|
||||
@@ -620,7 +619,7 @@ def test_response_content_creation_with_function_call() -> None:
|
||||
|
||||
def test_tools_to_response_tools_with_hosted_mcp() -> None:
|
||||
"""Test that HostedMCPTool is converted to the correct response tool dict."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
tool = HostedMCPTool(
|
||||
name="My MCP",
|
||||
@@ -650,7 +649,7 @@ def test_tools_to_response_tools_with_hosted_mcp() -> None:
|
||||
|
||||
def test_create_response_content_with_mcp_approval_request() -> None:
|
||||
"""Test that a non-streaming mcp_approval_request is parsed into FunctionApprovalRequestContent."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.output_parsed = None
|
||||
@@ -681,7 +680,7 @@ def test_create_response_content_with_mcp_approval_request() -> None:
|
||||
|
||||
def test_tools_to_response_tools_with_raw_image_generation() -> None:
|
||||
"""Test that raw image_generation tool dict is handled correctly with parameter mapping."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test with raw tool dict using user-friendly parameter names
|
||||
tool = {
|
||||
@@ -710,7 +709,7 @@ def test_tools_to_response_tools_with_raw_image_generation() -> None:
|
||||
|
||||
def test_tools_to_response_tools_with_raw_image_generation_openai_responses_params() -> None:
|
||||
"""Test raw image_generation tool with OpenAI-specific parameters."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test with OpenAI-specific parameters
|
||||
tool = {
|
||||
@@ -742,7 +741,7 @@ def test_tools_to_response_tools_with_raw_image_generation_openai_responses_para
|
||||
|
||||
def test_tools_to_response_tools_with_raw_image_generation_minimal() -> None:
|
||||
"""Test raw image_generation tool with minimal configuration."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test with minimal parameters (just type)
|
||||
tool = {"type": "image_generation"}
|
||||
@@ -760,7 +759,7 @@ def test_tools_to_response_tools_with_raw_image_generation_minimal() -> None:
|
||||
|
||||
def test_create_streaming_response_content_with_mcp_approval_request() -> None:
|
||||
"""Test that a streaming mcp_approval_request event is parsed into FunctionApprovalRequestContent."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
@@ -787,7 +786,7 @@ def test_end_to_end_mcp_approval_flow(span_exporter) -> None:
|
||||
"""End-to-end mocked test:
|
||||
model issues an mcp_approval_request, user approves, client sends mcp_approval_response.
|
||||
"""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# First mocked response: model issues an mcp_approval_request
|
||||
mock_response1 = MagicMock()
|
||||
@@ -851,7 +850,7 @@ def test_end_to_end_mcp_approval_flow(span_exporter) -> None:
|
||||
|
||||
def test_usage_details_basic() -> None:
|
||||
"""Test _usage_details_from_openai without cached or reasoning tokens."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.input_tokens = 100
|
||||
@@ -869,7 +868,7 @@ def test_usage_details_basic() -> None:
|
||||
|
||||
def test_usage_details_with_cached_tokens() -> None:
|
||||
"""Test _usage_details_from_openai with cached input tokens."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.input_tokens = 200
|
||||
@@ -887,7 +886,7 @@ def test_usage_details_with_cached_tokens() -> None:
|
||||
|
||||
def test_usage_details_with_reasoning_tokens() -> None:
|
||||
"""Test _usage_details_from_openai with reasoning tokens."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
mock_usage = MagicMock()
|
||||
mock_usage.input_tokens = 150
|
||||
@@ -905,7 +904,7 @@ def test_usage_details_with_reasoning_tokens() -> None:
|
||||
|
||||
def test_get_metadata_from_response() -> None:
|
||||
"""Test the _get_metadata_from_response method."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test with logprobs
|
||||
mock_output_with_logprobs = MagicMock()
|
||||
@@ -925,7 +924,7 @@ def test_get_metadata_from_response() -> None:
|
||||
|
||||
def test_streaming_response_basic_structure() -> None:
|
||||
"""Test that _create_streaming_response_content returns proper structure."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions(store=True)
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
@@ -942,6 +941,7 @@ def test_streaming_response_basic_structure() -> None:
|
||||
assert response.raw_representation is mock_event
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_response() -> None:
|
||||
"""Test OpenAI chat completion responses."""
|
||||
@@ -986,6 +986,7 @@ async def test_openai_responses_client_response() -> None:
|
||||
assert output.weather is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_response_tools() -> None:
|
||||
"""Test OpenAI chat completion responses."""
|
||||
@@ -1025,6 +1026,7 @@ async def test_openai_responses_client_response_tools() -> None:
|
||||
assert "sunny" in output.weather.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_streaming() -> None:
|
||||
"""Test OpenAI chat completion responses."""
|
||||
@@ -1071,6 +1073,7 @@ async def test_openai_responses_client_streaming() -> None:
|
||||
assert output.weather is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_streaming_tools() -> None:
|
||||
"""Test OpenAI chat completion responses."""
|
||||
@@ -1118,6 +1121,7 @@ async def test_openai_responses_client_streaming_tools() -> None:
|
||||
assert "sunny" in output.weather.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_web_search() -> None:
|
||||
openai_responses_client = OpenAIResponsesClient()
|
||||
@@ -1157,6 +1161,7 @@ async def test_openai_responses_client_web_search() -> None:
|
||||
assert response.text is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_web_search_streaming() -> None:
|
||||
openai_responses_client = OpenAIResponsesClient()
|
||||
@@ -1210,6 +1215,7 @@ async def test_openai_responses_client_web_search_streaming() -> None:
|
||||
assert full_message is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_file_search() -> None:
|
||||
openai_responses_client = OpenAIResponsesClient()
|
||||
@@ -1234,6 +1240,7 @@ async def test_openai_responses_client_file_search() -> None:
|
||||
assert "75" in response.text
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_streaming_file_search() -> None:
|
||||
openai_responses_client = OpenAIResponsesClient()
|
||||
@@ -1268,6 +1275,7 @@ async def test_openai_responses_client_streaming_file_search() -> None:
|
||||
assert "75" in full_message
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_basic_run():
|
||||
"""Test OpenAI Responses Client agent basic run functionality with OpenAIResponsesClient."""
|
||||
@@ -1284,6 +1292,7 @@ async def test_openai_responses_client_agent_basic_run():
|
||||
assert "hello world" in response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_basic_run_streaming():
|
||||
"""Test OpenAI Responses Client agent basic streaming functionality with OpenAIResponsesClient."""
|
||||
@@ -1301,6 +1310,7 @@ async def test_openai_responses_client_agent_basic_run_streaming():
|
||||
assert "streaming response test" in full_text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_thread_persistence():
|
||||
"""Test OpenAI Responses Client agent thread persistence across runs with OpenAIResponsesClient."""
|
||||
@@ -1324,6 +1334,7 @@ async def test_openai_responses_client_agent_thread_persistence():
|
||||
assert second_response.text is not None
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_thread_storage_with_store_true():
|
||||
"""Test OpenAI Responses Client agent with store=True to verify service_thread_id is returned."""
|
||||
@@ -1355,6 +1366,7 @@ async def test_openai_responses_client_agent_thread_storage_with_store_true():
|
||||
assert len(thread.service_thread_id) > 0
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_existing_thread():
|
||||
"""Test OpenAI Responses Client agent with existing thread to continue conversations across agent instances."""
|
||||
@@ -1389,6 +1401,7 @@ async def test_openai_responses_client_agent_existing_thread():
|
||||
assert "photography" in second_response.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_hosted_code_interpreter_tool():
|
||||
"""Test OpenAI Responses Client agent with HostedCodeInterpreterTool through OpenAIResponsesClient."""
|
||||
@@ -1410,6 +1423,7 @@ async def test_openai_responses_client_agent_hosted_code_interpreter_tool():
|
||||
assert contains_relevant_content or len(response.text.strip()) > 10
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_raw_image_generation_tool():
|
||||
"""Test OpenAI Responses Client agent with raw image_generation tool through OpenAIResponsesClient."""
|
||||
@@ -1446,6 +1460,7 @@ async def test_openai_responses_client_agent_raw_image_generation_tool():
|
||||
assert image_content_found, "Expected to find image content in response"
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_level_tool_persistence():
|
||||
"""Test that agent-level tools persist across multiple runs with OpenAI Responses Client."""
|
||||
@@ -1472,6 +1487,7 @@ async def test_openai_responses_client_agent_level_tool_persistence():
|
||||
assert any(term in second_response.text.lower() for term in ["miami", "sunny", "72"])
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_run_level_tool_isolation():
|
||||
"""Test that run-level tools are isolated to specific runs and don't persist with OpenAI Responses Client."""
|
||||
@@ -1511,6 +1527,7 @@ async def test_openai_responses_client_run_level_tool_isolation():
|
||||
assert call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_chat_options_run_level() -> None:
|
||||
"""Integration test for comprehensive ChatOptions parameter coverage with OpenAI Response Agent."""
|
||||
@@ -1534,6 +1551,7 @@ async def test_openai_responses_client_agent_chat_options_run_level() -> None:
|
||||
assert len(response.text) > 0
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_chat_options_agent_level() -> None:
|
||||
"""Integration test for comprehensive ChatOptions parameter coverage with OpenAI Response Agent."""
|
||||
@@ -1557,6 +1575,7 @@ async def test_openai_responses_client_agent_chat_options_agent_level() -> None:
|
||||
assert len(response.text) > 0
|
||||
|
||||
|
||||
@pytest.mark.flaky
|
||||
@skip_if_openai_integration_tests_disabled
|
||||
async def test_openai_responses_client_agent_hosted_mcp_tool() -> None:
|
||||
"""Integration test for HostedMCPTool with OpenAI Response Agent using Microsoft Learn MCP."""
|
||||
@@ -1587,7 +1606,7 @@ async def test_openai_responses_client_agent_hosted_mcp_tool() -> None:
|
||||
|
||||
def test_service_response_exception_includes_original_error_details() -> None:
|
||||
"""Test that ServiceResponseException messages include original error details in the new format."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
messages = [ChatMessage(role="user", text="test message")]
|
||||
|
||||
mock_response = MagicMock()
|
||||
@@ -1612,7 +1631,7 @@ def test_service_response_exception_includes_original_error_details() -> None:
|
||||
|
||||
def test_get_streaming_response_with_response_format() -> None:
|
||||
"""Test get_streaming_response with response_format."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
messages = [ChatMessage(role="user", text="Test streaming with format")]
|
||||
|
||||
# It will fail due to invalid API key, but exercises the code path
|
||||
@@ -1627,7 +1646,7 @@ def test_get_streaming_response_with_response_format() -> None:
|
||||
|
||||
def test_openai_content_parser_image_content() -> None:
|
||||
"""Test _openai_content_parser with image content variations."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test image content with detail parameter and file_id
|
||||
image_content_with_detail = UriContent(
|
||||
@@ -1651,7 +1670,7 @@ def test_openai_content_parser_image_content() -> None:
|
||||
|
||||
def test_openai_content_parser_audio_content() -> None:
|
||||
"""Test _openai_content_parser with audio content variations."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test WAV audio content
|
||||
wav_content = UriContent(uri="data:audio/wav;base64,abc123", media_type="audio/wav")
|
||||
@@ -1669,7 +1688,7 @@ def test_openai_content_parser_audio_content() -> None:
|
||||
|
||||
def test_openai_content_parser_unsupported_content() -> None:
|
||||
"""Test _openai_content_parser with unsupported content types."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test unsupported audio format
|
||||
unsupported_audio = UriContent(uri="data:audio/ogg;base64,ghi789", media_type="audio/ogg")
|
||||
@@ -1684,7 +1703,7 @@ def test_openai_content_parser_unsupported_content() -> None:
|
||||
|
||||
def test_create_streaming_response_content_code_interpreter() -> None:
|
||||
"""Test _create_streaming_response_content with code_interpreter_call."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
@@ -1708,7 +1727,7 @@ def test_create_streaming_response_content_code_interpreter() -> None:
|
||||
|
||||
def test_create_streaming_response_content_reasoning() -> None:
|
||||
"""Test _create_streaming_response_content with reasoning content."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
@@ -1732,7 +1751,7 @@ def test_create_streaming_response_content_reasoning() -> None:
|
||||
|
||||
def test_openai_content_parser_text_reasoning_comprehensive() -> None:
|
||||
"""Test _openai_content_parser with TextReasoningContent all additional properties."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test TextReasoningContent with all additional properties
|
||||
comprehensive_reasoning = TextReasoningContent(
|
||||
@@ -1754,7 +1773,7 @@ def test_openai_content_parser_text_reasoning_comprehensive() -> None:
|
||||
|
||||
def test_streaming_reasoning_text_delta_event() -> None:
|
||||
"""Test reasoning text delta event creates TextReasoningContent."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
@@ -1779,7 +1798,7 @@ def test_streaming_reasoning_text_delta_event() -> None:
|
||||
|
||||
def test_streaming_reasoning_text_done_event() -> None:
|
||||
"""Test reasoning text done event creates TextReasoningContent with complete text."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
@@ -1805,7 +1824,7 @@ def test_streaming_reasoning_text_done_event() -> None:
|
||||
|
||||
def test_streaming_reasoning_summary_text_delta_event() -> None:
|
||||
"""Test reasoning summary text delta event creates TextReasoningContent."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
@@ -1830,7 +1849,7 @@ def test_streaming_reasoning_summary_text_delta_event() -> None:
|
||||
|
||||
def test_streaming_reasoning_summary_text_done_event() -> None:
|
||||
"""Test reasoning summary text done event creates TextReasoningContent with complete text."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
@@ -1856,7 +1875,7 @@ def test_streaming_reasoning_summary_text_done_event() -> None:
|
||||
|
||||
def test_streaming_reasoning_events_preserve_metadata() -> None:
|
||||
"""Test that reasoning events preserve metadata like regular text events."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
chat_options = ChatOptions()
|
||||
function_call_ids: dict[int, tuple[str, str]] = {}
|
||||
|
||||
@@ -1894,7 +1913,7 @@ def test_streaming_reasoning_events_preserve_metadata() -> None:
|
||||
|
||||
def test_create_response_content_image_generation_raw_base64():
|
||||
"""Test image generation response parsing with raw base64 string."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Create a mock response with raw base64 image data (PNG signature)
|
||||
mock_response = MagicMock()
|
||||
@@ -1928,7 +1947,7 @@ def test_create_response_content_image_generation_raw_base64():
|
||||
|
||||
def test_create_response_content_image_generation_existing_data_uri():
|
||||
"""Test image generation response parsing with existing data URI."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Create a mock response with existing data URI
|
||||
mock_response = MagicMock()
|
||||
@@ -1961,7 +1980,7 @@ def test_create_response_content_image_generation_existing_data_uri():
|
||||
|
||||
def test_create_response_content_image_generation_format_detection():
|
||||
"""Test different image format detection from base64 data."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Test JPEG detection
|
||||
jpeg_signature = b"\xff\xd8\xff"
|
||||
@@ -2014,7 +2033,7 @@ def test_create_response_content_image_generation_format_detection():
|
||||
|
||||
def test_create_response_content_image_generation_fallback():
|
||||
"""Test image generation with invalid base64 falls back to PNG."""
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
|
||||
# Create a mock response with invalid base64
|
||||
mock_response = MagicMock()
|
||||
@@ -2046,7 +2065,7 @@ def test_create_response_content_image_generation_fallback():
|
||||
|
||||
|
||||
def test_prepare_options_store_parameter_handling() -> None:
|
||||
client = OpenAIResponsesClient(ai_model_id="test-model", api_key="test-key")
|
||||
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
|
||||
messages = [ChatMessage(role="user", text="Test message")]
|
||||
|
||||
test_conversation_id = "test-conversation-123"
|
||||
|
||||
@@ -5,8 +5,6 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework._workflows._checkpoint import CheckpointStorage, WorkflowCheckpoint
|
||||
from agent_framework._workflows._events import RequestInfoEvent, WorkflowEvent
|
||||
from agent_framework._workflows._executor import (
|
||||
@@ -295,7 +293,6 @@ def test_restore_state_falls_back_to_base_request_type() -> None:
|
||||
assert isinstance(restored.data, RequestInfoMessage)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_persists_pending_requests_in_runner_state() -> None:
|
||||
shared_state = SharedState()
|
||||
runner_ctx = _StubRunnerContext()
|
||||
|
||||
@@ -117,7 +117,6 @@ async def test_sequential_with_custom_executor_summary() -> None:
|
||||
assert msgs[2].role == Role.ASSISTANT and msgs[2].text.startswith("Summary of users:")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sequential_checkpoint_resume_round_trip() -> None:
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ export interface ChatResponseUpdate {
|
||||
response_id?: string;
|
||||
message_id?: string;
|
||||
conversation_id?: string;
|
||||
ai_model_id?: string;
|
||||
model_id?: string;
|
||||
created_at?: CreatedAtT;
|
||||
finish_reason?: FinishReason;
|
||||
additional_properties?: Record<string, unknown>;
|
||||
|
||||
@@ -46,7 +46,7 @@ def main():
|
||||
"You are a helpful weather and time assistant. Use the available tools to "
|
||||
"provide accurate weather information and current time for any location."
|
||||
),
|
||||
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-mini"),
|
||||
chat_client=OpenAIChatClient(model_id="gpt-4o-mini"),
|
||||
tools=[get_weather, get_time],
|
||||
)
|
||||
|
||||
@@ -54,7 +54,7 @@ def main():
|
||||
name="general-assistant",
|
||||
description="A simple conversational agent",
|
||||
instructions="You are a helpful assistant.",
|
||||
chat_client=OpenAIChatClient(ai_model_id="gpt-4o-mini"),
|
||||
chat_client=OpenAIChatClient(model_id="gpt-4o-mini"),
|
||||
)
|
||||
|
||||
# Collect entities for serving
|
||||
|
||||
@@ -42,7 +42,7 @@ agent = ChatAgent(
|
||||
and forecasts for any location. Always be helpful and provide detailed
|
||||
weather information when asked.
|
||||
""",
|
||||
chat_client=OpenAIChatClient(ai_model_id=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4o")),
|
||||
chat_client=OpenAIChatClient(model_id=os.environ.get("OPENAI_CHAT_MODEL_ID", "gpt-4o")),
|
||||
tools=[get_weather, get_forecast],
|
||||
)
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ async def math_agent(task: TaskType, llm: LLM) -> float:
|
||||
MCPStdioTool(name="calculator", command="uvx", args=["mcp-server-calculator"]) as mcp_server,
|
||||
ChatAgent(
|
||||
chat_client=OpenAIChatClient(
|
||||
ai_model_id=llm.model,
|
||||
model_id=llm.model,
|
||||
api_key="your-api-key",
|
||||
base_url=llm.endpoint,
|
||||
),
|
||||
|
||||
@@ -168,7 +168,7 @@ async def math_agent(task: MathProblem, llm: LLM) -> float:
|
||||
MCPStdioTool(name="calculator", command="uvx", args=["mcp-server-calculator"]) as mcp_server,
|
||||
ChatAgent(
|
||||
chat_client=OpenAIChatClient(
|
||||
ai_model_id=llm.model, # This is the model being trained
|
||||
model_id=llm.model, # This is the model being trained
|
||||
api_key=os.getenv("OPENAI_API_KEY") or "dummy", # Can be dummy when connecting to training LLM
|
||||
base_url=llm.endpoint, # vLLM server endpoint provided by agent-lightning
|
||||
),
|
||||
|
||||
@@ -103,14 +103,14 @@ class Tau2Agent(LitAgent):
|
||||
assistant_chat_client = OpenAIChatClient(
|
||||
base_url=llm.endpoint, # vLLM endpoint for the model being trained
|
||||
api_key=openai_api_key,
|
||||
ai_model_id=llm.model, # Model ID being trained
|
||||
model_id=llm.model, # Model ID being trained
|
||||
)
|
||||
|
||||
# User simulator: uses a fixed, capable model for consistent simulation
|
||||
user_simulator_chat_client = OpenAIChatClient(
|
||||
base_url=openai_base_url, # External API endpoint
|
||||
api_key=openai_api_key,
|
||||
ai_model_id="gpt-4.1", # Fixed model for user simulator
|
||||
model_id="gpt-4.1", # Fixed model for user simulator
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -18,10 +18,6 @@ from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
|
||||
|
||||
def test_import():
|
||||
"""Test that the module can be imported."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workflow_two_agents():
|
||||
"""Test a workflow with two OpenAI chat agents where first agent's result passes to second agent."""
|
||||
@@ -111,14 +107,12 @@ def workflow_two_agents():
|
||||
yield workflow
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openai_workflow_two_agents(workflow_two_agents):
|
||||
events = await workflow_two_agents.run("Please analyze the quarterly sales data")
|
||||
|
||||
assert "Based on the analysis 'Analyzed data shows trend upward', I recommend investing" in events.get_outputs()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observability(workflow_two_agents):
|
||||
r"""Expected trace tree:
|
||||
|
||||
|
||||
@@ -54,6 +54,27 @@ math = [
|
||||
"sympy>=1.13.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"uv>=0.8.2,<0.9.0",
|
||||
"pre-commit >= 3.7",
|
||||
"ruff>=0.11.8",
|
||||
"pytest>=8.4.1",
|
||||
"pytest-asyncio>=1.0.0",
|
||||
"pytest-cov>=6.2.1",
|
||||
"pytest-env>=1.1.5",
|
||||
"pytest-xdist[psutil]>=3.8.0",
|
||||
"pytest-timeout>=2.3.1",
|
||||
"pytest-retry>=1",
|
||||
"mypy>=1.16.1",
|
||||
"pyright>=1.1.402",
|
||||
#tasks
|
||||
"poethepoet>=0.36.0",
|
||||
"rich",
|
||||
"tomli",
|
||||
"tomli-w",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
gaia_viewer = "agent_framework_lab_gaia:viewer_main"
|
||||
lightning = "agent_framework_lab_lightning:main"
|
||||
|
||||
@@ -61,12 +61,12 @@ async def run_single_task():
|
||||
assistant_client = OpenAIChatClient(
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="your-api-key",
|
||||
ai_model_id="gpt-4o"
|
||||
model_id="gpt-4o"
|
||||
)
|
||||
user_client = OpenAIChatClient(
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="your-api-key",
|
||||
ai_model_id="gpt-4o-mini"
|
||||
model_id="gpt-4o-mini"
|
||||
)
|
||||
|
||||
# Get a task and run it
|
||||
|
||||
@@ -96,14 +96,14 @@ async def run_benchmark(assistant_model: str, user_model: str, debug_task_id: st
|
||||
assistant_chat_client = OpenAIChatClient(
|
||||
base_url=openai_base_url,
|
||||
api_key=openai_api_key,
|
||||
ai_model_id=assistant_model,
|
||||
model_id=assistant_model,
|
||||
)
|
||||
|
||||
# User simulator: simulates realistic customer behavior and requests
|
||||
user_simulator_chat_client = OpenAIChatClient(
|
||||
base_url=openai_base_url,
|
||||
api_key=openai_api_key,
|
||||
ai_model_id=user_model,
|
||||
model_id=user_model,
|
||||
)
|
||||
|
||||
# STEP 4: Filter task set for debug mode
|
||||
@@ -133,8 +133,8 @@ async def run_benchmark(assistant_model: str, user_model: str, debug_task_id: st
|
||||
# Initialize result structure for this task
|
||||
result: dict[str, Any] = {
|
||||
"config": {
|
||||
"assistant": assistant_chat_client.ai_model_id,
|
||||
"user": user_simulator_chat_client.ai_model_id,
|
||||
"assistant": assistant_chat_client.model_id,
|
||||
"user": user_simulator_chat_client.model_id,
|
||||
},
|
||||
"task": task,
|
||||
}
|
||||
@@ -183,8 +183,8 @@ async def run_benchmark(assistant_model: str, user_model: str, debug_task_id: st
|
||||
# Initialize result structure for this task
|
||||
result: dict[str, Any] = {
|
||||
"config": {
|
||||
"assistant": assistant_chat_client.ai_model_id,
|
||||
"user": user_simulator_chat_client.ai_model_id,
|
||||
"assistant": assistant_chat_client.model_id,
|
||||
"user": user_simulator_chat_client.model_id,
|
||||
},
|
||||
"task": task,
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ from uuid import uuid4
|
||||
|
||||
import redis.asyncio as redis
|
||||
from agent_framework import ChatMessage
|
||||
from agent_framework._pydantic import AFBaseModel
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class RedisStoreState(AFBaseModel):
|
||||
class RedisStoreState(BaseModel):
|
||||
"""State model for serializing and deserializing Redis chat message store data."""
|
||||
|
||||
thread_id: str
|
||||
|
||||
Reference in New Issue
Block a user