Python: [BREAKING]: Introducing Options as TypedDict and Generic (#3140)

* WIP typeddict for options

* updated all clients and ChatAgents

* updated everything

* added ADR

* fix mypy

* proper typevar imports

* fixed import

* fixed other imports

* slight update in the sample

* updated from feedback

* fixes

* fixed missing covariants and test fixes

* fixed typing

* updated anthropic thinking config

* ruff fixes

* fixed int tests

* fix tests and mypy

* updated integration tests

* updated docstring and test fix

* improved options handling in obser

* mypy fix

* updated a host of integration tests

* fix tests

* bedrock fix
This commit is contained in:
Eduard van Valkenburg
2026-01-13 16:41:05 +00:00
committed by GitHub
parent 5faa2851bb
commit 3e97425245
111 changed files with 6141 additions and 4715 deletions
+1
View File
@@ -25,6 +25,7 @@
"words": [
"aeiou",
"aiplatform",
"agui",
"azuredocindex",
"azuredocs",
"azurefunctions",
@@ -189,7 +189,7 @@ class A2AAgent(BaseAgent):
async def run(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
@@ -216,7 +216,7 @@ class A2AAgent(BaseAgent):
async def run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
@@ -16,7 +16,7 @@ from ._confirmation_strategies import (
from ._endpoint import add_agent_framework_fastapi_endpoint
from ._event_converters import AGUIEventConverter
from ._http_service import AGUIHttpService
from ._types import AGUIRequest
from ._types import AgentState, AGUIChatOptions, AGUIRequest, PredictStateConfig, RunMetadata
try:
__version__ = importlib.metadata.version(__name__)
@@ -30,11 +30,15 @@ __all__ = [
"AgentFrameworkAgent",
"add_agent_framework_fastapi_endpoint",
"AGUIChatClient",
"AGUIChatOptions",
"AGUIEventConverter",
"AGUIHttpService",
"AGUIRequest",
"AgentState",
"ConfirmationStrategy",
"DefaultConfirmationStrategy",
"PredictStateConfig",
"RunMetadata",
"TaskPlannerConfirmationStrategy",
"RecipeConfirmationStrategy",
"DocumentWriterConfirmationStrategy",
@@ -4,17 +4,17 @@
import json
import logging
import sys
import uuid
from collections.abc import AsyncIterable, MutableSequence
from functools import wraps
from typing import Any, TypeVar, cast
from typing import TYPE_CHECKING, Any, Generic, cast
import httpx
from agent_framework import (
AIFunction,
BaseChatClient,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
DataContent,
@@ -30,6 +30,26 @@ from ._http_service import AGUIHttpService
from ._message_adapters import agent_framework_messages_to_agui
from ._utils import convert_tools_to_agui_format
if TYPE_CHECKING:
from ._types import AGUIChatOptions
from typing import TypedDict
if sys.version_info >= (3, 13):
from typing import TypeVar
else:
from typing_extensions import TypeVar
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
logger: logging.Logger = logging.getLogger(__name__)
@@ -55,7 +75,14 @@ def _unwrap_server_function_call_contents(contents: MutableSequence[Contents | d
contents[idx] = content.function_call_content # type: ignore[assignment]
TBaseChatClient = TypeVar("TBaseChatClient", bound=type[BaseChatClient])
TBaseChatClient = TypeVar("TBaseChatClient", bound=type[BaseChatClient[Any]])
TAGUIChatOptions = TypeVar(
"TAGUIChatOptions",
bound=TypedDict, # type: ignore[valid-type]
default="AGUIChatOptions",
covariant=True,
)
def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseChatClient:
@@ -91,7 +118,7 @@ def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseCha
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class AGUIChatClient(BaseChatClient):
class AGUIChatClient(BaseChatClient[TAGUIChatOptions], Generic[TAGUIChatOptions]):
"""Chat client for communicating with AG-UI compliant servers.
This client implements the BaseChatClient interface and automatically handles:
@@ -168,6 +195,19 @@ class AGUIChatClient(BaseChatClient):
async with AGUIChatClient(endpoint="http://localhost:8888/") as client:
response = await client.get_response("Hello!")
print(response.messages[0].text)
Using custom ChatOptions with type safety:
.. code-block:: python
from typing import TypedDict
from agent_framework_ag_ui import AGUIChatClient, AGUIChatOptions
class MyOptions(AGUIChatOptions, total=False):
my_custom_option: str
client: AGUIChatClient[MyOptions] = AGUIChatClient(endpoint="http://localhost:8888/")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
OTEL_PROVIDER_NAME = "agui"
@@ -201,7 +241,7 @@ class AGUIChatClient(BaseChatClient):
"""Close the HTTP client."""
await self._http_service.close()
async def __aenter__(self) -> "AGUIChatClient":
async def __aenter__(self) -> Self:
"""Enter async context manager."""
return self
@@ -280,36 +320,38 @@ class AGUIChatClient(BaseChatClient):
"""
return agent_framework_messages_to_agui(messages)
def _get_thread_id(self, chat_options: ChatOptions) -> str:
def _get_thread_id(self, options: dict[str, Any]) -> str:
"""Get or generate thread ID from chat options.
Args:
chat_options: Chat options containing metadata
options: Chat options containing metadata
Returns:
Thread ID string
"""
thread_id = None
if chat_options.metadata:
thread_id = chat_options.metadata.get("thread_id")
metadata = options.get("metadata")
if metadata:
thread_id = metadata.get("thread_id")
if not thread_id:
thread_id = f"thread_{uuid.uuid4().hex}"
return thread_id
@override
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
"""Internal method to get non-streaming response.
Keyword Args:
messages: List of chat messages
chat_options: Chat options for the request
options: Chat options for the request
**kwargs: Additional keyword arguments
Returns:
@@ -318,23 +360,24 @@ class AGUIChatClient(BaseChatClient):
return await ChatResponse.from_chat_response_generator(
self._inner_get_streaming_response(
messages=messages,
chat_options=chat_options,
options=options,
**kwargs,
)
)
@override
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Internal method to get streaming response.
Keyword Args:
messages: List of chat messages
chat_options: Chat options for the request
options: Chat options for the request
**kwargs: Additional keyword arguments
Yields:
@@ -342,20 +385,21 @@ class AGUIChatClient(BaseChatClient):
"""
messages_to_send, state = self._extract_state_from_messages(messages)
thread_id = self._get_thread_id(chat_options)
thread_id = self._get_thread_id(options)
run_id = f"run_{uuid.uuid4().hex}"
agui_messages = self._convert_messages_to_agui_format(messages_to_send)
# Send client tools to server so LLM knows about them
# Client tools execute via ChatAgent's @use_function_invocation wrapper
agui_tools = convert_tools_to_agui_format(chat_options.tools)
agui_tools = convert_tools_to_agui_format(options.get("tools"))
# Build set of client tool names (matches .NET clientToolSet)
# Used to distinguish client vs server tools in response stream
client_tool_set: set[str] = set()
if chat_options.tools:
for tool in chat_options.tools:
tools = options.get("tools")
if tools:
for tool in tools:
if hasattr(tool, "name"):
client_tool_set.add(tool.name) # type: ignore[arg-type]
self._last_client_tool_set = client_tool_set # type: ignore[attr-defined]
@@ -13,7 +13,7 @@ logger = logging.getLogger(__name__)
def collect_server_tools(agent: Any) -> list[Any]:
"""Collect server tools from ChatAgent or duck-typed agent."""
if isinstance(agent, ChatAgent):
tools_from_agent = agent.chat_options.tools
tools_from_agent = agent.default_options.get("tools")
server_tools = list(tools_from_agent) if tools_from_agent else []
logger.info(f"[TOOLS] Agent has {len(server_tools)} configured tools")
for tool in server_tools:
@@ -23,9 +23,11 @@ def collect_server_tools(agent: Any) -> list[Any]:
return server_tools
try:
chat_options_attr = getattr(agent, "chat_options", None)
if chat_options_attr is not None:
return getattr(chat_options_attr, "tools", None) or []
default_options_attr = getattr(agent, "default_options", None)
if default_options_attr is not None:
if isinstance(default_options_attr, dict):
return default_options_attr.get("tools") or []
return getattr(default_options_attr, "tools", None) or []
except AttributeError:
return []
return []
@@ -319,7 +319,7 @@ class DefaultOrchestrator(Orchestrator):
response_format = None
if isinstance(context.agent, ChatAgent):
response_format = context.agent.chat_options.response_format
response_format = context.agent.default_options.get("response_format")
skip_text_content = response_format is not None
client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools"))
@@ -434,10 +434,10 @@ class DefaultOrchestrator(Orchestrator):
run_kwargs: dict[str, Any] = {
"thread": thread,
"tools": tools_param,
"metadata": safe_metadata,
"options": {"metadata": safe_metadata},
}
if safe_metadata:
run_kwargs["store"] = True
run_kwargs["options"]["store"] = True
async def _resolve_approval_responses(
messages: list[Any],
@@ -2,8 +2,23 @@
"""Type definitions for AG-UI integration."""
import sys
from typing import Any, TypedDict
from agent_framework import ChatOptions
if sys.version_info >= (3, 13):
from typing import TypeVar
else:
from typing_extensions import TypeVar
__all__ = [
"AGUIChatOptions",
"AgentState",
"PredictStateConfig",
"RunMetadata",
]
from pydantic import BaseModel, Field
@@ -48,3 +63,76 @@ class AGUIRequest(BaseModel):
None,
description="Optional shared state for agentic generative UI",
)
# region AG-UI Chat Options TypedDict
class AGUIChatOptions(ChatOptions, total=False):
"""AG-UI protocol-specific chat options dict.
Extends base ChatOptions for the AG-UI (Agent-UI) protocol.
AG-UI is a streaming protocol for connecting AI agents to user interfaces.
Options are forwarded to the remote AG-UI server.
See: https://github.com/ag-ui/ag-ui-protocol
Keys:
# Inherited from ChatOptions (forwarded to remote server):
model_id: The model identifier (forwarded as-is to server).
temperature: Sampling temperature.
top_p: Nucleus sampling parameter.
max_tokens: Maximum tokens to generate.
stop: Stop sequences.
tools: List of tools - sent to server so LLM knows about client tools.
Server executes its own tools; client tools execute locally via
@use_function_invocation middleware.
tool_choice: How the model should use tools.
metadata: Metadata dict containing thread_id for conversation continuity.
# Options with limited support (depends on remote server):
frequency_penalty: Forwarded if remote server supports it.
presence_penalty: Forwarded if remote server supports it.
seed: Forwarded if remote server supports it.
response_format: Forwarded if remote server supports it.
logit_bias: Forwarded if remote server supports it.
user: Forwarded if remote server supports it.
# Options not typically used in AG-UI:
store: Not applicable for AG-UI protocol.
allow_multiple_tool_calls: Handled by underlying server.
# AG-UI-specific options:
forward_props: Additional properties to forward to the AG-UI server.
Useful for passing custom parameters to specific server implementations.
context: Shared context/state to send to the server.
Note:
AG-UI is a protocol bridge - actual option support depends on the
remote server implementation. The client sends all options to the
server, which decides how to handle them.
Thread ID management:
- Pass ``thread_id`` in ``metadata`` to maintain conversation continuity
- If not provided, a new thread ID is auto-generated
"""
# AG-UI-specific options
forward_props: dict[str, Any]
"""Additional properties to forward to the AG-UI server."""
context: dict[str, Any]
"""Shared context/state to send to the server."""
# ChatOptions fields not applicable for AG-UI
store: None # type: ignore[misc]
"""Not applicable for AG-UI protocol."""
AGUI_OPTION_TRANSLATIONS: dict[str, str] = {}
"""Maps ChatOptions keys to AG-UI parameter names (protocol uses standard names)."""
TAGUIChatOptions = TypeVar("TAGUIChatOptions", bound=TypedDict, default="AGUIChatOptions", covariant=True) # type: ignore[valid-type]
# endregion
@@ -3,6 +3,7 @@
"""Human-in-the-loop agent demonstrating step customization (Feature 5)."""
from enum import Enum
from typing import Any
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
from pydantic import BaseModel, Field
@@ -42,7 +43,7 @@ def generate_task_steps(steps: list[TaskStep]) -> str:
return f"Generated {len(steps)} execution steps for the task."
def human_in_the_loop_agent(chat_client: ChatClientProtocol) -> ChatAgent:
def human_in_the_loop_agent(chat_client: ChatClientProtocol[Any]) -> ChatAgent[Any]:
"""Create a human-in-the-loop agent using tool-based approach for predictive state.
Args:
@@ -3,6 +3,7 @@
"""Recipe agent example demonstrating shared state management (Feature 3)."""
from enum import Enum
from typing import Any
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
from agent_framework.ag_ui import AgentFrameworkAgent, RecipeConfirmationStrategy
@@ -101,7 +102,7 @@ _RECIPE_INSTRUCTIONS = """You are a helpful recipe assistant that creates and mo
"""
def recipe_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
def recipe_agent(chat_client: ChatClientProtocol[Any]) -> AgentFrameworkAgent:
"""Create a recipe agent with streaming state updates.
Args:
@@ -3,6 +3,7 @@
"""Example agent demonstrating agentic generative UI with custom events during execution."""
import asyncio
from typing import Any
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
from agent_framework.ag_ui import AgentFrameworkAgent
@@ -87,8 +88,8 @@ _RESEARCH_ASSISTANT_INSTRUCTIONS = (
)
def research_assistant_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
"""Create a research assistant agent with progress events.
def research_assistant_agent(chat_client: ChatClientProtocol[Any]) -> AgentFrameworkAgent:
"""Create a research assistant agent.
Args:
chat_client: The chat client to use for the agent
@@ -2,10 +2,12 @@
"""Simple agentic chat example (Feature 1: Agentic Chat)."""
from typing import Any
from agent_framework import ChatAgent, ChatClientProtocol
def simple_agent(chat_client: ChatClientProtocol) -> ChatAgent:
def simple_agent(chat_client: ChatClientProtocol[Any]) -> ChatAgent[Any]:
"""Create a simple chat agent.
Args:
@@ -14,7 +16,7 @@ def simple_agent(chat_client: ChatClientProtocol) -> ChatAgent:
Returns:
A configured ChatAgent instance
"""
return ChatAgent(
return ChatAgent[Any](
name="simple_chat_agent",
instructions="You are a helpful assistant. Be concise and friendly.",
chat_client=chat_client,
@@ -2,6 +2,8 @@
"""Example agent demonstrating human-in-the-loop with function approvals."""
from typing import Any
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
from agent_framework.ag_ui import AgentFrameworkAgent, TaskPlannerConfirmationStrategy
@@ -59,7 +61,7 @@ _TASK_PLANNER_INSTRUCTIONS = (
)
def task_planner_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
def task_planner_agent(chat_client: ChatClientProtocol[Any]) -> AgentFrameworkAgent:
"""Create a task planner agent with user approval for actions.
Args:
@@ -52,7 +52,7 @@ def generate_task_steps(steps: list[TaskStep]) -> str:
return "Steps generated."
def _create_task_steps_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
def _create_task_steps_agent(chat_client: ChatClientProtocol[Any]) -> AgentFrameworkAgent:
"""Create the task steps agent using tool-based approach for streaming.
Args:
@@ -61,7 +61,7 @@ def _create_task_steps_agent(chat_client: ChatClientProtocol) -> AgentFrameworkA
Returns:
A configured AgentFrameworkAgent instance
"""
agent = ChatAgent(
agent = ChatAgent[Any](
name="task_steps_agent",
instructions="""You are a helpful assistant that breaks down tasks into actionable steps.
@@ -331,7 +331,7 @@ class TaskStepsAgentWithExecution:
yield run_finished_event
def task_steps_agent_wrapped(chat_client: ChatClientProtocol) -> TaskStepsAgentWithExecution:
def task_steps_agent_wrapped(chat_client: ChatClientProtocol[Any]) -> TaskStepsAgentWithExecution:
"""Create a task steps agent with execution simulation.
Args:
@@ -2,11 +2,17 @@
"""Example agent demonstrating Tool-based Generative UI (Feature 5)."""
from typing import Any
import sys
from typing import Any, TypedDict
from agent_framework import AIFunction, ChatAgent, ChatClientProtocol
from agent_framework import AIFunction, ChatAgent, ChatClientProtocol, ChatOptions
from agent_framework.ag_ui import AgentFrameworkAgent
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore # pragma: no cover
# Declaration-only tools (func=None) - actual rendering happens on the client side
generate_haiku = AIFunction[Any, str](
name="generate_haiku",
@@ -150,15 +156,17 @@ _UI_GENERATOR_INSTRUCTIONS = """You MUST use the provided tools to generate cont
For other requests, use the appropriate tool (create_chart, display_timeline, show_comparison_table).
"""
TOptions = TypeVar("TOptions", bound=TypedDict, default="ChatOptions") # type: ignore[valid-type]
def ui_generator_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
"""Create a UI generator agent with frontend rendering tools.
def ui_generator_agent(chat_client: ChatClientProtocol[TOptions]) -> AgentFrameworkAgent:
"""Create a UI generator agent with custom React component rendering.
Args:
chat_client: The chat client to use for the agent
Returns:
A configured AgentFrameworkAgent instance with UI generation tools
A configured AgentFrameworkAgent instance with UI generation capabilities
"""
agent = ChatAgent(
name="ui_generator",
@@ -166,7 +174,7 @@ def ui_generator_agent(chat_client: ChatClientProtocol) -> AgentFrameworkAgent:
chat_client=chat_client,
tools=[generate_haiku, create_chart, display_timeline, show_comparison_table],
# Force tool usage - the LLM MUST call a tool, cannot respond with plain text
chat_options={"tool_choice": "required"},
default_options={"tool_choice": "required"}, # type: ignore
)
return AgentFrameworkAgent(
@@ -57,7 +57,7 @@ def get_forecast(location: str, days: int = 3) -> str:
return f"{days}-day forecast for {location}:\n" + "\n".join(forecast)
def weather_agent(chat_client: ChatClientProtocol) -> ChatAgent:
def weather_agent(chat_client: ChatClientProtocol[Any]) -> ChatAgent[Any]:
"""Create a weather agent with get_weather and get_forecast tools.
Args:
@@ -66,7 +66,7 @@ def weather_agent(chat_client: ChatClientProtocol) -> ChatAgent:
Returns:
A configured ChatAgent instance with weather tools
"""
return ChatAgent(
return ChatAgent[Any](
name="weather_agent",
instructions=(
"You are a helpful weather assistant. "
@@ -4,6 +4,7 @@
import logging
import os
from typing import TYPE_CHECKING
import uvicorn
from agent_framework.ag_ui import add_agent_framework_fastapi_endpoint
@@ -19,6 +20,10 @@ from ..agents.task_steps_agent import task_steps_agent_wrapped
from ..agents.ui_generator_agent import ui_generator_agent
from ..agents.weather_agent import weather_agent
if TYPE_CHECKING:
from agent_framework import ChatOptions
from agent_framework._clients import BaseChatClient
# Configure logging to file and console (disabled by default - set ENABLE_DEBUG_LOGGING=1 to enable)
if os.getenv("ENABLE_DEBUG_LOGGING"):
log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "ag_ui_server.log")
@@ -60,7 +65,7 @@ app.add_middleware(
# Create a shared chat client for all agents
# You can use different chat clients for different agents if needed
chat_client = AzureOpenAIChatClient()
chat_client: BaseChatClient[ChatOptions] = AzureOpenAIChatClient()
# Agentic Chat - basic chat agent
add_agent_framework_fastapi_endpoint(
@@ -40,22 +40,22 @@ class TestableAGUIChatClient(AGUIChatClient):
"""Expose message conversion helper."""
return self._convert_messages_to_agui_format(messages)
def get_thread_id(self, chat_options: ChatOptions) -> str:
def get_thread_id(self, options: dict[str, Any]) -> str:
"""Expose thread id helper."""
return self._get_thread_id(chat_options)
return self._get_thread_id(options)
async def inner_get_streaming_response(
self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions
self, *, messages: MutableSequence[ChatMessage], options: dict[str, Any]
) -> AsyncIterable[ChatResponseUpdate]:
"""Proxy to protected streaming call."""
async for update in self._inner_get_streaming_response(messages=messages, chat_options=chat_options):
async for update in self._inner_get_streaming_response(messages=messages, options=options):
yield update
async def inner_get_response(
self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions
self, *, messages: MutableSequence[ChatMessage], options: dict[str, Any]
) -> ChatResponse:
"""Proxy to protected response call."""
return await self._inner_get_response(messages=messages, chat_options=chat_options)
return await self._inner_get_response(messages=messages, options=options)
class TestAGUIChatClient:
@@ -191,7 +191,7 @@ class TestAGUIChatClient:
chat_options = ChatOptions()
updates: list[ChatResponseUpdate] = []
async for update in client.inner_get_streaming_response(messages=messages, chat_options=chat_options):
async for update in client.inner_get_streaming_response(messages=messages, options=chat_options):
updates.append(update)
assert len(updates) == 4
@@ -221,9 +221,9 @@ class TestAGUIChatClient:
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [ChatMessage(role="user", text="Test message")]
chat_options = ChatOptions()
chat_options = {}
response = await client.inner_get_response(messages=messages, chat_options=chat_options)
response = await client.inner_get_response(messages=messages, options=chat_options)
assert response is not None
assert len(response.messages) > 0
@@ -266,7 +266,7 @@ class TestAGUIChatClient:
messages = [ChatMessage(role="user", text="Test with tools")]
chat_options = ChatOptions(tools=[test_tool])
response = await client.inner_get_response(messages=messages, chat_options=chat_options)
response = await client.inner_get_response(messages=messages, options=chat_options)
assert response is not None
@@ -288,10 +288,9 @@ class TestAGUIChatClient:
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [ChatMessage(role="user", text="Test server tool execution")]
chat_options = ChatOptions()
updates: list[ChatResponseUpdate] = []
async for update in client.get_streaming_response(messages, chat_options=chat_options):
async for update in client.get_streaming_response(messages):
updates.append(update)
function_calls = [
@@ -332,9 +331,8 @@ class TestAGUIChatClient:
monkeypatch.setattr(client.http_service, "post_run", mock_post_run)
messages = [ChatMessage(role="user", text="Test server tool execution")]
chat_options = ChatOptions(tool_choice="auto", tools=[client_tool])
async for _ in client.get_streaming_response(messages, chat_options=chat_options):
async for _ in client.get_streaming_response(messages, options={"tool_choice": "auto", "tools": [client_tool]}):
pass
async def test_state_transmission(self, monkeypatch: MonkeyPatch) -> None:
@@ -370,6 +368,6 @@ class TestAGUIChatClient:
chat_options = ChatOptions()
response = await client.inner_get_response(messages=messages, chat_options=chat_options)
response = await client.inner_get_response(messages=messages, options=chat_options)
assert response is not None
@@ -21,11 +21,15 @@ async def test_agent_initialization_basic():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
agent = ChatAgent[ChatOptions](
chat_client=StreamingChatClientStub(stream_fn),
name="test_agent",
instructions="Test",
)
wrapper = AgentFrameworkAgent(agent=agent)
assert wrapper.name == "test_agent"
@@ -39,7 +43,7 @@ async def test_agent_initialization_with_state_schema():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
@@ -55,7 +59,7 @@ async def test_agent_initialization_with_predict_state_config():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
@@ -71,7 +75,7 @@ async def test_agent_initialization_with_pydantic_state_schema():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
@@ -94,7 +98,7 @@ async def test_run_started_event_emission():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
@@ -118,7 +122,7 @@ async def test_predict_state_custom_event_emission():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
@@ -150,7 +154,7 @@ async def test_initial_state_snapshot_with_schema():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
@@ -180,7 +184,7 @@ async def test_state_initialization_object_type():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
@@ -207,7 +211,7 @@ async def test_state_initialization_array_type():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
@@ -234,7 +238,7 @@ async def test_run_finished_event_emission():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
@@ -256,7 +260,7 @@ async def test_tool_result_confirm_changes_accepted():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Document updated")])
@@ -303,7 +307,7 @@ async def test_tool_result_confirm_changes_rejected():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
@@ -337,7 +341,7 @@ async def test_tool_result_function_approval_accepted():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
@@ -383,7 +387,7 @@ async def test_tool_result_function_approval_rejected():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
@@ -422,10 +426,11 @@ async def test_thread_metadata_tracking():
thread_metadata: dict[str, Any] = {}
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
if chat_options.metadata:
thread_metadata.update(chat_options.metadata)
metadata = options.get("metadata")
if metadata:
thread_metadata.update(metadata)
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
@@ -447,15 +452,16 @@ async def test_thread_metadata_tracking():
async def test_state_context_injection():
"""Test that current state is injected into thread metadata."""
from agent_framework.ag_ui import AgentFrameworkAgent
from agent_framework_ag_ui import AgentFrameworkAgent
thread_metadata: dict[str, Any] = {}
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
if chat_options.metadata:
thread_metadata.update(chat_options.metadata)
metadata = options.get("metadata")
if metadata:
thread_metadata.update(metadata)
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
@@ -484,7 +490,7 @@ async def test_no_messages_provided():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
@@ -508,7 +514,7 @@ async def test_message_end_event_emission():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello world")])
@@ -536,7 +542,7 @@ async def test_error_handling_with_exception():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
if False:
yield ChatResponseUpdate(contents=[])
@@ -557,7 +563,7 @@ async def test_json_decode_error_in_tool_result():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
if False:
yield ChatResponseUpdate(contents=[])
@@ -594,7 +600,7 @@ async def test_suppressed_summary_with_document_state():
from agent_framework.ag_ui import AgentFrameworkAgent, DocumentWriterConfirmationStrategy
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Response")])
@@ -647,7 +653,7 @@ async def test_function_approval_mode_executes_tool():
return "2025/12/01 12:00:00"
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
# Capture the messages received by the chat client
messages_received.clear()
@@ -655,9 +661,9 @@ async def test_function_approval_mode_executes_tool():
yield ChatResponseUpdate(contents=[TextContent(text="Processing completed")])
agent = ChatAgent(
chat_client=StreamingChatClientStub(stream_fn),
name="test_agent",
instructions="Test",
chat_client=StreamingChatClientStub(stream_fn),
tools=[get_datetime],
)
wrapper = AgentFrameworkAgent(agent=agent)
@@ -738,7 +744,7 @@ async def test_function_approval_mode_rejection():
return "All data deleted"
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
# Capture the messages received by the chat client
messages_received.clear()
@@ -22,7 +22,7 @@ class DummyAgent:
"""Minimal agent stub to capture run_stream parameters."""
def __init__(self) -> None:
self.chat_options = SimpleNamespace(tools=[server_tool], response_format=None)
self.default_options: dict[str, Any] = {"tools": [server_tool], "response_format": None}
self.tools = [server_tool]
self.chat_client = SimpleNamespace(
function_invocation_configuration=FunctionInvocationConfiguration(),
@@ -29,7 +29,7 @@ def approval_tool(param: str) -> str:
return f"executed: {param}"
DEFAULT_CHAT_OPTIONS = SimpleNamespace(tools=[approval_tool], response_format=None)
DEFAULT_OPTIONS: dict[str, Any] = {"tools": [approval_tool], "response_format": None}
async def test_human_in_the_loop_json_decode_error() -> None:
@@ -54,7 +54,7 @@ async def test_human_in_the_loop_json_decode_error() -> None:
]
agent = StubAgent(
chat_options=SimpleNamespace(tools=[approval_tool], response_format=None),
default_options={"tools": [approval_tool], "response_format": None},
updates=[AgentRunResponseUpdate(contents=[TextContent(text="response")], role="assistant")],
)
context = TestExecutionContext(
@@ -106,7 +106,7 @@ async def test_sanitize_tool_history_confirm_changes() -> None:
input_data: dict[str, Any] = {"messages": []}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
context = TestExecutionContext(
input_data=input_data,
@@ -151,7 +151,7 @@ async def test_sanitize_tool_history_orphaned_tool_result() -> None:
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": []}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
context = TestExecutionContext(
input_data=input_data,
@@ -191,7 +191,7 @@ async def test_orphaned_tool_result_sanitization() -> None:
}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
context = TestExecutionContext(
input_data=input_data,
@@ -234,7 +234,7 @@ async def test_deduplicate_messages_empty_tool_results() -> None:
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": []}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
context = TestExecutionContext(
input_data=input_data,
@@ -279,7 +279,7 @@ async def test_deduplicate_messages_duplicate_assistant_tool_calls() -> None:
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": []}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
context = TestExecutionContext(
input_data=input_data,
@@ -323,7 +323,7 @@ async def test_deduplicate_messages_duplicate_system_messages() -> None:
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": []}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
context = TestExecutionContext(
input_data=input_data,
@@ -362,7 +362,7 @@ async def test_state_context_injection() -> None:
}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
context = TestExecutionContext(
input_data=input_data,
@@ -407,7 +407,7 @@ async def test_state_context_injection_with_tool_calls_and_input_state() -> None
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": [], "state": {"weather": "sunny"}}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
context = TestExecutionContext(
input_data=input_data,
@@ -449,7 +449,7 @@ async def test_structured_output_processing() -> None:
# Agent with structured output
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
updates=[
AgentRunResponseUpdate(
contents=[TextContent(text='{"ingredients": ["tomato"], "message": "Added tomato"}')],
@@ -457,7 +457,7 @@ async def test_structured_output_processing() -> None:
)
],
)
agent.chat_options.response_format = RecipeState
agent.default_options["response_format"] = RecipeState
context = TestExecutionContext(
input_data=input_data,
@@ -510,9 +510,9 @@ async def test_duplicate_client_tools_filtered() -> None:
}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
agent.chat_options.tools = [get_weather]
agent.default_options["tools"] = [get_weather]
context = TestExecutionContext(
input_data=input_data,
@@ -559,9 +559,9 @@ async def test_unique_client_tools_merged() -> None:
}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
agent.chat_options.tools = [server_tool]
agent.default_options["tools"] = [server_tool]
context = TestExecutionContext(
input_data=input_data,
@@ -587,7 +587,7 @@ async def test_empty_messages_handling() -> None:
input_data: dict[str, Any] = {"messages": []}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
context = TestExecutionContext(
input_data=input_data,
@@ -621,7 +621,7 @@ async def test_all_messages_filtered_handling() -> None:
}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
context = TestExecutionContext(
input_data=input_data,
@@ -663,7 +663,7 @@ async def test_confirm_changes_with_invalid_json_fallback() -> None:
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": []}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
context = TestExecutionContext(
input_data=input_data,
@@ -706,7 +706,7 @@ async def test_confirm_changes_closes_active_message_before_finish() -> None:
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": [{"role": "user", "content": "Start"}]}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
updates=updates,
)
context = TestExecutionContext(
@@ -751,7 +751,7 @@ async def test_tool_result_kept_when_call_id_matches() -> None:
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": []}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
context = TestExecutionContext(
input_data=input_data,
@@ -781,7 +781,7 @@ async def test_agent_protocol_fallback_paths() -> None:
"""Custom agent without ChatAgent type."""
def __init__(self) -> None:
self.chat_options = SimpleNamespace(tools=[], response_format=None)
self.default_options: dict[str, Any] = {"tools": [], "response_format": None}
self.chat_client = SimpleNamespace(function_invocation_configuration=SimpleNamespace())
self.messages_received: list[Any] = []
@@ -827,7 +827,7 @@ async def test_initial_state_snapshot_with_array_schema() -> None:
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": [], "state": {}}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
context = TestExecutionContext(
input_data=input_data,
@@ -859,9 +859,9 @@ async def test_response_format_skip_text_content() -> None:
input_data: dict[str, Any] = {"messages": []}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
default_options=DEFAULT_OPTIONS,
)
agent.chat_options.response_format = OutputModel
agent.default_options["response_format"] = OutputModel
context = TestExecutionContext(
input_data=input_data,
@@ -40,14 +40,14 @@ async def test_structured_output_with_recipe():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(
contents=[TextContent(text='{"recipe": {"name": "Pasta"}, "message": "Here is your recipe"}')]
)
agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
agent.chat_options = ChatOptions(response_format=RecipeOutput)
agent.default_options = ChatOptions(response_format=RecipeOutput)
wrapper = AgentFrameworkAgent(
agent=agent,
@@ -78,7 +78,7 @@ async def test_structured_output_with_steps():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
steps_data = {
"steps": [
@@ -89,7 +89,7 @@ async def test_structured_output_with_steps():
yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(steps_data))])
agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
agent.chat_options = ChatOptions(response_format=StepsOutput)
agent.default_options = ChatOptions(response_format=StepsOutput)
wrapper = AgentFrameworkAgent(
agent=agent,
@@ -124,7 +124,7 @@ async def test_structured_output_with_no_schema_match():
agent = ChatAgent(
name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_from_updates(updates))
)
agent.chat_options = ChatOptions(response_format=GenericOutput)
agent.default_options = ChatOptions(response_format=GenericOutput)
wrapper = AgentFrameworkAgent(
agent=agent,
@@ -154,12 +154,12 @@ async def test_structured_output_without_schema():
info: str
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text='{"data": {"key": "value"}, "info": "processed"}')])
agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
agent.chat_options = ChatOptions(response_format=DataOutput)
agent.default_options = ChatOptions(response_format=DataOutput)
wrapper = AgentFrameworkAgent(
agent=agent,
@@ -213,13 +213,13 @@ async def test_structured_output_with_message_field():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
output_data = {"recipe": {"name": "Salad"}, "message": "Fresh salad recipe ready"}
yield ChatResponseUpdate(contents=[TextContent(text=json.dumps(output_data))])
agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
agent.chat_options = ChatOptions(response_format=RecipeOutput)
agent.default_options = ChatOptions(response_format=RecipeOutput)
wrapper = AgentFrameworkAgent(
agent=agent,
@@ -248,13 +248,13 @@ async def test_empty_updates_no_structured_processing():
from agent_framework.ag_ui import AgentFrameworkAgent
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
if False:
yield ChatResponseUpdate(contents=[])
agent = ChatAgent(name="test", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
agent.chat_options = ChatOptions(response_format=RecipeOutput)
agent.default_options = ChatOptions(response_format=RecipeOutput)
wrapper = AgentFrameworkAgent(agent=agent)
+21 -11
View File
@@ -2,9 +2,10 @@
"""Shared test stubs for AG-UI tests."""
import sys
from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, MutableSequence
from types import SimpleNamespace
from typing import Any
from typing import Any, Generic
from agent_framework import (
AgentProtocol,
@@ -13,20 +14,25 @@ from agent_framework import (
AgentThread,
BaseChatClient,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
TextContent,
)
from agent_framework._clients import TOptions_co
from agent_framework_ag_ui._message_adapters import _deduplicate_messages, _sanitize_tool_history
from agent_framework_ag_ui._orchestrators import ExecutionContext
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
StreamFn = Callable[..., AsyncIterator[ChatResponseUpdate]]
ResponseFn = Callable[..., Awaitable[ChatResponse]]
class StreamingChatClientStub(BaseChatClient):
class StreamingChatClientStub(BaseChatClient[TOptions_co], Generic[TOptions_co]):
"""Typed streaming stub that satisfies ChatClientProtocol."""
def __init__(self, stream_fn: StreamFn, response_fn: ResponseFn | None = None) -> None:
@@ -34,20 +40,22 @@ class StreamingChatClientStub(BaseChatClient):
self._stream_fn = stream_fn
self._response_fn = response_fn
@override
async def _inner_get_streaming_response(
self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
self, *, messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
async for update in self._stream_fn(messages, chat_options, **kwargs):
async for update in self._stream_fn(messages, options, **kwargs):
yield update
@override
async def _inner_get_response(
self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
self, *, messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> ChatResponse:
if self._response_fn is not None:
return await self._response_fn(messages, chat_options, **kwargs)
return await self._response_fn(messages, options, **kwargs)
contents: list[Any] = []
async for update in self._stream_fn(messages, chat_options, **kwargs):
async for update in self._stream_fn(messages, options, **kwargs):
contents.extend(update.contents)
return ChatResponse(
@@ -60,7 +68,7 @@ def stream_from_updates(updates: list[ChatResponseUpdate]) -> StreamFn:
"""Create a stream function that yields from a static list of updates."""
async def _stream(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
for update in updates:
yield update
@@ -77,14 +85,16 @@ class StubAgent(AgentProtocol):
*,
agent_id: str = "stub-agent",
agent_name: str | None = "stub-agent",
chat_options: Any | None = None,
default_options: Any | None = None,
chat_client: Any | None = None,
) -> None:
self.id = agent_id
self.name = agent_name
self.description = "stub agent"
self.updates = updates or [AgentRunResponseUpdate(contents=[TextContent(text="response")], role="assistant")]
self.chat_options = chat_options or SimpleNamespace(tools=None, response_format=None)
self.default_options: dict[str, Any] = (
default_options if isinstance(default_options, dict) else {"tools": None, "response_format": None}
)
self.chat_client = chat_client or SimpleNamespace(function_invocation_configuration=None)
self.messages_received: list[Any] = []
self.tools_received: list[Any] | None = None
@@ -2,7 +2,7 @@
import importlib.metadata
from ._chat_client import AnthropicClient
from ._chat_client import AnthropicChatOptions, AnthropicClient
try:
__version__ = importlib.metadata.version(__name__)
@@ -10,6 +10,7 @@ except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"AnthropicChatOptions",
"AnthropicClient",
"__version__",
]
@@ -1,6 +1,8 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence
from typing import Any, ClassVar, Final, TypeVar
from typing import Any, ClassVar, Final, Generic, Literal, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
@@ -49,15 +51,132 @@ from anthropic.types.beta import (
BetaTextBlock,
BetaUsage,
)
from anthropic.types.beta.beta_bash_code_execution_tool_result_error import BetaBashCodeExecutionToolResultError
from anthropic.types.beta.beta_code_execution_tool_result_error import BetaCodeExecutionToolResultError
from anthropic.types.beta.beta_bash_code_execution_tool_result_error import (
BetaBashCodeExecutionToolResultError,
)
from anthropic.types.beta.beta_code_execution_tool_result_error import (
BetaCodeExecutionToolResultError,
)
from pydantic import SecretStr, ValidationError
if sys.version_info >= (3, 13):
from typing import TypeVar
else:
from typing_extensions import TypeVar
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
__all__ = [
"AnthropicChatOptions",
"AnthropicClient",
"ThinkingConfig",
]
logger = get_logger("agent_framework.anthropic")
ANTHROPIC_DEFAULT_MAX_TOKENS: Final[int] = 1024
BETA_FLAGS: Final[list[str]] = ["mcp-client-2025-04-04", "code-execution-2025-08-25"]
# region Anthropic Chat Options TypedDict
class ThinkingConfig(TypedDict, total=False):
"""Configuration for enabling Claude's extended thinking.
When enabled, responses include ``thinking`` content blocks showing Claude's
thinking process before the final answer. Requires a minimum budget of 1,024
tokens and counts towards your ``max_tokens`` limit.
See https://docs.claude.com/en/docs/build-with-claude/extended-thinking for details.
Keys:
type: "enabled" to enable extended thinking, "disabled" to disable.
budget_tokens: The token budget for thinking (minimum 1024, required when type="enabled").
"""
type: Literal["enabled", "disabled"]
budget_tokens: int
class AnthropicChatOptions(ChatOptions, total=False):
"""Anthropic-specific chat options.
Extends ChatOptions with options specific to Anthropic's Messages API.
Options that Anthropic doesn't support are typed as None to indicate they're unavailable.
Note:
Anthropic REQUIRES max_tokens to be specified. If not provided,
a default of 1024 will be used.
Keys:
model_id: The model to use for the request,
translates to ``model`` in Anthropic API.
temperature: Sampling temperature between 0 and 1.
top_p: Nucleus sampling parameter.
max_tokens: Maximum number of tokens to generate (REQUIRED).
stop: Stop sequences,
translates to ``stop_sequences`` in Anthropic API.
tools: List of tools (functions) available to the model.
tool_choice: How the model should use tools.
response_format: Structured output schema.
metadata: Request metadata with user_id for tracking.
user: User identifier, translates to ``metadata.user_id`` in Anthropic API.
instructions: System instructions for the model,
translates to ``system`` in Anthropic API.
top_k: Number of top tokens to consider for sampling.
service_tier: Service tier ("auto" or "standard_only").
thinking: Extended thinking configuration for Claude models.
When enabled, responses include ``thinking`` content blocks showing Claude's
thinking process before the final answer. Requires a minimum budget of 1,024
tokens and counts towards your ``max_tokens`` limit.
See https://docs.claude.com/en/docs/build-with-claude/extended-thinking for details.
container: Container configuration for skills.
additional_beta_flags: Additional beta flags to enable on the request.
"""
# Anthropic-specific generation parameters (supported by all models)
top_k: int
service_tier: Literal["auto", "standard_only"]
# Extended thinking (Claude models)
thinking: ThinkingConfig
# Skills
container: dict[str, Any]
# Beta features
additional_beta_flags: list[str]
# Unsupported base options (override with None to indicate not supported)
logit_bias: None # type: ignore[misc]
seed: None # type: ignore[misc]
frequency_penalty: None # type: ignore[misc]
presence_penalty: None # type: ignore[misc]
store: None # type: ignore[misc]
TAnthropicOptions = TypeVar(
"TAnthropicOptions",
bound=TypedDict, # type: ignore[valid-type]
default="AnthropicChatOptions",
covariant=True,
)
# Translation between framework options keys and Anthropic Messages API
OPTION_TRANSLATIONS: dict[str, str] = {
"model_id": "model",
"stop": "stop_sequences",
"instructions": "system",
}
# region Role and Finish Reason Maps
ROLE_MAP: dict[Role, str] = {
Role.USER: "user",
Role.ASSISTANT: "assistant",
@@ -111,13 +230,10 @@ class AnthropicSettings(AFBaseSettings):
chat_model_id: str | None = None
TAnthropicClient = TypeVar("TAnthropicClient", bound="AnthropicClient")
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class AnthropicClient(BaseChatClient):
class AnthropicClient(BaseChatClient[TAnthropicOptions], Generic[TAnthropicOptions]):
"""Anthropic Chat client."""
OTEL_PROVIDER_NAME: ClassVar[str] = "anthropic" # type: ignore[reportIncompatibleVariableOverride, misc]
@@ -177,6 +293,18 @@ class AnthropicClient(BaseChatClient):
anthropic_client=anthropic_client,
)
# Using custom ChatOptions with type safety:
from typing import TypedDict
from agent_framework.anthropic import AnthropicChatOptions
class MyOptions(AnthropicChatOptions, total=False):
my_custom_option: str
client: AnthropicClient[MyOptions] = AnthropicClient(model_id="claude-sonnet-4-5-20250929")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
anthropic_settings = AnthropicSettings(
@@ -212,29 +340,31 @@ class AnthropicClient(BaseChatClient):
# region Get response methods
@override
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
# prepare
run_options = self._prepare_options(messages, chat_options, **kwargs)
run_options = self._prepare_options(messages, options, **kwargs)
# execute
message = await self.anthropic_client.beta.messages.create(**run_options, stream=False)
# process
return self._process_message(message)
@override
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
# prepare
run_options = self._prepare_options(messages, chat_options, **kwargs)
run_options = self._prepare_options(messages, options, **kwargs)
# execute and process
async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True):
parsed_chunk = self._process_stream_event(chunk)
@@ -246,35 +376,31 @@ class AnthropicClient(BaseChatClient):
def _prepare_options(
self,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> dict[str, Any]:
"""Create run options for the Anthropic client based on messages and chat options.
"""Create run options for the Anthropic client based on messages and options.
Args:
messages: The list of chat messages.
chat_options: The chat options.
options: The options dict.
kwargs: Additional keyword arguments.
Returns:
A dictionary of run options for the Anthropic client.
"""
run_options: dict[str, Any] = chat_options.to_dict(
exclude={
"type",
"instructions", # handled via system message
"tool_choice", # handled separately
"allow_multiple_tool_calls", # handled via tool_choice
"additional_properties", # handled separately
}
)
# Prepend instructions from options if they exist
instructions = options.get("instructions")
if instructions:
from agent_framework._types import prepend_instructions_to_messages
# translations between ChatOptions and Anthropic API
translations = {
"model_id": "model",
"stop": "stop_sequences",
}
for old_key, new_key in translations.items():
messages = prepend_instructions_to_messages(list(messages), instructions, role="system")
# Start with a copy of options
run_options: dict[str, Any] = {k: v for k, v in options.items() if v is not None and k not in {"instructions"}}
# Translation between options keys and Anthropic Messages API
for old_key, new_key in OPTION_TRANSLATIONS.items():
if old_key in run_options and old_key != new_key:
run_options[new_key] = run_options.pop(old_key)
@@ -296,31 +422,30 @@ class AnthropicClient(BaseChatClient):
run_options["system"] = messages[0].text
# betas
run_options["betas"] = self._prepare_betas(chat_options)
run_options["betas"] = self._prepare_betas(options)
# extra headers
run_options["extra_headers"] = {"User-Agent": AGENT_FRAMEWORK_USER_AGENT}
# Handle user option -> metadata.user_id (Anthropic uses metadata.user_id instead of user)
if user := run_options.pop("user", None):
metadata = run_options.get("metadata", {})
if "user_id" not in metadata:
metadata["user_id"] = user
run_options["metadata"] = metadata
# tools, mcp servers and tool choice
if tools_config := self._prepare_tools_for_anthropic(chat_options):
if tools_config := self._prepare_tools_for_anthropic(options):
run_options.update(tools_config)
# additional properties
additional_options = {
key: value
for key, value in chat_options.additional_properties.items()
if value is not None and key != "additional_beta_flags"
}
if additional_options:
run_options.update(additional_options)
run_options.update(kwargs)
return run_options
def _prepare_betas(self, chat_options: ChatOptions) -> set[str]:
def _prepare_betas(self, options: dict[str, Any]) -> set[str]:
"""Prepare the beta flags for the Anthropic API request.
Args:
chat_options: The chat options that may contain additional beta flags.
options: The options dict that may contain additional beta flags.
Returns:
A set of beta flag strings to include in the request.
@@ -328,7 +453,7 @@ class AnthropicClient(BaseChatClient):
return {
*BETA_FLAGS,
*self.additional_beta_flags,
*chat_options.additional_properties.get("additional_beta_flags", []),
*options.get("additional_beta_flags", []),
}
def _prepare_messages_for_anthropic(self, messages: MutableSequence[ChatMessage]) -> list[dict[str, Any]]:
@@ -370,7 +495,10 @@ class AnthropicClient(BaseChatClient):
logger.debug(f"Ignoring unsupported data content media type: {content.media_type} for now")
case "uri":
if content.has_top_level_media_type("image"):
a_content.append({"type": "image", "source": {"type": "url", "url": content.uri}})
a_content.append({
"type": "image",
"source": {"type": "url", "url": content.uri},
})
else:
logger.debug(f"Ignoring unsupported data content media type: {content.media_type} for now")
case "function_call":
@@ -397,22 +525,25 @@ class AnthropicClient(BaseChatClient):
"content": a_content,
}
def _prepare_tools_for_anthropic(self, chat_options: ChatOptions) -> dict[str, Any] | None:
def _prepare_tools_for_anthropic(self, options: dict[str, Any]) -> dict[str, Any] | None:
"""Prepare tools and tool choice configuration for the Anthropic API request.
Args:
chat_options: The chat options containing tools and tool choice settings.
options: The options dict containing tools and tool choice settings.
Returns:
A dictionary with tools, mcp_servers, and tool_choice configuration, or None if empty.
"""
from agent_framework._types import validate_tool_mode
result: dict[str, Any] = {}
tools = options.get("tools")
# Process tools
if chat_options.tools:
if tools:
tool_list: list[MutableMapping[str, Any]] = []
mcp_server_list: list[MutableMapping[str, Any]] = []
for tool in chat_options.tools:
for tool in tools:
match tool:
case MutableMapping():
tool_list.append(tool)
@@ -457,34 +588,31 @@ class AnthropicClient(BaseChatClient):
result["mcp_servers"] = mcp_server_list
# Process tool choice
if chat_options.tool_choice is not None:
tool_choice_mode = (
chat_options.tool_choice if isinstance(chat_options.tool_choice, str) else chat_options.tool_choice.mode
)
match tool_choice_mode:
case "auto":
tool_choice: dict[str, Any] = {"type": "auto"}
if chat_options.allow_multiple_tool_calls is not None:
tool_choice["disable_parallel_tool_use"] = not chat_options.allow_multiple_tool_calls
result["tool_choice"] = tool_choice
case "required":
if (
not isinstance(chat_options.tool_choice, str)
and chat_options.tool_choice.required_function_name
):
tool_choice = {
"type": "tool",
"name": chat_options.tool_choice.required_function_name,
}
else:
tool_choice = {"type": "any"}
if chat_options.allow_multiple_tool_calls is not None:
tool_choice["disable_parallel_tool_use"] = not chat_options.allow_multiple_tool_calls
result["tool_choice"] = tool_choice
case "none":
result["tool_choice"] = {"type": "none"}
case _:
logger.debug(f"Ignoring unsupported tool choice mode: {tool_choice_mode} for now")
if options.get("tool_choice") is None:
return result or None
tool_mode = validate_tool_mode(options.get("tool_choice"))
allow_multiple = options.get("allow_multiple_tool_calls")
match tool_mode.get("mode"):
case "auto":
tool_choice: dict[str, Any] = {"type": "auto"}
if allow_multiple is not None:
tool_choice["disable_parallel_tool_use"] = not allow_multiple
result["tool_choice"] = tool_choice
case "required":
if "required_function_name" in tool_mode:
tool_choice = {
"type": "tool",
"name": tool_mode["required_function_name"],
}
else:
tool_choice = {"type": "any"}
if allow_multiple is not None:
tool_choice["disable_parallel_tool_use"] = not allow_multiple
result["tool_choice"] = tool_choice
case "none":
result["tool_choice"] = {"type": "none"}
case _:
logger.debug(f"Ignoring unsupported tool choice mode: {tool_mode} for now")
return result or None
@@ -531,7 +659,10 @@ class AnthropicClient(BaseChatClient):
return ChatResponseUpdate(
response_id=event.message.id,
contents=[*self._parse_contents_from_anthropic(event.message.content), *usage_details],
contents=[
*self._parse_contents_from_anthropic(event.message.content),
*usage_details,
],
model_id=event.message.model,
finish_reason=FINISH_REASON_MAP.get(event.message.stop_reason)
if event.message.stop_reason
@@ -579,7 +710,8 @@ class AnthropicClient(BaseChatClient):
return usage_details
def _parse_contents_from_anthropic(
self, content: Sequence[BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock]
self,
content: Sequence[BetaContentBlock | BetaRawContentBlockDelta | BetaTextBlock],
) -> list[Contents]:
"""Parse contents from the Anthropic message."""
contents: list[Contents] = []
@@ -609,7 +741,12 @@ class AnthropicClient(BaseChatClient):
contents.append(
CodeInterpreterToolCallContent(
call_id=content_block.id,
inputs=[TextContent(text=str(content_block.input), raw_representation=content_block)],
inputs=[
TextContent(
text=str(content_block.input),
raw_representation=content_block,
)
],
raw_representation=content_block,
)
)
@@ -630,7 +767,10 @@ class AnthropicClient(BaseChatClient):
parsed_output = self._parse_contents_from_anthropic(content_block.content)
elif isinstance(content_block.content, (str, bytes)):
parsed_output = [
TextContent(text=str(content_block.content), raw_representation=content_block)
TextContent(
text=str(content_block.content),
raw_representation=content_block,
)
]
else:
parsed_output = self._parse_contents_from_anthropic([content_block.content])
@@ -679,7 +819,8 @@ class AnthropicClient(BaseChatClient):
for code_file_content in content_block.content.content:
code_outputs.append(
HostedFileContent(
file_id=code_file_content.file_id, raw_representation=code_file_content
file_id=code_file_content.file_id,
raw_representation=code_file_content,
)
)
contents.append(
@@ -720,7 +861,8 @@ class AnthropicClient(BaseChatClient):
for bash_file_content in content_block.content.content:
contents.append(
HostedFileContent(
file_id=bash_file_content.file_id, raw_representation=bash_file_content
file_id=bash_file_content.file_id,
raw_representation=bash_file_content,
)
)
contents.append(
@@ -847,7 +989,12 @@ class AnthropicClient(BaseChatClient):
)
)
case "thinking" | "thinking_delta":
contents.append(TextReasoningContent(text=content_block.thinking, raw_representation=content_block))
contents.append(
TextReasoningContent(
text=content_block.thinking,
raw_representation=content_block,
)
)
case _:
logger.debug(f"Ignoring unsupported content type: {content_block.type} for now")
return contents
@@ -870,7 +1017,10 @@ class AnthropicClient(BaseChatClient):
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
TextSpanRegion(start_index=citation.start_char_index, end_index=citation.end_char_index)
TextSpanRegion(
start_index=citation.start_char_index,
end_index=citation.end_char_index,
)
)
case "page_location":
cit.title = citation.document_title
@@ -893,7 +1043,10 @@ class AnthropicClient(BaseChatClient):
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
TextSpanRegion(start_index=citation.start_block_index, end_index=citation.end_block_index)
TextSpanRegion(
start_index=citation.start_block_index,
end_index=citation.end_block_index,
)
)
case "web_search_result_location":
cit.title = citation.title
@@ -906,7 +1059,10 @@ class AnthropicClient(BaseChatClient):
if not cit.annotated_regions:
cit.annotated_regions = []
cit.annotated_regions.append(
TextSpanRegion(start_index=citation.start_block_index, end_index=citation.end_block_index)
TextSpanRegion(
start_index=citation.start_block_index,
end_index=citation.end_block_index,
)
)
case _:
logger.debug(f"Unknown citation type encountered: {citation.type}")
@@ -677,7 +677,7 @@ async def test_inner_get_response(mock_anthropic_client: MagicMock) -> None:
chat_options = ChatOptions(max_tokens=10)
response = await chat_client._inner_get_response( # type: ignore[attr-defined]
messages=messages, chat_options=chat_options
messages=messages, options=chat_options
)
assert response is not None
@@ -702,7 +702,7 @@ async def test_inner_get_streaming_response(mock_anthropic_client: MagicMock) ->
chunks: list[ChatResponseUpdate] = []
async for chunk in chat_client._inner_get_streaming_response( # type: ignore[attr-defined]
messages=messages, chat_options=chat_options
messages=messages, options=chat_options
):
if chunk:
chunks.append(chunk)
@@ -730,7 +730,7 @@ async def test_anthropic_client_integration_basic_chat() -> None:
messages = [ChatMessage(role=Role.USER, text="Say 'Hello, World!' and nothing else.")]
response = await client.get_response(messages=messages, chat_options=ChatOptions(max_tokens=50))
response = await client.get_response(messages=messages, options={"max_tokens": 50})
assert response is not None
assert len(response.messages) > 0
@@ -748,7 +748,7 @@ async def test_anthropic_client_integration_streaming_chat() -> None:
messages = [ChatMessage(role=Role.USER, text="Count from 1 to 5.")]
chunks = []
async for chunk in client.get_streaming_response(messages=messages, chat_options=ChatOptions(max_tokens=50)):
async for chunk in client.get_streaming_response(messages=messages, options={"max_tokens": 50}):
chunks.append(chunk)
assert len(chunks) > 0
@@ -766,7 +766,7 @@ async def test_anthropic_client_integration_function_calling() -> None:
response = await client.get_response(
messages=messages,
chat_options=ChatOptions(tools=tools, max_tokens=100),
options={"tools": tools, "max_tokens": 100},
)
assert response is not None
@@ -796,7 +796,7 @@ async def test_anthropic_client_integration_hosted_tools() -> None:
response = await client.get_response(
messages=messages,
chat_options=ChatOptions(tools=tools, max_tokens=100),
options={"tools": tools, "max_tokens": 100},
)
assert response is not None
@@ -814,7 +814,7 @@ async def test_anthropic_client_integration_with_system_message() -> None:
ChatMessage(role=Role.USER, text="Hello!"),
]
response = await client.get_response(messages=messages, chat_options=ChatOptions(max_tokens=50))
response = await client.get_response(messages=messages, options={"max_tokens": 50})
assert response is not None
assert len(response.messages) > 0
@@ -830,7 +830,7 @@ async def test_anthropic_client_integration_temperature_control() -> None:
response = await client.get_response(
messages=messages,
chat_options=ChatOptions(max_tokens=20, temperature=0.0),
options={"max_tokens": 20, "temperature": 0.0},
)
assert response is not None
@@ -91,16 +91,16 @@ try:
except ImportError:
_agentic_retrieval_available = False
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
"""Azure AI Search Context Provider for Agent Framework.
This module provides context providers for Azure AI Search integration with two modes:
@@ -2,7 +2,7 @@
import importlib.metadata
from ._chat_client import AzureAIAgentClient
from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions
from ._client import AzureAIClient
from ._shared import AzureAISettings
@@ -13,6 +13,7 @@ except importlib.metadata.PackageNotFoundError:
__all__ = [
"AzureAIAgentClient",
"AzureAIAgentOptions",
"AzureAIClient",
"AzureAISettings",
"__version__",
@@ -5,8 +5,8 @@ import json
import os
import re
import sys
from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence
from typing import Any, ClassVar, TypeVar
from collections.abc import AsyncIterable, Mapping, MutableMapping, MutableSequence, Sequence
from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
@@ -32,7 +32,6 @@ from agent_framework import (
Role,
TextContent,
TextSpanRegion,
ToolMode,
ToolProtocol,
UriContent,
UsageContent,
@@ -42,7 +41,7 @@ from agent_framework import (
use_chat_middleware,
use_function_invocation,
)
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError, ServiceResponseException
from agent_framework.observability import use_instrumentation
from azure.ai.agents.aio import AgentsClient
from azure.ai.agents.models import (
@@ -90,10 +89,18 @@ from azure.ai.agents.models import (
ToolOutput,
)
from azure.core.credentials_async import AsyncTokenCredential
from pydantic import ValidationError
from pydantic import BaseModel, ValidationError
from ._shared import AzureAISettings
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
@@ -102,14 +109,106 @@ else:
logger = get_logger("agent_framework.azure")
__all__ = ["AzureAIAgentClient", "AzureAIAgentOptions"]
TAzureAIAgentClient = TypeVar("TAzureAIAgentClient", bound="AzureAIAgentClient")
# region Azure AI Agent Options TypedDict
class AzureAIAgentOptions(ChatOptions, total=False):
"""Azure AI Foundry Agent Service-specific options dict.
Extends base ChatOptions with Azure AI Agent Service parameters.
Azure AI Agents provides a managed agent runtime with built-in
tools for code interpreter, file search, and web search.
See: https://learn.microsoft.com/azure/ai-services/agents/
Keys:
# Inherited from ChatOptions:
model_id: The model deployment name,
translates to ``model`` in Azure AI API.
temperature: Sampling temperature between 0 and 2.
top_p: Nucleus sampling parameter.
max_tokens: Maximum number of tokens to generate,
translates to ``max_completion_tokens`` in Azure AI API.
tools: List of tools available to the agent.
tool_choice: How the model should use tools.
allow_multiple_tool_calls: Whether to allow parallel tool calls,
translates to ``parallel_tool_calls`` in Azure AI API.
response_format: Structured output schema.
metadata: Request metadata for tracking.
instructions: System instructions for the agent.
# Options not supported in Azure AI Agent Service:
stop: Not supported.
seed: Not supported.
frequency_penalty: Not supported.
presence_penalty: Not supported.
user: Not supported.
store: Not supported.
logit_bias: Not supported.
# Azure AI Agent-specific options:
conversation_id: Thread ID to continue conversation in.
tool_resources: Resources for tools (file IDs, vector stores).
"""
# Azure AI Agent-specific options
conversation_id: str # type: ignore[misc]
"""Thread ID to continue a conversation in an existing thread."""
tool_resources: dict[str, Any]
"""Tool-specific resources for code_interpreter and file_search.
For code_interpreter: {"file_ids": ["file-abc123"]}
For file_search: {"vector_store_ids": ["vs-abc123"]}
"""
# ChatOptions fields not supported in Azure AI Agent Service
stop: None # type: ignore[misc]
"""Not supported in Azure AI Agent Service."""
seed: None # type: ignore[misc]
"""Not supported in Azure AI Agent Service."""
frequency_penalty: None # type: ignore[misc]
"""Not supported in Azure AI Agent Service."""
presence_penalty: None # type: ignore[misc]
"""Not supported in Azure AI Agent Service."""
user: None # type: ignore[misc]
"""Not supported in Azure AI Agent Service."""
store: None # type: ignore[misc]
"""Not supported in Azure AI Agent Service."""
logit_bias: None # type: ignore[misc]
"""Not supported in Azure AI Agent Service."""
AZURE_AI_AGENT_OPTION_TRANSLATIONS: dict[str, str] = {
"model_id": "model",
"max_tokens": "max_completion_tokens",
"allow_multiple_tool_calls": "parallel_tool_calls",
}
"""Maps ChatOptions keys to Azure AI Agents API parameter names."""
TAzureAIAgentOptions = TypeVar(
"TAzureAIAgentOptions",
bound=TypedDict, # type: ignore[valid-type]
default="AzureAIAgentOptions",
covariant=True,
)
# endregion
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class AzureAIAgentClient(BaseChatClient):
class AzureAIAgentClient(BaseChatClient[TAzureAIAgentOptions], Generic[TAzureAIAgentOptions]):
"""Azure AI Agent Chat client."""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc]
@@ -162,19 +261,31 @@ class AzureAIAgentClient(BaseChatClient):
# Using environment variables
# Set AZURE_AI_PROJECT_ENDPOINT=https://your-project.cognitiveservices.azure.com
# Set AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4
# Set AZURE_AI_MODEL_DEPLOYMENT_NAME=<model name>
credential = DefaultAzureCredential()
client = AzureAIAgentClient(credential=credential)
# Or passing parameters directly
client = AzureAIAgentClient(
project_endpoint="https://your-project.cognitiveservices.azure.com",
model_deployment_name="gpt-4",
model_deployment_name="<model name>",
credential=credential,
)
# Or loading from a .env file
client = AzureAIAgentClient(credential=credential, env_file_path="path/to/.env")
# Using custom ChatOptions with type safety:
from typing import TypedDict
from agent_framework_azure_ai import AzureAIAgentOptions
class MyOptions(AzureAIAgentOptions, total=False):
my_custom_option: str
client: AzureAIAgentClient[MyOptions] = AzureAIAgentClient(credential=credential)
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
azure_ai_settings = AzureAISettings(
@@ -240,46 +351,29 @@ class AzureAIAgentClient(BaseChatClient):
await self._cleanup_agent_if_needed()
await self._close_client_if_needed()
@classmethod
def from_settings(cls: type[TAzureAIAgentClient], settings: dict[str, Any]) -> TAzureAIAgentClient:
"""Initialize a AzureAIAgentClient from a dictionary of settings.
Args:
settings: A dictionary of settings for the service.
"""
return cls(
agents_client=settings.get("agents_client"),
agent_id=settings.get("agent_id"),
thread_id=settings.get("thread_id"),
project_endpoint=settings.get("project_endpoint"),
model_deployment_name=settings.get("model_deployment_name"),
agent_name=settings.get("agent_name"),
credential=settings.get("credential"),
env_file_path=settings.get("env_file_path"),
should_cleanup_agent=settings.get("should_cleanup_agent", True),
)
@override
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
return await ChatResponse.from_chat_response_generator(
updates=self._inner_get_streaming_response(messages=messages, chat_options=chat_options, **kwargs),
output_format_type=chat_options.response_format,
updates=self._inner_get_streaming_response(messages=messages, options=options, **kwargs),
output_format_type=options.get("response_format"),
)
@override
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: Mapping[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
# prepare
run_options, required_action_results = await self._prepare_options(messages, chat_options, **kwargs)
run_options, required_action_results = await self._prepare_options(messages, options, **kwargs)
agent_id = await self._get_agent_id_or_create(run_options)
# execute and process
@@ -783,46 +877,31 @@ class AzureAIAgentClient(BaseChatClient):
self._agent_definition = await self.agents_client.get_agent(self.agent_id)
return self._agent_definition
def _prepare_tool_choice(self, chat_options: ChatOptions) -> None:
"""Prepare the tools and tool choice for the chat options.
Args:
chat_options: The chat options to prepare.
"""
chat_tool_mode = chat_options.tool_choice
if chat_tool_mode is None or chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none":
chat_options.tools = None
chat_options.tool_choice = ToolMode.NONE
return
chat_options.tool_choice = chat_tool_mode
async def _prepare_options(
self,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: Mapping[str, Any],
**kwargs: Any,
) -> tuple[dict[str, Any], list[FunctionResultContent | FunctionApprovalResponseContent] | None]:
agent_definition = await self._load_agent_definition_if_needed()
# Use to_dict with exclusions for properties handled separately
run_options: dict[str, Any] = chat_options.to_dict(
exclude={
"type",
"instructions", # handled via messages
"tools", # handled separately
"tool_choice", # handled separately
"response_format", # handled separately
"additional_properties", # handled separately
"frequency_penalty", # not supported
"presence_penalty", # not supported
"user", # not supported
"stop", # not supported
"logit_bias", # not supported
"seed", # not supported
"store", # not supported
}
)
# Build run_options from options dict, excluding specific keys
exclude_keys = {
"type",
"instructions", # handled via messages
"tools", # handled separately
"tool_choice", # handled separately
"response_format", # handled separately
"additional_properties", # handled separately
"frequency_penalty", # not supported
"presence_penalty", # not supported
"user", # not supported
"stop", # not supported
"logit_bias", # not supported
"seed", # not supported
"store", # not supported
}
run_options: dict[str, Any] = {k: v for k, v in options.items() if k not in exclude_keys and v is not None}
# Translation between ChatOptions and Azure AI Agents API
translations = {
@@ -840,21 +919,31 @@ class AzureAIAgentClient(BaseChatClient):
# tools and tool_choice
if tool_definitions := await self._prepare_tool_definitions_and_resources(
chat_options, agent_definition, run_options
options, agent_definition, run_options
):
run_options["tools"] = tool_definitions
if tool_choice := self._prepare_tool_choice_mode(chat_options):
if tool_choice := self._prepare_tool_choice_mode(options):
run_options["tool_choice"] = tool_choice
# response format
if chat_options.response_format is not None:
run_options["response_format"] = ResponseFormatJsonSchemaType(
json_schema=ResponseFormatJsonSchema(
name=chat_options.response_format.__name__,
schema=chat_options.response_format.model_json_schema(),
response_format = options.get("response_format")
if response_format is not None:
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
# Pydantic model - convert to Azure format
run_options["response_format"] = ResponseFormatJsonSchemaType(
json_schema=ResponseFormatJsonSchema(
name=response_format.__name__,
schema=response_format.model_json_schema(),
)
)
elif isinstance(response_format, Mapping):
# Runtime JSON schema dict - pass through as-is
run_options["response_format"] = response_format
else:
raise ServiceInvalidRequestError(
"response_format must be a Pydantic BaseModel class or a dict with runtime JSON schema."
)
)
# messages
additional_messages, instructions, required_action_results = self._prepare_messages(messages)
@@ -873,41 +962,40 @@ class AzureAIAgentClient(BaseChatClient):
run_options["instructions"] = "\n".join(instructions)
# thread_id resolution (conversation_id takes precedence, then kwargs, then instance default)
run_options["thread_id"] = chat_options.conversation_id or kwargs.get("conversation_id") or self.thread_id
run_options["thread_id"] = options.get("conversation_id") or kwargs.get("conversation_id") or self.thread_id
return run_options, required_action_results
def _prepare_tool_choice_mode(
self, chat_options: ChatOptions
self, options: Mapping[str, Any]
) -> AgentsToolChoiceOptionMode | AgentsNamedToolChoice | None:
"""Prepare the tool choice mode for Azure AI Agents API."""
if chat_options.tool_choice is None:
tool_choice = options.get("tool_choice")
if tool_choice is None:
return None
if chat_options.tool_choice == "none":
if tool_choice == "none":
return AgentsToolChoiceOptionMode.NONE
if chat_options.tool_choice == "auto":
if tool_choice == "auto":
return AgentsToolChoiceOptionMode.AUTO
if (
isinstance(chat_options.tool_choice, ToolMode)
and chat_options.tool_choice == "required"
and chat_options.tool_choice.required_function_name is not None
):
return AgentsNamedToolChoice(
type=AgentsNamedToolChoiceType.FUNCTION,
function=FunctionName(name=chat_options.tool_choice.required_function_name),
)
if isinstance(tool_choice, Mapping) and tool_choice.get("mode") == "required":
req_fn = tool_choice.get("required_function_name")
if req_fn:
return AgentsNamedToolChoice(
type=AgentsNamedToolChoiceType.FUNCTION,
function=FunctionName(name=str(req_fn)),
)
return None
async def _prepare_tool_definitions_and_resources(
self,
chat_options: ChatOptions,
options: Mapping[str, Any],
agent_definition: Agent | None,
run_options: dict[str, Any],
) -> list[ToolDefinition | dict[str, Any]]:
"""Prepare tool definitions and resources for the run options."""
tool_definitions: list[ToolDefinition | dict[str, Any]] = []
# Add tools from existing agent (exclude function tools - passed via chat_options.tools)
# Add tools from existing agent (exclude function tools - passed via options.get("tools"))
if agent_definition is not None:
agent_tools = [tool for tool in agent_definition.tools if not isinstance(tool, FunctionToolDefinition)]
if agent_tools:
@@ -916,11 +1004,13 @@ class AzureAIAgentClient(BaseChatClient):
run_options["tool_resources"] = agent_definition.tool_resources
# Add run tools if tool_choice allows
if chat_options.tool_choice is not None and chat_options.tool_choice != "none" and chat_options.tools:
tool_definitions.extend(await self._prepare_tools_for_azure_ai(chat_options.tools, run_options))
tool_choice = options.get("tool_choice")
tools = options.get("tools")
if tool_choice is not None and tool_choice != "none" and tools:
tool_definitions.extend(await self._prepare_tools_for_azure_ai(tools, run_options))
# Handle MCP tool resources
mcp_resources = self._prepare_mcp_resources(chat_options.tools)
mcp_resources = self._prepare_mcp_resources(tools)
if mcp_resources:
if "tool_resources" not in run_options:
run_options["tool_resources"] = {}
@@ -2,12 +2,11 @@
import sys
from collections.abc import Mapping, MutableSequence
from typing import Any, ClassVar, TypeVar, cast
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypedDict, cast
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
ChatMessage,
ChatOptions,
HostedMCPTool,
TextContent,
get_logger,
@@ -32,27 +31,37 @@ from pydantic import BaseModel, ValidationError
from ._shared import AzureAISettings
if TYPE_CHECKING:
from agent_framework.openai import OpenAIResponsesOptions
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
logger = get_logger("agent_framework.azure")
TAzureAIClient = TypeVar("TAzureAIClient", bound="AzureAIClient")
TAzureAIClientOptions = TypeVar(
"TAzureAIClientOptions",
bound=TypedDict, # type: ignore[valid-type]
default="OpenAIResponsesOptions",
covariant=True,
)
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class AzureAIClient(OpenAIBaseResponsesClient):
class AzureAIClient(OpenAIBaseResponsesClient[TAzureAIClientOptions], Generic[TAzureAIClientOptions]):
"""Azure AI Agent client."""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc]
@@ -115,6 +124,18 @@ class AzureAIClient(OpenAIBaseResponsesClient):
# Or loading from a .env file
client = AzureAIClient(credential=credential, env_file_path="path/to/.env")
# Using custom ChatOptions with type safety:
from typing import TypedDict
from agent_framework import ChatOptions
class MyOptions(ChatOptions, total=False):
my_custom_option: str
client: AzureAIClient[MyOptions] = AzureAIClient(credential=credential)
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
azure_ai_settings = AzureAISettings(
@@ -266,7 +287,7 @@ class AzureAIClient(OpenAIBaseResponsesClient):
await self._close_client_if_needed()
def _create_text_format_config(
self, response_format: Any
self, response_format: type[BaseModel] | Mapping[str, Any]
) -> (
ResponseTextFormatConfigurationJsonSchema
| ResponseTextFormatConfigurationJsonObject
@@ -274,18 +295,25 @@ class AzureAIClient(OpenAIBaseResponsesClient):
):
"""Convert response_format into Azure text format configuration."""
if isinstance(response_format, type) and issubclass(response_format, BaseModel):
schema = response_format.model_json_schema()
# Ensure additionalProperties is explicitly false to satisfy Azure validation
if isinstance(schema, dict):
schema.setdefault("additionalProperties", False)
return ResponseTextFormatConfigurationJsonSchema(
name=response_format.__name__,
schema=response_format.model_json_schema(),
schema=schema,
)
if isinstance(response_format, Mapping):
format_config = self._convert_response_format(response_format)
format_type = format_config.get("type")
if format_type == "json_schema":
# Ensure schema includes additionalProperties=False to satisfy Azure validation
schema = dict(format_config.get("schema", {})) # type: ignore[assignment]
schema.setdefault("additionalProperties", False)
config_kwargs: dict[str, Any] = {
"name": format_config.get("name") or "response",
"schema": format_config["schema"],
"schema": schema,
}
if "strict" in format_config:
config_kwargs["strict"] = format_config["strict"]
@@ -303,7 +331,7 @@ class AzureAIClient(OpenAIBaseResponsesClient):
self,
run_options: dict[str, Any],
messages_instructions: str | None,
chat_options: ChatOptions | None = None,
chat_options: Mapping[str, Any] | None = None,
) -> dict[str, str]:
"""Determine which agent to use and create if needed.
@@ -315,11 +343,6 @@ class AzureAIClient(OpenAIBaseResponsesClient):
Returns:
dict[str, str]: The agent reference to use.
"""
# chat_options is needed separately because the base class excludes response_format
# from run_options (transforming it to text/text_format for OpenAI). Azure's agent
# creation API requires the original response_format to build its own config format.
if chat_options is None:
chat_options = ChatOptions()
# Agent name must be explicitly provided by the user.
if self.agent_name is None:
raise ServiceInitializationError(
@@ -356,12 +379,7 @@ class AzureAIClient(OpenAIBaseResponsesClient):
# response_format is accessed from chat_options or additional_properties
# since the base class excludes it from run_options
response_format: Any = (
chat_options.response_format
if chat_options.response_format is not None
else chat_options.additional_properties.get("response_format")
)
if response_format:
if chat_options and (response_format := chat_options.get("response_format")):
args["text"] = PromptAgentDefinitionText(format=self._create_text_format_config(response_format))
# Combine instructions from messages and options
@@ -392,12 +410,12 @@ class AzureAIClient(OpenAIBaseResponsesClient):
async def _prepare_options(
self,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> dict[str, Any]:
"""Take ChatOptions and create the specific options for Azure AI."""
prepared_messages, instructions = self._prepare_messages_for_azure_ai(messages)
run_options = await super()._prepare_options(prepared_messages, chat_options, **kwargs)
run_options = await super()._prepare_options(prepared_messages, options, **kwargs)
# WORKAROUND: Azure AI Projects 'create responses' API has schema divergence from OpenAI's
# Responses API. Azure requires 'type' at item level and 'annotations' in content items.
@@ -409,12 +427,20 @@ class AzureAIClient(OpenAIBaseResponsesClient):
if not self._is_application_endpoint:
# Application-scoped response APIs do not support "agent" property.
agent_reference = await self._get_agent_reference_or_create(run_options, instructions, chat_options)
agent_reference = await self._get_agent_reference_or_create(run_options, instructions, options)
run_options["extra_body"] = {"agent": agent_reference}
# Remove properties that are not supported on request level
# but were configured on agent level
exclude = ["model", "tools", "response_format", "temperature", "top_p", "text", "text_format"]
exclude = [
"model",
"tools",
"response_format",
"temperature",
"top_p",
"text",
"text_format",
]
for property in exclude:
run_options.pop(property, None)
@@ -467,9 +493,9 @@ class AzureAIClient(OpenAIBaseResponsesClient):
return transformed
@override
def _get_current_conversation_id(self, chat_options: ChatOptions, **kwargs: Any) -> str | None:
def _get_current_conversation_id(self, options: dict[str, Any], **kwargs: Any) -> str | None:
"""Get the current conversation ID from chat options or kwargs."""
return chat_options.conversation_id or kwargs.get("conversation_id") or self.conversation_id
return options.get("conversation_id") or kwargs.get("conversation_id") or self.conversation_id
def _prepare_messages_for_azure_ai(
self, messages: MutableSequence[ChatMessage]
@@ -31,7 +31,6 @@ from agent_framework import (
HostedWebSearchTool,
Role,
TextContent,
ToolMode,
UriContent,
)
from agent_framework._serialization import SerializationMixin
@@ -197,34 +196,6 @@ def test_azure_ai_chat_client_init_missing_model_deployment_for_agent_creation()
)
def test_azure_ai_chat_client_from_dict(mock_agents_client: MagicMock) -> None:
"""Test AzureAIAgentClient.from_dict method."""
settings = {
"agents_client": mock_agents_client,
"agent_id": "test-agent-id",
"thread_id": "test-thread-id",
"project_endpoint": "https://test-endpoint.com/",
"model_deployment_name": "test-model",
"agent_name": "TestAgent",
}
azure_ai_settings = AzureAISettings(
project_endpoint=settings["project_endpoint"],
model_deployment_name=settings["model_deployment_name"],
)
chat_client: AzureAIAgentClient = create_test_azure_ai_chat_client(
mock_agents_client,
agent_id=settings["agent_id"], # type: ignore
thread_id=settings["thread_id"], # type: ignore
azure_ai_settings=azure_ai_settings,
)
assert chat_client.agents_client is mock_agents_client
assert chat_client.agent_id == "test-agent-id"
assert chat_client.thread_id == "test-thread-id"
def test_azure_ai_chat_client_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None:
"""Test AzureAIAgentClient.__init__ when credential is missing and no agents_client provided."""
with pytest.raises(
@@ -253,7 +224,7 @@ def test_azure_ai_chat_client_init_validation_error(mock_azure_credential: Magic
)
def test_azure_ai_chat_client_from_settings() -> None:
def test_azure_ai_chat_client_from_dict() -> None:
"""Test from_settings class method."""
mock_agents_client = MagicMock()
settings = {
@@ -265,7 +236,7 @@ def test_azure_ai_chat_client_from_settings() -> None:
"agent_name": "TestAgent",
}
client = AzureAIAgentClient.from_settings(settings)
client = AzureAIAgentClient.from_dict(settings)
assert client.agents_client is mock_agents_client
assert client.agent_id == "test-agent"
@@ -372,7 +343,7 @@ async def test_azure_ai_chat_client_prepare_options_basic(mock_agents_client: Ma
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
messages = [ChatMessage(role=Role.USER, text="Hello")]
chat_options = ChatOptions(max_tokens=100, temperature=0.7)
chat_options: ChatOptions = {"max_tokens": 100, "temperature": 0.7}
run_options, tool_results = await chat_client._prepare_options(messages, chat_options) # type: ignore
@@ -386,7 +357,7 @@ async def test_azure_ai_chat_client_prepare_options_no_chat_options(mock_agents_
messages = [ChatMessage(role=Role.USER, text="Hello")]
run_options, tool_results = await chat_client._prepare_options(messages, ChatOptions()) # type: ignore
run_options, tool_results = await chat_client._prepare_options(messages, {}) # type: ignore
assert run_options is not None
assert tool_results is None
@@ -403,7 +374,7 @@ async def test_azure_ai_chat_client_prepare_options_with_image_content(mock_agen
image_content = UriContent(uri="https://example.com/image.jpg", media_type="image/jpeg")
messages = [ChatMessage(role=Role.USER, contents=[image_content])]
run_options, _ = await chat_client._prepare_options(messages, ChatOptions()) # type: ignore
run_options, _ = await chat_client._prepare_options(messages, {}) # type: ignore
assert "additional_messages" in run_options
assert len(run_options["additional_messages"]) == 1
@@ -494,7 +465,7 @@ async def test_azure_ai_chat_client_prepare_options_with_messages(mock_agents_cl
ChatMessage(role=Role.USER, text="Hello"),
]
run_options, _ = await chat_client._prepare_options(messages, ChatOptions()) # type: ignore
run_options, _ = await chat_client._prepare_options(messages, {}) # type: ignore
assert "instructions" in run_options
assert "You are a helpful assistant" in run_options["instructions"]
@@ -506,7 +477,7 @@ async def test_azure_ai_chat_client_inner_get_response(mock_agents_client: Magic
"""Test _inner_get_response method."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client, agent_id="test-agent")
messages = [ChatMessage(role=Role.USER, text="Hello")]
chat_options = ChatOptions()
chat_options: ChatOptions = {}
async def mock_streaming_response():
yield ChatResponseUpdate(role=Role.ASSISTANT, text="Hello back")
@@ -518,7 +489,7 @@ async def test_azure_ai_chat_client_inner_get_response(mock_agents_client: Magic
mock_response = ChatResponse(role=Role.ASSISTANT, text="Hello back")
mock_from_generator.return_value = mock_response
result = await chat_client._inner_get_response(messages=messages, chat_options=chat_options) # type: ignore
result = await chat_client._inner_get_response(messages=messages, options=chat_options) # type: ignore
assert result is mock_response
mock_from_generator.assert_called_once()
@@ -627,8 +598,7 @@ async def test_azure_ai_chat_client_prepare_options_with_none_tool_choice(
"""Test _prepare_options with tool_choice set to 'none'."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
chat_options = ChatOptions()
chat_options.tool_choice = "none"
chat_options: ChatOptions = {"tool_choice": "none"}
run_options, _ = await chat_client._prepare_options([], chat_options) # type: ignore
@@ -643,8 +613,7 @@ async def test_azure_ai_chat_client_prepare_options_with_auto_tool_choice(
"""Test _prepare_options with tool_choice set to 'auto'."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
chat_options = ChatOptions()
chat_options.tool_choice = "auto"
chat_options = {"tool_choice": "auto"}
run_options, _ = await chat_client._prepare_options([], chat_options) # type: ignore
@@ -653,35 +622,17 @@ async def test_azure_ai_chat_client_prepare_options_with_auto_tool_choice(
assert run_options["tool_choice"] == AgentsToolChoiceOptionMode.AUTO
async def test_azure_ai_chat_client_prepare_tool_choice_none_string(
mock_agents_client: MagicMock,
) -> None:
"""Test _prepare_tool_choice when tool_choice is string 'none'."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
# Create a mock tool for testing
mock_tool = MagicMock()
chat_options = ChatOptions(tools=[mock_tool], tool_choice="none")
# Call the method
chat_client._prepare_tool_choice(chat_options) # type: ignore
# Verify tools are cleared and tool_choice is set to NONE mode
assert chat_options.tools is None
assert chat_options.tool_choice == ToolMode.NONE.mode
async def test_azure_ai_chat_client_prepare_options_tool_choice_required_specific_function(
mock_agents_client: MagicMock,
) -> None:
"""Test _prepare_options with ToolMode.REQUIRED specifying a specific function name."""
"""Test _prepare_options with required tool_choice specifying a specific function name."""
chat_client = create_test_azure_ai_chat_client(mock_agents_client)
required_tool_mode = ToolMode.REQUIRED("specific_function_name")
required_tool_mode = {"mode": "required", "required_function_name": "specific_function_name"}
dict_tool = {"type": "function", "function": {"name": "test_function"}}
chat_options = ChatOptions(tools=[dict_tool], tool_choice=required_tool_mode)
chat_options = {"tools": [dict_tool], "tool_choice": required_tool_mode}
messages = [ChatMessage(role=Role.USER, text="Hello")]
run_options, _ = await chat_client._prepare_options(messages, chat_options) # type: ignore
@@ -703,8 +654,7 @@ async def test_azure_ai_chat_client_prepare_options_with_response_format(
class TestResponseModel(BaseModel):
name: str = Field(description="Test name")
chat_options = ChatOptions()
chat_options.response_format = TestResponseModel
chat_options: ChatOptions = {"response_format": TestResponseModel}
run_options, _ = await chat_client._prepare_options([], chat_options) # type: ignore
@@ -783,7 +733,7 @@ async def test_azure_ai_chat_client_prepare_options_mcp_never_require(mock_agent
mcp_tool = HostedMCPTool(name="Test MCP Tool", url="https://example.com/mcp", approval_mode="never_require")
messages = [ChatMessage(role=Role.USER, text="Hello")]
chat_options = ChatOptions(tools=[mcp_tool], tool_choice="auto")
chat_options: ChatOptions = {"tools": [mcp_tool], "tool_choice": "auto"}
with patch("agent_framework_azure_ai._chat_client.McpTool") as mock_mcp_tool_class:
# Mock _prepare_tools_for_azure_ai to avoid actual tool preparation
@@ -816,7 +766,7 @@ async def test_azure_ai_chat_client_prepare_options_mcp_with_headers(mock_agents
)
messages = [ChatMessage(role=Role.USER, text="Hello")]
chat_options = ChatOptions(tools=[mcp_tool], tool_choice="auto")
chat_options: ChatOptions = {"tools": [mcp_tool], "tool_choice": "auto"}
with patch("agent_framework_azure_ai._chat_client.McpTool") as mock_mcp_tool_class:
# Mock _prepare_tools_for_azure_ai to avoid actual tool preparation
@@ -1518,8 +1468,7 @@ async def test_azure_ai_chat_client_get_response_tools() -> None:
# Test that the agents_client can be used to get a response
response = await azure_ai_chat_client.get_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
options={"tools": [get_weather], "tool_choice": "auto"},
)
assert response is not None
@@ -1571,8 +1520,7 @@ async def test_azure_ai_chat_client_streaming_tools() -> None:
# Test that the agents_client can be used to get a response
response = azure_ai_chat_client.get_streaming_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
options={"tools": [get_weather], "tool_choice": "auto"},
)
full_message: str = ""
async for chunk in response:
@@ -1772,7 +1720,7 @@ async def test_azure_ai_chat_client_agent_hosted_mcp_tool() -> None:
) as agent:
response = await agent.run(
"How to create an Azure storage account using az cli?",
max_tokens=200,
options={"max_tokens": 200},
)
assert isinstance(response, AgentRunResponse)
@@ -1823,20 +1771,14 @@ async def test_azure_ai_chat_client_agent_chat_options_run_level() -> None:
) as agent:
response = await agent.run(
"Provide a brief, helpful response.",
max_tokens=100,
temperature=0.7,
top_p=0.9,
seed=123,
user="comprehensive-test-user",
tools=[get_weather],
tool_choice="auto",
frequency_penalty=0.1,
presence_penalty=0.1,
stop=["END"],
store=True,
logit_bias={"test": 1},
metadata={"test": "value"},
additional_properties={"custom_param": "test_value"},
options={
"max_tokens": 100,
"temperature": 0.7,
"top_p": 0.9,
"tool_choice": "auto",
"metadata": {"test": "value"},
},
)
assert isinstance(response, AgentRunResponse)
@@ -1850,20 +1792,14 @@ async def test_azure_ai_chat_client_agent_chat_options_agent_level() -> None:
async with ChatAgent(
chat_client=AzureAIAgentClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant.",
max_tokens=100,
temperature=0.7,
top_p=0.9,
seed=123,
user="comprehensive-test-user",
tools=[get_weather],
tool_choice="auto",
frequency_penalty=0.1,
presence_penalty=0.1,
stop=["END"],
store=True,
logit_bias={"test": 1},
metadata={"test": "value"},
request_kwargs={"custom_param": "test_value"},
default_options={
"max_tokens": 100,
"temperature": 0.7,
"top_p": 0.9,
"tool_choice": "auto",
"metadata": {"test": "value"},
},
) as agent:
response = await agent.run(
"Provide a brief, helpful response.",
@@ -1,19 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import os
from collections.abc import AsyncIterator
from collections.abc import AsyncGenerator, AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated
from typing import Annotated, Any
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
ChatAgent,
ChatClientProtocol,
ChatMessage,
ChatOptions,
ChatResponse,
HostedCodeInterpreterTool,
HostedMCPTool,
HostedWebSearchTool,
Role,
TextContent,
)
@@ -26,6 +31,7 @@ from azure.identity.aio import AzureCliCredential
from openai.types.responses.parsed_response import ParsedResponse
from openai.types.responses.response import Response as OpenAIResponse
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pytest import fixture, param
from agent_framework_azure_ai import AzureAIClient, AzureAISettings
@@ -41,6 +47,32 @@ skip_if_azure_ai_integration_tests_disabled = pytest.mark.skipif(
)
@pytest.fixture
def mock_project_client() -> MagicMock:
"""Fixture that provides a mock AIProjectClient."""
mock_client = MagicMock()
# Mock agents property
mock_client.agents = MagicMock()
mock_client.agents.create_version = AsyncMock()
# Mock conversations property
mock_client.conversations = MagicMock()
mock_client.conversations.create = AsyncMock()
# Mock telemetry property
mock_client.telemetry = MagicMock()
mock_client.telemetry.get_application_insights_connection_string = AsyncMock()
# Mock get_openai_client method
mock_client.get_openai_client = AsyncMock()
# Mock close method
mock_client.close = AsyncMock()
return mock_client
@asynccontextmanager
async def temporary_chat_client(agent_name: str) -> AsyncIterator[AzureAIClient]:
"""Async context manager that creates an Azure AI agent and yields an `AzureAIClient`.
@@ -121,7 +153,7 @@ def test_azure_ai_settings_init_with_explicit_values() -> None:
assert settings.model_deployment_name == "custom-model"
def test_azure_ai_client_init_with_project_client(mock_project_client: MagicMock) -> None:
def test_init_with_project_client(mock_project_client: MagicMock) -> None:
"""Test AzureAIClient initialization with existing project_client."""
with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings:
mock_settings.return_value.project_endpoint = None
@@ -140,7 +172,7 @@ def test_azure_ai_client_init_with_project_client(mock_project_client: MagicMock
assert isinstance(client, ChatClientProtocol)
def test_azure_ai_client_init_auto_create_client(
def test_init_auto_create_client(
azure_ai_unit_test_env: dict[str, str],
mock_azure_credential: MagicMock,
) -> None:
@@ -164,7 +196,7 @@ def test_azure_ai_client_init_auto_create_client(
mock_ai_project_client.assert_called_once()
def test_azure_ai_client_init_missing_project_endpoint() -> None:
def test_init_missing_project_endpoint() -> None:
"""Test AzureAIClient initialization when project_endpoint is missing and no project_client provided."""
with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings:
mock_settings.return_value.project_endpoint = None
@@ -174,7 +206,7 @@ def test_azure_ai_client_init_missing_project_endpoint() -> None:
AzureAIClient(credential=MagicMock())
def test_azure_ai_client_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None:
def test_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None:
"""Test AzureAIClient.__init__ when credential is missing and no project_client provided."""
with pytest.raises(
ServiceInitializationError, match="Azure credential is required when project_client is not provided"
@@ -185,7 +217,7 @@ def test_azure_ai_client_init_missing_credential(azure_ai_unit_test_env: dict[st
)
def test_azure_ai_client_init_validation_error(mock_azure_credential: MagicMock) -> None:
def test_init_validation_error(mock_azure_credential: MagicMock) -> None:
"""Test that ValidationError in AzureAISettings is properly handled."""
with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings:
mock_settings.side_effect = ValidationError.from_exception_data("test", [])
@@ -194,7 +226,7 @@ def test_azure_ai_client_init_validation_error(mock_azure_credential: MagicMock)
AzureAIClient(credential=mock_azure_credential)
async def test_azure_ai_client_get_agent_reference_or_create_existing_version(
async def test_get_agent_reference_or_create_existing_version(
mock_project_client: MagicMock,
) -> None:
"""Test _get_agent_reference_or_create when agent_version is already provided."""
@@ -205,7 +237,7 @@ async def test_azure_ai_client_get_agent_reference_or_create_existing_version(
assert agent_ref == {"name": "existing-agent", "version": "1.0", "type": "agent_reference"}
async def test_azure_ai_client_get_agent_reference_or_create_missing_agent_name(
async def test_get_agent_reference_or_create_missing_agent_name(
mock_project_client: MagicMock,
) -> None:
"""Test _get_agent_reference_or_create raises when agent_name is missing."""
@@ -215,7 +247,7 @@ async def test_azure_ai_client_get_agent_reference_or_create_missing_agent_name(
await client._get_agent_reference_or_create({}, None) # type: ignore
async def test_azure_ai_client_get_agent_reference_or_create_new_agent(
async def test_get_agent_reference_or_create_new_agent(
mock_project_client: MagicMock,
azure_ai_unit_test_env: dict[str, str],
) -> None:
@@ -239,7 +271,7 @@ async def test_azure_ai_client_get_agent_reference_or_create_new_agent(
assert client.agent_version == "1.0"
async def test_azure_ai_client_get_agent_reference_missing_model(
async def test_get_agent_reference_missing_model(
mock_project_client: MagicMock,
) -> None:
"""Test _get_agent_reference_or_create when model is missing for agent creation."""
@@ -249,7 +281,7 @@ async def test_azure_ai_client_get_agent_reference_missing_model(
await client._get_agent_reference_or_create({}, None) # type: ignore
async def test_azure_ai_client_prepare_messages_for_azure_ai_with_system_messages(
async def test_prepare_messages_for_azure_ai_with_system_messages(
mock_project_client: MagicMock,
) -> None:
"""Test _prepare_messages_for_azure_ai converts system/developer messages to instructions."""
@@ -269,7 +301,7 @@ async def test_azure_ai_client_prepare_messages_for_azure_ai_with_system_message
assert instructions == "You are a helpful assistant."
async def test_azure_ai_client_prepare_messages_for_azure_ai_no_system_messages(
async def test_prepare_messages_for_azure_ai_no_system_messages(
mock_project_client: MagicMock,
) -> None:
"""Test _prepare_messages_for_azure_ai with no system/developer messages."""
@@ -286,7 +318,7 @@ async def test_azure_ai_client_prepare_messages_for_azure_ai_no_system_messages(
assert instructions is None
def test_azure_ai_client_transform_input_for_azure_ai(mock_project_client: MagicMock) -> None:
def test_transform_input_for_azure_ai(mock_project_client: MagicMock) -> None:
"""Test _transform_input_for_azure_ai adds required fields for Azure AI schema.
WORKAROUND TEST: Azure AI Projects API requires 'type' at item level and
@@ -331,7 +363,7 @@ def test_azure_ai_client_transform_input_for_azure_ai(mock_project_client: Magic
assert result[1]["content"][0]["text"] == "Hi there!"
def test_azure_ai_client_transform_input_preserves_existing_fields(mock_project_client: MagicMock) -> None:
def test_transform_input_preserves_existing_fields(mock_project_client: MagicMock) -> None:
"""Test _transform_input_for_azure_ai preserves existing type and annotations."""
client = create_test_azure_ai_client(mock_project_client)
@@ -353,7 +385,7 @@ def test_azure_ai_client_transform_input_preserves_existing_fields(mock_project_
assert result[0]["content"][0]["annotations"] == [{"some": "annotation"}]
def test_azure_ai_client_transform_input_handles_non_dict_content(mock_project_client: MagicMock) -> None:
def test_transform_input_handles_non_dict_content(mock_project_client: MagicMock) -> None:
"""Test _transform_input_for_azure_ai handles non-dict content items."""
client = create_test_azure_ai_client(mock_project_client)
@@ -373,12 +405,11 @@ def test_azure_ai_client_transform_input_handles_non_dict_content(mock_project_c
assert result[0]["content"] == ["plain string content"]
async def test_azure_ai_client_prepare_options_basic(mock_project_client: MagicMock) -> None:
async def test_prepare_options_basic(mock_project_client: MagicMock) -> None:
"""Test prepare_options basic functionality."""
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
chat_options = ChatOptions()
with (
patch.object(client.__class__.__bases__[0], "_prepare_options", return_value={"model": "test-model"}),
@@ -388,7 +419,7 @@ async def test_azure_ai_client_prepare_options_basic(mock_project_client: MagicM
return_value={"name": "test-agent", "version": "1.0", "type": "agent_reference"},
),
):
run_options = await client._prepare_options(messages, chat_options)
run_options = await client._prepare_options(messages, {})
assert "extra_body" in run_options
assert run_options["extra_body"]["agent"]["name"] == "test-agent"
@@ -401,7 +432,7 @@ async def test_azure_ai_client_prepare_options_basic(mock_project_client: MagicM
("https://example.com/api/projects/my-project", True),
],
)
async def test_azure_ai_client_prepare_options_with_application_endpoint(
async def test_prepare_options_with_application_endpoint(
mock_azure_credential: MagicMock, endpoint: str, expects_agent: bool
) -> None:
client = AzureAIClient(
@@ -413,7 +444,6 @@ async def test_azure_ai_client_prepare_options_with_application_endpoint(
)
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
chat_options = ChatOptions()
with (
patch.object(client.__class__.__bases__[0], "_prepare_options", return_value={"model": "test-model"}),
@@ -423,7 +453,7 @@ async def test_azure_ai_client_prepare_options_with_application_endpoint(
return_value={"name": "test-agent", "version": "1", "type": "agent_reference"},
),
):
run_options = await client._prepare_options(messages, chat_options)
run_options = await client._prepare_options(messages, {})
if expects_agent:
assert "extra_body" in run_options
@@ -439,7 +469,7 @@ async def test_azure_ai_client_prepare_options_with_application_endpoint(
("https://example.com/api/projects/my-project", True),
],
)
async def test_azure_ai_client_prepare_options_with_application_project_client(
async def test_prepare_options_with_application_project_client(
mock_project_client: MagicMock, endpoint: str, expects_agent: bool
) -> None:
mock_project_client._config = MagicMock()
@@ -453,7 +483,6 @@ async def test_azure_ai_client_prepare_options_with_application_project_client(
)
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
chat_options = ChatOptions()
with (
patch.object(client.__class__.__bases__[0], "_prepare_options", return_value={"model": "test-model"}),
@@ -463,7 +492,7 @@ async def test_azure_ai_client_prepare_options_with_application_project_client(
return_value={"name": "test-agent", "version": "1", "type": "agent_reference"},
),
):
run_options = await client._prepare_options(messages, chat_options)
run_options = await client._prepare_options(messages, {})
if expects_agent:
assert "extra_body" in run_options
@@ -472,7 +501,7 @@ async def test_azure_ai_client_prepare_options_with_application_project_client(
assert "extra_body" not in run_options
async def test_azure_ai_client_initialize_client(mock_project_client: MagicMock) -> None:
async def test_initialize_client(mock_project_client: MagicMock) -> None:
"""Test _initialize_client method."""
client = create_test_azure_ai_client(mock_project_client)
@@ -485,7 +514,7 @@ async def test_azure_ai_client_initialize_client(mock_project_client: MagicMock)
mock_project_client.get_openai_client.assert_called_once()
def test_azure_ai_client_update_agent_name_and_description(mock_project_client: MagicMock) -> None:
def test_update_agent_name_and_description(mock_project_client: MagicMock) -> None:
"""Test _update_agent_name_and_description method."""
client = create_test_azure_ai_client(mock_project_client)
@@ -506,7 +535,7 @@ def test_azure_ai_client_update_agent_name_and_description(mock_project_client:
mock_update.assert_called_once_with(None)
async def test_azure_ai_client_async_context_manager(mock_project_client: MagicMock) -> None:
async def test_async_context_manager(mock_project_client: MagicMock) -> None:
"""Test async context manager functionality."""
client = create_test_azure_ai_client(mock_project_client, should_close_client=True)
@@ -519,7 +548,7 @@ async def test_azure_ai_client_async_context_manager(mock_project_client: MagicM
mock_project_client.close.assert_called_once()
async def test_azure_ai_client_close_method(mock_project_client: MagicMock) -> None:
async def test_close_method(mock_project_client: MagicMock) -> None:
"""Test close method."""
client = create_test_azure_ai_client(mock_project_client, should_close_client=True)
@@ -530,7 +559,7 @@ async def test_azure_ai_client_close_method(mock_project_client: MagicMock) -> N
mock_project_client.close.assert_called_once()
async def test_azure_ai_client_close_client_when_should_close_false(mock_project_client: MagicMock) -> None:
async def test_close_client_when_should_close_false(mock_project_client: MagicMock) -> None:
"""Test _close_client_if_needed when should_close_client is False."""
client = create_test_azure_ai_client(mock_project_client, should_close_client=False)
@@ -542,7 +571,7 @@ async def test_azure_ai_client_close_client_when_should_close_false(mock_project
mock_project_client.close.assert_not_called()
async def test_azure_ai_client_agent_creation_with_instructions(
async def test_agent_creation_with_instructions(
mock_project_client: MagicMock,
) -> None:
"""Test agent creation with combined instructions."""
@@ -564,7 +593,7 @@ async def test_azure_ai_client_agent_creation_with_instructions(
assert call_args[1]["definition"].instructions == "Message instructions. Option instructions. "
async def test_azure_ai_client_agent_creation_with_additional_args(
async def test_agent_creation_with_additional_args(
mock_project_client: MagicMock,
) -> None:
"""Test agent creation with additional arguments."""
@@ -588,7 +617,7 @@ async def test_azure_ai_client_agent_creation_with_additional_args(
assert definition.top_p == 0.8
async def test_azure_ai_client_agent_creation_with_tools(
async def test_agent_creation_with_tools(
mock_project_client: MagicMock,
) -> None:
"""Test agent creation with tools."""
@@ -610,7 +639,7 @@ async def test_azure_ai_client_agent_creation_with_tools(
assert call_args[1]["definition"].tools == test_tools
async def test_azure_ai_client_use_latest_version_existing_agent(
async def test_use_latest_version_existing_agent(
mock_project_client: MagicMock,
) -> None:
"""Test _get_agent_reference_or_create when use_latest_version=True and agent exists."""
@@ -634,7 +663,7 @@ async def test_azure_ai_client_use_latest_version_existing_agent(
assert client.agent_version == "2.5"
async def test_azure_ai_client_use_latest_version_agent_not_found(
async def test_use_latest_version_agent_not_found(
mock_project_client: MagicMock,
) -> None:
"""Test _get_agent_reference_or_create when use_latest_version=True but agent doesn't exist."""
@@ -663,7 +692,7 @@ async def test_azure_ai_client_use_latest_version_agent_not_found(
assert client.agent_version == "1.0"
async def test_azure_ai_client_use_latest_version_false(
async def test_use_latest_version_false(
mock_project_client: MagicMock,
) -> None:
"""Test _get_agent_reference_or_create when use_latest_version=False (default behavior)."""
@@ -685,7 +714,7 @@ async def test_azure_ai_client_use_latest_version_false(
assert agent_ref == {"name": "test-agent", "version": "1.0", "type": "agent_reference"}
async def test_azure_ai_client_use_latest_version_with_existing_agent_version(
async def test_use_latest_version_with_existing_agent_version(
mock_project_client: MagicMock,
) -> None:
"""Test that use_latest_version is ignored when agent_version is already provided."""
@@ -711,7 +740,7 @@ class ResponseFormatModel(BaseModel):
model_config = ConfigDict(extra="forbid")
async def test_azure_ai_client_agent_creation_with_response_format(
async def test_agent_creation_with_response_format(
mock_project_client: MagicMock,
) -> None:
"""Test agent creation with response_format configuration."""
@@ -724,7 +753,7 @@ async def test_azure_ai_client_agent_creation_with_response_format(
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent)
run_options = {"model": "test-model"}
chat_options = ChatOptions(response_format=ResponseFormatModel)
chat_options = {"response_format": ResponseFormatModel}
await client._get_agent_reference_or_create(run_options, None, chat_options) # type: ignore
@@ -751,9 +780,10 @@ async def test_azure_ai_client_agent_creation_with_response_format(
assert "name" in schema["properties"]
assert "value" in schema["properties"]
assert "description" in schema["properties"]
assert "additionalProperties" in schema
async def test_azure_ai_client_agent_creation_with_mapping_response_format(
async def test_agent_creation_with_mapping_response_format(
mock_project_client: MagicMock,
) -> None:
"""Test agent creation when response_format is provided as a mapping."""
@@ -786,9 +816,9 @@ async def test_azure_ai_client_agent_creation_with_mapping_response_format(
"schema": runtime_schema,
},
}
chat_options = ChatOptions(response_format=response_format_mapping) # type: ignore
chat_options = {"response_format": response_format_mapping}
await client._get_agent_reference_or_create(run_options, None, chat_options) # type: ignore
await client._get_agent_reference_or_create(run_options, None, chat_options)
call_args = mock_project_client.agents.create_version.call_args
created_definition = call_args[1]["definition"]
@@ -802,14 +832,14 @@ async def test_azure_ai_client_agent_creation_with_mapping_response_format(
assert format_config.strict is True
async def test_azure_ai_client_prepare_options_excludes_response_format(
async def test_prepare_options_excludes_response_format(
mock_project_client: MagicMock,
) -> None:
"""Test that prepare_options excludes response_format, text, and text_format from final run options."""
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
chat_options = ChatOptions()
chat_options: ChatOptions = {}
with (
patch.object(
@@ -932,30 +962,7 @@ def test_get_conversation_id_with_parsed_response_no_conversation() -> None:
assert result == "resp_parsed_12345"
@pytest.fixture
def mock_project_client() -> MagicMock:
"""Fixture that provides a mock AIProjectClient."""
mock_client = MagicMock()
# Mock agents property
mock_client.agents = MagicMock()
mock_client.agents.create_version = AsyncMock()
# Mock conversations property
mock_client.conversations = MagicMock()
mock_client.conversations.create = AsyncMock()
# Mock telemetry property
mock_client.telemetry = MagicMock()
mock_client.telemetry.get_application_insights_connection_string = AsyncMock()
# Mock get_openai_client method
mock_client.get_openai_client = AsyncMock()
# Mock close method
mock_client.close = AsyncMock()
return mock_client
# region Integration Tests
def get_weather(
@@ -965,143 +972,355 @@ 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_agent_basic_run() -> None:
"""Test ChatAgent basic run functionality with AzureAIClient."""
class OutputStruct(BaseModel):
"""A structured output for testing purposes."""
location: str
weather: str
@fixture
async def client() -> AsyncGenerator[AzureAIClient, None]:
"""Create a client to test with."""
agent_name = f"test-agent-{uuid4()}"
endpoint = os.environ["AZURE_AI_PROJECT_ENDPOINT"]
async with (
temporary_chat_client(agent_name="BasicRunAgent") as chat_client,
ChatAgent(chat_client=chat_client) as agent,
AzureCliCredential() as credential,
AIProjectClient(endpoint=endpoint, credential=credential) as project_client,
):
response = await agent.run("Hello! Please respond with 'Hello World' exactly.")
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
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 AzureAIClient."""
async with (
temporary_chat_client(agent_name="BasicRunStreamingAgent") as chat_client,
ChatAgent(chat_client=chat_client) as agent,
):
full_message: str = ""
async for chunk in agent.run_stream("Please respond with exactly: 'This is a streaming response test.'"):
assert chunk is not None
assert isinstance(chunk, AgentRunResponseUpdate)
if chunk.text:
full_message += chunk.text
# Validate streaming response
assert len(full_message) > 0
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_with_tools() -> None:
"""Test ChatAgent tools with AzureAIClient."""
async with (
temporary_chat_client(agent_name="RunToolsAgent") as chat_client,
ChatAgent(chat_client=chat_client, tools=[get_weather]) as agent,
):
response = await agent.run("What's the weather like in Seattle?")
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
assert any(word in response.text.lower() for word in ["sunny", "25"])
class ReleaseBrief(BaseModel):
"""Structured output model for release brief."""
title: str = Field(description="A short title for the release.")
summary: str = Field(description="A brief summary of what was released.")
highlights: list[str] = Field(description="Key highlights from the release.")
model_config = ConfigDict(extra="forbid")
@pytest.mark.flaky
@skip_if_azure_ai_integration_tests_disabled
async def test_azure_ai_chat_client_agent_with_response_format() -> None:
"""Test ChatAgent with response_format (structured output) using AzureAIClient."""
async with (
temporary_chat_client(agent_name="ResponseFormatAgent") as chat_client,
ChatAgent(chat_client=chat_client) as agent,
):
response = await agent.run(
"Summarize the following release notes into a ReleaseBrief:\n\n"
"Version 2.0 Release Notes:\n"
"- Added new streaming API for real-time responses\n"
"- Improved error handling with detailed messages\n"
"- Performance boost of 50% in batch processing\n"
"- Fixed memory leak in connection pooling",
response_format=ReleaseBrief,
client = AzureAIClient(
project_client=project_client,
agent_name=agent_name,
)
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.value is not None
assert isinstance(response.value, ReleaseBrief)
# Validate structured output fields
brief = response.value
assert len(brief.title) > 0
assert len(brief.summary) > 0
assert len(brief.highlights) > 0
try:
client.function_invocation_configuration.max_iterations = 1
yield client
finally:
await project_client.agents.delete(agent_name=agent_name)
@pytest.mark.flaky
@skip_if_azure_ai_integration_tests_disabled
async def test_azure_ai_chat_client_agent_with_runtime_json_schema() -> None:
"""Test ChatAgent with runtime JSON schema (structured output) using AzureAIClient."""
runtime_schema = {
"title": "WeatherDigest",
"type": "object",
"properties": {
"location": {"type": "string"},
"conditions": {"type": "string"},
"temperature_c": {"type": "number"},
"advisory": {"type": "string"},
},
"required": ["location", "conditions", "temperature_c", "advisory"],
"additionalProperties": False,
}
@pytest.mark.parametrize(
"option_name,option_value,needs_validation",
[
# Simple ChatOptions - just verify they don't fail
param("top_p", 0.9, False, id="top_p"),
param("max_tokens", 500, False, id="max_tokens"),
param("seed", 123, False, id="seed"),
param("user", "test-user-id", False, id="user"),
param("metadata", {"test_key": "test_value"}, False, id="metadata"),
param("frequency_penalty", 0.5, False, id="frequency_penalty"),
param("presence_penalty", 0.3, False, id="presence_penalty"),
param("stop", ["END"], False, id="stop"),
param("allow_multiple_tool_calls", True, False, id="allow_multiple_tool_calls"),
param("tool_choice", "none", True, id="tool_choice_none"),
param("tool_choice", "auto", True, id="tool_choice_auto"),
param("tool_choice", "required", True, id="tool_choice_required_any"),
param(
"tool_choice",
{"mode": "required", "required_function_name": "get_weather"},
True,
id="tool_choice_required",
),
# OpenAIResponsesOptions - just verify they don't fail
param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"),
param("truncation", "auto", False, id="truncation"),
param("top_logprobs", 5, False, id="top_logprobs"),
param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"),
param("max_tool_calls", 3, False, id="max_tool_calls"),
],
)
async def test_integration_options(
option_name: str,
option_value: Any,
needs_validation: bool,
client: AzureAIClient,
) -> None:
"""Parametrized test covering options that can be set at runtime for a Foundry Agent.
async with (
temporary_chat_client(agent_name="RuntimeSchemaAgent") as chat_client,
ChatAgent(chat_client=chat_client) as agent,
):
response = await agent.run(
"Give a brief weather digest for Seattle.",
additional_chat_options={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": runtime_schema["title"],
"strict": True,
"schema": runtime_schema,
Tests both streaming and non-streaming modes for each option to ensure
they don't cause failures. Options marked with needs_validation also
check that the feature actually works correctly.
This test reuses a single agent.
"""
# Prepare test message
if option_name.startswith("tool_choice"):
# Use weather-related prompt for tool tests
messages = [ChatMessage(role="user", text="What is the weather in Seattle?")]
else:
# Generic prompt for simple options
messages = [ChatMessage(role="user", text="Say 'Hello World' briefly.")]
# Build options dict
options: dict[str, Any] = {option_name: option_value, "tools": [get_weather]}
for streaming in [False, True]:
if streaming:
# Test streaming mode
response_gen = client.get_streaming_response(
messages=messages,
options=options,
)
output_format = option_value if option_name == "response_format" else None
response = await ChatResponse.from_chat_response_generator(response_gen, output_format_type=output_format)
else:
# Test non-streaming mode
response = await client.get_response(
messages=messages,
options=options,
)
assert response is not None
assert isinstance(response, ChatResponse)
assert response.text is not None, f"No text in response for option '{option_name}'"
assert len(response.text) > 0, f"Empty response for option '{option_name}'"
# Validate based on option type
if needs_validation:
if option_name.startswith("tool_choice"):
# Should have called the weather function
text = response.text.lower()
assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}"
elif option_name == "response_format":
if option_value == OutputStruct:
# Should have structured output
assert response.value is not None, "No structured output"
assert isinstance(response.value, OutputStruct)
assert "seattle" in response.value.location.lower()
else:
# Runtime JSON schema
assert response.value is None, "No structured output, can't parse any json."
response_value = json.loads(response.text)
assert isinstance(response_value, dict)
assert "location" in response_value
assert "seattle" in response_value["location"].lower()
@pytest.mark.flaky
@skip_if_azure_ai_integration_tests_disabled
@pytest.mark.parametrize(
"option_name,option_value,needs_validation",
[
param("temperature", 0.7, False, id="temperature"),
# Complex options requiring output validation
param("response_format", OutputStruct, True, id="response_format_pydantic"),
param(
"response_format",
{
"type": "json_schema",
"json_schema": {
"name": "WeatherDigest",
"strict": True,
"schema": {
"title": "WeatherDigest",
"type": "object",
"properties": {
"location": {"type": "string"},
"conditions": {"type": "string"},
"temperature_c": {"type": "number"},
"advisory": {"type": "string"},
},
"required": ["location", "conditions", "temperature_c", "advisory"],
"additionalProperties": False,
},
},
},
True,
id="response_format_runtime_json_schema",
),
],
)
async def test_integration_agent_options(
option_name: str,
option_value: Any,
needs_validation: bool,
) -> None:
"""Test Foundry agent level options in both streaming and non-streaming modes.
Tests both streaming and non-streaming modes for each option to ensure
they don't cause failures. Options marked with needs_validation also
check that the feature actually works correctly.
This test create a new client and uses it for both streaming and non-streaming tests.
"""
async with temporary_chat_client(agent_name=f"test-agent-{option_name.replace('_', '-')}-{uuid4()}") as client:
for streaming in [False, True]:
# Prepare test message
if option_name.startswith("response_format"):
# Use prompt that works well with structured output
messages = [ChatMessage(role="user", text="The weather in Seattle is sunny")]
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
else:
# Generic prompt for simple options
messages = [ChatMessage(role="user", text="Say 'Hello World' briefly.")]
# Build options dict
options = {option_name: option_value}
if streaming:
# Test streaming mode
response_gen = client.get_streaming_response(
messages=messages,
options=options,
)
output_format = option_value if option_name.startswith("response_format") else None
response = await ChatResponse.from_chat_response_generator(
response_gen, output_format_type=output_format
)
else:
# Test non-streaming mode
response = await client.get_response(
messages=messages,
options=options,
)
assert response is not None
assert isinstance(response, ChatResponse)
assert response.text is not None, f"No text in response for option '{option_name}'"
assert len(response.text) > 0, f"Empty response for option '{option_name}'"
# Validate based on option type
if needs_validation and option_name.startswith("response_format"):
if option_value == OutputStruct:
# Should have structured output
assert response.value is not None, "No structured output"
assert isinstance(response.value, OutputStruct)
assert "seattle" in response.value.location.lower()
else:
# Runtime JSON schema
assert response.value is None, "No structured output, can't parse any json."
response_value = json.loads(response.text)
assert isinstance(response_value, dict)
assert "location" in response_value
assert "seattle" in response_value["location"].lower()
@pytest.mark.flaky
@skip_if_azure_ai_integration_tests_disabled
async def test_integration_web_search() -> None:
async with temporary_chat_client(agent_name="af-int-test-web-search") as client:
for streaming in [False, True]:
content = {
"messages": "Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool()],
},
}
if streaming:
response = await ChatResponse.from_chat_response_generator(client.get_streaming_response(**content))
else:
response = await client.get_response(**content)
assert response is not None
assert isinstance(response, ChatResponse)
assert "Rumi" in response.text
assert "Mira" in response.text
assert "Zoey" in response.text
# Test that the client will use the web search tool with location
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
}
}
content = {
"messages": "What is the current weather? Do not ask for my current location.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool(additional_properties=additional_properties)],
},
}
if streaming:
response = await ChatResponse.from_chat_response_generator(client.get_streaming_response(**content))
else:
response = await client.get_response(**content)
assert response.text is not None
@pytest.mark.flaky
@skip_if_azure_ai_integration_tests_disabled
async def test_integration_agent_hosted_mcp_tool() -> None:
"""Integration test for HostedMCPTool with Azure Response Agent using Microsoft Learn MCP."""
async with temporary_chat_client(agent_name="af-int-test-mcp") as client:
response = await client.get_response(
"How to create an Azure storage account using az cli?",
options={
# this needs to be high enough to handle the full MCP tool response.
"max_tokens": 5000,
"tools": HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
description="A Microsoft Learn MCP server for documentation questions",
approval_mode="never_require",
),
},
)
assert isinstance(response, ChatResponse)
assert response.text
# Should contain Azure-related content since it's asking about Azure CLI
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
# Parse JSON and validate structure
import json
@pytest.mark.flaky
@skip_if_azure_ai_integration_tests_disabled
async def test_integration_agent_hosted_code_interpreter_tool():
"""Test Azure Responses Client agent with HostedCodeInterpreterTool through AzureAIClient."""
async with temporary_chat_client(agent_name="af-int-test-code-interpreter") as client:
response = await client.get_response(
"Calculate the sum of numbers from 1 to 10 using Python code.",
options={
"tools": [HostedCodeInterpreterTool()],
},
)
# Should contain calculation result (sum of 1-10 = 55) or code execution content
contains_relevant_content = any(
term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"]
)
assert contains_relevant_content or len(response.text.strip()) > 10
parsed = json.loads(response.text)
assert "location" in parsed
assert "conditions" in parsed
assert "temperature_c" in parsed
assert "advisory" in parsed
@pytest.mark.flaky
@skip_if_azure_ai_integration_tests_disabled
async def test_integration_agent_existing_thread():
"""Test Azure Responses Client agent with existing thread to continue conversations across agent instances."""
# First conversation - capture the thread
preserved_thread = None
async with (
temporary_chat_client(agent_name="af-int-test-existing-thread") as client,
ChatAgent(
chat_client=client,
instructions="You are a helpful assistant with good memory.",
) as first_agent,
):
# Start a conversation and capture the thread
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread, store=True)
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Preserve the thread for reuse
preserved_thread = thread
# Second conversation - reuse the thread in a new agent instance
if preserved_thread:
async with (
temporary_chat_client(agent_name="af-int-test-existing-thread-2") as client,
ChatAgent(
chat_client=client,
instructions="You are a helpful assistant with good memory.",
) as second_agent,
):
# Reuse the preserved thread
second_response = await second_agent.run("What is my hobby?", thread=preserved_thread)
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
assert "photography" in second_response.text.lower()
@@ -153,11 +153,11 @@ class AgentEntity:
for m in entry.messages
]
run_kwargs: dict[str, Any] = {"messages": chat_messages}
run_kwargs: dict[str, Any] = {"messages": chat_messages, "options": {}}
if not enable_tool_calls:
run_kwargs["tools"] = None
run_kwargs["options"]["tools"] = None
if response_format:
run_kwargs["response_format"] = response_format
run_kwargs["options"]["response_format"] = response_format
agent_run_response: AgentRunResponse = await self._invoke_agent(
run_kwargs=run_kwargs,
@@ -6,7 +6,7 @@ This module provides support for using agents inside Durable Function orchestrat
"""
import uuid
from collections.abc import AsyncIterator, Callable
from collections.abc import AsyncIterator, Callable, Sequence
from typing import TYPE_CHECKING, Any, TypeAlias, cast
from agent_framework import (
@@ -193,7 +193,7 @@ class DurableAIAgent(AgentProtocol):
# a typed AgentRunResponse result.
def run( # type: ignore[override]
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
response_format: type[BaseModel] | None = None,
@@ -282,7 +282,7 @@ class DurableAIAgent(AgentProtocol):
def run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
@@ -327,7 +327,7 @@ class DurableAIAgent(AgentProtocol):
"""
return "\n".join([msg.text or "" for msg in messages])
def _normalize_messages(self, messages: str | ChatMessage | list[str] | list[ChatMessage] | None) -> str:
def _normalize_messages(self, messages: str | ChatMessage | Sequence[str | ChatMessage] | None) -> str:
"""Convert supported message inputs to a single string."""
if messages is None:
return ""
@@ -2,7 +2,7 @@
import importlib.metadata
from ._chat_client import BedrockChatClient
from ._chat_client import BedrockChatClient, BedrockChatOptions, BedrockGuardrailConfig, BedrockSettings
try:
__version__ = importlib.metadata.version(__name__)
@@ -11,5 +11,8 @@ except importlib.metadata.PackageNotFoundError:
__all__ = [
"BedrockChatClient",
"BedrockChatOptions",
"BedrockGuardrailConfig",
"BedrockSettings",
"__version__",
]
@@ -2,9 +2,10 @@
import asyncio
import json
import sys
from collections import deque
from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence
from typing import Any, ClassVar
from typing import Any, ClassVar, Generic, Literal, TypedDict
from uuid import uuid4
from agent_framework import (
@@ -28,6 +29,7 @@ from agent_framework import (
prepare_function_call_results,
use_chat_middleware,
use_function_invocation,
validate_tool_mode,
)
from agent_framework._pydantic import AFBaseSettings
from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidResponseError
@@ -37,11 +39,151 @@ from botocore.client import BaseClient
from botocore.config import Config as BotoConfig
from pydantic import SecretStr, ValidationError
if sys.version_info >= (3, 13):
from typing import TypeVar
else:
from typing_extensions import TypeVar
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
logger = get_logger("agent_framework.bedrock")
__all__ = [
"BedrockChatClient",
"BedrockChatOptions",
"BedrockGuardrailConfig",
"BedrockSettings",
]
# region Bedrock Chat Options TypedDict
DEFAULT_REGION = "us-east-1"
DEFAULT_MAX_TOKENS = 1024
class BedrockGuardrailConfig(TypedDict, total=False):
"""Amazon Bedrock Guardrails configuration.
See: https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html
"""
guardrailIdentifier: str
"""The identifier of the guardrail to apply."""
guardrailVersion: str
"""The version of the guardrail to use."""
trace: Literal["enabled", "disabled"]
"""Whether to include guardrail trace information in the response."""
streamProcessingMode: Literal["sync", "async"]
"""How to process guardrails during streaming (sync blocks, async does not)."""
class BedrockChatOptions(ChatOptions, total=False):
"""Amazon Bedrock Converse API-specific chat options dict.
Extends base ChatOptions with Bedrock-specific parameters.
Bedrock uses a unified Converse API that works across multiple
foundation models (Claude, Titan, Llama, etc.).
See: https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html
Keys:
# Inherited from ChatOptions (mapped to Bedrock):
model_id: The Bedrock model identifier,
translates to ``modelId`` in Bedrock API.
temperature: Sampling temperature,
translates to ``inferenceConfig.temperature``.
top_p: Nucleus sampling parameter,
translates to ``inferenceConfig.topP``.
max_tokens: Maximum number of tokens to generate,
translates to ``inferenceConfig.maxTokens``.
stop: Stop sequences,
translates to ``inferenceConfig.stopSequences``.
tools: List of tools available to the model,
translates to ``toolConfig.tools``.
tool_choice: How the model should use tools,
translates to ``toolConfig.toolChoice``.
# Options not supported in Bedrock Converse API:
seed: Not supported.
frequency_penalty: Not supported.
presence_penalty: Not supported.
allow_multiple_tool_calls: Not supported (models handle parallel calls automatically).
response_format: Not directly supported (use model-specific prompting).
user: Not supported.
store: Not supported.
logit_bias: Not supported.
metadata: Not supported (use additional_properties for additionalModelRequestFields).
# Bedrock-specific options:
guardrailConfig: Guardrails configuration for content filtering.
performanceConfig: Performance optimization settings.
requestMetadata: Key-value metadata for the request.
promptVariables: Variables for prompt management (if using managed prompts).
"""
# Bedrock-specific options
guardrailConfig: BedrockGuardrailConfig
"""Guardrails configuration for content filtering and safety."""
performanceConfig: dict[str, Any]
"""Performance optimization settings (e.g., latency optimization).
See: https://docs.aws.amazon.com/bedrock/latest/userguide/inference-performance.html"""
requestMetadata: dict[str, str]
"""Key-value metadata for the request (max 2048 characters total)."""
promptVariables: dict[str, dict[str, str]]
"""Variables for prompt management when using managed prompts."""
# ChatOptions fields not supported in Bedrock
seed: None # type: ignore[misc]
"""Not supported in Bedrock Converse API."""
frequency_penalty: None # type: ignore[misc]
"""Not supported in Bedrock Converse API."""
presence_penalty: None # type: ignore[misc]
"""Not supported in Bedrock Converse API."""
allow_multiple_tool_calls: None # type: ignore[misc]
"""Not supported. Bedrock models handle parallel tool calls automatically."""
response_format: None # type: ignore[misc]
"""Not directly supported. Use model-specific prompting for JSON output."""
user: None # type: ignore[misc]
"""Not supported in Bedrock Converse API."""
store: None # type: ignore[misc]
"""Not supported in Bedrock Converse API."""
logit_bias: None # type: ignore[misc]
"""Not supported in Bedrock Converse API."""
BEDROCK_OPTION_TRANSLATIONS: dict[str, str] = {
"model_id": "modelId",
"max_tokens": "maxTokens",
"top_p": "topP",
"stop": "stopSequences",
}
"""Maps ChatOptions keys to Bedrock Converse API parameter names."""
TBedrockChatOptions = TypeVar("TBedrockChatOptions", bound=TypedDict, default="BedrockChatOptions", covariant=True) # type: ignore[valid-type]
# endregion
ROLE_MAP: dict[Role, str] = {
Role.USER: "user",
Role.ASSISTANT: "assistant",
@@ -74,7 +216,7 @@ class BedrockSettings(AFBaseSettings):
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class BedrockChatClient(BaseChatClient):
class BedrockChatClient(BaseChatClient[TBedrockChatOptions], Generic[TBedrockChatOptions]):
"""Async chat client for Amazon Bedrock's Converse API."""
OTEL_PROVIDER_NAME: ClassVar[str] = "aws.bedrock" # type: ignore[reportIncompatibleVariableOverride, misc]
@@ -106,6 +248,26 @@ class BedrockChatClient(BaseChatClient):
env_file_path: Optional .env file path used by ``BedrockSettings`` to load defaults.
env_file_encoding: Encoding for the optional .env file.
kwargs: Additional arguments forwarded to ``BaseChatClient``.
Examples:
.. code-block:: python
from agent_framework.bedrock import BedrockChatClient
# Basic usage with default credentials
client = BedrockChatClient(model_id="<model name>")
# Using custom ChatOptions with type safety:
from typing import TypedDict
from agent_framework_bedrock import BedrockChatOptions
class MyOptions(BedrockChatOptions, total=False):
my_custom_option: str
client = BedrockChatClient[MyOptions](model_id="<model name>")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
settings = BedrockSettings(
@@ -143,25 +305,27 @@ class BedrockChatClient(BaseChatClient):
session_kwargs["aws_session_token"] = settings.session_token.get_secret_value()
return Boto3Session(**session_kwargs)
@override
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
request = self._build_converse_request(messages, chat_options, **kwargs)
request = self._prepare_options(messages, options, **kwargs)
raw_response = await asyncio.to_thread(self._bedrock_client.converse, **request)
return self._process_converse_response(raw_response)
@override
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
response = await self._inner_get_response(messages=messages, chat_options=chat_options, **kwargs)
response = await self._inner_get_response(messages=messages, options=options, **kwargs)
contents = list(response.messages[0].contents if response.messages else [])
if response.usage_details:
contents.append(UsageContent(details=response.usage_details))
@@ -173,13 +337,13 @@ class BedrockChatClient(BaseChatClient):
raw_representation=response.raw_representation,
)
def _build_converse_request(
def _prepare_options(
self,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> dict[str, Any]:
model_id = chat_options.model_id or self.model_id
model_id = options.get("model_id") or self.model_id
if not model_id:
raise ServiceInitializationError(
"Bedrock model_id is required. Set via chat options or BEDROCK_CHAT_MODEL_ID environment variable."
@@ -188,40 +352,42 @@ class BedrockChatClient(BaseChatClient):
system_prompts, conversation = self._prepare_bedrock_messages(messages)
if not conversation:
raise ServiceInitializationError("At least one non-system message is required for Bedrock requests.")
# Prepend instructions from options if they exist
if instructions := options.get("instructions"):
system_prompts = [{"text": instructions}, *system_prompts]
payload: dict[str, Any] = {
run_options: dict[str, Any] = {
"modelId": model_id,
"messages": conversation,
"inferenceConfig": {"maxTokens": options.get("max_tokens", DEFAULT_MAX_TOKENS)},
}
if system_prompts:
payload["system"] = system_prompts
run_options["system"] = system_prompts
inference_config: dict[str, Any] = {}
inference_config["maxTokens"] = (
chat_options.max_tokens if chat_options.max_tokens is not None else DEFAULT_MAX_TOKENS
)
if chat_options.temperature is not None:
inference_config["temperature"] = chat_options.temperature
if chat_options.top_p is not None:
inference_config["topP"] = chat_options.top_p
if chat_options.stop is not None:
inference_config["stopSequences"] = chat_options.stop
if inference_config:
payload["inferenceConfig"] = inference_config
if (temperature := options.get("temperature")) is not None:
run_options["inferenceConfig"]["temperature"] = temperature
if (top_p := options.get("top_p")) is not None:
run_options["inferenceConfig"]["topP"] = top_p
if (stop := options.get("stop")) is not None:
run_options["inferenceConfig"]["stopSequences"] = stop
tool_config = self._convert_tools_to_bedrock_config(chat_options.tools)
if tool_choice := self._convert_tool_choice(chat_options.tool_choice):
if tool_config is None:
tool_config = {}
tool_config["toolChoice"] = tool_choice
tool_config = self._prepare_tools(options.get("tools"))
if tool_mode := validate_tool_mode(options.get("tool_choice")):
tool_config = tool_config or {}
match tool_mode.get("mode"):
case "auto" | "none":
tool_config["toolChoice"] = {tool_mode.get("mode"): {}}
case "required":
if required_name := tool_mode.get("required_function_name"):
tool_config["toolChoice"] = {"tool": {"name": required_name}}
else:
tool_config["toolChoice"] = {"any": {}}
case _:
raise ServiceInitializationError(f"Unsupported tool mode for Bedrock: {tool_mode.get('mode')}")
if tool_config:
payload["toolConfig"] = tool_config
run_options["toolConfig"] = tool_config
if chat_options.additional_properties:
payload.update(chat_options.additional_properties)
if kwargs:
payload.update(kwargs)
return payload
return run_options
def _prepare_bedrock_messages(
self, messages: Sequence[ChatMessage]
@@ -374,12 +540,10 @@ class BedrockChatClient(BaseChatClient):
return {"text": str(value)}
return {"text": str(value)}
def _convert_tools_to_bedrock_config(
self, tools: list[ToolProtocol | MutableMapping[str, Any]] | None
) -> dict[str, Any] | None:
def _prepare_tools(self, tools: list[ToolProtocol | MutableMapping[str, Any]] | None) -> dict[str, Any] | None:
converted: list[dict[str, Any]] = []
if not tools:
return None
converted: list[dict[str, Any]] = []
for tool in tools:
if isinstance(tool, MutableMapping):
converted.append(dict(tool))
@@ -396,24 +560,6 @@ class BedrockChatClient(BaseChatClient):
logger.debug("Ignoring unsupported tool type for Bedrock: %s", type(tool))
return {"tools": converted} if converted else None
def _convert_tool_choice(self, tool_choice: Any) -> dict[str, Any] | None:
if not tool_choice:
return None
mode = tool_choice.mode if hasattr(tool_choice, "mode") else str(tool_choice)
required_name = getattr(tool_choice, "required_function_name", None)
match mode:
case "auto":
return {"auto": {}}
case "none":
return {"none": {}}
case "required":
if required_name:
return {"tool": {"name": required_name}}
return {"any": {}}
case _:
logger.debug("Unsupported tool choice mode for Bedrock: %s", mode)
return None
@staticmethod
def _generate_tool_call_id() -> str:
return f"tool-call-{uuid4().hex}"
@@ -11,7 +11,6 @@ from agent_framework import (
FunctionResultContent,
Role,
TextContent,
ToolMode,
ai_function,
)
@@ -31,7 +30,7 @@ async def main() -> None:
chat_client=BedrockChatClient(),
instructions="You are a concise travel assistant.",
name="BedrockWeatherAgent",
tool_choice=ToolMode.AUTO,
tool_choice="auto",
tools=[get_weather],
)
@@ -6,7 +6,7 @@ import asyncio
from typing import Any
import pytest
from agent_framework import ChatMessage, ChatOptions, Role, TextContent
from agent_framework import ChatMessage, Role, TextContent
from agent_framework.exceptions import ServiceInitializationError
from agent_framework_bedrock import BedrockChatClient
@@ -46,7 +46,7 @@ def test_get_response_invokes_bedrock_runtime() -> None:
ChatMessage(role=Role.USER, contents=[TextContent(text="hello")]),
]
response = asyncio.run(client.get_response(messages=messages, chat_options=ChatOptions(max_tokens=32)))
response = asyncio.run(client.get_response(messages=messages, options={"max_tokens": 32}))
assert stub.calls, "Expected the runtime client to be called"
payload = stub.calls[0]
@@ -66,4 +66,4 @@ def test_build_request_requires_non_system_messages() -> None:
messages = [ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="Only system text")])]
with pytest.raises(ServiceInitializationError):
client._build_converse_request(messages, ChatOptions())
client._prepare_options(messages, {})
@@ -13,7 +13,6 @@ from agent_framework import (
FunctionResultContent,
Role,
TextContent,
ToolMode,
)
from pydantic import BaseModel
@@ -46,10 +45,13 @@ def test_build_request_includes_tool_config() -> None:
client = _build_client()
tool = AIFunction(name="get_weather", description="desc", func=_dummy_weather, input_model=_WeatherArgs)
options = ChatOptions(tools=[tool], tool_choice=ToolMode.REQUIRED("get_weather"))
options = {
"tools": [tool],
"tool_choice": {"mode": "required", "required_function_name": "get_weather"},
}
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="hi")])]
request = client._build_converse_request(messages, options)
request = client._prepare_options(messages, options)
assert request["toolConfig"]["tools"][0]["toolSpec"]["name"] == "get_weather"
assert request["toolConfig"]["toolChoice"] == {"tool": {"name": "get_weather"}}
@@ -57,7 +59,7 @@ def test_build_request_includes_tool_config() -> None:
def test_build_request_serializes_tool_history() -> None:
client = _build_client()
options = ChatOptions()
options: ChatOptions = {}
messages = [
ChatMessage(role=Role.USER, contents=[TextContent(text="how's weather?")]),
ChatMessage(
@@ -70,7 +72,7 @@ def test_build_request_serializes_tool_history() -> None:
),
]
request = client._build_converse_request(messages, options)
request = client._prepare_options(messages, options)
assistant_block = request["messages"][1]["content"][0]["toolUse"]
result_block = request["messages"][2]["content"][0]["toolResult"]
@@ -6,11 +6,6 @@ import logging
import sys
from collections.abc import Awaitable, Callable, Sequence
if sys.version_info >= (3, 11):
from typing import assert_never
else:
from typing_extensions import assert_never
from agent_framework import (
ChatMessage,
DataContent,
@@ -38,6 +33,11 @@ from chatkit.types import (
WorkflowItem,
)
if sys.version_info >= (3, 11):
from typing import assert_never
else:
from typing_extensions import assert_never
logger = logging.getLogger(__name__)
+226 -222
View File
@@ -7,7 +7,16 @@ from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping,
from contextlib import AbstractAsyncContextManager, AsyncExitStack
from copy import deepcopy
from itertools import chain
from typing import Any, ClassVar, Literal, Protocol, TypeVar, cast, runtime_checkable
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Generic,
Protocol,
TypedDict,
cast,
runtime_checkable,
)
from uuid import uuid4
from mcp import types
@@ -27,27 +36,73 @@ from ._types import (
AgentRunResponse,
AgentRunResponseUpdate,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Role,
ToolMode,
)
from .exceptions import AgentExecutionException, AgentInitializationError
from .observability import use_agent_instrumentation
if TYPE_CHECKING:
from ._types import ChatOptions
if sys.version_info >= (3, 13):
from typing import TypeVar
else:
from typing_extensions import TypeVar
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
logger = get_logger("agent_framework")
TThreadType = TypeVar("TThreadType", bound="AgentThread")
TOptions_co = TypeVar(
"TOptions_co",
bound=TypedDict, # type: ignore[valid-type]
default="ChatOptions",
covariant=True,
)
def _merge_options(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
"""Merge two options dicts, with override values taking precedence.
Args:
base: The base options dict.
override: The override options dict (values take precedence).
Returns:
A new merged options dict.
"""
result = dict(base)
for key, value in override.items():
if value is None:
continue
if key == "tools" and result.get("tools"):
# Combine tool lists
result["tools"] = list(result["tools"]) + list(value)
elif key == "logit_bias" and result.get("logit_bias"):
# Merge logit_bias dicts
result["logit_bias"] = {**result["logit_bias"], **value}
elif key == "metadata" and result.get("metadata"):
# Merge metadata dicts
result["metadata"] = {**result["metadata"], **value}
elif key == "instructions" and result.get("instructions"):
# Concatenate instructions
result["instructions"] = f"{result['instructions']}\n{value}"
else:
result[key] = value
return result
def _sanitize_agent_name(agent_name: str | None) -> str | None:
@@ -151,7 +206,7 @@ class AgentProtocol(Protocol):
async def run(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
@@ -182,7 +237,7 @@ class AgentProtocol(Protocol):
def run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
@@ -445,7 +500,7 @@ class BaseAgent(SerializationMixin):
def _normalize_messages(
self,
messages: str | ChatMessage | Sequence[str] | Sequence[ChatMessage] | None = None,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
) -> list[ChatMessage]:
if messages is None:
return []
@@ -464,20 +519,23 @@ class BaseAgent(SerializationMixin):
@use_agent_middleware
@use_agent_instrumentation(capture_usage=False) # type: ignore[arg-type,misc]
class ChatAgent(BaseAgent): # type: ignore[misc]
class ChatAgent(BaseAgent, Generic[TOptions_co]): # type: ignore[misc]
"""A Chat Client Agent.
This is the primary agent implementation that uses a chat client to interact
with language models. It supports tools, context providers, middleware, and
both streaming and non-streaming responses.
The generic type parameter TOptions specifies which options TypedDict this agent
accepts. This enables IDE autocomplete and type checking for provider-specific options.
Examples:
Basic usage:
.. code-block:: python
from agent_framework import ChatAgent
from agent_framework.clients import OpenAIChatClient
from agent_framework.openai import OpenAIChatClient
# Create a basic chat agent
client = OpenAIChatClient(model_id="gpt-4")
@@ -509,72 +567,55 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
async for update in agent.run_stream("What's the weather in Paris?"):
print(update.text, end="")
With additional provider specific options:
With typed options for IDE autocomplete:
.. code-block:: python
agent = ChatAgent(
from agent_framework import ChatAgent
from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions
client = OpenAIChatClient(model_id="gpt-4o")
agent: ChatAgent[OpenAIChatOptions] = ChatAgent(
chat_client=client,
name="reasoning-agent",
instructions="You are a reasoning assistant.",
model_id="gpt-5",
temperature=0.7,
max_tokens=500,
additional_chat_options={
"reasoning": {"effort": "high", "summary": "concise"}
}, # OpenAI Responses specific.
options={
"temperature": 0.7,
"max_tokens": 500,
"reasoning_effort": "high", # OpenAI-specific, IDE will autocomplete!
},
)
# Use streaming responses
async for update in agent.run_stream("How do you prove the pythagorean theorem?"):
print(update.text, end="")
# Or pass options at runtime
response = await agent.run(
"What is 25 * 47?",
options={"temperature": 0.0, "logprobs": True},
)
"""
AGENT_PROVIDER_NAME: ClassVar[str] = "microsoft.agent_framework"
def __init__(
self,
chat_client: ChatClientProtocol,
chat_client: ChatClientProtocol[TOptions_co],
instructions: str | None = None,
*,
id: str | None = None,
name: str | None = None,
description: str | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_provider: ContextProvider | None = None,
middleware: Sequence[Middleware] | None = None,
# chat options
allow_multiple_tool_calls: bool | None = None,
conversation_id: str | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model_id: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_chat_options: dict[str, Any] | None = None,
default_options: TOptions_co | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_provider: ContextProvider | None = None,
middleware: Sequence[Middleware] | None = None,
**kwargs: Any,
) -> None:
"""Initialize a ChatAgent instance.
Note:
The set of parameters from frequency_penalty to request_kwargs are used to
call the chat client. They can also be passed to both run methods.
When both are set, the ones passed to the run methods take precedence.
Args:
chat_client: The chat client to use for the agent.
instructions: Optional instructions for the agent.
@@ -586,35 +627,24 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
description: A brief description of the agent's purpose.
chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol.
If not provided, the default in-memory store will be used.
context_provider: The context provider to include during agent invocation.
middleware: List of middleware to intercept agent, chat and function invocations.
allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response.
conversation_id: The conversation ID for service-managed threads.
Cannot be used together with chat_message_store_factory.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: Additional metadata to include in the request.
model_id: The model_id to use for the agent.
This overrides the model_id set in the chat client if it contains one.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
context_provider: The context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
default_options: A TypedDict containing chat options. When using a typed agent like
``ChatAgent[OpenAIChatOptions]``, this enables IDE autocomplete for
provider-specific options including temperature, max_tokens, model_id,
tool_choice, and provider-specific options like reasoning_effort.
You can also create your own TypedDict for custom chat clients.
These can be overridden at runtime via the ``options`` parameter of ``run()`` and ``run_stream()``.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_chat_options: A dictionary of other values that will be passed through
to the chat_client ``get_response`` and ``get_streaming_response`` methods.
This can be used to pass provider specific parameters.
kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``.
Raises:
AgentInitializationError: If both conversation_id and chat_message_store_factory are provided.
"""
# Extract conversation_id from options for validation
opts = dict(default_options) if default_options else {}
conversation_id = opts.get("conversation_id")
if conversation_id is not None and chat_message_store_factory is not None:
raise AgentInitializationError(
"Cannot specify both conversation_id and chat_message_store_factory. "
@@ -634,37 +664,47 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
middleware=middleware,
**kwargs,
)
self.chat_client = chat_client
self.chat_client: ChatClientProtocol[TOptions_co] = chat_client
self.chat_message_store_factory = chat_message_store_factory
# Get tools from options or named parameter (named param takes precedence)
tools_ = tools if tools is not None else opts.pop("tools", None)
# Handle instructions - named parameter takes precedence over options
instructions_ = instructions if instructions is not None else opts.pop("instructions", None)
# We ignore the MCP Servers here and store them separately,
# we add their functions to the tools list at runtime
normalized_tools: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = ( # type:ignore[reportUnknownVariableType]
[] if tools is None else tools if isinstance(tools, list) else [tools] # type: ignore[list-item]
[] if tools_ is None else tools_ if isinstance(tools_, list) else [tools_] # type: ignore[list-item]
)
self._local_mcp_tools = [tool for tool in normalized_tools if isinstance(tool, MCPTool)]
agent_tools = [tool for tool in normalized_tools if not isinstance(tool, MCPTool)]
self.chat_options = ChatOptions(
model_id=model_id or (str(chat_client.model_id) if hasattr(chat_client, "model_id") else None),
allow_multiple_tool_calls=allow_multiple_tool_calls,
conversation_id=conversation_id,
frequency_penalty=frequency_penalty,
instructions=instructions,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=agent_tools,
top_p=top_p,
user=user,
additional_properties=additional_chat_options or {}, # type: ignore
)
# Build chat options dict
self.default_options: dict[str, Any] = {
"model_id": opts.pop("model_id", None) or (getattr(self.chat_client, "model_id", None)),
"allow_multiple_tool_calls": opts.pop("allow_multiple_tool_calls", None),
"conversation_id": conversation_id,
"frequency_penalty": opts.pop("frequency_penalty", None),
"instructions": instructions_,
"logit_bias": opts.pop("logit_bias", None),
"max_tokens": opts.pop("max_tokens", None),
"metadata": opts.pop("metadata", None),
"presence_penalty": opts.pop("presence_penalty", None),
"response_format": opts.pop("response_format", None),
"seed": opts.pop("seed", None),
"stop": opts.pop("stop", None),
"store": opts.pop("store", None),
"temperature": opts.pop("temperature", None),
"tool_choice": opts.pop("tool_choice", "auto"),
"tools": agent_tools,
"top_p": opts.pop("top_p", None),
"user": opts.pop("user", None),
**opts, # Remaining options are provider-specific
}
# Remove None values from chat_options
self.default_options = {k: v for k, v in self.default_options.items() if v is not None}
self._async_exit_stack = AsyncExitStack()
self._update_agent_name_and_description()
@@ -716,30 +756,15 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
async def run(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
allow_multiple_tool_calls: bool | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model_id: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_chat_options: dict[str, Any] | None = None,
options: TOptions_co | None = None,
**kwargs: Any,
) -> AgentRunResponse:
"""Run the agent with the given messages and options.
@@ -755,36 +780,29 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
Keyword Args:
thread: The thread to use for the agent.
allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: Additional metadata to include in the request.
model_id: The model_id to use for the agent.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_chat_options: Additional properties to include in the request.
Use this field for provider-specific parameters.
tools: The tools to use for this specific run (merged with default tools).
options: A TypedDict containing chat options. When using a typed agent like
``ChatAgent[OpenAIChatOptions]``, this enables IDE autocomplete for
provider-specific options including temperature, max_tokens, model_id,
tool_choice, and provider-specific options like reasoning_effort.
kwargs: Additional keyword arguments for the agent.
Will only be passed to functions that are called.
Returns:
An AgentRunResponse containing the agent's response.
"""
# Build options dict from provided options
opts = dict(options) if options else {}
# Get tools from options or named parameter (named param takes precedence)
tools_ = tools if tools is not None else opts.pop("tools", None)
input_messages = self._normalize_messages(messages)
thread, run_chat_options, thread_messages = await self._prepare_thread_and_messages(
thread=thread, input_messages=input_messages, **kwargs
)
normalized_tools: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = ( # type:ignore[reportUnknownVariableType]
[] if tools is None else tools if isinstance(tools, list) else [tools]
[] if tools_ is None else tools_ if isinstance(tools_, list) else [tools_]
)
agent_name = self._get_agent_name()
@@ -804,27 +822,30 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
await self._async_exit_stack.enter_async_context(mcp_server)
final_tools.extend(mcp_server.functions)
merged_additional_options = additional_chat_options or {}
co = run_chat_options & ChatOptions(
model_id=model_id,
conversation_id=thread.service_thread_id,
allow_multiple_tool_calls=allow_multiple_tool_calls,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=final_tools,
top_p=top_p,
user=user,
additional_properties=merged_additional_options, # type: ignore[arg-type]
)
# Build options dict from run() options merged with provided options
run_opts: dict[str, Any] = {
"model_id": opts.pop("model_id", None),
"conversation_id": thread.service_thread_id,
"allow_multiple_tool_calls": opts.pop("allow_multiple_tool_calls", None),
"frequency_penalty": opts.pop("frequency_penalty", None),
"logit_bias": opts.pop("logit_bias", None),
"max_tokens": opts.pop("max_tokens", None),
"metadata": opts.pop("metadata", None),
"presence_penalty": opts.pop("presence_penalty", None),
"response_format": opts.pop("response_format", None),
"seed": opts.pop("seed", None),
"stop": opts.pop("stop", None),
"store": opts.pop("store", None),
"temperature": opts.pop("temperature", None),
"tool_choice": opts.pop("tool_choice", None),
"tools": final_tools,
"top_p": opts.pop("top_p", None),
"user": opts.pop("user", None),
**opts, # Remaining options are provider-specific
}
# Remove None values and merge with chat_options
run_opts = {k: v for k, v in run_opts.items() if v is not None}
co = _merge_options(run_chat_options, run_opts)
# Ensure thread is forwarded in kwargs for tool invocation
kwargs["thread"] = thread
@@ -832,7 +853,7 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "chat_options"}
response = await self.chat_client.get_response(
messages=thread_messages,
chat_options=co,
options=co, # type: ignore[arg-type]
**filtered_kwargs,
)
@@ -863,30 +884,15 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
async def run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
allow_multiple_tool_calls: bool | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model_id: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_chat_options: dict[str, Any] | None = None,
options: TOptions_co | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentRunResponseUpdate]:
"""Stream the agent with the given messages and options.
@@ -902,30 +908,23 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
Keyword Args:
thread: The thread to use for the agent.
allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: Additional metadata to include in the request.
model_id: The model_id to use for the agent.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_chat_options: Additional properties to include in the request.
Use this field for provider-specific parameters.
kwargs: Any additional keyword arguments.
tools: The tools to use for this specific run (merged with agent-level tools).
options: A TypedDict containing chat options. When using a typed agent like
``ChatAgent[OpenAIChatOptions]``, this enables IDE autocomplete for
provider-specific options including temperature, max_tokens, model_id,
tool_choice, and provider-specific options like reasoning_effort.
kwargs: Additional keyword arguments for the agent.
Will only be passed to functions that are called.
Yields:
AgentRunResponseUpdate objects containing chunks of the agent's response.
"""
# Build options dict from provided options
opts = dict(options) if options else {}
# Get tools from options or named parameter (named param takes precedence)
tools_ = tools if tools is not None else opts.pop("tools", None)
input_messages = self._normalize_messages(messages)
thread, run_chat_options, thread_messages = await self._prepare_thread_and_messages(
thread=thread, input_messages=input_messages, **kwargs
@@ -934,7 +933,7 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
# Resolve final tool list (runtime provided tools + local MCP server tools)
final_tools: list[ToolProtocol | MutableMapping[str, Any] | Callable[..., Any]] = []
normalized_tools: list[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] = ( # type: ignore[reportUnknownVariableType]
[] if tools is None else tools if isinstance(tools, list) else [tools]
[] if tools_ is None else tools_ if isinstance(tools_, list) else [tools_]
)
# Normalize tools argument to a list without mutating the original parameter
for tool in normalized_tools:
@@ -950,27 +949,30 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
await self._async_exit_stack.enter_async_context(mcp_server)
final_tools.extend(mcp_server.functions)
merged_additional_options = additional_chat_options or {}
co = run_chat_options & ChatOptions(
conversation_id=thread.service_thread_id,
allow_multiple_tool_calls=allow_multiple_tool_calls,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
model_id=model_id,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=final_tools,
top_p=top_p,
user=user,
additional_properties=merged_additional_options, # type: ignore[arg-type]
)
# Build options dict from run_stream() options merged with provided options
run_opts: dict[str, Any] = {
"model_id": opts.pop("model_id", None),
"conversation_id": thread.service_thread_id,
"allow_multiple_tool_calls": opts.pop("allow_multiple_tool_calls", None),
"frequency_penalty": opts.pop("frequency_penalty", None),
"logit_bias": opts.pop("logit_bias", None),
"max_tokens": opts.pop("max_tokens", None),
"metadata": opts.pop("metadata", None),
"presence_penalty": opts.pop("presence_penalty", None),
"response_format": opts.pop("response_format", None),
"seed": opts.pop("seed", None),
"stop": opts.pop("stop", None),
"store": opts.pop("store", None),
"temperature": opts.pop("temperature", None),
"tool_choice": opts.pop("tool_choice", None),
"tools": final_tools,
"top_p": opts.pop("top_p", None),
"user": opts.pop("user", None),
**opts, # Remaining options are provider-specific
}
# Remove None values and merge with chat_options
run_opts = {k: v for k, v in run_opts.items() if v is not None}
co = _merge_options(run_chat_options, run_opts)
# Ensure thread is forwarded in kwargs for tool invocation
kwargs["thread"] = thread
@@ -979,7 +981,7 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
response_updates: list[ChatResponseUpdate] = []
async for update in self.chat_client.get_streaming_response(
messages=thread_messages,
chat_options=co,
options=co, # type: ignore[arg-type]
**filtered_kwargs,
):
response_updates.append(update)
@@ -998,7 +1000,9 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
raw_representation=update,
)
response = ChatResponse.from_chat_response_updates(response_updates, output_format_type=co.response_format)
response = ChatResponse.from_chat_response_updates(
response_updates, output_format_type=co.get("response_format")
)
await self._update_thread_with_type_and_conversation_id(thread, response.conversation_id)
await self._notify_thread_of_new_messages(
@@ -1043,9 +1047,9 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
service_thread_id=service_thread_id,
context_provider=self.context_provider,
)
if self.chat_options.conversation_id is not None:
if self.default_options.get("conversation_id") is not None:
return AgentThread(
service_thread_id=self.chat_options.conversation_id,
service_thread_id=self.default_options["conversation_id"],
context_provider=self.context_provider,
)
if self.chat_message_store_factory is not None:
@@ -1202,7 +1206,7 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
thread: AgentThread | None,
input_messages: list[ChatMessage] | None = None,
**kwargs: Any,
) -> tuple[AgentThread, ChatOptions, list[ChatMessage]]:
) -> tuple[AgentThread, dict[str, Any], list[ChatMessage]]:
"""Prepare the thread and messages for agent execution.
This method prepares the conversation thread, merges context provider data,
@@ -1222,7 +1226,7 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
Raises:
AgentExecutionException: If the conversation IDs on the thread and agent don't match.
"""
chat_options = deepcopy(self.chat_options) if self.chat_options else ChatOptions()
chat_options = deepcopy(self.default_options) if self.default_options else {}
thread = thread or self.get_new_thread()
if thread.service_thread_id and thread.context_provider:
await thread.context_provider.thread_created(thread.service_thread_id)
@@ -1239,21 +1243,21 @@ class ChatAgent(BaseAgent): # type: ignore[misc]
if context.messages:
thread_messages.extend(context.messages)
if context.tools:
if chat_options.tools is not None:
chat_options.tools.extend(context.tools)
if chat_options.get("tools") is not None:
chat_options["tools"].extend(context.tools)
else:
chat_options.tools = list(context.tools)
chat_options["tools"] = list(context.tools)
if context.instructions:
chat_options.instructions = (
chat_options["instructions"] = (
context.instructions
if not chat_options.instructions
else f"{chat_options.instructions}\n{context.instructions}"
if not chat_options.get("instructions")
else f"{chat_options['instructions']}\n{context.instructions}"
)
thread_messages.extend(input_messages or [])
if (
thread.service_thread_id
and chat_options.conversation_id
and thread.service_thread_id != chat_options.conversation_id
and chat_options.get("conversation_id")
and thread.service_thread_id != chat_options["conversation_id"]
):
raise AgentExecutionException(
"The conversation_id set on the agent is different from the one set on the thread, "
+112 -490
View File
@@ -1,11 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import sys
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable, Callable, MutableMapping, MutableSequence, Sequence
from typing import TYPE_CHECKING, Any, ClassVar, Literal, Protocol, TypeVar, runtime_checkable
from pydantic import BaseModel
from collections.abc import (
AsyncIterable,
Callable,
MutableMapping,
MutableSequence,
Sequence,
)
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Generic,
Protocol,
TypedDict,
runtime_checkable,
)
from ._logging import get_logger
from ._memory import ContextProvider
@@ -18,11 +31,27 @@ from ._middleware import (
)
from ._serialization import SerializationMixin
from ._threads import ChatMessageStoreProtocol
from ._tools import FUNCTION_INVOKING_CHAT_CLIENT_MARKER, FunctionInvocationConfiguration, ToolProtocol
from ._types import ChatMessage, ChatOptions, ChatResponse, ChatResponseUpdate, ToolMode, prepare_messages
from ._tools import (
FUNCTION_INVOKING_CHAT_CLIENT_MARKER,
FunctionInvocationConfiguration,
ToolProtocol,
)
from ._types import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
prepare_messages,
validate_chat_options,
)
if sys.version_info >= (3, 13):
from typing import TypeVar
else:
from typing_extensions import TypeVar
if TYPE_CHECKING:
from ._agents import ChatAgent
from ._types import ChatOptions
TInput = TypeVar("TInput", contravariant=True)
@@ -39,14 +68,26 @@ __all__ = [
# region ChatClientProtocol Protocol
# Contravariant for the Protocol
TOptions_contra = TypeVar(
"TOptions_contra",
bound=TypedDict, # type: ignore[valid-type]
default="ChatOptions",
contravariant=True,
)
@runtime_checkable
class ChatClientProtocol(Protocol):
class ChatClientProtocol(Protocol[TOptions_contra]): #
"""A protocol for a chat client that can generate responses.
This protocol defines the interface that all chat clients must implement,
including methods for generating both streaming and non-streaming responses.
The generic type parameter TOptions specifies which options TypedDict this
client accepts, enabling IDE autocomplete and type checking for provider-specific
options.
Note:
Protocols use structural subtyping (duck typing). Classes don't need
to explicitly inherit from this protocol to be considered compatible.
@@ -59,10 +100,6 @@ class ChatClientProtocol(Protocol):
# Any class implementing the required methods is compatible
class CustomChatClient:
@property
def additional_properties(self) -> dict[str, Any]:
return {}
async def get_response(self, messages, **kwargs):
# Your custom implementation
return ChatResponse(messages=[], response_id="custom")
@@ -81,61 +118,21 @@ class ChatClientProtocol(Protocol):
assert isinstance(client, ChatClientProtocol)
"""
@property
def additional_properties(self) -> dict[str, Any]:
"""Get additional properties associated with the client."""
...
additional_properties: dict[str, Any]
async def get_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
messages: str | ChatMessage | Sequence[str | ChatMessage],
*,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model_id: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
options: TOptions_contra | None = None,
**kwargs: Any,
) -> ChatResponse:
"""Send input and return the response.
Args:
messages: The sequence of input messages to send.
Keyword Args:
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: Additional metadata to include in the request.
model_id: The model_id to use for the agent.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_properties: Additional properties to include in the request.
kwargs: Any additional keyword arguments.
Will only be passed to functions that are called.
options: Chat options as a TypedDict.
**kwargs: Additional chat options.
Returns:
The response messages generated by the client.
@@ -147,155 +144,48 @@ class ChatClientProtocol(Protocol):
def get_streaming_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
messages: str | ChatMessage | Sequence[str | ChatMessage],
*,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model_id: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
options: TOptions_contra | None = None,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Send input messages and stream the response.
Args:
messages: The sequence of input messages to send.
Keyword Args:
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: Additional metadata to include in the request.
model_id: The model_id to use for the agent.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_properties: Additional properties to include in the request.
kwargs: Any additional keyword arguments.
Will only be passed to functions that are called.
options: Chat options as a TypedDict.
**kwargs: Additional chat options.
Yields:
ChatResponseUpdate: An async iterable of chat response updates containing
the content of the response messages generated by the client.
Raises:
ValueError: If the input message sequence is ``None``.
ChatResponseUpdate: Partial response updates as they're generated.
"""
...
# endregion
# region ChatClientBase
def _merge_chat_options(
*,
base_chat_options: ChatOptions | Any | None,
model_id: str | None = None,
allow_multiple_tool_calls: bool | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: list[ToolProtocol | dict[str, Any] | Callable[..., Any]] | None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
) -> ChatOptions:
"""Merge base chat options with direct parameters to create a new ChatOptions instance.
When both base_chat_options and individual parameters are provided, the individual
parameters take precedence and override the corresponding values in base_chat_options.
Tools from both sources are combined into a single list.
Keyword Args:
base_chat_options: Optional base ChatOptions to merge with direct parameters.
model_id: The model_id to use for the agent.
allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: Additional metadata to include in the request.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The normalized tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_properties: Additional properties to include in the request.
Returns:
A new ChatOptions instance with merged values.
Raises:
TypeError: If base_chat_options is not None and not an instance of ChatOptions.
"""
# Validate base_chat_options type if provided
if base_chat_options is not None and not isinstance(base_chat_options, ChatOptions):
raise TypeError("chat_options must be an instance of ChatOptions")
if base_chat_options is None:
base_chat_options = ChatOptions()
return base_chat_options & ChatOptions(
model_id=model_id,
allow_multiple_tool_calls=allow_multiple_tool_calls,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
top_p=top_p,
tool_choice=tool_choice,
tools=tools,
user=user,
additional_properties=additional_properties,
)
# Covariant for the BaseChatClient
TOptions_co = TypeVar(
"TOptions_co",
bound=TypedDict, # type: ignore[valid-type]
default="ChatOptions",
covariant=True,
)
class BaseChatClient(SerializationMixin, ABC):
class BaseChatClient(SerializationMixin, ABC, Generic[TOptions_co]):
"""Base class for chat clients.
This abstract base class provides core functionality for chat client implementations,
including middleware support, message preparation, and tool normalization.
The generic type parameter TOptions specifies which options TypedDict this client
accepts. This enables IDE autocomplete and type checking for provider-specific options
when using the typed overloads of get_response and get_streaming_response.
Note:
BaseChatClient cannot be instantiated directly as it's an abstract base class.
Subclasses must implement ``_inner_get_response()`` and ``_inner_get_streaming_response()``.
@@ -308,13 +198,13 @@ class BaseChatClient(SerializationMixin, ABC):
class CustomChatClient(BaseChatClient):
async def _inner_get_response(self, *, messages, chat_options, **kwargs):
async def _inner_get_response(self, *, messages, options, **kwargs):
# Your custom implementation
return ChatResponse(
messages=[ChatMessage(role="assistant", text="Hello!")], response_id="custom-response"
)
async def _inner_get_streaming_response(self, *, messages, chat_options, **kwargs):
async def _inner_get_streaming_response(self, *, messages, options, **kwargs):
# Your custom streaming implementation
from agent_framework import ChatResponseUpdate
@@ -379,57 +269,6 @@ class BaseChatClient(SerializationMixin, ABC):
return result
def _filter_internal_kwargs(self, kwargs: dict[str, Any]) -> dict[str, Any]:
"""Filter out internal framework parameters that shouldn't be passed to chat client implementations.
Keyword Args:
kwargs: The original kwargs dictionary.
Returns:
A filtered kwargs dictionary without internal parameters.
"""
return {k: v for k, v in kwargs.items() if not k.startswith("_")}
@staticmethod
async def _normalize_tools(
tools: ToolProtocol
| MutableMapping[str, Any]
| Callable[..., Any]
| Sequence[ToolProtocol | MutableMapping[str, Any] | Callable[..., Any]]
| None = None,
) -> list[ToolProtocol | dict[str, Any] | Callable[..., Any]]:
"""Normalize tools input to a consistent list format.
Expands MCP tools to their constituent functions, connecting them if needed.
Args:
tools: The tools in various supported formats.
Returns:
A normalized list of tools.
"""
from typing import cast
final_tools: list[ToolProtocol | dict[str, Any] | Callable[..., Any]] = []
if not tools:
return final_tools
# Use cast when a sequence is passed (likely already a list)
tools_list = (
cast(list[ToolProtocol | MutableMapping[str, Any] | Callable[..., Any]], tools)
if isinstance(tools, Sequence) and not isinstance(tools, (str, bytes))
else [tools]
)
for tool in tools_list: # type: ignore[reportUnknownType]
from ._mcp import MCPTool
if isinstance(tool, MCPTool):
if not tool.is_connected:
await tool.connect()
final_tools.extend(tool.functions) # type: ignore
continue
final_tools.append(tool) # type: ignore
return final_tools
# region Internal methods to be implemented by the derived classes
@abstractmethod
@@ -437,14 +276,14 @@ class BaseChatClient(SerializationMixin, ABC):
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
"""Send a chat request to the AI service.
Keyword Args:
messages: The chat messages to send.
chat_options: The options for the request.
options: The options dict for the request.
kwargs: Any additional keyword arguments.
Returns:
@@ -456,14 +295,14 @@ class BaseChatClient(SerializationMixin, ABC):
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Send a streaming chat request to the AI service.
Keyword Args:
messages: The chat messages to send.
chat_options: The chat_options for the request.
options: The options dict for the request.
kwargs: Any additional keyword arguments.
Yields:
@@ -482,222 +321,51 @@ class BaseChatClient(SerializationMixin, ABC):
async def get_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
messages: str | ChatMessage | Sequence[str | ChatMessage],
*,
allow_multiple_tool_calls: bool | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model_id: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
options: TOptions_co | None = None,
**kwargs: Any,
) -> ChatResponse:
"""Get a response from a chat client.
When both ``chat_options`` (in kwargs) and individual parameters are provided,
the individual parameters take precedence and override the corresponding values
in ``chat_options``. Tools from both sources are combined into a single list.
Args:
messages: The message or messages to send to the model.
Keyword Args:
allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: Additional metadata to include in the request.
model_id: The model_id to use for the agent.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
Default is `auto`.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_properties: Additional properties to include in the request.
Can be used for provider-specific parameters.
kwargs: Any additional keyword arguments.
May include ``chat_options`` which provides base values that can be overridden by direct parameters.
options: Chat options as a TypedDict.
**kwargs: Other keyword arguments, can be used to pass function specific parameters.
Returns:
A chat response from the model_id.
A chat response from the model.
"""
# Normalize tools and merge with base chat_options
normalized_tools = await self._normalize_tools(tools)
chat_options = _merge_chat_options(
base_chat_options=kwargs.pop("chat_options", None),
model_id=model_id,
allow_multiple_tool_calls=allow_multiple_tool_calls,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=normalized_tools,
top_p=top_p,
user=user,
additional_properties=additional_properties,
return await self._inner_get_response(
messages=prepare_messages(messages),
options=await validate_chat_options(dict(options) if options else {}),
**kwargs,
)
if chat_options.instructions:
system_msg = ChatMessage(role="system", text=chat_options.instructions)
prepped_messages = [system_msg, *prepare_messages(messages)]
else:
prepped_messages = prepare_messages(messages)
self._prepare_tool_choice(chat_options=chat_options)
filtered_kwargs = self._filter_internal_kwargs(kwargs)
return await self._inner_get_response(messages=prepped_messages, chat_options=chat_options, **filtered_kwargs)
async def get_streaming_response(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
messages: str | ChatMessage | Sequence[str | ChatMessage],
*,
allow_multiple_tool_calls: bool | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model_id: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = None,
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: dict[str, Any] | None = None,
options: TOptions_co | None = None,
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
"""Get a streaming response from a chat client.
When both ``chat_options`` (in kwargs) and individual parameters are provided,
the individual parameters take precedence and override the corresponding values
in ``chat_options``. Tools from both sources are combined into a single list.
Args:
messages: The message or messages to send to the model.
Keyword Args:
allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: Additional metadata to include in the request.
model_id: The model_id to use for the agent.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
Default is `auto`.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_properties: Additional properties to include in the request.
Can be used for provider-specific parameters.
kwargs: Any additional keyword arguments.
May include ``chat_options`` which provides base values that can be overridden by direct parameters.
options: Chat options as a TypedDict.
**kwargs: Other keyword arguments, can be used to pass function specific parameters.
Yields:
ChatResponseUpdate: A stream representing the response(s) from the LLM.
"""
# Normalize tools and merge with base chat_options
normalized_tools = await self._normalize_tools(tools)
chat_options = _merge_chat_options(
base_chat_options=kwargs.pop("chat_options", None),
model_id=model_id,
allow_multiple_tool_calls=allow_multiple_tool_calls,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=normalized_tools,
top_p=top_p,
user=user,
additional_properties=additional_properties,
)
if chat_options.instructions:
system_msg = ChatMessage(role="system", text=chat_options.instructions)
prepped_messages = [system_msg, *prepare_messages(messages)]
else:
prepped_messages = prepare_messages(messages)
self._prepare_tool_choice(chat_options=chat_options)
filtered_kwargs = self._filter_internal_kwargs(kwargs)
async for update in self._inner_get_streaming_response(
messages=prepped_messages, chat_options=chat_options, **filtered_kwargs
messages=prepare_messages(messages),
options=await validate_chat_options(dict(options) if options else {}),
**kwargs,
):
yield update
def _prepare_tool_choice(self, chat_options: ChatOptions) -> None:
"""Prepare the tools and tool choice for the chat options.
This function should be overridden by subclasses to customize tool handling,
as it currently parses only AIFunctions.
Args:
chat_options: The chat options to prepare.
"""
chat_tool_mode = chat_options.tool_choice
# Explicitly disabled: clear tools and set to NONE
if chat_tool_mode == ToolMode.NONE or chat_tool_mode == "none":
chat_options.tools = None
chat_options.tool_choice = ToolMode.NONE
return
# No tools available: set to NONE regardless of requested mode
if not chat_options.tools:
chat_options.tool_choice = ToolMode.NONE
# Tools available but no explicit mode: default to AUTO
elif chat_tool_mode is None:
chat_options.tool_choice = ToolMode.AUTO
# Tools available with explicit mode: preserve the mode
else:
chat_options.tool_choice = chat_tool_mode
def service_url(self) -> str:
"""Get the URL of the service.
@@ -716,33 +384,17 @@ class BaseChatClient(SerializationMixin, ABC):
name: str | None = None,
description: str | None = None,
instructions: str | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_provider: ContextProvider | None = None,
middleware: Sequence[Middleware] | None = None,
allow_multiple_tool_calls: bool | None = None,
conversation_id: str | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: dict[str, Any] | None = None,
model_id: str | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | dict[str, Any] | None = "auto",
tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_chat_options: dict[str, Any] | None = None,
default_options: TOptions_co | None = None,
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_provider: ContextProvider | None = None,
middleware: Sequence[Middleware] | None = None,
**kwargs: Any,
) -> "ChatAgent":
) -> "ChatAgent[TOptions_co]":
"""Create a ChatAgent with this client.
This is a convenience method that creates a ChatAgent instance with this
@@ -754,30 +406,14 @@ class BaseChatClient(SerializationMixin, ABC):
description: A brief description of the agent's purpose.
instructions: Optional instructions for the agent.
These will be put into the messages sent to the chat client service as a system message.
tools: The tools to use for the request.
default_options: A TypedDict containing chat options. When using a typed client like
``OpenAIChatClient``, this enables IDE autocomplete for provider-specific options
including temperature, max_tokens, model_id, tool_choice, and more.
chat_message_store_factory: Factory function to create an instance of ChatMessageStoreProtocol.
If not provided, the default in-memory store will be used.
context_provider: Context provider to include during agent invocation.
middleware: List of middleware to intercept chat and function invocations.
allow_multiple_tool_calls: Whether to allow multiple tool calls per agent turn.
conversation_id: The conversation ID to associate with the agent's messages.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
metadata: Additional metadata to include in the request.
model_id: The model_id to use for the agent.
presence_penalty: The presence penalty to use.
response_format: The format of the response.
seed: The random seed to use.
stop: The stop sequence(s) for the request.
store: Whether to store the response.
temperature: The sampling temperature to use.
tool_choice: The tool choice for the request.
tools: The tools to use for the request.
top_p: The nucleus sampling probability to use.
user: The user to associate with the request.
additional_chat_options: A dictionary of other values that will be passed through
to the chat_client ``get_response`` and ``get_streaming_response`` methods.
This can be used to pass provider specific parameters.
context_provider: Context providers to include during agent invocation.
middleware: List of middleware to intercept agent and function invocations.
kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``.
Returns:
@@ -786,14 +422,16 @@ class BaseChatClient(SerializationMixin, ABC):
Examples:
.. code-block:: python
from agent_framework.clients import OpenAIChatClient
from agent_framework.openai import OpenAIChatClient
# Create a client
client = OpenAIChatClient(model_id="gpt-4")
# Create an agent using the convenience method
agent = client.create_agent(
name="assistant", instructions="You are a helpful assistant.", temperature=0.7
name="assistant",
instructions="You are a helpful assistant.",
default_options={"temperature": 0.7, "max_tokens": 500},
)
# Run the agent
@@ -807,26 +445,10 @@ class BaseChatClient(SerializationMixin, ABC):
name=name,
description=description,
instructions=instructions,
tools=tools,
default_options=default_options,
chat_message_store_factory=chat_message_store_factory,
context_provider=context_provider,
middleware=middleware,
allow_multiple_tool_calls=allow_multiple_tool_calls,
conversation_id=conversation_id,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
metadata=metadata,
model_id=model_id,
presence_penalty=presence_penalty,
response_format=response_format,
seed=seed,
stop=stop,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=tools,
top_p=top_p,
user=user,
additional_chat_options=additional_chat_options,
**kwargs,
)
@@ -2,7 +2,7 @@
import inspect
from abc import ABC, abstractmethod
from collections.abc import AsyncIterable, Awaitable, Callable, MutableSequence, Sequence
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableSequence, Sequence
from enum import Enum
from functools import update_wrapper
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeAlias, TypedDict, TypeVar
@@ -18,7 +18,7 @@ if TYPE_CHECKING:
from ._clients import ChatClientProtocol
from ._threads import AgentThread
from ._tools import AIFunction
from ._types import ChatOptions, ChatResponse, ChatResponseUpdate
from ._types import ChatResponse, ChatResponseUpdate
__all__ = [
@@ -38,7 +38,7 @@ __all__ = [
]
TAgent = TypeVar("TAgent", bound="AgentProtocol")
TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol")
TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol[Any]")
TContext = TypeVar("TContext")
@@ -206,7 +206,7 @@ class ChatContext(SerializationMixin):
Attributes:
chat_client: The chat client being invoked.
messages: The messages being sent to the chat client.
chat_options: The options for the chat request.
options: The options for the chat request as a dict.
is_streaming: Whether this is a streaming invocation.
metadata: Metadata dictionary for sharing data between chat middleware.
result: Chat execution result. Can be observed after calling ``next()``
@@ -227,7 +227,7 @@ class ChatContext(SerializationMixin):
async def process(self, context: ChatContext, next):
print(f"Chat client: {context.chat_client.__class__.__name__}")
print(f"Messages: {len(context.messages)}")
print(f"Model: {context.chat_options.model_id}")
print(f"Model: {context.options.get('model_id')}")
# Store metadata
context.metadata["input_tokens"] = self.count_tokens(context.messages)
@@ -246,7 +246,7 @@ class ChatContext(SerializationMixin):
self,
chat_client: "ChatClientProtocol",
messages: "MutableSequence[ChatMessage]",
chat_options: "ChatOptions",
options: Mapping[str, Any] | None,
is_streaming: bool = False,
metadata: dict[str, Any] | None = None,
result: "ChatResponse | AsyncIterable[ChatResponseUpdate] | None" = None,
@@ -258,7 +258,7 @@ class ChatContext(SerializationMixin):
Args:
chat_client: The chat client being invoked.
messages: The messages being sent to the chat client.
chat_options: The options for the chat request.
options: The options for the chat request as a dict.
is_streaming: Whether this is a streaming invocation.
metadata: Metadata dictionary for sharing data between chat middleware.
result: Chat execution result.
@@ -267,7 +267,7 @@ class ChatContext(SerializationMixin):
"""
self.chat_client = chat_client
self.messages = messages
self.chat_options = chat_options
self.options = options
self.is_streaming = is_streaming
self.metadata = metadata if metadata is not None else {}
self.result = result
@@ -974,7 +974,7 @@ class ChatMiddlewarePipeline(BaseMiddlewarePipeline):
self,
chat_client: "ChatClientProtocol",
messages: "MutableSequence[ChatMessage]",
chat_options: "ChatOptions",
options: Mapping[str, Any] | None,
context: ChatContext,
final_handler: Callable[[ChatContext], Awaitable["ChatResponse"]],
**kwargs: Any,
@@ -984,7 +984,7 @@ class ChatMiddlewarePipeline(BaseMiddlewarePipeline):
Args:
chat_client: The chat client being invoked.
messages: The messages being sent to the chat client.
chat_options: The options for the chat request.
options: The options for the chat request as a dict.
context: The chat invocation context.
final_handler: The final handler that performs the actual chat execution.
**kwargs: Additional keyword arguments.
@@ -995,7 +995,8 @@ class ChatMiddlewarePipeline(BaseMiddlewarePipeline):
# Update context with chat client, messages, and options
context.chat_client = chat_client
context.messages = messages
context.chat_options = chat_options
if options:
context.options = options
if not self._middleware:
return await final_handler(context)
@@ -1023,7 +1024,7 @@ class ChatMiddlewarePipeline(BaseMiddlewarePipeline):
self,
chat_client: "ChatClientProtocol",
messages: "MutableSequence[ChatMessage]",
chat_options: "ChatOptions",
options: Mapping[str, Any] | None,
context: ChatContext,
final_handler: Callable[[ChatContext], AsyncIterable["ChatResponseUpdate"]],
**kwargs: Any,
@@ -1033,7 +1034,7 @@ class ChatMiddlewarePipeline(BaseMiddlewarePipeline):
Args:
chat_client: The chat client being invoked.
messages: The messages being sent to the chat client.
chat_options: The options for the chat request.
options: The options for the chat request as a dict.
context: The chat invocation context.
final_handler: The final handler that performs the actual streaming chat execution.
**kwargs: Additional keyword arguments.
@@ -1044,7 +1045,8 @@ class ChatMiddlewarePipeline(BaseMiddlewarePipeline):
# Update context with chat client, messages, and options
context.chat_client = chat_client
context.messages = messages
context.chat_options = chat_options
if options:
context.options = options
context.is_streaming = True
if not self._middleware:
@@ -1346,6 +1348,8 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien
async def middleware_enabled_get_response(
self: Any,
messages: Any,
*,
options: Mapping[str, Any] | None = None,
**kwargs: Any,
) -> Any:
"""Middleware-enabled get_response method."""
@@ -1366,30 +1370,35 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien
# If no chat middleware, use original method
if not chat_middleware_list:
return await original_get_response(self, messages, **kwargs)
return await original_get_response(
self,
messages,
options=options, # type: ignore[arg-type]
**kwargs,
)
# Create pipeline and execute with middleware
from ._types import ChatOptions
# Extract chat_options or create default
chat_options = kwargs.pop("chat_options", ChatOptions())
pipeline = ChatMiddlewarePipeline(chat_middleware_list) # type: ignore[arg-type]
context = ChatContext(
chat_client=self,
messages=prepare_messages(messages),
chat_options=chat_options,
options=options,
is_streaming=False,
kwargs=kwargs,
)
async def final_handler(ctx: ChatContext) -> Any:
return await original_get_response(self, list(ctx.messages), chat_options=ctx.chat_options, **ctx.kwargs)
return await original_get_response(
self,
list(ctx.messages),
options=ctx.options, # type: ignore[arg-type]
**ctx.kwargs,
)
return await pipeline.execute(
chat_client=self,
messages=context.messages,
chat_options=context.chat_options,
options=options,
context=context,
final_handler=final_handler,
**kwargs,
@@ -1398,6 +1407,8 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien
def middleware_enabled_get_streaming_response(
self: Any,
messages: Any,
*,
options: dict[str, Any] | None = None,
**kwargs: Any,
) -> Any:
"""Middleware-enabled get_streaming_response method."""
@@ -1418,34 +1429,37 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien
# If no chat middleware, use original method
if not chat_middleware_list:
async for update in original_get_streaming_response(self, messages, **kwargs):
async for update in original_get_streaming_response(
self,
messages,
options=options, # type: ignore[arg-type]
**kwargs,
):
yield update
return
# Create pipeline and execute with middleware
from ._types import ChatOptions
# Extract chat_options or create default
chat_options = kwargs.pop("chat_options", ChatOptions())
pipeline = ChatMiddlewarePipeline(chat_middleware_list) # type: ignore[arg-type]
context = ChatContext(
chat_client=self,
messages=prepare_messages(messages),
chat_options=chat_options,
options=options or {},
is_streaming=True,
kwargs=kwargs,
)
def final_handler(ctx: ChatContext) -> Any:
return original_get_streaming_response(
self, list(ctx.messages), chat_options=ctx.chat_options, **ctx.kwargs
self,
list(ctx.messages),
options=ctx.options, # type: ignore[arg-type]
**ctx.kwargs,
)
async for update in pipeline.execute_stream(
chat_client=self,
messages=context.messages,
chat_options=context.chat_options,
options=options or {},
context=context,
final_handler=final_handler,
**kwargs,
+33 -33
View File
@@ -59,21 +59,12 @@ if TYPE_CHECKING:
FunctionCallContent,
)
if sys.version_info >= (3, 12):
from typing import (
TypedDict, # pragma: no cover
override, # type: ignore # pragma: no cover
)
else:
from typing_extensions import (
TypedDict, # pragma: no cover
override, # type: ignore[import] # pragma: no cover
)
from typing import overload
if sys.version_info >= (3, 11):
from typing import overload # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import overload # pragma: no cover
from typing_extensions import override # type: ignore[import] # pragma: no cover
logger = get_logger()
@@ -97,7 +88,7 @@ logger = get_logger()
FUNCTION_INVOKING_CHAT_CLIENT_MARKER: Final[str] = "__function_invoking_chat_client__"
DEFAULT_MAX_ITERATIONS: Final[int] = 40
DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST: Final[int] = 3
TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol")
TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol[Any]")
# region Helpers
ArgsT = TypeVar("ArgsT", bound=BaseModel)
@@ -1764,19 +1755,19 @@ def _update_conversation_id(kwargs: dict[str, Any], conversation_id: str | None)
kwargs["conversation_id"] = conversation_id
def _extract_tools(kwargs: dict[str, Any]) -> Any:
"""Extract tools from kwargs or chat_options.
def _extract_tools(options: dict[str, Any] | None) -> Any:
"""Extract tools from options dict.
Args:
options: The options dict containing chat options.
Returns:
ToolProtocol | Callable[..., Any] | MutableMapping[str, Any] |
Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None
"""
from ._types import ChatOptions
tools = kwargs.get("tools")
if not tools and (chat_options := kwargs.get("chat_options")) and isinstance(chat_options, ChatOptions):
tools = chat_options.tools
return tools
if options and isinstance(options, dict):
return options.get("tools")
return None
def _collect_approval_responses(
@@ -1869,6 +1860,8 @@ def _handle_function_calls_response(
async def function_invocation_wrapper(
self: "ChatClientProtocol",
messages: "str | ChatMessage | list[str] | list[ChatMessage]",
*,
options: dict[str, Any] | None = None,
**kwargs: Any,
) -> "ChatResponse":
from ._middleware import extract_and_merge_function_middleware
@@ -1897,7 +1890,7 @@ def _handle_function_calls_response(
for attempt_idx in range(config.max_iterations if config.enabled else 0):
fcc_todo = _collect_approval_responses(prepped_messages)
if fcc_todo:
tools = _extract_tools(kwargs)
tools = _extract_tools(options)
# Only execute APPROVED function calls, not rejected ones
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
approved_function_results: list[Contents] = []
@@ -1929,8 +1922,9 @@ def _handle_function_calls_response(
_replace_approval_contents_with_results(prepped_messages, fcc_todo, approved_function_results)
# Filter out internal framework kwargs before passing to clients.
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
response = await func(self, messages=prepped_messages, **filtered_kwargs)
# Also exclude tools and tool_choice since they are now in options dict.
filtered_kwargs = {k: v for k, v in kwargs.items() if k not in ("thread", "tools", "tool_choice")}
response = await func(self, messages=prepped_messages, options=options, **filtered_kwargs)
# if there are function calls, we will handle them first
function_results = {
it.call_id for it in response.messages[0].contents if isinstance(it, FunctionResultContent)
@@ -1946,7 +1940,7 @@ def _handle_function_calls_response(
prepped_messages = []
# we load the tools here, since middleware might have changed them compared to before calling func.
tools = _extract_tools(kwargs)
tools = _extract_tools(options)
if function_calls and tools:
# Use the stored middleware pipeline instead of extracting from kwargs
# because kwargs may have been modified by the underlying function
@@ -2029,11 +2023,13 @@ def _handle_function_calls_response(
return response
# Failsafe: give up on tools, ask model for plain answer
kwargs["tool_choice"] = "none"
if options is None:
options = {}
options["tool_choice"] = "none"
# Filter out internal framework kwargs before passing to clients.
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
response = await func(self, messages=prepped_messages, **filtered_kwargs)
response = await func(self, messages=prepped_messages, options=options, **filtered_kwargs)
if fcc_messages:
for msg in reversed(fcc_messages):
response.messages.insert(0, msg)
@@ -2065,6 +2061,8 @@ def _handle_function_calls_streaming_response(
async def streaming_function_invocation_wrapper(
self: "ChatClientProtocol",
messages: "str | ChatMessage | list[str] | list[ChatMessage]",
*,
options: dict[str, Any] | None = None,
**kwargs: Any,
) -> AsyncIterable["ChatResponseUpdate"]:
"""Wrap the inner get streaming response method to handle tool calls."""
@@ -2093,7 +2091,7 @@ def _handle_function_calls_streaming_response(
for attempt_idx in range(config.max_iterations if config.enabled else 0):
fcc_todo = _collect_approval_responses(prepped_messages)
if fcc_todo:
tools = _extract_tools(kwargs)
tools = _extract_tools(options)
# Only execute APPROVED function calls, not rejected ones
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
approved_function_results: list[Contents] = []
@@ -2119,7 +2117,7 @@ def _handle_function_calls_streaming_response(
all_updates: list["ChatResponseUpdate"] = []
# Filter out internal framework kwargs before passing to clients.
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
async for update in func(self, messages=prepped_messages, **filtered_kwargs):
async for update in func(self, messages=prepped_messages, options=options, **filtered_kwargs):
all_updates.append(update)
yield update
@@ -2157,7 +2155,7 @@ def _handle_function_calls_streaming_response(
prepped_messages = []
# we load the tools here, since middleware might have changed them compared to before calling func.
tools = _extract_tools(kwargs)
tools = _extract_tools(options)
if function_calls and tools:
# Use the stored middleware pipeline instead of extracting from kwargs
# because kwargs may have been modified by the underlying function
@@ -2236,10 +2234,12 @@ def _handle_function_calls_streaming_response(
return
# Failsafe: give up on tools, ask model for plain answer
kwargs["tool_choice"] = "none"
if options is None:
options = {}
options["tool_choice"] = "none"
# Filter out internal framework kwargs before passing to clients.
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
async for update in func(self, messages=prepped_messages, **filtered_kwargs):
async for update in func(self, messages=prepped_messages, options=options, **filtered_kwargs):
yield update
return streaming_function_invocation_wrapper
+340 -342
View File
@@ -13,7 +13,7 @@ from collections.abc import (
Sequence,
)
from copy import deepcopy
from typing import Any, ClassVar, Literal, TypeVar, cast, overload
from typing import Any, ClassVar, Literal, TypedDict, TypeVar, cast, overload
from pydantic import BaseModel, ValidationError
@@ -36,6 +36,7 @@ __all__ = [
"BaseAnnotation",
"BaseContent",
"ChatMessage",
"ChatOptions", # Backward compatibility alias
"ChatOptions",
"ChatResponse",
"ChatResponseUpdate",
@@ -64,7 +65,12 @@ __all__ = [
"UriContent",
"UsageContent",
"UsageDetails",
"merge_chat_options",
"prepare_function_call_results",
"prepend_instructions_to_messages",
"validate_chat_options",
"validate_tool_mode",
"validate_tools",
]
logger = get_logger("agent_framework")
@@ -2457,7 +2463,7 @@ class ChatMessage(SerializationMixin):
def prepare_messages(
messages: str | ChatMessage | list[str] | list[ChatMessage], system_instructions: str | list[str] | None = None
messages: str | ChatMessage | Sequence[str | ChatMessage], system_instructions: str | Sequence[str] | None = None
) -> list[ChatMessage]:
"""Convert various message input formats into a list of ChatMessage objects.
@@ -2488,6 +2494,49 @@ def prepare_messages(
return return_messages
def prepend_instructions_to_messages(
messages: list[ChatMessage],
instructions: str | Sequence[str] | None,
role: Role | Literal["system", "user", "assistant"] = "system",
) -> list[ChatMessage]:
"""Prepend instructions to a list of messages with a specified role.
This is a helper method for chat clients that need to add instructions
from options as messages. Different providers support different roles for
instructions (e.g., OpenAI uses "system", some providers might use "user").
Args:
messages: The existing list of ChatMessage objects.
instructions: The instructions to prepend. Can be a single string or a sequence of strings.
role: The role to use for the instruction messages. Defaults to "system".
Returns:
A new list with instruction messages prepended.
Examples:
.. code-block:: python
from agent_framework import prepend_instructions_to_messages, ChatMessage
messages = [ChatMessage(role="user", text="Hello")]
instructions = "You are a helpful assistant"
# Prepend as system message (default)
messages_with_instructions = prepend_instructions_to_messages(messages, instructions)
# Or use a different role
messages_with_user_instructions = prepend_instructions_to_messages(messages, instructions, role="user")
"""
if instructions is None:
return messages
if isinstance(instructions, str):
instructions = [instructions]
instruction_messages = [ChatMessage(role=role, text=instr) for instr in instructions]
return [*instruction_messages, *messages]
# region ChatResponse
@@ -2845,7 +2894,7 @@ class ChatResponse(SerializationMixin):
cls: type[TChatResponse],
updates: AsyncIterable["ChatResponseUpdate"],
*,
output_format_type: type[BaseModel] | None = None,
output_format_type: type[BaseModel] | Mapping[str, Any] | None = None,
) -> TChatResponse:
"""Joins multiple updates into a single ChatResponse.
@@ -2870,7 +2919,7 @@ class ChatResponse(SerializationMixin):
async for update in updates:
_process_update(msg, update)
_finalize_response(msg)
if output_format_type:
if output_format_type and isinstance(output_format_type, type) and issubclass(output_format_type, BaseModel):
msg.try_parse_value(output_format_type)
return msg
@@ -2884,7 +2933,7 @@ class ChatResponse(SerializationMixin):
def try_parse_value(self, output_format_type: type[BaseModel]) -> None:
"""If there is a value, does nothing, otherwise tries to parse the text into the value."""
if self.value is None:
if self.value is None and isinstance(output_format_type, type) and issubclass(output_format_type, BaseModel):
try:
self.value = output_format_type.model_validate_json(self.text) # type: ignore[reportUnknownMemberType]
except ValidationError as ex:
@@ -3301,372 +3350,321 @@ class AgentRunResponseUpdate(SerializationMixin):
# region ChatOptions
class ToolMode(SerializationMixin, metaclass=EnumLike):
"""Defines if and how tools are used in a chat request.
class ToolMode(TypedDict, total=False):
"""Tool choice mode for the chat options.
Examples:
.. code-block:: python
from agent_framework import ToolMode
# Use predefined tool modes
auto_mode = ToolMode.AUTO # Model decides when to use tools
required_mode = ToolMode.REQUIRED_ANY # Model must use a tool
none_mode = ToolMode.NONE # No tools allowed
# Require a specific function
specific_mode = ToolMode.REQUIRED(function_name="get_weather")
print(specific_mode.required_function_name) # "get_weather"
# Compare modes
print(auto_mode == "auto") # True
Fields:
mode: One of "auto", "required", or "none".
required_function_name: Optional function name when `mode == "required"`.
"""
# Constants configuration for EnumLike metaclass
_constants: ClassVar[dict[str, tuple[str, ...]]] = {
"AUTO": ("auto",),
"REQUIRED_ANY": ("required",),
"NONE": ("none",),
}
# Type annotations for constants
AUTO: "ToolMode"
REQUIRED_ANY: "ToolMode"
NONE: "ToolMode"
def __init__(
self,
mode: Literal["auto", "required", "none"] = "none",
*,
required_function_name: str | None = None,
) -> None:
"""Initialize ToolMode.
Args:
mode: The tool mode - "auto", "required", or "none".
Keyword Args:
required_function_name: Optional function name for required mode.
"""
self.mode = mode
self.required_function_name = required_function_name
@classmethod
def REQUIRED(cls, function_name: str | None = None) -> "ToolMode":
"""Returns a ToolMode that requires the specified function to be called."""
return cls(mode="required", required_function_name=function_name)
def __eq__(self, other: object) -> bool:
"""Checks equality with another ToolMode or string."""
if isinstance(other, str):
return self.mode == other
if isinstance(other, ToolMode):
return self.mode == other.mode and self.required_function_name == other.required_function_name
return False
def __hash__(self) -> int:
"""Return hash of the ToolMode for use in sets and dicts."""
return hash((self.mode, self.required_function_name))
def serialize_model(self) -> str:
"""Serializes the ToolMode to just the mode string."""
return self.mode
def __str__(self) -> str:
"""Returns the string representation of the mode."""
return self.mode
def __repr__(self) -> str:
"""Returns the string representation of the ToolMode."""
if self.required_function_name:
return f"ToolMode(mode={self.mode!r}, required_function_name={self.required_function_name!r})"
return f"ToolMode(mode={self.mode!r})"
mode: Literal["auto", "required", "none"]
required_function_name: str
class ChatOptions(SerializationMixin):
"""Common request settings for AI services.
# region TypedDict-based Chat Options
class ChatOptions(TypedDict, total=False):
"""Common request settings for AI services as a TypedDict.
All fields are optional (total=False) to allow partial specification.
Provider-specific TypedDicts extend this with additional options.
These options represent the common denominator across chat providers.
Individual implementations may raise errors for unsupported options.
Examples:
.. code-block:: python
from agent_framework import ChatOptions, ai_function
# Create basic chat options
options = ChatOptions(
model_id="gpt-4",
temperature=0.7,
max_tokens=1000,
)
from agent_framework import ChatOptions, ToolMode
# Type-safe options
options: ChatOptions = {
"temperature": 0.7,
"max_tokens": 1000,
"model_id": "gpt-4",
}
# With tools
@ai_function
def get_weather(location: str) -> str:
'''Get weather for a location.'''
return f"Weather in {location}"
options_with_tools: ChatOptions = {
"model_id": "gpt-4",
"tool_choice": "auto",
"temperature": 0.7,
}
options = ChatOptions(
model_id="gpt-4",
tools=get_weather,
tool_choice="auto",
)
# Require a specific tool to be called
options_required = ChatOptions(
model_id="gpt-4",
tools=get_weather,
tool_choice=ToolMode.REQUIRED(function_name="get_weather"),
)
# Combine options
base_options = ChatOptions(temperature=0.5)
extended_options = ChatOptions(max_tokens=500, tools=get_weather)
combined = base_options & extended_options
# Used with Unpack for function signatures
# async def get_response(self, **options: Unpack[ChatOptions]) -> ChatResponse:
"""
DEFAULT_EXCLUDE: ClassVar[set[str]] = {"_tools"} # Internal field, use .tools property
# Model selection
model_id: str
def __init__(
self,
*,
model_id: str | None = None,
allow_multiple_tool_calls: bool | None = None,
conversation_id: str | None = None,
frequency_penalty: float | None = None,
instructions: str | None = None,
logit_bias: MutableMapping[str | int, float] | None = None,
max_tokens: int | None = None,
metadata: MutableMapping[str, str] | None = None,
presence_penalty: float | None = None,
response_format: type[BaseModel] | None = None,
seed: int | None = None,
stop: str | Sequence[str] | None = None,
store: bool | None = None,
temperature: float | None = None,
tool_choice: ToolMode | Literal["auto", "required", "none"] | Mapping[str, Any] | None = None,
tools: ToolProtocol
# Generation parameters
temperature: float
top_p: float
max_tokens: int
stop: str | Sequence[str]
seed: int
logit_bias: dict[str | int, float]
# Penalty parameters
frequency_penalty: float
presence_penalty: float
# Tool configuration (forward reference to avoid circular import)
tools: "ToolProtocol | Callable[..., Any] | MutableMapping[str, Any] | Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]] | None" # noqa: E501
tool_choice: ToolMode | Literal["auto", "required", "none"]
allow_multiple_tool_calls: bool
# Response configuration
response_format: type[BaseModel] | dict[str, Any]
# Metadata
metadata: dict[str, Any]
user: str
store: bool
conversation_id: str
# System/instructions
instructions: str
# region Chat Options Utility Functions
async def validate_chat_options(options: dict[str, Any]) -> dict[str, Any]:
"""Validate and normalize chat options dictionary.
Validates numeric constraints and converts types as needed.
Args:
options: The options dictionary to validate.
Returns:
The validated and normalized options dictionary.
Raises:
ValueError: If any option value is invalid.
Examples:
.. code-block:: python
from agent_framework import validate_chat_options
options = await validate_chat_options({
"temperature": 0.7,
"max_tokens": 1000,
})
"""
result = dict(options) # Make a copy
# Validate numeric constraints
if (freq_pen := result.get("frequency_penalty")) is not None:
if not (-2.0 <= freq_pen <= 2.0):
raise ValueError("frequency_penalty must be between -2.0 and 2.0")
result["frequency_penalty"] = float(freq_pen)
if (pres_pen := result.get("presence_penalty")) is not None:
if not (-2.0 <= pres_pen <= 2.0):
raise ValueError("presence_penalty must be between -2.0 and 2.0")
result["presence_penalty"] = float(pres_pen)
if (temp := result.get("temperature")) is not None:
if not (0.0 <= temp <= 2.0):
raise ValueError("temperature must be between 0.0 and 2.0")
result["temperature"] = float(temp)
if (top_p := result.get("top_p")) is not None:
if not (0.0 <= top_p <= 1.0):
raise ValueError("top_p must be between 0.0 and 1.0")
result["top_p"] = float(top_p)
if (max_tokens := result.get("max_tokens")) is not None and max_tokens <= 0:
raise ValueError("max_tokens must be greater than 0")
# Validate and normalize tools
if "tools" in result:
result["tools"] = await validate_tools(result["tools"])
return result
async def validate_tools(
tools: (
ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None = None,
top_p: float | None = None,
user: str | None = None,
additional_properties: MutableMapping[str, Any] | None = None,
**kwargs: Any,
):
"""Initialize ChatOptions.
| None
),
) -> list[ToolProtocol | MutableMapping[str, Any]]:
"""Validate and normalize tools into a list.
Keyword Args:
model_id: The AI model ID to use.
allow_multiple_tool_calls: Whether to allow multiple tool calls.
conversation_id: The conversation ID.
frequency_penalty: The frequency penalty (must be between -2.0 and 2.0).
instructions: the instructions, will be turned into a system or equivalent message.
logit_bias: The logit bias mapping.
max_tokens: The maximum number of tokens (must be > 0).
metadata: Metadata mapping.
presence_penalty: The presence penalty (must be between -2.0 and 2.0).
response_format: Structured output response format schema. Must be a valid Pydantic model.
seed: Random seed for reproducibility.
stop: Stop sequences.
store: Whether to store the conversation.
temperature: The temperature (must be between 0.0 and 2.0).
tool_choice: The tool choice mode.
tools: List of available tools.
top_p: The top-p value (must be between 0.0 and 1.0).
user: The user ID.
additional_properties: Provider-specific additional properties, can also be passed as kwargs.
**kwargs: Additional properties to include in additional_properties.
"""
# Validate numeric constraints and convert types as needed
if frequency_penalty is not None:
if not (-2.0 <= frequency_penalty <= 2.0):
raise ValueError("frequency_penalty must be between -2.0 and 2.0")
frequency_penalty = float(frequency_penalty)
if presence_penalty is not None:
if not (-2.0 <= presence_penalty <= 2.0):
raise ValueError("presence_penalty must be between -2.0 and 2.0")
presence_penalty = float(presence_penalty)
if temperature is not None:
if not (0.0 <= temperature <= 2.0):
raise ValueError("temperature must be between 0.0 and 2.0")
temperature = float(temperature)
if top_p is not None:
if not (0.0 <= top_p <= 1.0):
raise ValueError("top_p must be between 0.0 and 1.0")
top_p = float(top_p)
if max_tokens is not None and max_tokens <= 0:
raise ValueError("max_tokens must be greater than 0")
Converts callables to AIFunction objects, expands MCP tools to their constituent
functions (connecting them if needed), and ensures all tools are either ToolProtocol
instances or MutableMappings.
if additional_properties is None:
additional_properties = {}
if kwargs:
additional_properties.update(kwargs)
Args:
tools: Tools to validate - can be a single tool, callable, or sequence.
self.additional_properties = cast(dict[str, Any], additional_properties)
self.model_id = model_id
self.allow_multiple_tool_calls = allow_multiple_tool_calls
self.conversation_id = conversation_id
self.frequency_penalty = frequency_penalty
self.instructions = instructions
self.logit_bias = logit_bias
self.max_tokens = max_tokens
self.metadata = metadata
self.presence_penalty = presence_penalty
self.response_format = response_format
self.seed = seed
self.stop = stop
self.store = store
self.temperature = temperature
self.tool_choice = self._validate_tool_mode(tool_choice)
self._tools = self._validate_tools(tools)
self.top_p = top_p
self.user = user
Returns:
Normalized list of tools, or None if no tools provided.
def __deepcopy__(self, memo: dict[int, Any]) -> "ChatOptions":
"""Create a runtime-safe copy without deep-copying tool instances."""
clone = type(self).__new__(type(self))
memo[id(self)] = clone
for key, value in self.__dict__.items():
if key == "_tools":
setattr(clone, key, list(value) if value is not None else None)
continue
if key in {"logit_bias", "metadata", "additional_properties"}:
setattr(clone, key, self._safe_deepcopy_mapping(value, memo))
continue
setattr(clone, key, self._safe_deepcopy_value(value, memo))
return clone
Examples:
.. code-block:: python
@staticmethod
def _safe_deepcopy_mapping(
value: MutableMapping[str, Any] | None, memo: dict[int, Any]
) -> MutableMapping[str, Any] | None:
"""Deep copy helper that falls back to a shallow copy for problematic mappings."""
if value is None:
return None
try:
return deepcopy(value, memo) # type: ignore[arg-type]
except Exception:
return dict(value)
from agent_framework import validate_tools, ai_function
@staticmethod
def _safe_deepcopy_value(value: Any, memo: dict[int, Any]) -> Any:
"""Deep copy helper that avoids failing on non-copyable instances."""
try:
return deepcopy(value, memo)
except Exception:
return value
@property
def tools(self) -> list[ToolProtocol | MutableMapping[str, Any]] | None:
"""Return the tools that are specified."""
return self._tools
@ai_function
def my_tool(x: int) -> int:
return x * 2
@tools.setter
def tools(
self,
new_tools: ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None,
) -> None:
"""Set the tools."""
self._tools = self._validate_tools(new_tools)
@classmethod
def _validate_tools(
cls,
tools: (
ToolProtocol
| Callable[..., Any]
| MutableMapping[str, Any]
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]
| None
),
) -> list[ToolProtocol | MutableMapping[str, Any]] | None:
"""Parse the tools field."""
if not tools:
return None
if not isinstance(tools, Sequence):
if not isinstance(tools, (ToolProtocol, MutableMapping)):
return [ai_function(tools)]
return [tools]
return [tool if isinstance(tool, (ToolProtocol, MutableMapping)) else ai_function(tool) for tool in tools]
# Single tool
tools = await validate_tools(my_tool)
@classmethod
def _validate_tool_mode(
cls, tool_choice: ToolMode | Literal["auto", "required", "none"] | Mapping[str, Any] | None
) -> ToolMode | None:
"""Validates the tool_choice field to ensure it is a valid ToolMode."""
if not tool_choice:
return None
if isinstance(tool_choice, str):
match tool_choice:
case "auto":
return ToolMode.AUTO
case "required":
return ToolMode.REQUIRED_ANY
case "none":
return ToolMode.NONE
case _:
raise ContentError(f"Invalid tool choice: {tool_choice}")
if isinstance(tool_choice, (dict, Mapping)):
return ToolMode.from_dict(tool_choice) # type: ignore
return tool_choice
# List of tools
tools = await validate_tools([my_tool, another_tool])
"""
# Sequence of tools - convert callables and expand MCP tools
final_tools: list[ToolProtocol | MutableMapping[str, Any]] = []
if not tools:
return final_tools
if not isinstance(tools, Sequence) or isinstance(tools, (str, MutableMapping)):
# Single tool (not a sequence, or is a mapping which shouldn't be treated as sequence)
if not isinstance(tools, (ToolProtocol, MutableMapping)):
return [ai_function(tools)]
return [tools]
for tool in tools:
# Import MCPTool here to avoid circular imports
from ._mcp import MCPTool
def __and__(self, other: object) -> "ChatOptions":
"""Combines two ChatOptions instances.
The values from the other ChatOptions take precedence.
List and dicts are combined.
"""
if not isinstance(other, ChatOptions):
return self
other_tools = other.tools
# tool_choice has a specialized serialize method. Save it here so we can fix it later.
tool_choice = other.tool_choice or self.tool_choice
# response_format is a class type that can't be serialized. Save it here so we can restore it later.
response_format = self.response_format
# Start with a shallow copy of self that preserves tool objects
combined = ChatOptions.from_dict(self.to_dict())
combined.tool_choice = self.tool_choice
combined.tools = list(self.tools) if self.tools else None
combined.logit_bias = dict(self.logit_bias) if self.logit_bias else None
combined.metadata = dict(self.metadata) if self.metadata else None
combined.response_format = response_format
# Apply scalar and mapping updates from the other options
updated_data = other.to_dict(exclude_none=True, exclude={"tools"})
logit_bias = updated_data.pop("logit_bias", {})
metadata = updated_data.pop("metadata", {})
additional_properties: dict[str, Any] = updated_data.pop("additional_properties", {})
for key, value in updated_data.items():
setattr(combined, key, value)
combined.tool_choice = tool_choice
# Preserve response_format from other if it exists, otherwise keep self's
if other.response_format is not None:
combined.response_format = other.response_format
if other.instructions:
combined.instructions = "\n".join([combined.instructions or "", other.instructions or ""])
combined.logit_bias = (
{**(combined.logit_bias or {}), **logit_bias} if logit_bias or combined.logit_bias else None
)
combined.metadata = {**(combined.metadata or {}), **metadata} if metadata or combined.metadata else None
if combined.additional_properties and additional_properties:
combined.additional_properties.update(additional_properties)
if isinstance(tool, MCPTool):
# Expand MCP tools to their constituent functions
if not tool.is_connected:
await tool.connect()
final_tools.extend(tool.functions) # type: ignore
elif isinstance(tool, (ToolProtocol, MutableMapping)):
final_tools.append(tool)
else:
if additional_properties:
combined.additional_properties = additional_properties
if other_tools:
if combined.tools is None:
combined.tools = list(other_tools)
# Convert callable to AIFunction
final_tools.append(ai_function(tool))
return final_tools
def validate_tool_mode(
tool_choice: ToolMode | Literal["auto", "required", "none"] | None,
) -> ToolMode:
"""Validate and normalize tool_choice to a ToolMode dict.
Args:
tool_choice: The tool choice value to validate.
Returns:
A ToolMode dict (contains keys: "mode", and optionally "required_function_name").
Raises:
ContentError: If the tool_choice string is invalid.
"""
if not tool_choice:
return {"mode": "none"}
if isinstance(tool_choice, str):
if tool_choice not in ("auto", "required", "none"):
raise ContentError(f"Invalid tool choice: {tool_choice}")
return {"mode": tool_choice}
if "mode" not in tool_choice:
raise ContentError("tool_choice dict must contain 'mode' key")
if tool_choice["mode"] not in ("auto", "required", "none"):
raise ContentError(f"Invalid tool choice: {tool_choice['mode']}")
if tool_choice["mode"] != "required" and "required_function_name" in tool_choice:
raise ContentError("tool_choice with mode other than 'required' cannot have 'required_function_name'")
return tool_choice
def merge_chat_options(
base: dict[str, Any] | None,
override: dict[str, Any] | None,
) -> dict[str, Any]:
"""Merge two chat options dictionaries.
Values from override take precedence over base.
Lists and dicts are combined (not replaced).
Instructions are concatenated with newlines.
Args:
base: The base options dictionary.
override: The override options dictionary.
Returns:
A new merged options dictionary.
Examples:
.. code-block:: python
from agent_framework import merge_chat_options
base = {"temperature": 0.5, "model_id": "gpt-4"}
override = {"temperature": 0.7, "max_tokens": 1000}
merged = merge_chat_options(base, override)
# {"temperature": 0.7, "model_id": "gpt-4", "max_tokens": 1000}
"""
if not base:
return dict(override) if override else {}
if not override:
return dict(base)
result: dict[str, Any] = {}
# Copy base values (shallow copy for simple values, dict copy for dicts)
for key, value in base.items():
if isinstance(value, dict):
result[key] = dict(value)
elif isinstance(value, list):
result[key] = list(value)
else:
result[key] = value
# Apply overrides
for key, value in override.items():
if value is None:
continue
if key == "instructions":
# Concatenate instructions
base_instructions = result.get("instructions")
if base_instructions:
result["instructions"] = f"{base_instructions}\n{value}"
else:
for tool in other_tools:
if tool not in combined.tools:
combined.tools.append(tool)
return combined
result["instructions"] = value
elif key == "tools":
# Merge tools lists
base_tools = result.get("tools")
if base_tools and value:
# Add tools that aren't already present
merged_tools = list(base_tools)
for tool in value if isinstance(value, list) else [value]:
if tool not in merged_tools:
merged_tools.append(tool)
result["tools"] = merged_tools
elif value:
result["tools"] = list(value) if isinstance(value, list) else [value]
elif key in ("logit_bias", "metadata", "additional_properties"):
# Merge dicts
base_dict = result.get(key)
if base_dict and isinstance(value, dict):
result[key] = {**base_dict, **value}
elif value:
result[key] = dict(value) if isinstance(value, dict) else value
elif key == "tool_choice":
# tool_choice from override takes precedence
result["tool_choice"] = value if value else result.get("tool_choice")
elif key == "response_format":
# response_format from override takes precedence if set
result["response_format"] = value
else:
# Simple override
result[key] = value
return result
@@ -359,7 +359,7 @@ class AgentExecutor(Executor):
# Build the final AgentRunResponse from the collected updates
if isinstance(self._agent, ChatAgent):
response_format = self._agent.chat_options.response_format
response_format = self._agent.default_options.get("response_format")
response = AgentRunResponse.from_agent_run_response_updates(
updates,
output_format_type=response_format,
@@ -17,13 +17,19 @@ Design Pattern:
import asyncio
import inspect
import sys
import typing
from collections.abc import Awaitable, Callable
from typing import Any, overload
from typing import Any
from ._executor import Executor
from ._workflow_context import WorkflowContext, validate_workflow_context_annotation
if sys.version_info >= (3, 11):
from typing import overload # pragma: no cover
else:
from typing_extensions import overload # pragma: no cover
class FunctionExecutor(Executor):
"""Executor that wraps a user-defined function.
@@ -1481,16 +1481,20 @@ class GroupChatBuilder:
display_name = manager.id if isinstance(manager, Executor) else manager.name or "manager"
# Enforce ManagerSelectionResponse for ChatAgent managers
if isinstance(manager, ChatAgent):
configured_format = manager.chat_options.response_format
if configured_format is None:
manager.chat_options.response_format = ManagerSelectionResponse
elif configured_format is not ManagerSelectionResponse:
configured_format_name = getattr(configured_format, "__name__", str(configured_format))
raise ValueError(
"Manager ChatAgent response_format must be ManagerSelectionResponse. "
f"Received '{configured_format_name}' for manager '{display_name}'."
)
if (
isinstance(manager, ChatAgent)
and manager.default_options.setdefault("response_format", ManagerSelectionResponse)
!= ManagerSelectionResponse
):
configured_format_name = getattr(
manager.default_options.get("response_format"),
"__name__",
str(manager.default_options.get("response_format")),
)
raise ValueError(
"Manager ChatAgent response_format must be ManagerSelectionResponse. "
f"Received '{configured_format_name}' for manager '{display_name}'."
)
self._manager_participant = manager
self._manager_name = display_name
@@ -92,20 +92,24 @@ def _create_handoff_tool(alias: str, description: str | None = None) -> AIFuncti
def _clone_chat_agent(agent: ChatAgent) -> ChatAgent:
"""Produce a deep copy of the ChatAgent while preserving runtime configuration."""
options = agent.chat_options
options = agent.default_options
middleware = list(agent.middleware or [])
# Reconstruct the original tools list by combining regular tools with MCP tools.
# ChatAgent.__init__ separates MCP tools into _local_mcp_tools during initialization,
# so we need to recombine them here to pass the complete tools list to the constructor.
# This makes sure MCP tools are preserved when cloning agents for handoff workflows.
all_tools = list(options.tools) if options.tools else []
tools_from_options = options.get("tools")
all_tools = list(tools_from_options) if tools_from_options else []
if agent._local_mcp_tools: # type: ignore
all_tools.extend(agent._local_mcp_tools) # type: ignore
logit_bias = options.get("logit_bias")
metadata = options.get("metadata")
return ChatAgent(
chat_client=agent.chat_client,
instructions=options.instructions,
instructions=options.get("instructions"),
id=agent.id,
name=agent.name,
description=agent.description,
@@ -114,22 +118,21 @@ def _clone_chat_agent(agent: ChatAgent) -> ChatAgent:
middleware=middleware,
# Disable parallel tool calls to prevent the agent from invoking multiple handoff tools at once.
allow_multiple_tool_calls=False,
frequency_penalty=options.frequency_penalty,
logit_bias=dict(options.logit_bias) if options.logit_bias else None,
max_tokens=options.max_tokens,
metadata=dict(options.metadata) if options.metadata else None,
model_id=options.model_id,
presence_penalty=options.presence_penalty,
response_format=options.response_format,
seed=options.seed,
stop=options.stop,
store=options.store,
temperature=options.temperature,
tool_choice=options.tool_choice, # type: ignore[arg-type]
frequency_penalty=options.get("frequency_penalty"),
logit_bias=dict(logit_bias) if logit_bias else None,
max_tokens=options.get("max_tokens"),
metadata=dict(metadata) if metadata else None,
model_id=options.get("model_id"),
presence_penalty=options.get("presence_penalty"),
response_format=options.get("response_format"),
seed=options.get("seed"),
stop=options.get("stop"),
store=options.get("store"),
temperature=options.get("temperature"),
tool_choice=options.get("tool_choice"), # type: ignore[arg-type]
tools=all_tools if all_tools else None,
top_p=options.top_p,
user=options.user,
additional_chat_options=dict(options.additional_properties),
top_p=options.get("top_p"),
user=options.get("user"),
)
@@ -1980,8 +1983,8 @@ class HandoffBuilder:
Returns:
Dict mapping tool names (in various formats) to executor IDs for handoff resolution
"""
chat_options = agent.chat_options
existing_tools = list(chat_options.tools or [])
default_options = agent.default_options
existing_tools = list(default_options.get("tools") or [])
existing_names = {getattr(tool, "name", "") for tool in existing_tools if hasattr(tool, "name")}
tool_targets: dict[str, str] = {}
@@ -1998,9 +2001,9 @@ class HandoffBuilder:
tool_targets[alias.lower()] = executor.id
if new_tools:
chat_options.tools = existing_tools + new_tools
default_options["tools"] = existing_tools + new_tools
else:
chat_options.tools = existing_tools
default_options["tools"] = existing_tools
return tool_targets
@@ -2432,7 +2432,7 @@ class MagenticBuilder:
manager_agent = ChatAgent(
name="Coordinator",
chat_client=OpenAIChatClient(model_id="gpt-4o"),
chat_options=ChatOptions(temperature=0.3, seed=42),
options=ChatOptions(temperature=0.3, seed=42),
instructions="Be concise and focus on accuracy",
)
@@ -5,7 +5,7 @@ from typing import Any
IMPORT_PATH = "agent_framework_anthropic"
PACKAGE_NAME = "agent-framework-anthropic"
_IMPORTS = ["__version__", "AnthropicClient"]
_IMPORTS = ["__version__", "AnthropicClient", "AnthropicChatOptions"]
def __getattr__(name: str) -> Any:
@@ -1,11 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_anthropic import (
AnthropicChatOptions,
AnthropicClient,
__version__,
)
__all__ = [
"AnthropicChatOptions",
"AnthropicClient",
"__version__",
]
@@ -8,14 +8,19 @@ _IMPORTS: dict[str, tuple[str, str]] = {
"AgentFunctionApp": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
"AgentResponseCallbackProtocol": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
"AzureAIAgentClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
"AzureAIAgentOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
"AzureAIClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
"AzureAISearchContextProvider": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"),
"AzureAISearchSettings": ("agent_framework_azure_ai_search", "agent-framework-azure-ai-search"),
"AzureAISettings": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
"AzureOpenAIAssistantsClient": ("agent_framework.azure._assistants_client", "agent-framework-core"),
"AzureOpenAIAssistantsOptions": ("agent_framework.azure._assistants_client", "agent-framework-core"),
"AzureOpenAIChatClient": ("agent_framework.azure._chat_client", "agent-framework-core"),
"AzureOpenAIChatOptions": ("agent_framework.azure._chat_client", "agent-framework-core"),
"AzureOpenAIResponsesClient": ("agent_framework.azure._responses_client", "agent-framework-core"),
"AzureOpenAIResponsesOptions": ("agent_framework.azure._responses_client", "agent-framework-core"),
"AzureOpenAISettings": ("agent_framework.azure._shared", "agent-framework-core"),
"AzureUserSecurityContext": ("agent_framework.azure._chat_client", "agent-framework-core"),
"DurableAIAgent": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
"get_entra_auth_token": ("agent_framework.azure._entra_id_authentication", "agent-framework-core"),
}
@@ -1,22 +1,47 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, ClassVar
from typing import TYPE_CHECKING, Any, ClassVar, Generic
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
from pydantic import ValidationError
from ..exceptions import ServiceInitializationError
from ..openai import OpenAIAssistantsClient
from ..openai._assistants_client import OpenAIAssistantsOptions
from ._shared import AzureOpenAISettings
if TYPE_CHECKING:
from azure.core.credentials import TokenCredential
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore # pragma: no cover
from typing import TypedDict
__all__ = ["AzureOpenAIAssistantsClient"]
class AzureOpenAIAssistantsClient(OpenAIAssistantsClient):
# region Azure OpenAI Assistants Options TypedDict
TAzureOpenAIAssistantsOptions = TypeVar(
"TAzureOpenAIAssistantsOptions",
bound=TypedDict, # type: ignore[valid-type]
default="OpenAIAssistantsOptions",
covariant=True,
)
# endregion
class AzureOpenAIAssistantsClient(
OpenAIAssistantsClient[TAzureOpenAIAssistantsOptions], Generic[TAzureOpenAIAssistantsOptions]
):
"""Azure OpenAI Assistants client."""
DEFAULT_AZURE_API_VERSION: ClassVar[str] = "2024-05-01-preview"
@@ -95,6 +120,18 @@ class AzureOpenAIAssistantsClient(OpenAIAssistantsClient):
# Or loading from a .env file
client = AzureOpenAIAssistantsClient(env_file_path="path/to/.env")
# Using custom ChatOptions with type safety:
from typing import TypedDict
from agent_framework.azure import AzureOpenAIAssistantsOptions
class MyOptions(AzureOpenAIAssistantsOptions, total=False):
my_custom_option: str
client: AzureOpenAIAssistantsClient[MyOptions] = AzureOpenAIAssistantsClient()
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
azure_openai_settings = AzureOpenAISettings(
@@ -4,7 +4,7 @@ import json
import logging
import sys
from collections.abc import Mapping
from typing import Any, TypeVar
from typing import Any, Generic, TypedDict
from azure.core.credentials import TokenCredential
from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI
@@ -22,13 +22,17 @@ from agent_framework import (
)
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.observability import use_instrumentation
from agent_framework.openai._chat_client import OpenAIBaseChatClient
from agent_framework.openai._chat_client import OpenAIBaseChatClient, OpenAIChatOptions
from ._shared import (
AzureOpenAIConfigMixin,
AzureOpenAISettings,
)
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
@@ -36,6 +40,99 @@ else:
logger: logging.Logger = logging.getLogger(__name__)
__all__ = ["AzureOpenAIChatClient", "AzureOpenAIChatOptions", "AzureUserSecurityContext"]
# region Azure OpenAI Chat Options TypedDict
class AzureUserSecurityContext(TypedDict, total=False):
"""User security context for Azure AI applications.
These fields help security operations teams investigate and mitigate security
incidents by providing context about the application and end user.
Learn more: https://learn.microsoft.com/azure/well-architected/service-guides/cosmos-db
"""
application_name: str
"""Name of the application making the request."""
end_user_id: str
"""Unique identifier for the end user (recommend hashing username/email)."""
end_user_tenant_id: str
"""Microsoft 365 tenant ID the end user belongs to. Required for multi-tenant apps."""
source_ip: str
"""The original client's IP address."""
class AzureOpenAIChatOptions(OpenAIChatOptions, total=False):
"""Azure OpenAI-specific chat options dict.
Extends OpenAIChatOptions with Azure-specific options including
the "On Your Data" feature and enhanced security context.
See: https://learn.microsoft.com/azure/ai-foundry/openai/reference-preview-latest
Keys:
# Inherited from OpenAIChatOptions/ChatOptions:
model_id: The model to use for the request,
translates to ``model`` in Azure OpenAI API.
temperature: Sampling temperature between 0 and 2.
top_p: Nucleus sampling parameter.
max_tokens: Maximum number of tokens to generate,
translates to ``max_completion_tokens`` in Azure OpenAI API.
stop: Stop sequences.
seed: Random seed for reproducibility.
frequency_penalty: Frequency penalty between -2.0 and 2.0.
presence_penalty: Presence penalty between -2.0 and 2.0.
tools: List of tools (functions) available to the model.
tool_choice: How the model should use tools.
allow_multiple_tool_calls: Whether to allow parallel tool calls,
translates to ``parallel_tool_calls`` in Azure OpenAI API.
response_format: Structured output schema.
metadata: Request metadata for tracking.
user: End-user identifier for abuse monitoring.
store: Whether to store the conversation.
instructions: System instructions for the model.
logit_bias: Token bias values (-100 to 100).
logprobs: Whether to return log probabilities.
top_logprobs: Number of top log probabilities to return (0-20).
# Azure-specific options:
data_sources: Azure "On Your Data" data sources configuration.
user_security_context: Enhanced security context for Azure Defender.
n: Number of chat completions to generate (not recommended, incurs costs).
"""
# Azure-specific options
data_sources: list[dict[str, Any]]
"""Azure "On Your Data" data sources for retrieval-augmented generation.
Supported types: azure_search, azure_cosmos_db, elasticsearch, pinecone, mongo_db.
See: https://learn.microsoft.com/azure/ai-foundry/openai/references/on-your-data
"""
user_security_context: AzureUserSecurityContext
"""Enhanced security context for Azure Defender integration."""
n: int
"""Number of chat completion choices to generate for each input message.
Note: You will be charged based on tokens across all choices. Keep n=1 to minimize costs."""
TAzureOpenAIChatOptions = TypeVar(
"TAzureOpenAIChatOptions",
bound=TypedDict, # type: ignore[valid-type]
default="AzureOpenAIChatOptions",
covariant=True,
)
# endregion
TChatResponse = TypeVar("TChatResponse", ChatResponse, ChatResponseUpdate)
TAzureOpenAIChatClient = TypeVar("TAzureOpenAIChatClient", bound="AzureOpenAIChatClient")
@@ -43,7 +140,9 @@ TAzureOpenAIChatClient = TypeVar("TAzureOpenAIChatClient", bound="AzureOpenAICha
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class AzureOpenAIChatClient(AzureOpenAIConfigMixin, OpenAIBaseChatClient):
class AzureOpenAIChatClient(
AzureOpenAIConfigMixin, OpenAIBaseChatClient[TAzureOpenAIChatOptions], Generic[TAzureOpenAIChatOptions]
):
"""Azure OpenAI Chat completion class."""
def __init__(
@@ -103,17 +202,31 @@ class AzureOpenAIChatClient(AzureOpenAIConfigMixin, OpenAIBaseChatClient):
# Using environment variables
# Set AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com
# Set AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4
# Set AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=<model name>
# Set AZURE_OPENAI_API_KEY=your-key
client = AzureOpenAIChatClient()
# Or passing parameters directly
client = AzureOpenAIChatClient(
endpoint="https://your-endpoint.openai.azure.com", deployment_name="gpt-4", api_key="your-key"
endpoint="https://your-endpoint.openai.azure.com",
deployment_name="<model name>",
api_key="your-key",
)
# Or loading from a .env file
client = AzureOpenAIChatClient(env_file_path="path/to/.env")
# Using custom ChatOptions with type safety:
from typing import TypedDict
from agent_framework.azure import AzureOpenAIChatOptions
class MyOptions(AzureOpenAIChatOptions, total=False):
my_custom_option: str
client: AzureOpenAIChatClient[MyOptions] = AzureOpenAIChatClient()
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
# Filter out any None values from the arguments
@@ -2,7 +2,7 @@
import sys
from collections.abc import Mapping
from typing import Any, TypeVar
from typing import TYPE_CHECKING, Any, Generic, TypedDict
from urllib.parse import urljoin
from azure.core.credentials import TokenCredential
@@ -19,18 +19,37 @@ from ._shared import (
AzureOpenAISettings,
)
if TYPE_CHECKING:
from agent_framework.openai._responses_client import OpenAIResponsesOptions
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore # pragma: no cover
TAzureOpenAIResponsesClient = TypeVar("TAzureOpenAIResponsesClient", bound="AzureOpenAIResponsesClient")
__all__ = ["AzureOpenAIResponsesClient"]
TAzureOpenAIResponsesOptions = TypeVar(
"TAzureOpenAIResponsesOptions",
bound=TypedDict, # type: ignore[valid-type]
default="OpenAIResponsesOptions",
covariant=True,
)
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class AzureOpenAIResponsesClient(AzureOpenAIConfigMixin, OpenAIBaseResponsesClient):
class AzureOpenAIResponsesClient(
AzureOpenAIConfigMixin,
OpenAIBaseResponsesClient[TAzureOpenAIResponsesOptions],
Generic[TAzureOpenAIResponsesOptions],
):
"""Azure Responses completion class."""
def __init__(
@@ -101,6 +120,18 @@ class AzureOpenAIResponsesClient(AzureOpenAIConfigMixin, OpenAIBaseResponsesClie
# Or loading from a .env file
client = AzureOpenAIResponsesClient(env_file_path="path/to/.env")
# Using custom ChatOptions with type safety:
from typing import TypedDict
from agent_framework.azure import AzureOpenAIResponsesOptions
class MyOptions(AzureOpenAIResponsesOptions, total=False):
my_custom_option: str
client: AzureOpenAIResponsesClient[MyOptions] = AzureOpenAIResponsesClient()
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
if model_id := kwargs.pop("model_id", None) and not deployment_name:
deployment_name = str(model_id)
@@ -59,7 +59,7 @@ __all__ = [
TAgent = TypeVar("TAgent", bound="AgentProtocol")
TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol")
TChatClient = TypeVar("TChatClient", bound="ChatClientProtocol[Any]")
logger = get_logger()
@@ -1063,6 +1063,8 @@ def _trace_get_response(
async def trace_get_response(
self: "ChatClientProtocol",
messages: "str | ChatMessage | list[str] | list[ChatMessage]",
*,
options: dict[str, Any] | None = None,
**kwargs: Any,
) -> "ChatResponse":
global OBSERVABILITY_SETTINGS
@@ -1071,18 +1073,15 @@ def _trace_get_response(
return await func(
self,
messages=messages,
options=options,
**kwargs,
)
if "token_usage_histogram" not in self.additional_properties:
self.additional_properties["token_usage_histogram"] = _get_token_usage_histogram()
if "operation_duration_histogram" not in self.additional_properties:
self.additional_properties["operation_duration_histogram"] = _get_duration_histogram()
model_id = (
kwargs.get("model_id")
or (chat_options.model_id if (chat_options := kwargs.get("chat_options")) else None)
or getattr(self, "model_id", None)
or "unknown"
)
options = options or {}
model_id = kwargs.get("model_id") or options.get("model_id") or getattr(self, "model_id", None) or "unknown"
service_url = str(
service_url_func()
if (service_url_func := getattr(self, "service_url", None)) and callable(service_url_func)
@@ -1101,7 +1100,7 @@ def _trace_get_response(
start_time_stamp = perf_counter()
end_time_stamp: float | None = None
try:
response = await func(self, messages=messages, **kwargs)
response = await func(self, messages=messages, options=options, **kwargs)
end_time_stamp = perf_counter()
except Exception as exception:
end_time_stamp = perf_counter()
@@ -1152,12 +1151,16 @@ def _trace_get_streaming_response(
@wraps(func)
async def trace_get_streaming_response(
self: "ChatClientProtocol", messages: "str | ChatMessage | list[str] | list[ChatMessage]", **kwargs: Any
self: "ChatClientProtocol",
messages: "str | ChatMessage | list[str] | list[ChatMessage]",
*,
options: dict[str, Any] | None = None,
**kwargs: Any,
) -> AsyncIterable["ChatResponseUpdate"]:
global OBSERVABILITY_SETTINGS
if not OBSERVABILITY_SETTINGS.ENABLED:
# If model diagnostics are not enabled, just return the completion
async for update in func(self, messages=messages, **kwargs):
async for update in func(self, messages=messages, options=options, **kwargs):
yield update
return
if "token_usage_histogram" not in self.additional_properties:
@@ -1165,12 +1168,8 @@ def _trace_get_streaming_response(
if "operation_duration_histogram" not in self.additional_properties:
self.additional_properties["operation_duration_histogram"] = _get_duration_histogram()
model_id = (
kwargs.get("model_id")
or (chat_options.model_id if (chat_options := kwargs.get("chat_options")) else None)
or getattr(self, "model_id", None)
or "unknown"
)
options = options or {}
model_id = kwargs.get("model_id") or options.get("model_id") or getattr(self, "model_id", None) or "unknown"
service_url = str(
service_url_func()
if (service_url_func := getattr(self, "service_url", None)) and callable(service_url_func)
@@ -1194,7 +1193,7 @@ def _trace_get_streaming_response(
start_time_stamp = perf_counter()
end_time_stamp: float | None = None
try:
async for update in func(self, messages=messages, **kwargs):
async for update in func(self, messages=messages, options=options, **kwargs):
all_updates.append(update)
yield update
end_time_stamp = perf_counter()
@@ -1341,7 +1340,11 @@ def _trace_agent_run(
if not OBSERVABILITY_SETTINGS.ENABLED:
# If model diagnostics are not enabled, just return the completion
return await run_func(self, messages=messages, thread=thread, **kwargs)
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "chat_options"}
from ._types import merge_chat_options
default_options = getattr(self, "default_options", {})
options = merge_chat_options(default_options, kwargs.get("options", {}))
attributes = _get_span_attributes(
operation_name=OtelAttr.AGENT_INVOKE_OPERATION,
provider_name=provider_name,
@@ -1349,8 +1352,8 @@ def _trace_agent_run(
agent_name=self.name or self.id,
agent_description=self.description,
thread_id=thread.service_thread_id if thread else None,
chat_options=getattr(self, "chat_options", None),
**filtered_kwargs,
all_options=options,
**kwargs,
)
with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
@@ -1358,7 +1361,7 @@ def _trace_agent_run(
span=span,
provider_name=provider_name,
messages=messages,
system_instructions=getattr(getattr(self, "chat_options", None), "instructions", None),
system_instructions=_get_instructions_from_options(options),
)
try:
response = await run_func(self, messages=messages, thread=thread, **kwargs)
@@ -1409,11 +1412,12 @@ def _trace_agent_run_stream(
yield streaming_agent_response
return
from ._types import AgentRunResponse
from ._types import AgentRunResponse, merge_chat_options
all_updates: list["AgentRunResponseUpdate"] = []
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "chat_options"}
default_options = getattr(self, "default_options", {})
options = merge_chat_options(default_options, kwargs.get("options", {}))
attributes = _get_span_attributes(
operation_name=OtelAttr.AGENT_INVOKE_OPERATION,
provider_name=provider_name,
@@ -1421,8 +1425,8 @@ def _trace_agent_run_stream(
agent_name=self.name or self.id,
agent_description=self.description,
thread_id=thread.service_thread_id if thread else None,
chat_options=getattr(self, "chat_options", None),
**filtered_kwargs,
all_options=options,
**kwargs,
)
with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span:
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
@@ -1430,7 +1434,7 @@ def _trace_agent_run_stream(
span=span,
provider_name=provider_name,
messages=messages,
system_instructions=getattr(getattr(self, "chat_options", None), "instructions", None),
system_instructions=_get_instructions_from_options(options),
)
try:
async for update in run_streaming_func(self, messages=messages, thread=thread, **kwargs):
@@ -1586,7 +1590,9 @@ def _get_span(
Note: `attributes` must contain the `span_name_attribute` key.
"""
span = get_tracer().start_span(f"{attributes[OtelAttr.OPERATION]} {attributes[span_name_attribute]}")
operation = attributes.get(OtelAttr.OPERATION, "operation")
span_name = attributes.get(span_name_attribute, "unknown")
span = get_tracer().start_span(f"{operation} {span_name}")
span.set_attributes(attributes)
with trace.use_span(
span=span,
@@ -1597,65 +1603,96 @@ def _get_span(
yield current_span
def _get_instructions_from_options(options: Any) -> str | None:
"""Extract instructions from options dict."""
if options is None:
return None
if isinstance(options, dict):
return options.get("instructions")
return None
# Mapping configuration for extracting span attributes
# Each entry: source_keys -> (otel_attribute_key, transform_func, check_options_first, default_value)
# - source_keys: single key or list of keys to check (first non-None value wins)
# - otel_attribute_key: target OTEL attribute name
# - transform_func: optional transformation function, can return None to skip attribute
# - check_options_first: whether to check options dict before kwargs
# - default_value: optional default value if key is not found (use None to skip)
OTEL_ATTR_MAP: dict[str | tuple[str, ...], tuple[str, Callable[[Any], Any] | None, bool, Any]] = {
"choice_count": (OtelAttr.CHOICE_COUNT, None, False, 1),
"operation_name": (OtelAttr.OPERATION, None, False, None),
"system_name": (SpanAttributes.LLM_SYSTEM, None, False, None),
"provider_name": (OtelAttr.PROVIDER_NAME, None, False, None),
"service_url": (OtelAttr.ADDRESS, None, False, None),
"conversation_id": (OtelAttr.CONVERSATION_ID, None, True, None),
"seed": (OtelAttr.SEED, None, True, None),
"frequency_penalty": (OtelAttr.FREQUENCY_PENALTY, None, True, None),
"max_tokens": (SpanAttributes.LLM_REQUEST_MAX_TOKENS, None, True, None),
"stop": (OtelAttr.STOP_SEQUENCES, None, True, None),
"temperature": (SpanAttributes.LLM_REQUEST_TEMPERATURE, None, True, None),
"top_p": (SpanAttributes.LLM_REQUEST_TOP_P, None, True, None),
"presence_penalty": (OtelAttr.PRESENCE_PENALTY, None, True, None),
"top_k": (OtelAttr.TOP_K, None, True, None),
"encoding_formats": (
OtelAttr.ENCODING_FORMATS,
lambda v: json.dumps(v if isinstance(v, list) else [v]),
True,
None,
),
"agent_id": (OtelAttr.AGENT_ID, None, False, None),
"agent_name": (OtelAttr.AGENT_NAME, None, False, None),
"agent_description": (OtelAttr.AGENT_DESCRIPTION, None, False, None),
# Multiple source keys - checks model_id in options, then model in kwargs, then model_id in kwargs
("model_id", "model"): (SpanAttributes.LLM_REQUEST_MODEL, None, True, None),
# Tools with validation - returns None if no valid tools
"tools": (
OtelAttr.TOOL_DEFINITIONS,
lambda tools: (
json.dumps(tools_dict)
if (tools_dict := __import__("agent_framework._tools", fromlist=["_tools_to_dict"])._tools_to_dict(tools))
else None
),
True,
None,
),
# Error type extraction
"error": (OtelAttr.ERROR_TYPE, lambda e: type(e).__name__, False, None),
# thread_id overrides conversation_id - processed after conversation_id due to dict ordering
"thread_id": (OtelAttr.CONVERSATION_ID, None, False, None),
}
def _get_span_attributes(**kwargs: Any) -> dict[str, Any]:
"""Get the span attributes from a kwargs dictionary."""
from ._tools import _tools_to_dict
from ._types import ChatOptions
attributes: dict[str, Any] = {}
chat_options: ChatOptions | None = kwargs.get("chat_options")
if chat_options is None:
chat_options = ChatOptions()
if operation_name := kwargs.get("operation_name"):
attributes[OtelAttr.OPERATION] = operation_name
if choice_count := kwargs.get("choice_count", 1):
attributes[OtelAttr.CHOICE_COUNT] = choice_count
if system_name := kwargs.get("system_name"):
attributes[SpanAttributes.LLM_SYSTEM] = system_name
if provider_name := kwargs.get("provider_name"):
attributes[OtelAttr.PROVIDER_NAME] = provider_name
if model_id := kwargs.get("model", chat_options.model_id):
attributes[SpanAttributes.LLM_REQUEST_MODEL] = model_id
if service_url := kwargs.get("service_url"):
attributes[OtelAttr.ADDRESS] = service_url
if conversation_id := kwargs.get("conversation_id", chat_options.conversation_id):
attributes[OtelAttr.CONVERSATION_ID] = conversation_id
if seed := kwargs.get("seed", chat_options.seed):
attributes[OtelAttr.SEED] = seed
if frequency_penalty := kwargs.get("frequency_penalty", chat_options.frequency_penalty):
attributes[OtelAttr.FREQUENCY_PENALTY] = frequency_penalty
if max_tokens := kwargs.get("max_tokens", chat_options.max_tokens):
attributes[SpanAttributes.LLM_REQUEST_MAX_TOKENS] = max_tokens
if stop := kwargs.get("stop", chat_options.stop):
attributes[OtelAttr.STOP_SEQUENCES] = stop
if temperature := kwargs.get("temperature", chat_options.temperature):
attributes[SpanAttributes.LLM_REQUEST_TEMPERATURE] = temperature
if top_p := kwargs.get("top_p", chat_options.top_p):
attributes[SpanAttributes.LLM_REQUEST_TOP_P] = top_p
if presence_penalty := kwargs.get("presence_penalty", chat_options.presence_penalty):
attributes[OtelAttr.PRESENCE_PENALTY] = presence_penalty
if top_k := kwargs.get("top_k"):
attributes[OtelAttr.TOP_K] = top_k
if encoding_formats := kwargs.get("encoding_formats"):
attributes[OtelAttr.ENCODING_FORMATS] = json.dumps(
encoding_formats if isinstance(encoding_formats, list) else [encoding_formats]
)
if tools := kwargs.get("tools", chat_options.tools):
tools_as_json_list = _tools_to_dict(tools)
if tools_as_json_list:
attributes[OtelAttr.TOOL_DEFINITIONS] = json.dumps(tools_as_json_list)
if error := kwargs.get("error"):
attributes[OtelAttr.ERROR_TYPE] = type(error).__name__
# agent attributes
if agent_id := kwargs.get("agent_id"):
attributes[OtelAttr.AGENT_ID] = agent_id
if agent_name := kwargs.get("agent_name"):
attributes[OtelAttr.AGENT_NAME] = agent_name
if agent_description := kwargs.get("agent_description"):
attributes[OtelAttr.AGENT_DESCRIPTION] = agent_description
if thread_id := kwargs.get("thread_id"):
# override if thread is set
attributes[OtelAttr.CONVERSATION_ID] = thread_id
options = kwargs.get("all_options", kwargs.get("options"))
if options is not None and not isinstance(options, dict):
options = None
for source_keys, (otel_key, transform_func, check_options, default_value) in OTEL_ATTR_MAP.items():
# Normalize to tuple of keys
keys = (source_keys,) if isinstance(source_keys, str) else source_keys
value = None
for key in keys:
if check_options and options is not None:
value = options.get(key)
if value is None:
value = kwargs.get(key)
if value is not None:
break
# Apply default value if no value found
if value is None and default_value is not None:
value = default_value
if value is not None:
result = transform_func(value) if transform_func else value
# Allow transform_func to return None to skip attribute
if result is not None:
attributes[otel_key] = result
return attributes
@@ -2,8 +2,15 @@
import json
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, MutableSequence
from typing import Any, cast
from collections.abc import (
AsyncIterable,
Awaitable,
Callable,
Mapping,
MutableMapping,
MutableSequence,
)
from typing import Any, Generic, Literal, TypedDict, cast
from openai import AsyncOpenAI
from openai.types.beta.threads import (
@@ -22,7 +29,12 @@ from pydantic import ValidationError
from .._clients import BaseChatClient
from .._middleware import use_chat_middleware
from .._tools import AIFunction, HostedCodeInterpreterTool, HostedFileSearchTool, use_function_invocation
from .._tools import (
AIFunction,
HostedCodeInterpreterTool,
HostedFileSearchTool,
use_function_invocation,
)
from .._types import (
ChatMessage,
ChatOptions,
@@ -35,7 +47,6 @@ from .._types import (
MCPServerToolCallContent,
Role,
TextContent,
ToolMode,
UriContent,
UsageContent,
UsageDetails,
@@ -45,19 +56,162 @@ from ..exceptions import ServiceInitializationError
from ..observability import use_instrumentation
from ._shared import OpenAIConfigMixin, OpenAISettings
if sys.version_info >= (3, 13):
from typing import TypeVar
else:
from typing_extensions import TypeVar
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
from typing_extensions import Self # pragma: no cover
__all__ = ["OpenAIAssistantsClient"]
__all__ = [
"AssistantToolResources",
"OpenAIAssistantsClient",
"OpenAIAssistantsOptions",
]
# region OpenAI Assistants Options TypedDict
class VectorStoreToolResource(TypedDict, total=False):
"""Vector store configuration for file search tool resources."""
vector_store_ids: list[str]
"""IDs of vector stores attached to this assistant."""
class CodeInterpreterToolResource(TypedDict, total=False):
"""Code interpreter tool resource configuration."""
file_ids: list[str]
"""File IDs accessible by the code interpreter tool. Max 20 files per assistant."""
class AssistantToolResources(TypedDict, total=False):
"""Tool resources attached to the assistant.
See: https://platform.openai.com/docs/api-reference/assistants/createAssistant#assistants-createassistant-tool_resources
"""
code_interpreter: CodeInterpreterToolResource
"""Resources for code interpreter tool, including file IDs."""
file_search: VectorStoreToolResource
"""Resources for file search tool, including vector store IDs."""
class OpenAIAssistantsOptions(ChatOptions, total=False):
"""OpenAI Assistants API-specific options dict.
Extends base ChatOptions with Assistants API-specific parameters
for creating and running assistants.
See: https://platform.openai.com/docs/api-reference/assistants
Keys:
# Inherited from ChatOptions:
model_id: The model to use for the assistant,
translates to ``model`` in OpenAI API.
temperature: Sampling temperature between 0 and 2.
top_p: Nucleus sampling parameter.
max_tokens: Maximum number of tokens to generate,
translates to ``max_completion_tokens`` in OpenAI API.
tools: List of tools (functions, code_interpreter, file_search).
tool_choice: How the model should use tools.
allow_multiple_tool_calls: Whether to allow parallel tool calls,
translates to ``parallel_tool_calls`` in OpenAI API.
response_format: Structured output schema.
metadata: Request metadata for tracking.
# Options not supported in Assistants API (inherited but unused):
stop: Not supported.
seed: Not supported (use assistant-level configuration instead).
frequency_penalty: Not supported.
presence_penalty: Not supported.
user: Not supported.
store: Not supported.
# Assistants-specific options:
name: Name of the assistant.
description: Description of the assistant.
instructions: System instructions for the assistant.
tool_resources: Resources for tools (file IDs, vector stores).
reasoning_effort: Effort level for o-series reasoning models.
conversation_id: Thread ID to continue conversation in.
"""
# Assistants-specific options
name: str
"""Name of the assistant (max 256 characters)."""
description: str
"""Description of the assistant (max 512 characters)."""
tool_resources: AssistantToolResources
"""Tool-specific resources like file IDs and vector stores."""
reasoning_effort: Literal["low", "medium", "high"]
"""Effort level for o-series reasoning models (o1, o3-mini).
Higher effort = more reasoning time and potentially better results."""
conversation_id: str # type: ignore[misc]
"""Thread ID to continue a conversation in an existing thread."""
# OpenAI/ChatOptions fields not supported in Assistants API
stop: None # type: ignore[misc]
"""Not supported in Assistants API."""
seed: None # type: ignore[misc]
"""Not supported in Assistants API (use assistant-level configuration)."""
frequency_penalty: None # type: ignore[misc]
"""Not supported in Assistants API."""
presence_penalty: None # type: ignore[misc]
"""Not supported in Assistants API."""
user: None # type: ignore[misc]
"""Not supported in Assistants API."""
store: None # type: ignore[misc]
"""Not supported in Assistants API."""
ASSISTANTS_OPTION_TRANSLATIONS: dict[str, str] = {
"model_id": "model",
"max_tokens": "max_completion_tokens",
"allow_multiple_tool_calls": "parallel_tool_calls",
}
"""Maps ChatOptions keys to OpenAI Assistants API parameter names."""
TOpenAIAssistantsOptions = TypeVar(
"TOpenAIAssistantsOptions",
bound=TypedDict, # type: ignore[valid-type]
default="OpenAIAssistantsOptions",
covariant=True,
)
# endregion
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
class OpenAIAssistantsClient(
OpenAIConfigMixin,
BaseChatClient[TOpenAIAssistantsOptions],
Generic[TOpenAIAssistantsOptions],
):
"""OpenAI Assistants client."""
def __init__(
@@ -118,6 +272,18 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
# Or loading from a .env file
client = OpenAIAssistantsClient(env_file_path="path/to/.env")
# Using custom ChatOptions with type safety:
from typing import TypedDict
from agent_framework.openai import OpenAIAssistantsOptions
class MyOptions(OpenAIAssistantsOptions, total=False):
my_custom_option: str
client: OpenAIAssistantsClient[MyOptions] = OpenAIAssistantsClient(model_id="gpt-4")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
openai_settings = OpenAISettings(
@@ -159,7 +325,12 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
"""Async context manager entry."""
return self
async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: Any) -> None:
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: Any,
) -> None:
"""Async context manager exit - clean up any assistants we created."""
await self.close()
@@ -171,34 +342,32 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
object.__setattr__(self, "assistant_id", None)
object.__setattr__(self, "_should_delete_assistant", False)
@override
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
return await ChatResponse.from_chat_response_generator(
updates=self._inner_get_streaming_response(messages=messages, chat_options=chat_options, **kwargs),
output_format_type=chat_options.response_format,
updates=self._inner_get_streaming_response(messages=messages, options=options, **kwargs),
output_format_type=options.get("response_format"),
)
@override
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
# prepare
run_options, tool_results = self._prepare_options(messages, chat_options, **kwargs)
run_options, tool_results = self._prepare_options(messages, options, **kwargs)
# Get the thread ID
thread_id: str | None = (
chat_options.conversation_id
if chat_options.conversation_id is not None
else run_options.get("conversation_id", self.thread_id)
)
thread_id: str | None = options.get("conversation_id", run_options.get("conversation_id", self.thread_id))
if thread_id is None and tool_results is not None:
raise ValueError("No thread ID was provided, but chat messages includes tool results.")
@@ -256,7 +425,9 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
if thread_run is not None and tool_run_id is not None and tool_run_id == thread_run.id and tool_outputs:
# There's an active run and we have tool results to submit, so submit the results.
stream = client.beta.threads.runs.submit_tool_outputs_stream( # type: ignore[reportDeprecated]
run_id=tool_run_id, thread_id=thread_run.thread_id, tool_outputs=tool_outputs
run_id=tool_run_id,
thread_id=thread_run.thread_id,
tool_outputs=tool_outputs,
)
final_thread_id = thread_run.thread_id
else:
@@ -408,7 +579,11 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
function_name = tool_call.function.name
function_arguments = json.loads(tool_call.function.arguments)
contents.append(
FunctionCallContent(call_id=call_id, name=function_name, arguments=function_arguments)
FunctionCallContent(
call_id=call_id,
name=function_name,
arguments=function_arguments,
)
)
return contents
@@ -416,59 +591,75 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
def _prepare_options(
self,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions | None,
options: dict[str, Any],
**kwargs: Any,
) -> tuple[dict[str, Any], list[FunctionResultContent] | None]:
from .._types import validate_tool_mode
run_options: dict[str, Any] = {**kwargs}
if chat_options is not None:
run_options["max_completion_tokens"] = chat_options.max_tokens
run_options["model"] = chat_options.model_id
run_options["top_p"] = chat_options.top_p
run_options["temperature"] = chat_options.temperature
# Extract options from the dict
max_tokens = options.get("max_tokens")
model_id = options.get("model_id")
top_p = options.get("top_p")
temperature = options.get("temperature")
allow_multiple_tool_calls = options.get("allow_multiple_tool_calls")
tool_choice = options.get("tool_choice")
tools = options.get("tools")
response_format = options.get("response_format")
if chat_options.allow_multiple_tool_calls is not None:
run_options["parallel_tool_calls"] = chat_options.allow_multiple_tool_calls
if max_tokens is not None:
run_options["max_completion_tokens"] = max_tokens
if model_id is not None:
run_options["model"] = model_id
if top_p is not None:
run_options["top_p"] = top_p
if temperature is not None:
run_options["temperature"] = temperature
if chat_options.tool_choice is not None:
tool_definitions: list[MutableMapping[str, Any]] = []
if chat_options.tool_choice != "none" and chat_options.tools is not None:
for tool in chat_options.tools:
if isinstance(tool, AIFunction):
tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType]
elif isinstance(tool, HostedCodeInterpreterTool):
tool_definitions.append({"type": "code_interpreter"})
elif isinstance(tool, HostedFileSearchTool):
params: dict[str, Any] = {
"type": "file_search",
}
if tool.max_results is not None:
params["max_num_results"] = tool.max_results
tool_definitions.append(params)
elif isinstance(tool, MutableMapping):
tool_definitions.append(tool)
if allow_multiple_tool_calls is not None:
run_options["parallel_tool_calls"] = allow_multiple_tool_calls
if len(tool_definitions) > 0:
run_options["tools"] = tool_definitions
if chat_options.tool_choice == "none" or chat_options.tool_choice == "auto":
run_options["tool_choice"] = chat_options.tool_choice.mode
elif (
isinstance(chat_options.tool_choice, ToolMode)
and chat_options.tool_choice == "required"
and chat_options.tool_choice.required_function_name is not None
):
run_options["tool_choice"] = {
"type": "function",
"function": {"name": chat_options.tool_choice.required_function_name},
tool_mode = validate_tool_mode(tool_choice)
tool_definitions: list[MutableMapping[str, Any]] = []
if tool_mode["mode"] != "none" and tools is not None:
for tool in tools:
if isinstance(tool, AIFunction):
tool_definitions.append(tool.to_json_schema_spec()) # type: ignore[reportUnknownArgumentType]
elif isinstance(tool, HostedCodeInterpreterTool):
tool_definitions.append({"type": "code_interpreter"})
elif isinstance(tool, HostedFileSearchTool):
params: dict[str, Any] = {
"type": "file_search",
}
if tool.max_results is not None:
params["max_num_results"] = tool.max_results
tool_definitions.append(params)
elif isinstance(tool, MutableMapping):
tool_definitions.append(tool)
if chat_options.response_format is not None:
if len(tool_definitions) > 0:
run_options["tools"] = tool_definitions
if (mode := tool_mode["mode"]) == "required" and (
func_name := tool_mode.get("required_function_name")
) is not None:
run_options["tool_choice"] = {
"type": "function",
"function": {"name": func_name},
}
else:
run_options["tool_choice"] = mode
if response_format is not None:
if isinstance(response_format, dict):
run_options["response_format"] = response_format
else:
run_options["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": chat_options.response_format.__name__,
"schema": chat_options.response_format.model_json_schema(),
"name": response_format.__name__,
"schema": response_format.model_json_schema(),
},
}
@@ -5,7 +5,7 @@ import sys
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, MutableSequence, Sequence
from datetime import datetime, timezone
from itertools import chain
from typing import Any, TypeVar
from typing import Any, Generic, Literal, TypedDict
from openai import AsyncOpenAI, BadRequestError
from openai.lib._parsing._completions import type_to_response_format_param
@@ -49,34 +49,105 @@ from ..observability import use_instrumentation
from ._exceptions import OpenAIContentFilterException
from ._shared import OpenAIBase, OpenAIConfigMixin, OpenAISettings
if sys.version_info >= (3, 13):
from typing import TypeVar
else:
from typing_extensions import TypeVar
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
__all__ = ["OpenAIChatClient"]
__all__ = ["OpenAIChatClient", "OpenAIChatOptions"]
logger = get_logger("agent_framework.openai")
# region OpenAI Chat Options TypedDict
class PredictionTextContent(TypedDict, total=False):
"""Prediction text content options for OpenAI Chat completions."""
type: Literal["text"]
text: str
class Prediction(TypedDict, total=False):
"""Prediction options for OpenAI Chat completions."""
type: Literal["content"]
content: str | list[PredictionTextContent]
class OpenAIChatOptions(ChatOptions, total=False):
"""OpenAI-specific chat options dict.
Extends ChatOptions with options specific to OpenAI's Chat Completions API.
Keys:
model_id: The model to use for the request,
translates to ``model`` in OpenAI API.
temperature: Sampling temperature between 0 and 2.
top_p: Nucleus sampling parameter.
max_tokens: Maximum number of tokens to generate,
translates to ``max_completion_tokens`` in OpenAI API.
stop: Stop sequences.
seed: Random seed for reproducibility.
frequency_penalty: Frequency penalty between -2.0 and 2.0.
presence_penalty: Presence penalty between -2.0 and 2.0.
tools: List of tools (functions) available to the model.
tool_choice: How the model should use tools.
allow_multiple_tool_calls: Whether to allow parallel tool calls,
translates to ``parallel_tool_calls`` in OpenAI API.
response_format: Structured output schema.
metadata: Request metadata for tracking.
user: End-user identifier for abuse monitoring.
store: Whether to store the conversation.
instructions: System instructions for the model (prepended as system message).
# OpenAI-specific options (supported by all models):
logit_bias: Token bias values (-100 to 100).
logprobs: Whether to return log probabilities.
top_logprobs: Number of top log probabilities to return (0-20).
prediction: Whether to use predicted return tokens.
"""
# OpenAI-specific generation parameters (supported by all models)
logit_bias: dict[str | int, float] # type: ignore[misc]
logprobs: bool
top_logprobs: int
prediction: Prediction
TOpenAIChatOptions = TypeVar("TOpenAIChatOptions", bound=TypedDict, default="OpenAIChatOptions", covariant=True) # type: ignore[valid-type]
OPTION_TRANSLATIONS: dict[str, str] = {
"model_id": "model",
"allow_multiple_tool_calls": "parallel_tool_calls",
"max_tokens": "max_completion_tokens",
}
# region Base Client
class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
class OpenAIBaseChatClient(OpenAIBase, BaseChatClient[TOpenAIChatOptions], Generic[TOpenAIChatOptions]):
"""OpenAI Chat completion class."""
@override
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
client = await self._ensure_client()
# prepare
options_dict = self._prepare_options(messages, chat_options)
options_dict = self._prepare_options(messages, options)
try:
# execute and process
return self._parse_response_from_openai(
await client.chat.completions.create(stream=False, **options_dict), chat_options
await client.chat.completions.create(stream=False, **options_dict), options
)
except BadRequestError as ex:
if ex.code == "content_filter":
@@ -94,16 +165,17 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
inner_exception=ex,
) from ex
@override
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
client = await self._ensure_client()
# prepare
options_dict = self._prepare_options(messages, chat_options)
options_dict = self._prepare_options(messages, options)
options_dict["stream_options"] = {"include_usage": True}
try:
# execute and process
@@ -129,49 +201,45 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
# region content creation
def _prepare_tools_for_openai(
self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]
) -> list[dict[str, Any]]:
def _prepare_tools_for_openai(self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]) -> dict[str, Any]:
chat_tools: list[dict[str, Any]] = []
web_search_options: dict[str, Any] | None = None
for tool in tools:
if isinstance(tool, ToolProtocol):
match tool:
case AIFunction():
chat_tools.append(tool.to_json_schema_spec())
case HostedWebSearchTool():
web_search_options = (
{
"user_location": {
"approximate": tool.additional_properties.get("user_location", None),
"type": "approximate",
}
}
if tool.additional_properties and "user_location" in tool.additional_properties
else {}
)
case _:
logger.debug("Unsupported tool passed (type: %s), ignoring", type(tool))
else:
chat_tools.append(tool if isinstance(tool, dict) else dict(tool))
return chat_tools
ret_dict: dict[str, Any] = {}
if chat_tools:
ret_dict["tools"] = chat_tools
if web_search_options is not None:
ret_dict["web_search_options"] = web_search_options
return ret_dict
def _process_web_search_tool(
self, tools: Sequence[ToolProtocol | MutableMapping[str, Any]]
) -> dict[str, Any] | None:
for tool in tools:
if isinstance(tool, HostedWebSearchTool):
# Web search tool requires special handling
return (
{
"user_location": {
"approximate": tool.additional_properties.get("user_location", None),
"type": "approximate",
}
}
if tool.additional_properties and "user_location" in tool.additional_properties
else {}
)
def _prepare_options(self, messages: MutableSequence[ChatMessage], options: dict[str, Any]) -> dict[str, Any]:
# Prepend instructions from options if they exist
from .._types import prepend_instructions_to_messages, validate_tool_mode
return None
if instructions := options.get("instructions"):
messages = prepend_instructions_to_messages(list(messages), instructions, role="system")
def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]:
run_options = chat_options.to_dict(
exclude={
"type",
"instructions", # included as system message
"response_format", # handled separately
"additional_properties", # handled separately
}
)
# Start with a copy of options
run_options = {k: v for k, v in options.items() if v is not None and k not in {"instructions", "tools"}}
# messages
if messages and "messages" not in run_options:
@@ -179,13 +247,8 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
if "messages" not in run_options:
raise ServiceInvalidRequestError("Messages are required for chat completions")
# Translation between ChatOptions and Chat Completion API
translations = {
"model_id": "model",
"allow_multiple_tool_calls": "parallel_tool_calls",
"max_tokens": "max_completion_tokens",
}
for old_key, new_key in translations.items():
# Translation between options keys and Chat Completion API
for old_key, new_key in OPTION_TRANSLATIONS.items():
if old_key in run_options and old_key != new_key:
run_options[new_key] = run_options.pop(old_key)
@@ -196,32 +259,33 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
run_options["model"] = self.model_id
# tools
if chat_options.tools is not None:
# Preprocess web search tool if it exists
if web_search_options := self._process_web_search_tool(chat_options.tools):
run_options["web_search_options"] = web_search_options
run_options["tools"] = self._prepare_tools_for_openai(chat_options.tools)
if not run_options.get("tools", None):
run_options.pop("tools", None)
tools = options.get("tools")
if tools is not None:
run_options.update(self._prepare_tools_for_openai(tools))
if not run_options.get("tools"):
run_options.pop("parallel_tool_calls", None)
run_options.pop("tool_choice", None)
# tool_choice: ToolMode serializes to {"type": "tool_mode", "mode": "..."}, extract mode
if (tool_choice := run_options.get("tool_choice")) and isinstance(tool_choice, dict) and "mode" in tool_choice:
run_options["tool_choice"] = tool_choice["mode"]
if tool_choice := run_options.pop("tool_choice", None):
tool_mode = validate_tool_mode(tool_choice)
if (mode := tool_mode.get("mode")) == "required" and (
func_name := tool_mode.get("required_function_name")
) is not None:
run_options["tool_choice"] = {
"type": "function",
"function": {"name": func_name},
}
else:
run_options["tool_choice"] = mode
# response format
if chat_options.response_format:
run_options["response_format"] = type_to_response_format_param(chat_options.response_format)
# additional properties
additional_options = {
key: value for key, value in chat_options.additional_properties.items() if value is not None
}
if additional_options:
run_options.update(additional_options)
if response_format := options.get("response_format"):
if isinstance(response_format, dict):
run_options["response_format"] = response_format
else:
run_options["response_format"] = type_to_response_format_param(response_format)
return run_options
def _parse_response_from_openai(self, response: ChatCompletion, chat_options: ChatOptions) -> "ChatResponse":
def _parse_response_from_openai(self, response: ChatCompletion, options: dict[str, Any]) -> "ChatResponse":
"""Parse a response from OpenAI into a ChatResponse."""
response_metadata = self._get_metadata_from_chat_response(response)
messages: list[ChatMessage] = []
@@ -246,7 +310,7 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
model_id=response.model,
additional_properties=response_metadata,
finish_reason=finish_reason,
response_format=chat_options.response_format,
response_format=options.get("response_format"),
)
def _parse_response_update_from_openai(
@@ -502,13 +566,11 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient):
# region Public client
TOpenAIChatClient = TypeVar("TOpenAIChatClient", bound="OpenAIChatClient")
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class OpenAIChatClient(OpenAIConfigMixin, OpenAIBaseChatClient):
class OpenAIChatClient(OpenAIConfigMixin, OpenAIBaseChatClient[TOpenAIChatOptions], Generic[TOpenAIChatOptions]):
"""OpenAI Chat completion class."""
def __init__(
@@ -552,14 +614,26 @@ class OpenAIChatClient(OpenAIConfigMixin, OpenAIBaseChatClient):
# Using environment variables
# Set OPENAI_API_KEY=sk-...
# Set OPENAI_CHAT_MODEL_ID=gpt-4
# Set OPENAI_CHAT_MODEL_ID=<model name>
client = OpenAIChatClient()
# Or passing parameters directly
client = OpenAIChatClient(model_id="gpt-4", api_key="sk-...")
client = OpenAIChatClient(model_id="<model name>", api_key="sk-...")
# Or loading from a .env file
client = OpenAIChatClient(env_file_path="path/to/.env")
# Using custom ChatOptions with type safety:
from typing import TypedDict
from agent_framework.openai import OpenAIChatOptions
class MyOptions(OpenAIChatOptions, total=False):
my_custom_option: str
client: OpenAIChatClient[MyOptions] = OpenAIChatClient(model_id="<model name>")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
openai_settings = OpenAISettings(
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from collections.abc import (
AsyncIterable,
Awaitable,
@@ -11,7 +12,7 @@ from collections.abc import (
)
from datetime import datetime, timezone
from itertools import chain
from typing import Any, TypeVar, cast
from typing import Any, Generic, Literal, TypedDict, cast
from openai import AsyncOpenAI, BadRequestError
from openai.types.responses.file_search_tool_param import FileSearchToolParam
@@ -30,9 +31,6 @@ from openai.types.responses.tool_param import (
Mcp,
ToolParam,
)
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, ValidationError
@@ -78,6 +76,8 @@ from .._types import (
UsageDetails,
_parse_content,
prepare_function_call_results,
prepend_instructions_to_messages,
validate_tool_mode,
)
from ..exceptions import (
ServiceInitializationError,
@@ -88,37 +88,150 @@ from ..observability import use_instrumentation
from ._exceptions import OpenAIContentFilterException
from ._shared import OpenAIBase, OpenAIConfigMixin, OpenAISettings
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
logger = get_logger("agent_framework.openai")
__all__ = ["OpenAIResponsesClient"]
__all__ = ["OpenAIResponsesClient", "OpenAIResponsesOptions"]
# region OpenAI Responses Options TypedDict
class ReasoningOptions(TypedDict, total=False):
"""Configuration options for reasoning models (gpt-5, o-series).
See: https://platform.openai.com/docs/guides/reasoning
"""
effort: Literal["low", "medium", "high"]
"""The effort level for reasoning. Higher effort means more reasoning tokens."""
summary: Literal["auto", "concise", "detailed"]
"""How to summarize reasoning in the response."""
class StreamOptions(TypedDict, total=False):
"""Options for streaming responses."""
include_usage: bool
"""Whether to include usage statistics in stream events."""
class OpenAIResponsesOptions(ChatOptions, total=False):
"""OpenAI Responses API-specific chat options.
Extends ChatOptions with options specific to OpenAI's Responses API.
These options provide fine-grained control over response generation,
reasoning, and API behavior.
See: https://platform.openai.com/docs/api-reference/responses/create
"""
# Responses API-specific parameters
include: list[str]
"""Additional output data to include in the response.
Supported values include:
- 'web_search_call.action.sources'
- 'code_interpreter_call.outputs'
- 'file_search_call.results'
- 'message.input_image.image_url'
- 'message.output_text.logprobs'
- 'reasoning.encrypted_content'
"""
max_tool_calls: int
"""Maximum number of total calls to built-in tools in a response."""
prompt: dict[str, Any]
"""Reference to a prompt template and its variables.
Learn more: https://platform.openai.com/docs/guides/text#reusable-prompts"""
prompt_cache_key: str
"""Used by OpenAI to cache responses for similar requests.
Replaces the deprecated 'user' field for caching purposes."""
prompt_cache_retention: Literal["24h"]
"""Retention policy for prompt cache. Set to '24h' for extended caching."""
reasoning: ReasoningOptions
"""Configuration for reasoning models (gpt-5, o-series).
See: https://platform.openai.com/docs/guides/reasoning"""
safety_identifier: str
"""A stable identifier for detecting policy violations.
Recommend hashing username/email to avoid sending identifying info."""
service_tier: Literal["auto", "default", "flex", "priority"]
"""Processing type for serving the request.
- 'auto': Use project settings
- 'default': Standard pricing/performance
- 'flex': Flexible processing
- 'priority': Priority processing"""
stream_options: StreamOptions
"""Options for streaming responses. Only set when stream=True."""
top_logprobs: int
"""Number of most likely tokens (0-20) to return at each position."""
truncation: Literal["auto", "disabled"]
"""Truncation strategy for model response.
- 'auto': Truncate from beginning if exceeds context
- 'disabled': Fail with 400 error if exceeds context"""
TOpenAIResponsesOptions = TypeVar(
"TOpenAIResponsesOptions",
bound=TypedDict, # type: ignore[valid-type]
default="OpenAIResponsesOptions",
covariant=True,
)
# endregion
# region ResponsesClient
class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
class OpenAIBaseResponsesClient(
OpenAIBase,
BaseChatClient[TOpenAIResponsesOptions],
Generic[TOpenAIResponsesOptions],
):
"""Base class for all OpenAI Responses based API's."""
FILE_SEARCH_MAX_RESULTS: int = 50
# region Inner Methods
@override
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
client = await self._ensure_client()
# prepare
run_options = await self._prepare_options(messages, chat_options, **kwargs)
run_options = await self._prepare_options(messages, options, **kwargs)
try:
# execute and process
if "text_format" in run_options:
response = await client.responses.parse(stream=False, **run_options)
else:
response = await client.responses.create(stream=False, **run_options)
return self._parse_response_from_openai(response, chat_options=chat_options)
return self._parse_response_from_openai(response, options=options)
except BadRequestError as ex:
if ex.code == "content_filter":
raise OpenAIContentFilterException(
@@ -135,16 +248,17 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
inner_exception=ex,
) from ex
@override
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
client = await self._ensure_client()
# prepare
run_options = await self._prepare_options(messages, chat_options, **kwargs)
run_options = await self._prepare_options(messages, options, **kwargs)
function_call_ids: dict[int, tuple[str, str]] = {} # output_index: (call_id, name)
try:
# execute and process
@@ -152,7 +266,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
async for chunk in await client.responses.create(stream=True, **run_options):
yield self._parse_chunk_from_openai(
chunk,
chat_options=chat_options,
options=options,
function_call_ids=function_call_ids,
)
return
@@ -160,7 +274,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
async for chunk in response:
yield self._parse_chunk_from_openai(
chunk,
chat_options=chat_options,
options=options,
function_call_ids=function_call_ids,
)
except BadRequestError as ex:
@@ -319,25 +433,30 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
)
)
case HostedWebSearchTool():
location: dict[str, str] | None = (
web_search_tool = WebSearchToolParam(type="web_search")
if location := (
tool.additional_properties.get("user_location", None)
if tool.additional_properties
else None
)
response_tools.append(
WebSearchToolParam(
type="web_search",
user_location=WebSearchUserLocation(
type="approximate",
city=location.get("city", None),
country=location.get("country", None),
region=location.get("region", None),
timezone=location.get("timezone", None),
)
if location
else None,
)
)
):
web_search_tool["user_location"] = {
"type": "approximate",
"city": location.get("city", None),
"country": location.get("country", None),
"region": location.get("region", None),
"timezone": location.get("timezone", None),
}
if filters := (
tool.additional_properties.get("filters", None) if tool.additional_properties else None
):
web_search_tool["filters"] = filters
if search_context_size := (
tool.additional_properties.get("search_context_size", None)
if tool.additional_properties
else None
):
web_search_tool["search_context_size"] = search_context_size
response_tools.append(web_search_tool)
case HostedImageGenerationTool():
mapped_tool: dict[str, Any] = {"type": "image_generation"}
if tool.options:
@@ -389,25 +508,29 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
async def _prepare_options(
self,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> dict[str, Any]:
"""Take ChatOptions and create the specific options for Responses API."""
run_options: dict[str, Any] = chat_options.to_dict(
exclude={
"type",
"presence_penalty", # not supported
"frequency_penalty", # not supported
"logit_bias", # not supported
"seed", # not supported
"stop", # not supported
"instructions", # already added as system message
"response_format", # handled separately
"conversation_id", # handled separately
"additional_properties", # handled separately
}
)
"""Take options dict and create the specific options for Responses API."""
# Exclude keys that are not supported or handled separately
exclude_keys = {
"type",
"presence_penalty", # not supported
"frequency_penalty", # not supported
"logit_bias", # not supported
"seed", # not supported
"stop", # not supported
"instructions", # already added as system message
"response_format", # handled separately
"conversation_id", # handled separately
"tool_choice", # handled separately
}
run_options: dict[str, Any] = {k: v for k, v in options.items() if k not in exclude_keys and v is not None}
# messages
# Handle instructions by prepending to messages as system message
if instructions := options.get("instructions"):
messages = prepend_instructions_to_messages(list(messages), instructions, role="system")
request_input = self._prepare_messages_for_openai(messages)
if not request_input:
raise ServiceInvalidRequestError("Messages are required for chat completions")
@@ -416,7 +539,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
# model id
self._check_model_presence(run_options)
# translations between ChatOptions and Responses API
# translations between options and Responses API
translations = {
"model_id": "model",
"allow_multiple_tool_calls": "parallel_tool_calls",
@@ -428,7 +551,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
run_options[new_key] = run_options.pop(old_key)
# Handle different conversation ID formats
if conversation_id := self._get_current_conversation_id(chat_options, **kwargs):
if conversation_id := self._get_current_conversation_id(options, **kwargs):
if conversation_id.startswith("resp_"):
# For response IDs, set previous_response_id and remove conversation property
run_options["previous_response_id"] = conversation_id
@@ -440,32 +563,27 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
run_options["previous_response_id"] = conversation_id
# tools
if tools := self._prepare_tools_for_openai(chat_options.tools):
if tools := self._prepare_tools_for_openai(options.get("tools")):
run_options["tools"] = tools
# tool_choice: convert ToolMode to appropriate format
if tool_choice := options.get("tool_choice"):
tool_mode = validate_tool_mode(tool_choice)
if (mode := tool_mode.get("mode")) == "required" and (
func_name := tool_mode.get("required_function_name")
) is not None:
run_options["tool_choice"] = {
"type": "function",
"name": func_name,
}
else:
run_options["tool_choice"] = mode
else:
run_options.pop("parallel_tool_calls", None)
run_options.pop("tool_choice", None)
# tool_choice: ToolMode serializes to {"type": "tool_mode", "mode": "..."}, extract mode
if (tool_choice := run_options.get("tool_choice")) and isinstance(tool_choice, dict) and "mode" in tool_choice:
run_options["tool_choice"] = tool_choice["mode"]
# additional properties (excluding response_format which is handled separately)
additional_options = {
key: value
for key, value in chat_options.additional_properties.items()
if value is not None and key != "response_format"
}
if additional_options:
run_options.update(additional_options)
# response format and text config (after additional_properties so user can pass text via additional_properties)
# Check both chat_options.response_format and additional_properties for response_format
response_format: Any = (
chat_options.response_format
if chat_options.response_format is not None
else chat_options.additional_properties.get("response_format")
)
text_config: Any = run_options.pop("text", None)
# response format and text config
response_format = options.get("response_format")
text_config = run_options.pop("text", None)
response_format, text_config = self._prepare_response_and_text_format(
response_format=response_format, text_config=text_config
)
@@ -476,19 +594,19 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
return run_options
def _check_model_presence(self, run_options: dict[str, Any]) -> None:
def _check_model_presence(self, options: dict[str, Any]) -> None:
"""Check if the 'model' param is present, and if not raise a Error.
Since AzureAIClients use a different param for this, this method is overridden in those clients.
"""
if not run_options.get("model"):
if not options.get("model"):
if not self.model_id:
raise ValueError("model_id must be a non-empty string")
run_options["model"] = self.model_id
options["model"] = self.model_id
def _get_current_conversation_id(self, chat_options: ChatOptions, **kwargs: Any) -> str | None:
"""Get the current conversation ID from chat options or kwargs."""
return chat_options.conversation_id or kwargs.get("conversation_id")
def _get_current_conversation_id(self, options: dict[str, Any], **kwargs: Any) -> str | None:
"""Get the current conversation ID from options dict or kwargs."""
return options.get("conversation_id") or kwargs.get("conversation_id")
def _prepare_messages_for_openai(self, chat_messages: Sequence[ChatMessage]) -> list[dict[str, Any]]:
"""Prepare the chat messages for a request.
@@ -680,7 +798,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
def _parse_response_from_openai(
self,
response: OpenAIResponse | ParsedResponse[BaseModel],
chat_options: ChatOptions,
options: dict[str, Any],
) -> "ChatResponse":
"""Parse an OpenAI Responses API response into a ChatResponse."""
structured_response: BaseModel | None = response.output_parsed if isinstance(response, ParsedResponse) else None # type: ignore[reportUnknownMemberType]
@@ -918,20 +1036,22 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
"raw_representation": response,
}
if conversation_id := self._get_conversation_id(response, chat_options.store):
if conversation_id := self._get_conversation_id(response, options.get("store")):
args["conversation_id"] = conversation_id
if response.usage and (usage_details := self._parse_usage_from_openai(response.usage)):
args["usage_details"] = usage_details
if structured_response:
args["value"] = structured_response
elif chat_options.response_format:
args["response_format"] = chat_options.response_format
elif (response_format := options.get("response_format")) and isinstance(response_format, type):
# Only pass response_format to ChatResponse if it's a Pydantic model type,
# not a runtime JSON schema dict
args["response_format"] = response_format
return ChatResponse(**args)
def _parse_chunk_from_openai(
self,
event: OpenAIResponseStreamEvent,
chat_options: ChatOptions,
options: dict[str, Any],
function_call_ids: dict[int, tuple[str, str]],
) -> ChatResponseUpdate:
"""Parse an OpenAI Responses API streaming event into a ChatResponseUpdate."""
@@ -1023,13 +1143,13 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
metadata.update(self._get_metadata_from_response(event))
case "response.created":
response_id = event.response.id
conversation_id = self._get_conversation_id(event.response, chat_options.store)
conversation_id = self._get_conversation_id(event.response, options.get("store"))
case "response.in_progress":
response_id = event.response.id
conversation_id = self._get_conversation_id(event.response, chat_options.store)
conversation_id = self._get_conversation_id(event.response, options.get("store"))
case "response.completed":
response_id = event.response.id
conversation_id = self._get_conversation_id(event.response, chat_options.store)
conversation_id = self._get_conversation_id(event.response, options.get("store"))
model = event.response.model
if event.response.usage:
usage = self._parse_usage_from_openai(event.response.usage)
@@ -1296,13 +1416,14 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
return {}
TOpenAIResponsesClient = TypeVar("TOpenAIResponsesClient", bound="OpenAIResponsesClient")
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class OpenAIResponsesClient(OpenAIConfigMixin, OpenAIBaseResponsesClient):
class OpenAIResponsesClient(
OpenAIConfigMixin,
OpenAIBaseResponsesClient[TOpenAIResponsesOptions],
Generic[TOpenAIResponsesOptions],
):
"""OpenAI Responses client class."""
def __init__(
@@ -1355,6 +1476,18 @@ class OpenAIResponsesClient(OpenAIConfigMixin, OpenAIBaseResponsesClient):
# Or loading from a .env file
client = OpenAIResponsesClient(env_file_path="path/to/.env")
# Using custom ChatOptions with type safety:
from typing import TypedDict
from agent_framework.openai import OpenAIResponsesOptions
class MyOptions(OpenAIResponsesOptions, total=False):
my_custom_option: str
client: OpenAIResponsesClient[MyOptions] = OpenAIResponsesClient(model_id="gpt-4o")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
"""
try:
openai_settings = OpenAISettings(
@@ -24,7 +24,6 @@ from .._logging import get_logger
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
from ..exceptions import ServiceInitializationError
logger: logging.Logger = get_logger("agent_framework.openai")
@@ -43,7 +42,7 @@ RESPONSE_TYPE = Union[
_legacy_response.HttpxBinaryResponseContent,
]
OPTION_TYPE = Union[ChatOptions, dict[str, Any]]
OPTION_TYPE = dict[str, Any]
__all__ = ["OpenAISettings"]
@@ -299,8 +299,7 @@ async def test_azure_assistants_client_get_response_tools() -> None:
# Test that the client can be used to get a response
response = await azure_assistants_client.get_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
options={"tools": [get_weather], "tool_choice": "auto"},
)
assert response is not None
@@ -352,8 +351,7 @@ async def test_azure_assistants_client_streaming_tools() -> None:
# Test that the client can be used to get a response
response = azure_assistants_client.get_streaming_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
options={"tools": [get_weather], "tool_choice": "auto"},
)
full_message: str = ""
async for chunk in response:
@@ -212,7 +212,7 @@ async def test_cmc_with_logit_bias(
azure_chat_client = AzureOpenAIChatClient()
await azure_chat_client.get_response(messages=chat_history, logit_bias=token_bias)
await azure_chat_client.get_response(messages=chat_history, options={"logit_bias": token_bias})
mock_create.assert_awaited_once_with(
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
@@ -237,7 +237,7 @@ async def test_cmc_with_stop(
azure_chat_client = AzureOpenAIChatClient()
await azure_chat_client.get_response(messages=chat_history, stop=stop)
await azure_chat_client.get_response(messages=chat_history, options={"stop": stop})
mock_create.assert_awaited_once_with(
model=azure_openai_unit_test_env["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"],
@@ -300,7 +300,7 @@ async def test_azure_on_your_data(
content = await azure_chat_client.get_response(
messages=messages_in,
additional_properties={"extra_body": expected_data_settings},
options={"extra_body": expected_data_settings},
)
assert len(content.messages) == 1
assert len(content.messages[0].contents) == 1
@@ -370,7 +370,7 @@ async def test_azure_on_your_data_string(
content = await azure_chat_client.get_response(
messages=messages_in,
additional_properties={"extra_body": expected_data_settings},
options={"extra_body": expected_data_settings},
)
assert len(content.messages) == 1
assert len(content.messages[0].contents) == 1
@@ -429,7 +429,7 @@ async def test_azure_on_your_data_fail(
content = await azure_chat_client.get_response(
messages=messages_in,
additional_properties={"extra_body": expected_data_settings},
options={"extra_body": expected_data_settings},
)
assert len(content.messages) == 1
assert len(content.messages[0].contents) == 1
@@ -652,8 +652,7 @@ async def test_azure_openai_chat_client_response_tools() -> None:
# Test that the client can be used to get a response
response = await azure_chat_client.get_response(
messages=messages,
tools=[get_story_text],
tool_choice="auto",
options={"tools": [get_story_text], "tool_choice": "auto"},
)
assert response is not None
@@ -709,8 +708,7 @@ async def test_azure_openai_chat_client_streaming_tools() -> None:
# Test that the client can be used to get a response
response = azure_chat_client.get_streaming_response(
messages=messages,
tools=[get_story_text],
tool_choice="auto",
options={"tools": [get_story_text], "tool_choice": "auto"},
)
full_message: str = ""
async for chunk in response:
@@ -1,26 +1,25 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import os
from typing import Annotated
from typing import Annotated, Any
import pytest
from azure.identity import AzureCliCredential
from pydantic import BaseModel
from pytest import param
from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
AgentThread,
ChatAgent,
ChatClientProtocol,
ChatMessage,
ChatResponse,
ChatResponseUpdate,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedMCPTool,
HostedVectorStoreContent,
TextContent,
HostedWebSearchTool,
ai_function,
)
from agent_framework.azure import AzureOpenAIResponsesClient
@@ -74,7 +73,7 @@ async def delete_vector_store(client: AzureOpenAIResponsesClient, file_id: str,
def test_init(azure_openai_unit_test_env: dict[str, str]) -> None:
# Test successful initialization
azure_responses_client = AzureOpenAIResponsesClient()
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert azure_responses_client.model_id == azure_openai_unit_test_env["AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME"]
assert isinstance(azure_responses_client, ChatClientProtocol)
@@ -141,283 +140,286 @@ 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."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert isinstance(azure_responses_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change.",
)
)
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = await azure_responses_client.get_response(messages=messages)
assert response is not None
assert isinstance(response, ChatResponse)
assert "scientists" in response.text
messages.clear()
messages.append(ChatMessage(role="user", text="The weather in New York is sunny"))
messages.append(ChatMessage(role="user", text="What is the weather in New York?"))
# Test that the client can be used to get a structured response
structured_response = await azure_responses_client.get_response( # type: ignore[reportAssignmentType]
messages=messages,
response_format=OutputStruct,
)
assert structured_response is not None
assert isinstance(structured_response, ChatResponse)
assert isinstance(structured_response.value, OutputStruct)
assert structured_response.value.location == "New York"
assert "sunny" in structured_response.value.weather.lower()
# region Integration Tests
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_response_tools() -> None:
"""Test azure responses client tools."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert isinstance(azure_responses_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(ChatMessage(role="user", text="What is the weather in New York?"))
# Test that the client can be used to get a response
response = await azure_responses_client.get_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
)
assert response is not None
assert isinstance(response, ChatResponse)
assert "sunny" in response.text
messages.clear()
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
# Test that the client can be used to get a response
structured_response: ChatResponse = await azure_responses_client.get_response( # type: ignore[reportAssignmentType]
messages=messages,
tools=[get_weather],
tool_choice="auto",
response_format=OutputStruct,
)
assert structured_response is not None
assert isinstance(structured_response, ChatResponse)
assert isinstance(structured_response.value, OutputStruct)
assert "Seattle" in structured_response.value.location
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."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert isinstance(azure_responses_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change.",
)
)
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = azure_responses_client.get_streaming_response(messages=messages)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert "scientists" in full_message
messages.clear()
messages.append(ChatMessage(role="user", text="The weather in Seattle is sunny"))
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
structured_response = await ChatResponse.from_chat_response_generator(
azure_responses_client.get_streaming_response(
messages=messages,
response_format=OutputStruct,
@pytest.mark.parametrize(
"option_name,option_value,needs_validation",
[
# Simple ChatOptions - just verify they don't fail
param("temperature", 0.7, False, id="temperature"),
param("top_p", 0.9, False, id="top_p"),
param("max_tokens", 500, False, id="max_tokens"),
param("seed", 123, False, id="seed"),
param("user", "test-user-id", False, id="user"),
param("metadata", {"test_key": "test_value"}, False, id="metadata"),
param("frequency_penalty", 0.5, False, id="frequency_penalty"),
param("presence_penalty", 0.3, False, id="presence_penalty"),
param("stop", ["END"], False, id="stop"),
param("allow_multiple_tool_calls", True, False, id="allow_multiple_tool_calls"),
param("tool_choice", "none", True, id="tool_choice_none"),
# OpenAIResponsesOptions - just verify they don't fail
param("safety_identifier", "user-hash-abc123", False, id="safety_identifier"),
param("truncation", "auto", False, id="truncation"),
param("top_logprobs", 5, False, id="top_logprobs"),
param("prompt_cache_key", "test-cache-key", False, id="prompt_cache_key"),
param("max_tool_calls", 3, False, id="max_tool_calls"),
# Complex options requiring output validation
param("tools", [get_weather], True, id="tools_function"),
param("tool_choice", "auto", True, id="tool_choice_auto"),
param(
"tool_choice",
{"mode": "required", "required_function_name": "get_weather"},
True,
id="tool_choice_required",
),
output_format_type=OutputStruct,
)
assert structured_response is not None
assert isinstance(structured_response, ChatResponse)
assert isinstance(structured_response.value, OutputStruct)
assert "Seattle" in structured_response.value.location
assert "sunny" in structured_response.value.weather.lower()
param("response_format", OutputStruct, True, id="response_format_pydantic"),
param(
"response_format",
{
"type": "json_schema",
"json_schema": {
"name": "WeatherDigest",
"strict": True,
"schema": {
"title": "WeatherDigest",
"type": "object",
"properties": {
"location": {"type": "string"},
"conditions": {"type": "string"},
"temperature_c": {"type": "number"},
"advisory": {"type": "string"},
},
"required": ["location", "conditions", "temperature_c", "advisory"],
"additionalProperties": False,
},
},
},
True,
id="response_format_runtime_json_schema",
),
],
)
async def test_integration_options(
option_name: str,
option_value: Any,
needs_validation: bool,
) -> None:
"""Parametrized test covering all ChatOptions and OpenAIResponsesOptions.
Tests both streaming and non-streaming modes for each option to ensure
they don't cause failures. Options marked with needs_validation also
check that the feature actually works correctly.
"""
client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
# to ensure toolmode required does not endlessly loop
client.function_invocation_configuration.max_iterations = 1
for streaming in [False, True]:
# Prepare test message
if option_name == "tools" or option_name == "tool_choice":
# Use weather-related prompt for tool tests
messages = [ChatMessage(role="user", text="What is the weather in Seattle?")]
elif option_name == "response_format":
# Use prompt that works well with structured output
messages = [ChatMessage(role="user", text="The weather in Seattle is sunny")]
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
else:
# Generic prompt for simple options
messages = [ChatMessage(role="user", text="Say 'Hello World' briefly.")]
# Build options dict
options: dict[str, Any] = {option_name: option_value}
# Add tools if testing tool_choice to avoid errors
if option_name == "tool_choice":
options["tools"] = [get_weather]
if streaming:
# Test streaming mode
response_gen = client.get_streaming_response(
messages=messages,
options=options,
)
output_format = option_value if option_name == "response_format" else None
response = await ChatResponse.from_chat_response_generator(response_gen, output_format_type=output_format)
else:
# Test non-streaming mode
response = await client.get_response(
messages=messages,
options=options,
)
assert response is not None
assert isinstance(response, ChatResponse)
assert response.text is not None, f"No text in response for option '{option_name}'"
assert len(response.text) > 0, f"Empty response for option '{option_name}'"
# Validate based on option type
if needs_validation:
if option_name == "tools" or option_name == "tool_choice":
# Should have called the weather function
text = response.text.lower()
assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}"
elif option_name == "response_format":
if option_value == OutputStruct:
# Should have structured output
assert response.value is not None, "No structured output"
assert isinstance(response.value, OutputStruct)
assert "seattle" in response.value.location.lower()
else:
# Runtime JSON schema
assert response.value is None, "No structured output, can't parse any json."
response_value = json.loads(response.text)
assert isinstance(response_value, dict)
assert "location" in response_value
assert "seattle" in response_value["location"].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."""
async def test_integration_web_search() -> None:
client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
for streaming in [False, True]:
content = {
"messages": "Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool()],
},
}
if streaming:
response = await ChatResponse.from_chat_response_generator(client.get_streaming_response(**content))
else:
response = await client.get_response(**content)
assert response is not None
assert isinstance(response, ChatResponse)
assert "Rumi" in response.text
assert "Mira" in response.text
assert "Zoey" in response.text
# Test that the client will use the web search tool with location
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
}
}
content = {
"messages": "What is the current weather? Do not ask for my current location.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool(additional_properties=additional_properties)],
},
}
if streaming:
response = await ChatResponse.from_chat_response_generator(client.get_streaming_response(**content))
else:
response = await client.get_response(**content)
assert response.text is not None
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_integration_client_file_search() -> None:
"""Test Azure responses client with file search tool."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert isinstance(azure_responses_client, ChatClientProtocol)
messages: list[ChatMessage] = [ChatMessage(role="user", text="What is the weather in Seattle?")]
# Test that the client can be used to get a response
response = azure_responses_client.get_streaming_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert "sunny" in full_message
messages.clear()
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
structured_response = azure_responses_client.get_streaming_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
response_format=OutputStruct,
)
full_message = ""
async for chunk in structured_response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
output = OutputStruct.model_validate_json(full_message)
assert "Seattle" in output.location
assert "sunny" in output.weather.lower()
@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."""
agent = AzureOpenAIResponsesClient(credential=AzureCliCredential()).create_agent(
instructions="You are a helpful assistant.",
)
# Test basic run
response = await agent.run("Hello! Please respond with 'Hello World' exactly.")
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
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."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
) as agent:
# Test streaming run
full_text = ""
async for chunk in agent.run_stream("Please respond with exactly: 'This is a streaming response test.'"):
assert isinstance(chunk, AgentRunResponseUpdate)
if chunk.text:
full_text += chunk.text
assert len(full_text) > 0
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."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# First interaction
first_response = await agent.run("My favorite programming language is Python. Remember this.", thread=thread)
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Second interaction - test memory
second_response = await agent.run("What is my favorite programming language?", thread=thread)
assert isinstance(second_response, AgentRunResponse)
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."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant.",
) as agent:
# Create a new thread
thread = AgentThread()
# Initially, service_thread_id should be None
assert thread.service_thread_id is None
# Run with store=True to store messages on Azure/OpenAI side
response = await agent.run(
"Hello! Please remember that my name is Alex.",
thread=thread,
store=True,
file_id, vector_store = await create_vector_store(azure_responses_client)
try:
# Test that the client will use the file search tool
response = await azure_responses_client.get_response(
messages=[
ChatMessage(
role="user",
text="What is the weather today? Do a file search to find the answer.",
)
],
options={"tools": [HostedFileSearchTool(inputs=vector_store)], "tool_choice": "auto"},
)
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
# After store=True, service_thread_id should be populated
assert thread.service_thread_id is not None
assert isinstance(thread.service_thread_id, str)
assert len(thread.service_thread_id) > 0
assert "sunny" in response.text.lower()
assert "75" in response.text
finally:
await delete_vector_store(azure_responses_client, file_id, vector_store.vector_store_id)
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_agent_existing_thread():
async def test_integration_client_file_search_streaming() -> None:
"""Test Azure responses client with file search tool and streaming."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
file_id, vector_store = await create_vector_store(azure_responses_client)
# Test that the client will use the file search tool
try:
response = azure_responses_client.get_streaming_response(
messages=[
ChatMessage(
role="user",
text="What is the weather today? Do a file search to find the answer.",
)
],
options={"tools": [HostedFileSearchTool(inputs=vector_store)], "tool_choice": "auto"},
)
assert response is not None
full_response = await ChatResponse.from_chat_response_generator(response)
assert "sunny" in full_response.text.lower()
assert "75" in full_response.text
finally:
await delete_vector_store(azure_responses_client, file_id, vector_store.vector_store_id)
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_integration_client_agent_hosted_mcp_tool() -> None:
"""Integration test for HostedMCPTool with Azure Response Agent using Microsoft Learn MCP."""
client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
response = await client.get_response(
"How to create an Azure storage account using az cli?",
options={
# this needs to be high enough to handle the full MCP tool response.
"max_tokens": 5000,
"tools": HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
description="A Microsoft Learn MCP server for documentation questions",
approval_mode="never_require",
),
},
)
assert isinstance(response, ChatResponse)
assert response.text
# Should contain Azure-related content since it's asking about Azure CLI
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_integration_client_agent_hosted_code_interpreter_tool():
"""Test Azure Responses Client agent with HostedCodeInterpreterTool through AzureOpenAIResponsesClient."""
client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
response = await client.get_response(
"Calculate the sum of numbers from 1 to 10 using Python code.",
options={
"tools": [HostedCodeInterpreterTool()],
},
)
# Should contain calculation result (sum of 1-10 = 55) or code execution content
contains_relevant_content = any(
term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"]
)
assert contains_relevant_content or len(response.text.strip()) > 10
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_integration_client_agent_existing_thread():
"""Test Azure Responses Client agent with existing thread to continue conversations across agent instances."""
# First conversation - capture the thread
preserved_thread = None
@@ -428,7 +430,7 @@ async def test_azure_responses_client_agent_existing_thread():
) as first_agent:
# Start a conversation and capture the thread
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread)
first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread, store=True)
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
@@ -448,189 +450,3 @@ async def test_azure_responses_client_agent_existing_thread():
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
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."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant that can execute Python code.",
tools=[HostedCodeInterpreterTool()],
) as agent:
# Test code interpreter functionality
response = await agent.run("Calculate the sum of numbers from 1 to 10 using Python code.")
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
# Should contain calculation result (sum of 1-10 = 55) or code execution content
contains_relevant_content = any(
term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"]
)
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."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant that uses available tools.",
tools=[get_weather], # Agent-level tool
) as agent:
# First run - agent-level tool should be available
first_response = await agent.run("What's the weather like in Chicago?")
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Should use the agent-level weather tool
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"])
# Second run - agent-level tool should still be available (persistence test)
second_response = await agent.run("What's the weather in Miami?")
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
# Should use the agent-level weather tool again
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."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant.",
) as agent:
response = await agent.run(
"Provide a brief, helpful response.",
max_tokens=100,
temperature=0.7,
top_p=0.9,
seed=123,
user="comprehensive-test-user",
tools=[get_weather],
tool_choice="auto",
)
assert isinstance(response, AgentRunResponse)
assert response.text is not 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."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant.",
max_tokens=100,
temperature=0.7,
top_p=0.9,
seed=123,
user="comprehensive-test-user",
tools=[get_weather],
tool_choice="auto",
) as agent:
response = await agent.run(
"Provide a brief, helpful response.",
)
assert isinstance(response, AgentRunResponse)
assert response.text is not 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."""
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
description="A Microsoft Learn MCP server for documentation questions",
approval_mode="never_require",
),
) as agent:
response = await agent.run(
"How to create an Azure storage account using az cli?",
# this needs to be high enough to handle the full MCP tool response.
max_tokens=5000,
)
assert isinstance(response, AgentRunResponse)
assert response.text
# Should contain Azure-related content since it's asking about Azure CLI
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_file_search() -> None:
"""Test Azure responses client with file search tool."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert isinstance(azure_responses_client, ChatClientProtocol)
file_id, vector_store = await create_vector_store(azure_responses_client)
# Test that the client will use the file search tool
response = await azure_responses_client.get_response(
messages=[
ChatMessage(
role="user",
text="What is the weather today? Do a file search to find the answer.",
)
],
tools=[HostedFileSearchTool(inputs=vector_store)],
tool_choice="auto",
)
await delete_vector_store(azure_responses_client, file_id, vector_store.vector_store_id)
assert "sunny" in response.text.lower()
assert "75" in response.text
@pytest.mark.flaky
@skip_if_azure_integration_tests_disabled
async def test_azure_responses_client_file_search_streaming() -> None:
"""Test Azure responses client with file search tool and streaming."""
azure_responses_client = AzureOpenAIResponsesClient(credential=AzureCliCredential())
assert isinstance(azure_responses_client, ChatClientProtocol)
file_id, vector_store = await create_vector_store(azure_responses_client)
# Test that the client will use the file search tool
response = azure_responses_client.get_streaming_response(
messages=[
ChatMessage(
role="user",
text="What is the weather today? Do a file search to find the answer.",
)
],
tools=[HostedFileSearchTool(inputs=vector_store)],
tool_choice="auto",
)
assert response is not None
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
await delete_vector_store(azure_responses_client, file_id, vector_store.vector_store_id)
assert "sunny" in full_message.lower()
assert "75" in full_message
+10 -10
View File
@@ -4,7 +4,7 @@ import asyncio
import logging
import sys
from collections.abc import AsyncIterable, MutableSequence
from typing import Any
from typing import Any, Generic
from unittest.mock import patch
from uuid import uuid4
@@ -18,7 +18,6 @@ from agent_framework import (
AgentThread,
BaseChatClient,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Role,
@@ -28,6 +27,7 @@ from agent_framework import (
use_chat_middleware,
use_function_invocation,
)
from agent_framework._clients import TOptions_co
if sys.version_info >= (3, 12):
from typing import override # type: ignore
@@ -113,7 +113,7 @@ class MockChatClient:
@use_chat_middleware
class MockBaseChatClient(BaseChatClient):
class MockBaseChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]):
"""Mock implementation of the BaseChatClient."""
def __init__(self, **kwargs: Any):
@@ -127,27 +127,27 @@ class MockBaseChatClient(BaseChatClient):
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
"""Send a chat request to the AI service.
Args:
messages: The chat messages to send.
chat_options: The options for the request.
options: The options dict for the request.
kwargs: Any additional keyword arguments.
Returns:
The chat response contents representing the response(s).
"""
logger.debug(f"Running base chat client inner, with: {messages=}, {chat_options=}, {kwargs=}")
logger.debug(f"Running base chat client inner, with: {messages=}, {options=}, {kwargs=}")
self.call_count += 1
if not self.run_responses:
return ChatResponse(messages=ChatMessage(role="assistant", text=f"test response - {messages[-1].text}"))
response = self.run_responses.pop(0)
if chat_options.tool_choice == "none":
if options.get("tool_choice") == "none":
return ChatResponse(
messages=ChatMessage(
role="assistant",
@@ -163,14 +163,14 @@ class MockBaseChatClient(BaseChatClient):
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
logger.debug(f"Running base chat client inner stream, with: {messages=}, {chat_options=}, {kwargs=}")
logger.debug(f"Running base chat client inner stream, with: {messages=}, {options=}, {kwargs=}")
if not self.streaming_responses:
yield ChatResponseUpdate(text=f"update - {messages[0].text}", role="assistant")
return
if chat_options.tool_choice == "none":
if options.get("tool_choice") == "none":
yield ChatResponseUpdate(text="I broke out of the function invocation loop...", role="assistant")
return
response = self.streaming_responses.pop(0)
+39 -28
View File
@@ -118,8 +118,8 @@ async def test_prepare_thread_does_not_mutate_agent_chat_options(chat_client: Ch
tool = HostedCodeInterpreterTool()
agent = ChatAgent(chat_client=chat_client, tools=[tool])
assert agent.chat_options.tools is not None
base_tools = agent.chat_options.tools
assert agent.default_options.get("tools") is not None
base_tools = agent.default_options["tools"]
thread = agent.get_new_thread()
_, prepared_chat_options, _ = await agent._prepare_thread_and_messages( # type: ignore[reportPrivateUsage]
@@ -127,11 +127,11 @@ async def test_prepare_thread_does_not_mutate_agent_chat_options(chat_client: Ch
input_messages=[ChatMessage(role=Role.USER, text="Test")],
)
assert prepared_chat_options.tools is not None
assert base_tools is not prepared_chat_options.tools
assert prepared_chat_options.get("tools") is not None
assert base_tools is not prepared_chat_options["tools"]
prepared_chat_options.tools.append(HostedCodeInterpreterTool()) # type: ignore[arg-type]
assert len(agent.chat_options.tools) == 1
prepared_chat_options["tools"].append(HostedCodeInterpreterTool()) # type: ignore[arg-type]
assert len(agent.default_options["tools"]) == 1
async def test_chat_client_agent_update_thread_id(chat_client_base: ChatClientProtocol) -> None:
@@ -597,61 +597,68 @@ async def test_chat_agent_tool_choice_run_level_overrides_agent_level(
chat_client_base: Any, ai_function_tool: Any
) -> None:
"""Verify that tool_choice passed to run() overrides agent-level tool_choice."""
from agent_framework import ChatOptions, ToolMode
captured_options: list[ChatOptions] = []
captured_options: list[dict[str, Any]] = []
# Store the original inner method
original_inner = chat_client_base._inner_get_response
async def capturing_inner(
*, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
*, messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> ChatResponse:
captured_options.append(chat_options)
return await original_inner(messages=messages, chat_options=chat_options, **kwargs)
captured_options.append(options)
return await original_inner(messages=messages, options=options, **kwargs)
chat_client_base._inner_get_response = capturing_inner
# Create agent with agent-level tool_choice="auto" and a tool (tools required for tool_choice to be meaningful)
agent = ChatAgent(chat_client=chat_client_base, tool_choice="auto", tools=[ai_function_tool])
agent = ChatAgent(
chat_client=chat_client_base,
tools=[ai_function_tool],
options={"tool_choice": "auto"},
)
# Run with run-level tool_choice="required"
await agent.run("Hello", tool_choice="required")
await agent.run("Hello", options={"tool_choice": "required"})
# Verify the client received tool_choice="required", not "auto"
assert len(captured_options) >= 1
assert captured_options[0].tool_choice == "required"
assert captured_options[0].tool_choice == ToolMode.REQUIRED_ANY
assert captured_options[0]["tool_choice"] == "required"
async def test_chat_agent_tool_choice_agent_level_used_when_run_level_not_specified(
chat_client_base: Any, ai_function_tool: Any
) -> None:
"""Verify that agent-level tool_choice is used when run() doesn't specify one."""
from agent_framework import ChatOptions, ToolMode
from agent_framework import ChatOptions
captured_options: list[ChatOptions] = []
original_inner = chat_client_base._inner_get_response
async def capturing_inner(
*, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
*, messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> ChatResponse:
captured_options.append(chat_options)
return await original_inner(messages=messages, chat_options=chat_options, **kwargs)
captured_options.append(options)
return await original_inner(messages=messages, options=options, **kwargs)
chat_client_base._inner_get_response = capturing_inner
# Create agent with agent-level tool_choice="required" and a tool
agent = ChatAgent(chat_client=chat_client_base, tool_choice="required", tools=[ai_function_tool])
agent = ChatAgent(
chat_client=chat_client_base,
tools=[ai_function_tool],
default_options={"tool_choice": "required"},
)
# Run without specifying tool_choice
await agent.run("Hello")
# Verify the client received tool_choice="required" from agent-level
assert len(captured_options) >= 1
assert captured_options[0].tool_choice == "required"
assert captured_options[0].tool_choice == ToolMode.REQUIRED_ANY
assert captured_options[0]["tool_choice"] == "required"
# older code compared to ToolMode constants; ensure value is 'required'
assert captured_options[0]["tool_choice"] == "required"
async def test_chat_agent_tool_choice_none_at_run_preserves_agent_level(
@@ -665,19 +672,23 @@ async def test_chat_agent_tool_choice_none_at_run_preserves_agent_level(
original_inner = chat_client_base._inner_get_response
async def capturing_inner(
*, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
*, messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> ChatResponse:
captured_options.append(chat_options)
return await original_inner(messages=messages, chat_options=chat_options, **kwargs)
captured_options.append(options)
return await original_inner(messages=messages, options=options, **kwargs)
chat_client_base._inner_get_response = capturing_inner
# Create agent with agent-level tool_choice="auto" and a tool
agent = ChatAgent(chat_client=chat_client_base, tool_choice="auto", tools=[ai_function_tool])
agent = ChatAgent(
chat_client=chat_client_base,
tools=[ai_function_tool],
default_options={"tool_choice": "auto"},
)
# Run with explicitly passing None (same as not specifying)
await agent.run("Hello", tool_choice=None)
await agent.run("Hello", options={"tool_choice": None})
# Verify the client received tool_choice="auto" from agent-level
assert len(captured_options) >= 1
assert captured_options[0].tool_choice == "auto"
assert captured_options[0]["tool_choice"] == "auto"
@@ -0,0 +1,433 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import os
from typing import Annotated
import pytest
from pydantic import BaseModel
from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
AgentThread,
ChatAgent,
HostedCodeInterpreterTool,
HostedImageGenerationTool,
HostedMCPTool,
MCPStreamableHTTPTool,
ai_function,
)
from agent_framework.openai import OpenAIResponsesClient
skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true"
or os.getenv("OPENAI_API_KEY", "") in ("", "test-dummy-key"),
reason="No real OPENAI_API_KEY provided; skipping integration tests."
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
else "Integration tests are disabled.",
)
@ai_function
async def get_weather(location: Annotated[str, "The location as a city name"]) -> str:
"""Get the current weather in a given location."""
# Implementation of the tool to get weather
return f"The current weather in {location} is sunny."
@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."""
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
) as agent:
# Test streaming run
full_text = ""
async for chunk in agent.run_stream("Please respond with exactly: 'This is a streaming response test.'"):
assert isinstance(chunk, AgentRunResponseUpdate)
if chunk.text:
full_text += chunk.text
assert len(full_text) > 0
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."""
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
thread = agent.get_new_thread()
# First interaction
first_response = await agent.run("My favorite programming language is Python. Remember this.", thread=thread)
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Second interaction - test memory
second_response = await agent.run("What is my favorite programming language?", thread=thread)
assert isinstance(second_response, AgentRunResponse)
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."""
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant.",
) as agent:
# Create a new thread
thread = AgentThread()
# Initially, service_thread_id should be None
assert thread.service_thread_id is None
# Run with store=True to store messages on OpenAI side
response = await agent.run(
"Hello! Please remember that my name is Alex.",
thread=thread,
options={"store": True},
)
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
# After store=True, service_thread_id should be populated
assert thread.service_thread_id is not None
assert isinstance(thread.service_thread_id, str)
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."""
# First conversation - capture the thread
preserved_thread = None
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant with good memory.",
) as first_agent:
# Start a conversation and capture the thread
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My hobby is photography. Remember this.", thread=thread)
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Preserve the thread for reuse
preserved_thread = thread
# Second conversation - reuse the thread in a new agent instance
if preserved_thread:
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant with good memory.",
) as second_agent:
# Reuse the preserved thread
second_response = await second_agent.run("What is my hobby?", thread=preserved_thread)
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
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."""
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant that can execute Python code.",
tools=[HostedCodeInterpreterTool()],
) as agent:
# Test code interpreter functionality
response = await agent.run("Calculate the sum of numbers from 1 to 10 using Python code.")
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
# Should contain calculation result (sum of 1-10 = 55) or code execution content
contains_relevant_content = any(
term in response.text.lower() for term in ["55", "sum", "code", "python", "calculate", "10"]
)
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_image_generation_tool():
"""Test OpenAI Responses Client agent with raw image_generation tool through OpenAIResponsesClient."""
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant that can generate images.",
tools=HostedImageGenerationTool(options={"image_size": "1024x1024", "media_type": "png"}),
) as agent:
# Test image generation functionality
response = await agent.run("Generate an image of a cute red panda sitting on a tree branch in a forest.")
assert isinstance(response, AgentRunResponse)
assert response.messages
# Verify we got image content - look for ImageGenerationToolResultContent
image_content_found = False
for message in response.messages:
for content in message.contents:
if content.type == "image_generation_tool_result" and content.outputs:
image_content_found = True
break
if image_content_found:
break
# The test passes if we got image content
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."""
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant that uses available tools.",
tools=[get_weather], # Agent-level tool
) as agent:
# First run - agent-level tool should be available
first_response = await agent.run("What's the weather like in Chicago?")
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Should use the agent-level weather tool
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"])
# Second run - agent-level tool should still be available (persistence test)
second_response = await agent.run("What's the weather in Miami?")
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
# Should use the agent-level weather tool again
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."""
# Counter to track how many times the weather tool is called
call_count = 0
@ai_function
async def get_weather_with_counter(
location: Annotated[str, "The location as a city name"],
) -> str:
"""Get the current weather in a given location."""
nonlocal call_count
call_count += 1
return f"The weather in {location} is sunny and 72°F."
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant.",
) as agent:
# First run - use run-level tool
first_response = await agent.run(
"What's the weather like in Chicago?",
tools=[get_weather_with_counter], # Run-level tool
)
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Should use the run-level weather tool (call count should be 1)
assert call_count == 1
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"])
# Second run - run-level tool should NOT persist (key isolation test)
second_response = await agent.run("What's the weather like in Miami?")
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
# Should NOT use the weather tool since it was only run-level in previous call
# Call count should still be 1 (no additional calls)
assert call_count == 1
@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."""
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant.",
tools=[get_weather],
default_options={
"max_tokens": 100,
"temperature": 0.7,
"top_p": 0.9,
"seed": 123,
"user": "comprehensive-test-user",
"tool_choice": "auto",
},
) as agent:
response = await agent.run(
"Provide a brief, helpful response.",
)
assert isinstance(response, AgentRunResponse)
assert response.text is not 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."""
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
description="A Microsoft Learn MCP server for documentation questions",
approval_mode="never_require",
),
) as agent:
response = await agent.run(
"How to create an Azure storage account using az cli?",
# this needs to be high enough to handle the full MCP tool response.
options={"max_tokens": 5000},
)
assert isinstance(response, AgentRunResponse)
assert response.text
# Should contain Azure-related content since it's asking about Azure CLI
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_agent_local_mcp_tool() -> None:
"""Integration test for MCPStreamableHTTPTool with OpenAI Response Agent using Microsoft Learn MCP."""
mcp_tool = MCPStreamableHTTPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
)
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=[mcp_tool],
) as agent:
response = await agent.run(
"How to create an Azure storage account using az cli?",
options={"max_tokens": 200},
)
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
# Should contain Azure-related content since it's asking about Azure CLI
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
class ReleaseBrief(BaseModel):
"""Structured output model for release brief testing."""
title: str
summary: str
highlights: list[str]
model_config = {"extra": "forbid"}
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_agent_with_response_format_pydantic() -> None:
"""Integration test for response_format with Pydantic model using OpenAI Responses Client."""
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant that returns structured JSON responses.",
) as agent:
response = await agent.run(
"Summarize the following release notes into a ReleaseBrief:\n\n"
"Version 2.0 Release Notes:\n"
"- Added new streaming API for real-time responses\n"
"- Improved error handling with detailed messages\n"
"- Performance boost of 50% in batch processing\n"
"- Fixed memory leak in connection pooling",
options={
"response_format": ReleaseBrief,
},
)
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.value is not None
assert isinstance(response.value, ReleaseBrief)
# Validate structured output fields
brief = response.value
assert len(brief.title) > 0
assert len(brief.summary) > 0
assert len(brief.highlights) > 0
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_agent_with_runtime_json_schema() -> None:
"""Integration test for response_format with runtime JSON schema using OpenAI Responses Client."""
runtime_schema = {
"title": "WeatherDigest",
"type": "object",
"properties": {
"location": {"type": "string"},
"conditions": {"type": "string"},
"temperature_c": {"type": "number"},
"advisory": {"type": "string"},
},
"required": ["location", "conditions", "temperature_c", "advisory"],
"additionalProperties": False,
}
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="Return only JSON that matches the provided schema. Do not add commentary.",
) as agent:
response = await agent.run(
"Give a brief weather digest for Seattle.",
options={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": runtime_schema["title"],
"strict": True,
"schema": runtime_schema,
},
},
},
)
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
# Parse JSON and validate structure
parsed = json.loads(response.text)
assert "location" in parsed
assert "conditions" in parsed
assert "temperature_c" in parsed
assert "advisory" in parsed
@@ -7,7 +7,6 @@ from agent_framework import (
BaseChatClient,
ChatClientProtocol,
ChatMessage,
ChatOptions,
Role,
)
@@ -50,12 +49,22 @@ async def test_chat_client_instructions_handling(chat_client_base: ChatClientPro
chat_client_base,
"_inner_get_response",
) as mock_inner_get_response:
await chat_client_base.get_response("hello", chat_options=ChatOptions(instructions=instructions))
await chat_client_base.get_response("hello", options={"instructions": instructions})
mock_inner_get_response.assert_called_once()
_, kwargs = mock_inner_get_response.call_args
messages = kwargs.get("messages", [])
assert len(messages) == 2
assert messages[0].role == Role.SYSTEM
assert messages[0].text == instructions
assert messages[1].role == Role.USER
assert messages[1].text == "hello"
assert len(messages) == 1
assert messages[0].role == Role.USER
assert messages[0].text == "hello"
from agent_framework._types import prepend_instructions_to_messages
appended_messages = prepend_instructions_to_messages(
[ChatMessage(role=Role.USER, text="hello")],
instructions,
)
assert len(appended_messages) == 2
assert appended_messages[0].role == Role.SYSTEM
assert appended_messages[0].text == "You are a helpful assistant."
assert appended_messages[1].role == Role.USER
assert appended_messages[1].text == "hello"
@@ -1,6 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Awaitable, Callable
from typing import Any
import pytest
@@ -8,7 +9,6 @@ from agent_framework import (
ChatAgent,
ChatClientProtocol,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
FunctionApprovalRequestContent,
@@ -39,7 +39,7 @@ async def test_base_client_with_function_calling(chat_client_base: ChatClientPro
),
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [ai_func]})
assert exec_counter == 1
assert len(response.messages) == 3
assert response.messages[0].role == Role.ASSISTANT
@@ -79,7 +79,7 @@ async def test_base_client_with_function_calling_resets(chat_client_base: ChatCl
),
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [ai_func]})
assert exec_counter == 2
assert len(response.messages) == 5
assert response.messages[0].role == Role.ASSISTANT
@@ -121,7 +121,9 @@ async def test_base_client_with_streaming_function_calling(chat_client_base: Cha
],
]
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[ai_func]):
async for update in chat_client_base.get_streaming_response(
"hello", options={"tool_choice": "auto", "tools": [ai_func]}
):
updates.append(update)
assert len(updates) == 4 # two updates with the function call, the function result and the final text
assert updates[0].contents[0].call_id == "1"
@@ -371,18 +373,18 @@ async def test_function_invocation_scenarios(
]
# Execute the test
chat_options = ChatOptions(tool_choice="auto", tools=tools)
options: dict[str, Any] = {"tool_choice": "auto", "tools": tools}
if thread_type == "service":
# For service threads, we need to pass conversation_id via ChatOptions
chat_options.store = True
chat_options.conversation_id = conversation_id
# For service threads, we need to pass conversation_id via options
options["store"] = True
options["conversation_id"] = conversation_id
if not streaming:
response = await chat_client_base.get_response("hello", chat_options=chat_options)
response = await chat_client_base.get_response("hello", options=options)
messages = response.messages
else:
updates = []
async for update in chat_client_base.get_streaming_response("hello", chat_options=chat_options):
async for update in chat_client_base.get_streaming_response("hello", options=options):
updates.append(update)
messages = updates
@@ -492,7 +494,9 @@ async def test_rejected_approval(chat_client_base: ChatClientProtocol):
]
# Get the response with approval requests
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[func_approved, func_rejected])
response = await chat_client_base.get_response(
"hello", options={"tool_choice": "auto", "tools": [func_approved, func_rejected]}
)
# Approval requests are now added to the assistant message, not a separate message
assert len(response.messages) == 1
# Assistant message should have: 2 FunctionCallContent + 2 FunctionApprovalRequestContent
@@ -519,7 +523,9 @@ async def test_rejected_approval(chat_client_base: ChatClientProtocol):
all_messages = response.messages + [ChatMessage(role="user", contents=[approved_response, rejected_response])]
# Call get_response which will process the approvals
await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[func_approved, func_rejected])
await chat_client_base.get_response(
all_messages, options={"tool_choice": "auto", "tools": [func_approved, func_rejected]}
)
# Verify the approval/rejection was processed correctly
# Find the results in the input messages (modified in-place)
@@ -574,7 +580,9 @@ async def test_approval_requests_in_assistant_message(chat_client_base: ChatClie
),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[func_with_approval])
response = await chat_client_base.get_response(
"hello", options={"tool_choice": "auto", "tools": [func_with_approval]}
)
# Should have one assistant message containing both the call and approval request
assert len(response.messages) == 1
@@ -610,7 +618,9 @@ async def test_persisted_approval_messages_replay_correctly(chat_client_base: Ch
]
# Get approval request
response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[func_with_approval])
response1 = await chat_client_base.get_response(
"hello", options={"tool_choice": "auto", "tools": [func_with_approval]}
)
# Store messages (like a thread would)
persisted_messages = [
@@ -628,7 +638,9 @@ async def test_persisted_approval_messages_replay_correctly(chat_client_base: Ch
persisted_messages.append(ChatMessage(role="user", contents=[approval_response]))
# Continue with all persisted messages
response2 = await chat_client_base.get_response(persisted_messages, tool_choice="auto", tools=[func_with_approval])
response2 = await chat_client_base.get_response(
persisted_messages, options={"tool_choice": "auto", "tools": [func_with_approval]}
)
# Should execute successfully
assert response2 is not None
@@ -656,7 +668,9 @@ async def test_no_duplicate_function_calls_after_approval_processing(chat_client
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[func_with_approval])
response1 = await chat_client_base.get_response(
"hello", options={"tool_choice": "auto", "tools": [func_with_approval]}
)
approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0]
approval_response = FunctionApprovalResponseContent(
@@ -666,7 +680,7 @@ async def test_no_duplicate_function_calls_after_approval_processing(chat_client
)
all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])]
await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[func_with_approval])
await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [func_with_approval]})
# Count function calls with the same call_id
function_call_count = sum(
@@ -699,7 +713,9 @@ async def test_rejection_result_uses_function_call_id(chat_client_base: ChatClie
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[func_with_approval])
response1 = await chat_client_base.get_response(
"hello", options={"tool_choice": "auto", "tools": [func_with_approval]}
)
approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0]
rejection_response = FunctionApprovalResponseContent(
@@ -709,7 +725,7 @@ async def test_rejection_result_uses_function_call_id(chat_client_base: ChatClie
)
all_messages = response1.messages + [ChatMessage(role="user", contents=[rejection_response])]
await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[func_with_approval])
await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [func_with_approval]})
# Find the rejection result
rejection_result = next(
@@ -753,7 +769,7 @@ async def test_max_iterations_limit(chat_client_base: ChatClientProtocol):
# Set max_iterations to 1 in additional_properties
chat_client_base.function_invocation_configuration.max_iterations = 1
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [ai_func]})
# With max_iterations=1, we should:
# 1. Execute first function call (exec_counter=1)
@@ -780,7 +796,7 @@ async def test_function_invocation_config_enabled_false(chat_client_base: ChatCl
# Disable function invocation
chat_client_base.function_invocation_configuration.enabled = False
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[ai_func])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [ai_func]})
# Function should not be executed - when enabled=False, the loop doesn't run
assert exec_counter == 0
@@ -827,7 +843,7 @@ async def test_function_invocation_config_max_consecutive_errors(chat_client_bas
# Set max_consecutive_errors to 2
chat_client_base.function_invocation_configuration.max_consecutive_errors_per_request = 2
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[error_func])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [error_func]})
# Should stop after 2 consecutive errors and force a non-tool response
error_results = [
@@ -870,7 +886,7 @@ async def test_function_invocation_config_terminate_on_unknown_calls_false(chat_
# Set terminate_on_unknown_calls to False (default)
chat_client_base.function_invocation_configuration.terminate_on_unknown_calls = False
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[known_func])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [known_func]})
# Should have a result message indicating the tool wasn't found
assert len(response.messages) == 3
@@ -904,7 +920,7 @@ async def test_function_invocation_config_terminate_on_unknown_calls_true(chat_c
# Should raise an exception when encountering an unknown function
with pytest.raises(KeyError, match='Error: Requested function "unknown_function" not found'):
await chat_client_base.get_response("hello", tool_choice="auto", tools=[known_func])
await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [known_func]})
assert exec_counter == 0
@@ -940,7 +956,7 @@ async def test_function_invocation_config_additional_tools(chat_client_base: Cha
chat_client_base.function_invocation_configuration.additional_tools = [hidden_func]
# Only pass visible_func in the tools parameter
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[visible_func])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [visible_func]})
# Additional tools are treated as declaration_only, so not executed
# The function call should be in the messages but not executed
@@ -976,7 +992,7 @@ async def test_function_invocation_config_include_detailed_errors_false(chat_cli
# Set include_detailed_errors to False (default)
chat_client_base.function_invocation_configuration.include_detailed_errors = False
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[error_func])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [error_func]})
# Should have a generic error message
error_result = next(
@@ -1008,7 +1024,7 @@ async def test_function_invocation_config_include_detailed_errors_true(chat_clie
# Set include_detailed_errors to True
chat_client_base.function_invocation_configuration.include_detailed_errors = True
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[error_func])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [error_func]})
# Should have detailed error message
error_result = next(
@@ -1076,7 +1092,7 @@ async def test_argument_validation_error_with_detailed_errors(chat_client_base:
# Set include_detailed_errors to True
chat_client_base.function_invocation_configuration.include_detailed_errors = True
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[typed_func])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [typed_func]})
# Should have detailed validation error
error_result = next(
@@ -1108,7 +1124,7 @@ async def test_argument_validation_error_without_detailed_errors(chat_client_bas
# Set include_detailed_errors to False (default)
chat_client_base.function_invocation_configuration.include_detailed_errors = False
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[typed_func])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [typed_func]})
# Should have generic validation error
error_result = next(
@@ -1175,7 +1191,7 @@ async def test_unapproved_tool_execution_raises_exception(chat_client_base: Chat
]
# Get approval request
response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[test_func])
response1 = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [test_func]})
approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0]
@@ -1190,7 +1206,7 @@ async def test_unapproved_tool_execution_raises_exception(chat_client_base: Chat
all_messages = response1.messages + [ChatMessage(role="user", contents=[rejection_response])]
# This should handle the rejection gracefully (not raise ToolException to user)
await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[test_func])
await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [test_func]})
# Should have a rejection result
rejection_result = next(
@@ -1235,7 +1251,7 @@ async def test_approved_function_call_with_error_without_detailed_errors(chat_cl
chat_client_base.function_invocation_configuration.include_detailed_errors = False
# Get approval request
response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[error_func])
response1 = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [error_func]})
approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0]
@@ -1249,7 +1265,7 @@ async def test_approved_function_call_with_error_without_detailed_errors(chat_cl
all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])]
# Execute the approved function (which will error)
await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[error_func])
await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [error_func]})
# Should have executed the function
assert exec_counter == 1
@@ -1299,7 +1315,7 @@ async def test_approved_function_call_with_error_with_detailed_errors(chat_clien
chat_client_base.function_invocation_configuration.include_detailed_errors = True
# Get approval request
response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[error_func])
response1 = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [error_func]})
approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0]
@@ -1313,7 +1329,7 @@ async def test_approved_function_call_with_error_with_detailed_errors(chat_clien
all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])]
# Execute the approved function (which will error)
await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[error_func])
await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [error_func]})
# Should have executed the function
assert exec_counter == 1
@@ -1361,7 +1377,7 @@ async def test_approved_function_call_with_validation_error(chat_client_base: Ch
chat_client_base.function_invocation_configuration.include_detailed_errors = True
# Get approval request
response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[typed_func])
response1 = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [typed_func]})
approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0]
@@ -1375,7 +1391,7 @@ async def test_approved_function_call_with_validation_error(chat_client_base: Ch
all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])]
# Execute the approved function (which will fail validation)
await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[typed_func])
await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [typed_func]})
# Should NOT have executed the function (validation failed before execution)
assert exec_counter == 0
@@ -1418,7 +1434,7 @@ async def test_approved_function_call_successful_execution(chat_client_base: Cha
]
# Get approval request
response1 = await chat_client_base.get_response("hello", tool_choice="auto", tools=[success_func])
response1 = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [success_func]})
approval_req = [c for c in response1.messages[0].contents if isinstance(c, FunctionApprovalRequestContent)][0]
@@ -1432,7 +1448,7 @@ async def test_approved_function_call_successful_execution(chat_client_base: Cha
all_messages = response1.messages + [ChatMessage(role="user", contents=[approval_response])]
# Execute the approved function
await chat_client_base.get_response(all_messages, tool_choice="auto", tools=[success_func])
await chat_client_base.get_response(all_messages, options={"tool_choice": "auto", "tools": [success_func]})
# Should have executed successfully
assert exec_counter == 1
@@ -1476,7 +1492,9 @@ async def test_declaration_only_tool(chat_client_base: ChatClientProtocol):
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[declaration_func])
response = await chat_client_base.get_response(
"hello", options={"tool_choice": "auto", "tools": [declaration_func]}
)
# Should have the function call in messages but not a result
function_calls = [
@@ -1530,7 +1548,7 @@ async def test_multiple_function_calls_parallel_execution(chat_client_base: Chat
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[func1, func2])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [func1, func2]})
# Both functions should have been executed
assert "func1_start" in exec_order
@@ -1566,7 +1584,7 @@ async def test_callable_function_converted_to_ai_function(chat_client_base: Chat
]
# Pass plain function (will be auto-converted)
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[plain_function])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [plain_function]})
# Function should be executed
assert exec_counter == 1
@@ -1598,7 +1616,7 @@ async def test_conversation_id_handling(chat_client_base: ChatClientProtocol):
),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[test_func])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [test_func]})
# Should have executed the function
results = [
@@ -1625,7 +1643,7 @@ async def test_function_result_appended_to_existing_assistant_message(chat_clien
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[test_func])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [test_func]})
# Should have messages with both function call and function result
assert len(response.messages) >= 2
@@ -1667,7 +1685,7 @@ async def test_error_recovery_resets_counter(chat_client_base: ChatClientProtoco
ChatResponse(messages=ChatMessage(role="assistant", text="done")),
]
response = await chat_client_base.get_response("hello", tool_choice="auto", tools=[sometimes_fails])
response = await chat_client_base.get_response("hello", options={"tool_choice": "auto", "tools": [sometimes_fails]})
# Should have both an error and a success
error_results = [
@@ -1714,7 +1732,7 @@ async def test_streaming_approval_request_generated(chat_client_base: ChatClient
# Get the streaming response with approval request
updates = []
async for update in chat_client_base.get_streaming_response(
"hello", tool_choice="auto", tools=[func_with_approval]
"hello", options={"tool_choice": "auto", "tools": [func_with_approval]}
):
updates.append(update)
@@ -1770,7 +1788,9 @@ async def test_streaming_max_iterations_limit(chat_client_base: ChatClientProtoc
chat_client_base.function_invocation_configuration.max_iterations = 1
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[ai_func]):
async for update in chat_client_base.get_streaming_response(
"hello", options={"tool_choice": "auto", "tools": [ai_func]}
):
updates.append(update)
# With max_iterations=1, we should only execute first function
@@ -1798,7 +1818,9 @@ async def test_streaming_function_invocation_config_enabled_false(chat_client_ba
chat_client_base.function_invocation_configuration.enabled = False
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[ai_func]):
async for update in chat_client_base.get_streaming_response(
"hello", options={"tool_choice": "auto", "tools": [ai_func]}
):
updates.append(update)
# Function should not be executed - when enabled=False, the loop doesn't run
@@ -1841,7 +1863,9 @@ async def test_streaming_function_invocation_config_max_consecutive_errors(chat_
chat_client_base.function_invocation_configuration.max_consecutive_errors_per_request = 2
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[error_func]):
async for update in chat_client_base.get_streaming_response(
"hello", options={"tool_choice": "auto", "tools": [error_func]}
):
updates.append(update)
# Should stop after 2 consecutive errors
@@ -1887,7 +1911,9 @@ async def test_streaming_function_invocation_config_terminate_on_unknown_calls_f
chat_client_base.function_invocation_configuration.terminate_on_unknown_calls = False
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[known_func]):
async for update in chat_client_base.get_streaming_response(
"hello", options={"tool_choice": "auto", "tools": [known_func]}
):
updates.append(update)
# Should have a result message indicating the tool wasn't found
@@ -1926,7 +1952,9 @@ async def test_streaming_function_invocation_config_terminate_on_unknown_calls_t
# Should raise an exception when encountering an unknown function
with pytest.raises(KeyError, match='Error: Requested function "unknown_function" not found'):
async for _ in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[known_func]):
async for _ in chat_client_base.get_streaming_response(
"hello", options={"tool_choice": "auto", "tools": [known_func]}
):
pass
assert exec_counter == 0
@@ -1953,7 +1981,9 @@ async def test_streaming_function_invocation_config_include_detailed_errors_true
chat_client_base.function_invocation_configuration.include_detailed_errors = True
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[error_func]):
async for update in chat_client_base.get_streaming_response(
"hello", options={"tool_choice": "auto", "tools": [error_func]}
):
updates.append(update)
# Should have detailed error message
@@ -1989,7 +2019,9 @@ async def test_streaming_function_invocation_config_include_detailed_errors_fals
chat_client_base.function_invocation_configuration.include_detailed_errors = False
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[error_func]):
async for update in chat_client_base.get_streaming_response(
"hello", options={"tool_choice": "auto", "tools": [error_func]}
):
updates.append(update)
# Should have a generic error message
@@ -2023,7 +2055,9 @@ async def test_streaming_argument_validation_error_with_detailed_errors(chat_cli
chat_client_base.function_invocation_configuration.include_detailed_errors = True
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[typed_func]):
async for update in chat_client_base.get_streaming_response(
"hello", options={"tool_choice": "auto", "tools": [typed_func]}
):
updates.append(update)
# Should have detailed validation error
@@ -2057,7 +2091,9 @@ async def test_streaming_argument_validation_error_without_detailed_errors(chat_
chat_client_base.function_invocation_configuration.include_detailed_errors = False
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[typed_func]):
async for update in chat_client_base.get_streaming_response(
"hello", options={"tool_choice": "auto", "tools": [typed_func]}
):
updates.append(update)
# Should have generic validation error
@@ -2105,7 +2141,9 @@ async def test_streaming_multiple_function_calls_parallel_execution(chat_client_
]
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[func1, func2]):
async for update in chat_client_base.get_streaming_response(
"hello", options={"tool_choice": "auto", "tools": [func1, func2]}
):
updates.append(update)
# Both functions should have been executed
@@ -2144,7 +2182,7 @@ async def test_streaming_approval_requests_in_assistant_message(chat_client_base
updates = []
async for update in chat_client_base.get_streaming_response(
"hello", tool_choice="auto", tools=[func_with_approval]
"hello", options={"tool_choice": "auto", "tools": [func_with_approval]}
):
updates.append(update)
@@ -2189,7 +2227,9 @@ async def test_streaming_error_recovery_resets_counter(chat_client_base: ChatCli
]
updates = []
async for update in chat_client_base.get_streaming_response("hello", tool_choice="auto", tools=[sometimes_fails]):
async for update in chat_client_base.get_streaming_response(
"hello", options={"tool_choice": "auto", "tools": [sometimes_fails]}
):
updates.append(update)
# Should have both an error and a success
@@ -2246,8 +2286,7 @@ async def test_terminate_loop_single_function_call(chat_client_base: ChatClientP
response = await chat_client_base.get_response(
"hello",
tool_choice="auto",
tools=[ai_func],
options={"tool_choice": "auto", "tools": [ai_func]},
middleware=[TerminateLoopMiddleware()],
)
@@ -2314,8 +2353,7 @@ async def test_terminate_loop_multiple_function_calls_one_terminates(chat_client
response = await chat_client_base.get_response(
"hello",
tool_choice="auto",
tools=[normal_func, terminating_func],
options={"tool_choice": "auto", "tools": [normal_func, terminating_func]},
middleware=[SelectiveTerminateMiddleware()],
)
@@ -2366,8 +2404,7 @@ async def test_terminate_loop_streaming_single_function_call(chat_client_base: C
updates = []
async for update in chat_client_base.get_streaming_response(
"hello",
tool_choice="auto",
tools=[ai_func],
options={"tool_choice": "auto", "tools": [ai_func]},
middleware=[TerminateLoopMiddleware()],
):
updates.append(update)
@@ -0,0 +1,218 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for kwargs propagation from get_response() to @ai_function tools."""
from typing import Any
from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
TextContent,
ai_function,
)
from agent_framework._tools import _handle_function_calls_response, _handle_function_calls_streaming_response
class TestKwargsPropagationToAIFunction:
"""Test cases for kwargs flowing from get_response() to @ai_function tools."""
async def test_kwargs_propagate_to_ai_function_with_kwargs(self) -> None:
"""Test that kwargs passed to get_response() are available in @ai_function **kwargs."""
captured_kwargs: dict[str, Any] = {}
@ai_function
def capture_kwargs_tool(x: int, **kwargs: Any) -> str:
"""A tool that captures kwargs for testing."""
captured_kwargs.update(kwargs)
return f"result: x={x}"
# Create a mock client
mock_client = type("MockClient", (), {})()
call_count = [0]
async def mock_get_response(self, messages, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
# First call: return a function call
return ChatResponse(
messages=[
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(call_id="call_1", name="capture_kwargs_tool", arguments='{"x": 42}')
],
)
]
)
# Second call: return final response
return ChatResponse(messages=[ChatMessage(role="assistant", text="Done!")])
# Wrap the function with function invocation decorator
wrapped = _handle_function_calls_response(mock_get_response)
# Call with custom kwargs that should propagate to the tool
# Note: tools are passed in options dict, custom kwargs are passed separately
result = await wrapped(
mock_client,
messages=[],
options={"tools": [capture_kwargs_tool]},
user_id="user-123",
session_token="secret-token",
custom_data={"key": "value"},
)
# Verify the tool was called and received the kwargs
assert "user_id" in captured_kwargs, f"Expected 'user_id' in captured kwargs: {captured_kwargs}"
assert captured_kwargs["user_id"] == "user-123"
assert "session_token" in captured_kwargs
assert captured_kwargs["session_token"] == "secret-token"
assert "custom_data" in captured_kwargs
assert captured_kwargs["custom_data"] == {"key": "value"}
# Verify result
assert result.messages[-1].text == "Done!"
async def test_kwargs_not_forwarded_to_ai_function_without_kwargs(self) -> None:
"""Test that kwargs are NOT forwarded to @ai_function that doesn't accept **kwargs."""
@ai_function
def simple_tool(x: int) -> str:
"""A simple tool without **kwargs."""
# This should not receive any extra kwargs
return f"result: x={x}"
mock_client = type("MockClient", (), {})()
call_count = [0]
async def mock_get_response(self, messages, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
return ChatResponse(
messages=[
ChatMessage(
role="assistant",
contents=[FunctionCallContent(call_id="call_1", name="simple_tool", arguments='{"x": 99}')],
)
]
)
return ChatResponse(messages=[ChatMessage(role="assistant", text="Completed!")])
wrapped = _handle_function_calls_response(mock_get_response)
# Call with kwargs - the tool should work but not receive them
result = await wrapped(
mock_client,
messages=[],
options={"tools": [simple_tool]},
user_id="user-123", # This kwarg should be ignored by the tool
)
# Verify the tool was called successfully (no error from extra kwargs)
assert result.messages[-1].text == "Completed!"
async def test_kwargs_isolated_between_function_calls(self) -> None:
"""Test that kwargs don't leak between different function call invocations."""
invocation_kwargs: list[dict[str, Any]] = []
@ai_function
def tracking_tool(name: str, **kwargs: Any) -> str:
"""A tool that tracks kwargs from each invocation."""
invocation_kwargs.append(dict(kwargs))
return f"called with {name}"
mock_client = type("MockClient", (), {})()
call_count = [0]
async def mock_get_response(self, messages, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
# Two function calls in one response
return ChatResponse(
messages=[
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
call_id="call_1", name="tracking_tool", arguments='{"name": "first"}'
),
FunctionCallContent(
call_id="call_2", name="tracking_tool", arguments='{"name": "second"}'
),
],
)
]
)
return ChatResponse(messages=[ChatMessage(role="assistant", text="All done!")])
wrapped = _handle_function_calls_response(mock_get_response)
# Call with kwargs
result = await wrapped(
mock_client,
messages=[],
options={"tools": [tracking_tool]},
request_id="req-001",
trace_context={"trace_id": "abc"},
)
# Both invocations should have received the same kwargs
assert len(invocation_kwargs) == 2
for kwargs in invocation_kwargs:
assert kwargs.get("request_id") == "req-001"
assert kwargs.get("trace_context") == {"trace_id": "abc"}
assert result.messages[-1].text == "All done!"
async def test_streaming_response_kwargs_propagation(self) -> None:
"""Test that kwargs propagate to @ai_function in streaming mode."""
captured_kwargs: dict[str, Any] = {}
@ai_function
def streaming_capture_tool(value: str, **kwargs: Any) -> str:
"""A tool that captures kwargs during streaming."""
captured_kwargs.update(kwargs)
return f"processed: {value}"
mock_client = type("MockClient", (), {})()
call_count = [0]
async def mock_get_streaming_response(self, messages, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
# First call: return function call update
yield ChatResponseUpdate(
role="assistant",
contents=[
FunctionCallContent(
call_id="stream_call_1",
name="streaming_capture_tool",
arguments='{"value": "streaming-test"}',
)
],
is_finished=True,
)
else:
# Second call: return final response
yield ChatResponseUpdate(text=TextContent(text="Stream complete!"), role="assistant", is_finished=True)
wrapped = _handle_function_calls_streaming_response(mock_get_streaming_response)
# Collect streaming updates
updates: list[ChatResponseUpdate] = []
async for update in wrapped(
mock_client,
messages=[],
options={"tools": [streaming_capture_tool]},
streaming_session="session-xyz",
correlation_id="corr-123",
):
updates.append(update)
# Verify kwargs were captured by the tool
assert "streaming_session" in captured_kwargs, f"Expected 'streaming_session' in {captured_kwargs}"
assert captured_kwargs["streaming_session"] == "session-xyz"
assert captured_kwargs["correlation_id"] == "corr-123"
@@ -29,7 +29,6 @@ from agent_framework._middleware import (
FunctionMiddlewarePipeline,
)
from agent_framework._tools import AIFunction
from agent_framework._types import ChatOptions
class TestAgentRunContext:
@@ -100,12 +99,12 @@ class TestChatContext:
def test_init_with_defaults(self, mock_chat_client: Any) -> None:
"""Test ChatContext initialization with default values."""
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(chat_client=mock_chat_client, messages=messages, chat_options=chat_options)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
assert context.chat_client is mock_chat_client
assert context.messages == messages
assert context.chat_options is chat_options
assert context.options is chat_options
assert context.is_streaming is False
assert context.metadata == {}
assert context.result is None
@@ -114,13 +113,13 @@ class TestChatContext:
def test_init_with_custom_values(self, mock_chat_client: Any) -> None:
"""Test ChatContext initialization with custom values."""
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions(temperature=0.5)
chat_options: dict[str, Any] = {"temperature": 0.5}
metadata = {"key": "value"}
context = ChatContext(
chat_client=mock_chat_client,
messages=messages,
chat_options=chat_options,
options=chat_options,
is_streaming=True,
metadata=metadata,
terminate=True,
@@ -128,7 +127,7 @@ class TestChatContext:
assert context.chat_client is mock_chat_client
assert context.messages == messages
assert context.chat_options is chat_options
assert context.options is chat_options
assert context.is_streaming is True
assert context.metadata == metadata
assert context.terminate is True
@@ -562,8 +561,8 @@ class TestChatMiddlewarePipeline:
"""Test pipeline execution with no middleware."""
pipeline = ChatMiddlewarePipeline()
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(chat_client=mock_chat_client, messages=messages, chat_options=chat_options)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
expected_response = ChatResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="response")])
@@ -589,8 +588,8 @@ class TestChatMiddlewarePipeline:
middleware = OrderTrackingChatMiddleware("test")
pipeline = ChatMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(chat_client=mock_chat_client, messages=messages, chat_options=chat_options)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
expected_response = ChatResponse(messages=[ChatMessage(role=Role.ASSISTANT, text="response")])
@@ -606,8 +605,8 @@ class TestChatMiddlewarePipeline:
"""Test pipeline streaming execution with no middleware."""
pipeline = ChatMiddlewarePipeline()
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(chat_client=mock_chat_client, messages=messages, chat_options=chat_options)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="chunk1")])
@@ -637,10 +636,8 @@ class TestChatMiddlewarePipeline:
middleware = StreamOrderTrackingChatMiddleware("test")
pipeline = ChatMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(
chat_client=mock_chat_client, messages=messages, chat_options=chat_options, is_streaming=True
)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options, is_streaming=True)
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
execution_order.append("handler_start")
@@ -662,8 +659,8 @@ class TestChatMiddlewarePipeline:
middleware = self.PreNextTerminateChatMiddleware()
pipeline = ChatMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(chat_client=mock_chat_client, messages=messages, chat_options=chat_options)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
execution_order: list[str] = []
async def final_handler(ctx: ChatContext) -> ChatResponse:
@@ -682,8 +679,8 @@ class TestChatMiddlewarePipeline:
middleware = self.PostNextTerminateChatMiddleware()
pipeline = ChatMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(chat_client=mock_chat_client, messages=messages, chat_options=chat_options)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
execution_order: list[str] = []
async def final_handler(ctx: ChatContext) -> ChatResponse:
@@ -702,10 +699,8 @@ class TestChatMiddlewarePipeline:
middleware = self.PreNextTerminateChatMiddleware()
pipeline = ChatMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(
chat_client=mock_chat_client, messages=messages, chat_options=chat_options, is_streaming=True
)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options, is_streaming=True)
execution_order: list[str] = []
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
@@ -729,10 +724,8 @@ class TestChatMiddlewarePipeline:
middleware = self.PostNextTerminateChatMiddleware()
pipeline = ChatMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(
chat_client=mock_chat_client, messages=messages, chat_options=chat_options, is_streaming=True
)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options, is_streaming=True)
execution_order: list[str] = []
async def final_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
@@ -962,8 +955,8 @@ class TestMixedMiddleware:
pipeline = ChatMiddlewarePipeline([ClassChatMiddleware(), function_chat_middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(chat_client=mock_chat_client, messages=messages, chat_options=chat_options)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
async def final_handler(ctx: ChatContext) -> ChatResponse:
execution_order.append("handler")
@@ -1093,8 +1086,8 @@ class TestMultipleMiddlewareOrdering:
middleware = [FirstChatMiddleware(), SecondChatMiddleware(), ThirdChatMiddleware()]
pipeline = ChatMiddlewarePipeline(middleware) # type: ignore
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(chat_client=mock_chat_client, messages=messages, chat_options=chat_options)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
async def final_handler(ctx: ChatContext) -> ChatResponse:
execution_order.append("handler")
@@ -1203,7 +1196,7 @@ class TestContextContentValidation:
# Verify context has all expected attributes
assert hasattr(context, "chat_client")
assert hasattr(context, "messages")
assert hasattr(context, "chat_options")
assert hasattr(context, "options")
assert hasattr(context, "is_streaming")
assert hasattr(context, "metadata")
assert hasattr(context, "result")
@@ -1216,8 +1209,8 @@ class TestContextContentValidation:
assert context.messages[0].text == "test"
assert context.is_streaming is False
assert isinstance(context.metadata, dict)
assert isinstance(context.chat_options, ChatOptions)
assert context.chat_options.temperature == 0.5
assert isinstance(context.options, dict)
assert context.options.get("temperature") == 0.5
# Add custom metadata
context.metadata["validated"] = True
@@ -1227,8 +1220,8 @@ class TestContextContentValidation:
middleware = ChatContextValidationMiddleware()
pipeline = ChatMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions(temperature=0.5)
context = ChatContext(chat_client=mock_chat_client, messages=messages, chat_options=chat_options)
chat_options: dict[str, Any] = {"temperature": 0.5}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
async def final_handler(ctx: ChatContext) -> ChatResponse:
# Verify metadata was set by middleware
@@ -1331,10 +1324,10 @@ class TestStreamingScenarios:
middleware = ChatStreamingFlagMiddleware()
pipeline = ChatMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
chat_options: dict[str, Any] = {}
# Test non-streaming
context = ChatContext(chat_client=mock_chat_client, messages=messages, chat_options=chat_options)
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
async def final_handler(ctx: ChatContext) -> ChatResponse:
streaming_flags.append(ctx.is_streaming)
@@ -1344,7 +1337,7 @@ class TestStreamingScenarios:
# Test streaming
context_stream = ChatContext(
chat_client=mock_chat_client, messages=messages, chat_options=chat_options, is_streaming=True
chat_client=mock_chat_client, messages=messages, options=chat_options, is_streaming=True
)
async def final_stream_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
@@ -1373,10 +1366,8 @@ class TestStreamingScenarios:
middleware = ChatStreamProcessingMiddleware()
pipeline = ChatMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(
chat_client=mock_chat_client, messages=messages, chat_options=chat_options, is_streaming=True
)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options, is_streaming=True)
async def final_stream_handler(ctx: ChatContext) -> AsyncIterable[ChatResponseUpdate]:
chunks_processed.append("stream_start")
@@ -1590,8 +1581,8 @@ class TestMiddlewareExecutionControl:
middleware = NoNextChatMiddleware()
pipeline = ChatMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(chat_client=mock_chat_client, messages=messages, chat_options=chat_options)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
handler_called = False
@@ -1618,10 +1609,8 @@ class TestMiddlewareExecutionControl:
middleware = NoNextStreamingChatMiddleware()
pipeline = ChatMiddlewarePipeline([middleware])
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(
chat_client=mock_chat_client, messages=messages, chat_options=chat_options, is_streaming=True
)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options, is_streaming=True)
handler_called = False
@@ -1656,8 +1645,8 @@ class TestMiddlewareExecutionControl:
pipeline = ChatMiddlewarePipeline([FirstChatMiddleware(), SecondChatMiddleware()])
messages = [ChatMessage(role=Role.USER, text="test")]
chat_options = ChatOptions()
context = ChatContext(chat_client=mock_chat_client, messages=messages, chat_options=chat_options)
chat_options: dict[str, Any] = {}
context = ChatContext(chat_client=mock_chat_client, messages=messages, options=chat_options)
handler_called = False
@@ -734,7 +734,7 @@ class TestChatAgentFunctionMiddlewareWithTools:
async def test_function_middleware_can_access_and_override_custom_kwargs(
self, chat_client: "MockChatClient"
) -> None:
"""Test that function middleware can access and override custom parameters like temperature."""
"""Test that function middleware can access and override custom parameters."""
captured_kwargs: dict[str, Any] = {}
modified_kwargs: dict[str, Any] = {}
middleware_called = False
@@ -747,38 +747,20 @@ class TestChatAgentFunctionMiddlewareWithTools:
middleware_called = True
# Capture the original kwargs
captured_kwargs["has_chat_options"] = "chat_options" in context.kwargs
captured_kwargs["has_custom_param"] = "custom_param" in context.kwargs
captured_kwargs["custom_param"] = context.kwargs.get("custom_param")
# Capture original chat_options values if present
if "chat_options" in context.kwargs:
chat_options = context.kwargs["chat_options"]
captured_kwargs["original_temperature"] = getattr(chat_options, "temperature", None)
captured_kwargs["original_max_tokens"] = getattr(chat_options, "max_tokens", None)
# Modify some kwargs
context.kwargs["temperature"] = 0.9
context.kwargs["max_tokens"] = 500
context.kwargs["new_param"] = "added_by_middleware"
# Also modify chat_options if present
if "chat_options" in context.kwargs:
context.kwargs["chat_options"].temperature = 0.9
context.kwargs["chat_options"].max_tokens = 500
# Store modified kwargs for verification
modified_kwargs["temperature"] = context.kwargs.get("temperature")
modified_kwargs["max_tokens"] = context.kwargs.get("max_tokens")
modified_kwargs["new_param"] = context.kwargs.get("new_param")
modified_kwargs["custom_param"] = context.kwargs.get("custom_param")
# Capture modified chat_options values if present
if "chat_options" in context.kwargs:
chat_options = context.kwargs["chat_options"]
modified_kwargs["chat_options_temperature"] = getattr(chat_options, "temperature", None)
modified_kwargs["chat_options_max_tokens"] = getattr(chat_options, "max_tokens", None)
await next(context)
chat_client.responses = [
@@ -800,9 +782,9 @@ class TestChatAgentFunctionMiddlewareWithTools:
# Create ChatAgent with function middleware
agent = ChatAgent(chat_client=chat_client, middleware=[kwargs_middleware], tools=[sample_tool_function])
# Execute the agent with custom parameters
# Execute the agent with custom parameters passed as kwargs
messages = [ChatMessage(role=Role.USER, text="test message")]
response = await agent.run(messages, temperature=0.7, max_tokens=100, custom_param="test_value")
response = await agent.run(messages, custom_param="test_value")
# Verify response
assert response is not None
@@ -812,19 +794,14 @@ class TestChatAgentFunctionMiddlewareWithTools:
assert middleware_called, "Function middleware was not called"
# Verify middleware captured the original kwargs
assert captured_kwargs["has_chat_options"] is True
assert captured_kwargs["has_custom_param"] is True
assert captured_kwargs["custom_param"] == "test_value"
assert captured_kwargs["original_temperature"] == 0.7
assert captured_kwargs["original_max_tokens"] == 100
# Verify middleware could modify the kwargs
assert modified_kwargs["temperature"] == 0.9
assert modified_kwargs["max_tokens"] == 500
assert modified_kwargs["new_param"] == "added_by_middleware"
assert modified_kwargs["custom_param"] == "test_value"
assert modified_kwargs["chat_options_temperature"] == 0.9
assert modified_kwargs["chat_options_max_tokens"] == 500
class TestMiddlewareDynamicRebuild:
@@ -366,7 +366,7 @@ class TestChatMiddleware:
# Execute the chat client directly with tools - this should trigger function invocation and middleware
messages = [ChatMessage(role=Role.USER, text="What's the weather in San Francisco?")]
response = await chat_client.get_response(messages, tools=[sample_tool])
response = await chat_client.get_response(messages, options={"tools": [sample_tool]})
# Verify response
assert response is not None
@@ -423,7 +423,7 @@ class TestChatMiddleware:
# Execute the chat client directly with run-level middleware and tools
messages = [ChatMessage(role=Role.USER, text="What's the weather in New York?")]
response = await chat_client.get_response(
messages, tools=[sample_tool], middleware=[run_level_function_middleware]
messages, options={"tools": [sample_tool]}, middleware=[run_level_function_middleware]
)
# Verify response
@@ -17,7 +17,6 @@ from agent_framework import (
AgentThread,
BaseChatClient,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Role,
@@ -215,7 +214,7 @@ def mock_chat_client():
return "https://test.example.com"
async def _inner_get_response(
self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
self, *, messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
):
return ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, text="Test response")],
@@ -224,7 +223,7 @@ def mock_chat_client():
)
async def _inner_get_streaming_response(
self, *, messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
self, *, messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
):
yield ChatResponseUpdate(text="Hello", role=Role.ASSISTANT)
yield ChatResponseUpdate(text=" world", role=Role.ASSISTANT)
@@ -405,7 +404,7 @@ def mock_chat_agent():
self.id = "test_agent_id"
self.name = "test_agent"
self.description = "Test agent description"
self.chat_options = ChatOptions(model_id="TestModel")
self.default_options: dict[str, Any] = {"model_id": "TestModel"}
async def run(self, messages=None, *, thread=None, **kwargs):
return AgentRunResponse(
+13 -11
View File
@@ -429,7 +429,7 @@ async def test_ai_function_invoke_ignores_additional_kwargs() -> None:
result = await simple_tool.invoke(
arguments=args,
api_token="secret-token",
chat_options={"model_id": "dummy"},
options={"model_id": "dummy"},
)
assert result == "HELLO WORLD"
@@ -1035,7 +1035,7 @@ async def test_non_streaming_single_function_no_approval():
wrapped = _handle_function_calls_response(mock_get_response)
# Execute
result = await wrapped(mock_client, messages=[], tools=[no_approval_tool])
result = await wrapped(mock_client, messages=[], options={"tools": [no_approval_tool]})
# Verify: should have 3 messages: function call, function result, final answer
assert len(result.messages) == 3
@@ -1075,7 +1075,7 @@ async def test_non_streaming_single_function_requires_approval():
wrapped = _handle_function_calls_response(mock_get_response)
# Execute
result = await wrapped(mock_client, messages=[], tools=[requires_approval_tool])
result = await wrapped(mock_client, messages=[], options={"tools": [requires_approval_tool]})
# Verify: should return 1 message with function call and approval request
from agent_framework import FunctionApprovalRequestContent
@@ -1121,7 +1121,7 @@ async def test_non_streaming_two_functions_both_no_approval():
wrapped = _handle_function_calls_response(mock_get_response)
# Execute
result = await wrapped(mock_client, messages=[], tools=[no_approval_tool])
result = await wrapped(mock_client, messages=[], options={"tools": [no_approval_tool]})
# Verify: should have function calls, results, and final answer
from agent_framework import FunctionResultContent
@@ -1167,7 +1167,7 @@ async def test_non_streaming_two_functions_both_require_approval():
wrapped = _handle_function_calls_response(mock_get_response)
# Execute
result = await wrapped(mock_client, messages=[], tools=[requires_approval_tool])
result = await wrapped(mock_client, messages=[], options={"tools": [requires_approval_tool]})
# Verify: should return 1 message with function calls and approval requests
from agent_framework import FunctionApprovalRequestContent
@@ -1213,7 +1213,7 @@ async def test_non_streaming_two_functions_mixed_approval():
wrapped = _handle_function_calls_response(mock_get_response)
# Execute
result = await wrapped(mock_client, messages=[], tools=[no_approval_tool, requires_approval_tool])
result = await wrapped(mock_client, messages=[], options={"tools": [no_approval_tool, requires_approval_tool]})
# Verify: should return approval requests for both (when one needs approval, all are sent for approval)
from agent_framework import FunctionApprovalRequestContent
@@ -1253,7 +1253,7 @@ async def test_streaming_single_function_no_approval():
# Execute and collect updates
updates = []
async for update in wrapped(mock_client, messages=[], tools=[no_approval_tool]):
async for update in wrapped(mock_client, messages=[], options={"tools": [no_approval_tool]}):
updates.append(update)
# Verify: should have function call update, tool result update (injected), and final update
@@ -1298,7 +1298,7 @@ async def test_streaming_single_function_requires_approval():
# Execute and collect updates
updates = []
async for update in wrapped(mock_client, messages=[], tools=[requires_approval_tool]):
async for update in wrapped(mock_client, messages=[], options={"tools": [requires_approval_tool]}):
updates.append(update)
# Verify: should yield function call and then approval request
@@ -1343,7 +1343,7 @@ async def test_streaming_two_functions_both_no_approval():
# Execute and collect updates
updates = []
async for update in wrapped(mock_client, messages=[], tools=[no_approval_tool]):
async for update in wrapped(mock_client, messages=[], options={"tools": [no_approval_tool]}):
updates.append(update)
# Verify: should have both function calls, one tool result update with both results, and final message
@@ -1392,7 +1392,7 @@ async def test_streaming_two_functions_both_require_approval():
# Execute and collect updates
updates = []
async for update in wrapped(mock_client, messages=[], tools=[requires_approval_tool]):
async for update in wrapped(mock_client, messages=[], options={"tools": [requires_approval_tool]}):
updates.append(update)
# Verify: should yield both function calls and then approval requests
@@ -1439,7 +1439,9 @@ async def test_streaming_two_functions_mixed_approval():
# Execute and collect updates
updates = []
async for update in wrapped(mock_client, messages=[], tools=[no_approval_tool, requires_approval_tool]):
async for update in wrapped(
mock_client, messages=[], options={"tools": [no_approval_tool, requires_approval_tool]}
):
updates.append(update)
# Verify: should yield both function calls and then approval requests (when one needs approval, all wait)
+96 -111
View File
@@ -43,6 +43,7 @@ from agent_framework import (
UsageContent,
UsageDetails,
ai_function,
merge_chat_options,
prepare_function_call_results,
)
from agent_framework.exceptions import AdditionItemMismatch, ContentError
@@ -866,117 +867,149 @@ async def test_chat_response_from_async_generator_output_format_in_method():
def test_chat_tool_mode():
"""Test the ToolMode class to ensure it initializes correctly."""
# Create instances of ToolMode
auto_mode = ToolMode.AUTO
required_any = ToolMode.REQUIRED_ANY
required_mode = ToolMode.REQUIRED("example_function")
none_mode = ToolMode.NONE
auto_mode: ToolMode = {"mode": "auto"}
required_any: ToolMode = {"mode": "required"}
required_mode: ToolMode = {"mode": "required", "required_function_name": "example_function"}
none_mode: ToolMode = {"mode": "none"}
# Check the type and content
assert auto_mode.mode == "auto"
assert auto_mode.required_function_name is None
assert required_any.mode == "required"
assert required_any.required_function_name is None
assert required_mode.mode == "required"
assert required_mode.required_function_name == "example_function"
assert none_mode.mode == "none"
assert none_mode.required_function_name is None
assert auto_mode["mode"] == "auto"
assert "required_function_name" not in auto_mode
assert required_any["mode"] == "required"
assert "required_function_name" not in required_any
assert required_mode["mode"] == "required"
assert required_mode["required_function_name"] == "example_function"
assert none_mode["mode"] == "none"
assert "required_function_name" not in none_mode
# Ensure the instances are of type ToolMode
assert isinstance(auto_mode, ToolMode)
assert isinstance(required_any, ToolMode)
assert isinstance(required_mode, ToolMode)
assert isinstance(none_mode, ToolMode)
assert ToolMode.REQUIRED("example_function") == ToolMode.REQUIRED("example_function")
# serializer returns just the mode
assert ToolMode.REQUIRED_ANY.serialize_model() == "required"
# equality of dicts
assert {"mode": "required", "required_function_name": "example_function"} == {
"mode": "required",
"required_function_name": "example_function",
}
def test_chat_tool_mode_from_dict():
"""Test creating ToolMode from a dictionary."""
mode_dict = {"mode": "required", "required_function_name": "example_function"}
mode = ToolMode(**mode_dict)
mode: ToolMode = {"mode": "required", "required_function_name": "example_function"}
# Check the type and content
assert mode.mode == "required"
assert mode.required_function_name == "example_function"
# Ensure the instance is of type ToolMode
assert isinstance(mode, ToolMode)
assert mode["mode"] == "required"
assert mode["required_function_name"] == "example_function"
# region ChatOptions
def test_chat_options_init() -> None:
options = ChatOptions()
assert options.model_id is None
"""Test that ChatOptions can be created as a TypedDict."""
options: ChatOptions = {}
assert options.get("model_id") is None
# With values
options_with_model: ChatOptions = {"model_id": "gpt-4o", "temperature": 0.7}
assert options_with_model.get("model_id") == "gpt-4o"
assert options_with_model.get("temperature") == 0.7
def test_chat_options_tool_choice_validation_errors():
with raises((ContentError, TypeError)):
ChatOptions(tool_choice="invalid-choice")
def test_chat_options_tool_choice_validation():
"""Test validate_tool_mode utility function."""
from agent_framework._types import validate_tool_mode
# Valid string values
assert validate_tool_mode("auto") == {"mode": "auto"}
assert validate_tool_mode("required") == {"mode": "required"}
assert validate_tool_mode("none") == {"mode": "none"}
# Valid ToolMode dict values
assert validate_tool_mode({"mode": "auto"}) == {"mode": "auto"}
assert validate_tool_mode({"mode": "required"}) == {"mode": "required"}
assert validate_tool_mode({"mode": "required", "required_function_name": "example_function"}) == {
"mode": "required",
"required_function_name": "example_function",
}
assert validate_tool_mode({"mode": "none"}) == {"mode": "none"}
# None should return mode==none
assert validate_tool_mode(None) == {"mode": "none"}
with raises(ContentError):
validate_tool_mode("invalid_mode")
with raises(ContentError):
validate_tool_mode({"mode": "invalid_mode"})
with raises(ContentError):
validate_tool_mode({"mode": "auto", "required_function_name": "should_not_be_here"})
def test_chat_options_and(ai_function_tool, ai_tool) -> None:
options1 = ChatOptions(model_id="gpt-4o", tools=[ai_function_tool], logit_bias={"x": 1}, metadata={"a": "b"})
options2 = ChatOptions(model_id="gpt-4.1", tools=[ai_tool], additional_properties={"p": 1})
def test_chat_options_merge(ai_function_tool, ai_tool) -> None:
"""Test merge_chat_options utility function."""
from agent_framework import merge_chat_options
options1: ChatOptions = {
"model_id": "gpt-4o",
"tools": [ai_function_tool],
"logit_bias": {"x": 1},
"metadata": {"a": "b"},
}
options2: ChatOptions = {"model_id": "gpt-4.1", "tools": [ai_tool]}
assert options1 != options2
options3 = options1 & options2
assert options3.model_id == "gpt-4.1"
assert options3.tools == [ai_function_tool, ai_tool]
assert options3.logit_bias == {"x": 1}
assert options3.metadata == {"a": "b"}
assert options3.additional_properties.get("p") == 1
# Merge options - override takes precedence for non-collection fields
options3 = merge_chat_options(options1, options2)
assert options3.get("model_id") == "gpt-4.1"
assert options3.get("tools") == [ai_function_tool, ai_tool] # tools are combined
assert options3.get("logit_bias") == {"x": 1} # base value preserved
assert options3.get("metadata") == {"a": "b"} # base value preserved
def test_chat_options_and_tool_choice_override() -> None:
"""Test that tool_choice from other takes precedence in ChatOptions merge."""
# Agent-level defaults to "auto"
agent_options = ChatOptions(model_id="gpt-4o", tool_choice="auto")
agent_options: ChatOptions = {"model_id": "gpt-4o", "tool_choice": "auto"}
# Run-level specifies "required"
run_options = ChatOptions(tool_choice="required")
run_options: ChatOptions = {"tool_choice": "required"}
merged = agent_options & run_options
merged = merge_chat_options(agent_options, run_options)
# Run-level should override agent-level
assert merged.tool_choice == "required"
assert merged.model_id == "gpt-4o" # Other fields preserved
assert merged.get("tool_choice") == "required"
assert merged.get("model_id") == "gpt-4o" # Other fields preserved
def test_chat_options_and_tool_choice_none_in_other_uses_self() -> None:
"""Test that when other.tool_choice is None, self.tool_choice is used."""
agent_options = ChatOptions(tool_choice="auto")
run_options = ChatOptions(model_id="gpt-4.1") # tool_choice is None
agent_options: ChatOptions = {"tool_choice": "auto"}
run_options: ChatOptions = {"model_id": "gpt-4.1"} # tool_choice is None
merged = agent_options & run_options
merged = merge_chat_options(agent_options, run_options)
# Should keep agent-level tool_choice since run-level is None
assert merged.tool_choice == "auto"
assert merged.model_id == "gpt-4.1"
assert merged.get("tool_choice") == "auto"
assert merged.get("model_id") == "gpt-4.1"
def test_chat_options_and_tool_choice_with_tool_mode() -> None:
"""Test ChatOptions merge with ToolMode objects."""
agent_options = ChatOptions(tool_choice=ToolMode.AUTO)
run_options = ChatOptions(tool_choice=ToolMode.REQUIRED_ANY)
agent_options: ChatOptions = {"tool_choice": "auto"}
run_options: ChatOptions = {"tool_choice": "required"}
merged = agent_options & run_options
merged = merge_chat_options(agent_options, run_options)
assert merged.tool_choice == ToolMode.REQUIRED_ANY
assert merged.tool_choice == "required" # ToolMode equality with string
assert merged.get("tool_choice") == "required"
assert merged.get("tool_choice") == "required"
def test_chat_options_and_tool_choice_required_specific_function() -> None:
"""Test ChatOptions merge with required specific function."""
agent_options = ChatOptions(tool_choice="auto")
run_options = ChatOptions(tool_choice=ToolMode.REQUIRED(function_name="get_weather"))
agent_options: ChatOptions = {"tool_choice": "auto"}
run_options: ChatOptions = {"tool_choice": {"mode": "required", "required_function_name": "get_weather"}}
merged = agent_options & run_options
merged = merge_chat_options(agent_options, run_options)
assert merged.tool_choice == "required"
assert merged.tool_choice.required_function_name == "get_weather"
tool_choice = merged.get("tool_choice")
assert tool_choice == {"mode": "required", "required_function_name": "get_weather"}
assert tool_choice["required_function_name"] == "get_weather"
# region Agent Response Fixtures
@@ -1249,7 +1282,7 @@ def test_function_call_content_parse_numeric_or_list():
def test_chat_tool_mode_eq_with_string():
assert ToolMode.AUTO == "auto"
assert {"mode": "auto"} == {"mode": "auto"}
# region AgentRunResponse
@@ -1437,30 +1470,6 @@ def test_chat_message_from_dict_with_mixed_content():
assert len(message_dict["contents"]) == 3
def test_chat_options_edge_cases():
"""Test ChatOptions with edge cases for better coverage."""
# Test with tools conversion
def sample_tool():
return "test"
options = ChatOptions(tools=[sample_tool], tool_choice="auto")
assert options.tool_choice == ToolMode.AUTO
# Test to_dict with ToolMode
options_dict = options.to_dict()
assert "tool_choice" in options_dict
# Test from_dict with tool_choice dict
data_with_dict_tool_choice = {
"model_id": "gpt-4",
"tool_choice": {"mode": "required", "required_function_name": "test_func"},
}
options_from_dict = ChatOptions.from_dict(data_with_dict_tool_choice)
assert options_from_dict.tool_choice.mode == "required"
assert options_from_dict.tool_choice.required_function_name == "test_func"
def test_text_content_add_type_error():
"""Test TextContent __add__ raises TypeError for incompatible types."""
t1 = TextContent("Hello")
@@ -1501,30 +1510,6 @@ def test_comprehensive_serialization_methods():
assert result_content.result == "success"
def test_chat_options_tool_choice_variations():
"""Test ChatOptions from_dict and to_dict with various tool_choice values."""
# Test with string tool_choice
data = {"model_id": "gpt-4", "tool_choice": "auto", "temperature": 0.7}
options = ChatOptions.from_dict(data)
assert options.tool_choice == ToolMode.AUTO
# Test with dict tool_choice
data_dict = {
"model_id": "gpt-4",
"tool_choice": {"mode": "required", "required_function_name": "test_func"},
"temperature": 0.7,
}
options_dict = ChatOptions.from_dict(data_dict)
assert options_dict.tool_choice.mode == "required"
assert options_dict.tool_choice.required_function_name == "test_func"
# Test to_dict with ToolMode
options_dict_serialized = options_dict.to_dict()
assert "tool_choice" in options_dict_serialized
assert isinstance(options_dict_serialized["tool_choice"], dict)
def test_chat_message_complex_content_serialization():
"""Test ChatMessage serialization with various content types."""
@@ -17,7 +17,6 @@ from agent_framework import (
ChatAgent,
ChatClientProtocol,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
@@ -27,7 +26,6 @@ from agent_framework import (
HostedVectorStoreContent,
Role,
TextContent,
ToolMode,
UriContent,
UsageContent,
ai_function,
@@ -43,6 +41,8 @@ skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
else "Integration tests are disabled.",
)
INTEGRATION_TEST_MODEL = "gpt-4.1-nano"
def create_test_openai_assistants_client(
mock_async_openai: MagicMock,
@@ -117,7 +117,7 @@ def mock_async_openai() -> MagicMock:
return mock_client
def test_openai_assistants_client_init_with_client(mock_async_openai: MagicMock) -> None:
def test_init_with_client(mock_async_openai: MagicMock) -> None:
"""Test OpenAIAssistantsClient initialization with existing client."""
chat_client = create_test_openai_assistants_client(
mock_async_openai, model_id="gpt-4", assistant_id="existing-assistant-id", thread_id="test-thread-id"
@@ -131,7 +131,7 @@ def test_openai_assistants_client_init_with_client(mock_async_openai: MagicMock)
assert isinstance(chat_client, ChatClientProtocol)
def test_openai_assistants_client_init_auto_create_client(
def test_init_auto_create_client(
openai_unit_test_env: dict[str, str],
mock_async_openai: MagicMock,
) -> None:
@@ -151,7 +151,7 @@ def test_openai_assistants_client_init_auto_create_client(
assert not chat_client._should_delete_assistant # type: ignore
def test_openai_assistants_client_init_validation_fail() -> None:
def test_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
@@ -159,7 +159,7 @@ def test_openai_assistants_client_init_validation_fail() -> None:
@pytest.mark.parametrize("exclude_list", [["OPENAI_CHAT_MODEL_ID"]], indirect=True)
def test_openai_assistants_client_init_missing_model_id(openai_unit_test_env: dict[str, str]) -> None:
def test_init_missing_model_id(openai_unit_test_env: dict[str, str]) -> None:
"""Test OpenAIAssistantsClient initialization with missing model ID."""
with pytest.raises(ServiceInitializationError):
OpenAIAssistantsClient(
@@ -168,13 +168,13 @@ def test_openai_assistants_client_init_missing_model_id(openai_unit_test_env: di
@pytest.mark.parametrize("exclude_list", [["OPENAI_API_KEY"]], indirect=True)
def test_openai_assistants_client_init_missing_api_key(openai_unit_test_env: dict[str, str]) -> None:
def test_init_missing_api_key(openai_unit_test_env: dict[str, str]) -> None:
"""Test OpenAIAssistantsClient initialization with missing API key."""
with pytest.raises(ServiceInitializationError):
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:
def test_init_with_default_headers(openai_unit_test_env: dict[str, str]) -> None:
"""Test OpenAIAssistantsClient initialization with default headers."""
default_headers = {"X-Unit-Test": "test-guid"}
@@ -193,7 +193,7 @@ def test_openai_assistants_client_init_with_default_headers(openai_unit_test_env
assert chat_client.client.default_headers[key] == value
async def test_openai_assistants_client_get_assistant_id_or_create_existing_assistant(
async def test_get_assistant_id_or_create_existing_assistant(
mock_async_openai: MagicMock,
) -> None:
"""Test _get_assistant_id_or_create when assistant_id is already provided."""
@@ -206,7 +206,7 @@ async def test_openai_assistants_client_get_assistant_id_or_create_existing_assi
mock_async_openai.beta.assistants.create.assert_not_called()
async def test_openai_assistants_client_get_assistant_id_or_create_create_new(
async def test_get_assistant_id_or_create_create_new(
mock_async_openai: MagicMock,
) -> None:
"""Test _get_assistant_id_or_create when creating a new assistant."""
@@ -221,7 +221,7 @@ async def test_openai_assistants_client_get_assistant_id_or_create_create_new(
mock_async_openai.beta.assistants.create.assert_called_once()
async def test_openai_assistants_client_aclose_should_not_delete(
async def test_aclose_should_not_delete(
mock_async_openai: MagicMock,
) -> None:
"""Test close when assistant should not be deleted."""
@@ -236,7 +236,7 @@ async def test_openai_assistants_client_aclose_should_not_delete(
assert not chat_client._should_delete_assistant # type: ignore
async def test_openai_assistants_client_aclose_should_delete(mock_async_openai: MagicMock) -> None:
async def test_aclose_should_delete(mock_async_openai: MagicMock) -> None:
"""Test close method calls cleanup."""
chat_client = create_test_openai_assistants_client(
mock_async_openai, assistant_id="assistant-to-delete", should_delete_assistant=True
@@ -249,7 +249,7 @@ async def test_openai_assistants_client_aclose_should_delete(mock_async_openai:
assert not chat_client._should_delete_assistant # type: ignore
async def test_openai_assistants_client_async_context_manager(mock_async_openai: MagicMock) -> None:
async def test_async_context_manager(mock_async_openai: MagicMock) -> None:
"""Test async context manager functionality."""
chat_client = create_test_openai_assistants_client(
mock_async_openai, assistant_id="assistant-to-delete", should_delete_assistant=True
@@ -263,7 +263,7 @@ async def test_openai_assistants_client_async_context_manager(mock_async_openai:
mock_async_openai.beta.assistants.delete.assert_called_once_with("assistant-to-delete")
def test_openai_assistants_client_serialize(openai_unit_test_env: dict[str, str]) -> None:
def test_serialize(openai_unit_test_env: dict[str, str]) -> None:
"""Test serialization of OpenAIAssistantsClient."""
default_headers = {"X-Unit-Test": "test-guid"}
@@ -294,7 +294,7 @@ def test_openai_assistants_client_serialize(openai_unit_test_env: dict[str, str]
assert "User-Agent" not in dumped_settings["default_headers"]
async def test_openai_assistants_client_get_active_thread_run_none_thread_id(mock_async_openai: MagicMock) -> None:
async def test_get_active_thread_run_none_thread_id(mock_async_openai: MagicMock) -> None:
"""Test _get_active_thread_run with None thread_id returns None."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -305,7 +305,7 @@ async def test_openai_assistants_client_get_active_thread_run_none_thread_id(moc
mock_async_openai.beta.threads.runs.list.assert_not_called()
async def test_openai_assistants_client_get_active_thread_run_with_active_run(mock_async_openai: MagicMock) -> None:
async def test_get_active_thread_run_with_active_run(mock_async_openai: MagicMock) -> None:
"""Test _get_active_thread_run finds an active run."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -326,7 +326,7 @@ async def test_openai_assistants_client_get_active_thread_run_with_active_run(mo
mock_async_openai.beta.threads.runs.list.assert_called_once_with(thread_id="thread-123", limit=1, order="desc")
async def test_openai_assistants_client_prepare_thread_create_new(mock_async_openai: MagicMock) -> None:
async def test_prepare_thread_create_new(mock_async_openai: MagicMock) -> None:
"""Test _prepare_thread creates new thread when thread_id is None."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -353,7 +353,7 @@ async def test_openai_assistants_client_prepare_thread_create_new(mock_async_ope
)
async def test_openai_assistants_client_prepare_thread_cancel_existing_run(mock_async_openai: MagicMock) -> None:
async def test_prepare_thread_cancel_existing_run(mock_async_openai: MagicMock) -> None:
"""Test _prepare_thread cancels existing run when provided."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -369,7 +369,7 @@ async def test_openai_assistants_client_prepare_thread_cancel_existing_run(mock_
mock_async_openai.beta.threads.runs.cancel.assert_called_once_with(run_id="run-456", thread_id="thread-123")
async def test_openai_assistants_client_prepare_thread_existing_no_run(mock_async_openai: MagicMock) -> None:
async def test_prepare_thread_existing_no_run(mock_async_openai: MagicMock) -> None:
"""Test _prepare_thread with existing thread_id but no active run."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -382,7 +382,7 @@ async def test_openai_assistants_client_prepare_thread_existing_no_run(mock_asyn
mock_async_openai.beta.threads.runs.cancel.assert_not_called()
async def test_openai_assistants_client_process_stream_events_thread_run_created(mock_async_openai: MagicMock) -> None:
async def test_process_stream_events_thread_run_created(mock_async_openai: MagicMock) -> None:
"""Test _process_stream_events with thread.run.created event."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -415,7 +415,7 @@ async def test_openai_assistants_client_process_stream_events_thread_run_created
assert update.raw_representation == mock_response.data
async def test_openai_assistants_client_process_stream_events_message_delta_text(mock_async_openai: MagicMock) -> None:
async def test_process_stream_events_message_delta_text(mock_async_openai: MagicMock) -> None:
"""Test _process_stream_events with thread.message.delta event containing text."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -459,7 +459,7 @@ async def test_openai_assistants_client_process_stream_events_message_delta_text
assert update.raw_representation == mock_message_delta
async def test_openai_assistants_client_process_stream_events_requires_action(mock_async_openai: MagicMock) -> None:
async def test_process_stream_events_requires_action(mock_async_openai: MagicMock) -> None:
"""Test _process_stream_events with thread.run.requires_action event."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -502,7 +502,7 @@ async def test_openai_assistants_client_process_stream_events_requires_action(mo
chat_client._parse_function_calls_from_assistants.assert_called_once_with(mock_run, None) # type: ignore
async def test_openai_assistants_client_process_stream_events_run_step_created(mock_async_openai: MagicMock) -> None:
async def test_process_stream_events_run_step_created(mock_async_openai: MagicMock) -> None:
"""Test _process_stream_events with thread.run.step.created event."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -534,7 +534,7 @@ async def test_openai_assistants_client_process_stream_events_run_step_created(m
assert len(updates) == 0
async def test_openai_assistants_client_process_stream_events_run_completed_with_usage(
async def test_process_stream_events_run_completed_with_usage(
mock_async_openai: MagicMock,
) -> None:
"""Test _process_stream_events with thread.run.completed event containing usage."""
@@ -585,7 +585,7 @@ async def test_openai_assistants_client_process_stream_events_run_completed_with
assert update.raw_representation == mock_run
def test_openai_assistants_client_parse_function_calls_from_assistants_basic(mock_async_openai: MagicMock) -> None:
def test_parse_function_calls_from_assistants_basic(mock_async_openai: MagicMock) -> None:
"""Test _parse_function_calls_from_assistants with a simple function call."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -614,22 +614,22 @@ def test_openai_assistants_client_parse_function_calls_from_assistants_basic(moc
assert contents[0].arguments == {"location": "Seattle"}
def test_openai_assistants_client_prepare_options_basic(mock_async_openai: MagicMock) -> None:
def test_prepare_options_basic(mock_async_openai: MagicMock) -> None:
"""Test _prepare_options with basic chat options."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
# Create basic chat options
chat_options = ChatOptions(
max_tokens=100,
model_id="gpt-4",
temperature=0.7,
top_p=0.9,
)
# Create basic chat options as a dict
options = {
"max_tokens": 100,
"model_id": "gpt-4",
"temperature": 0.7,
"top_p": 0.9,
}
messages = [ChatMessage(role=Role.USER, text="Hello")]
# Call the method
run_options, tool_results = chat_client._prepare_options(messages, chat_options) # type: ignore
run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore
# Check basic options were set
assert run_options["max_completion_tokens"] == 100
@@ -639,7 +639,7 @@ def test_openai_assistants_client_prepare_options_basic(mock_async_openai: Magic
assert tool_results is None
def test_openai_assistants_client_prepare_options_with_ai_function_tool(mock_async_openai: MagicMock) -> None:
def test_prepare_options_with_ai_function_tool(mock_async_openai: MagicMock) -> None:
"""Test _prepare_options with AIFunction tool."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -650,15 +650,15 @@ def test_openai_assistants_client_prepare_options_with_ai_function_tool(mock_asy
"""A test function."""
return f"Result for {query}"
chat_options = ChatOptions(
tools=[test_function],
tool_choice="auto",
)
options = {
"tools": [test_function],
"tool_choice": "auto",
}
messages = [ChatMessage(role=Role.USER, text="Hello")]
# Call the method
run_options, tool_results = chat_client._prepare_options(messages, chat_options) # type: ignore
run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore
# Check tools were set correctly
assert "tools" in run_options
@@ -668,22 +668,22 @@ def test_openai_assistants_client_prepare_options_with_ai_function_tool(mock_asy
assert run_options["tool_choice"] == "auto"
def test_openai_assistants_client_prepare_options_with_code_interpreter(mock_async_openai: MagicMock) -> None:
def test_prepare_options_with_code_interpreter(mock_async_openai: MagicMock) -> None:
"""Test _prepare_options with HostedCodeInterpreterTool."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
# Create a real HostedCodeInterpreterTool
code_tool = HostedCodeInterpreterTool()
chat_options = ChatOptions(
tools=[code_tool],
tool_choice="auto",
)
options = {
"tools": [code_tool],
"tool_choice": "auto",
}
messages = [ChatMessage(role=Role.USER, text="Calculate something")]
# Call the method
run_options, tool_results = chat_client._prepare_options(messages, chat_options) # type: ignore
run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore
# Check code interpreter tool was set correctly
assert "tools" in run_options
@@ -692,39 +692,39 @@ def test_openai_assistants_client_prepare_options_with_code_interpreter(mock_asy
assert run_options["tool_choice"] == "auto"
def test_openai_assistants_client_prepare_options_tool_choice_none(mock_async_openai: MagicMock) -> None:
def test_prepare_options_tool_choice_none(mock_async_openai: MagicMock) -> None:
"""Test _prepare_options with tool_choice set to 'none'."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
chat_options = ChatOptions(
tool_choice="none",
)
options = {
"tool_choice": "none",
}
messages = [ChatMessage(role=Role.USER, text="Hello")]
# Call the method
run_options, tool_results = chat_client._prepare_options(messages, chat_options) # type: ignore
run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore
# Should set tool_choice to none and not include tools
assert run_options["tool_choice"] == "none"
assert "tools" not in run_options
def test_openai_assistants_client_prepare_options_required_function(mock_async_openai: MagicMock) -> None:
def test_prepare_options_required_function(mock_async_openai: MagicMock) -> None:
"""Test _prepare_options with required function tool choice."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
# Create a required function tool choice
tool_choice = ToolMode(mode="required", required_function_name="specific_function")
# Create a required function tool choice as dict
tool_choice = {"mode": "required", "required_function_name": "specific_function"}
chat_options = ChatOptions(
tool_choice=tool_choice,
)
options = {
"tool_choice": tool_choice,
}
messages = [ChatMessage(role=Role.USER, text="Hello")]
# Call the method
run_options, tool_results = chat_client._prepare_options(messages, chat_options) # type: ignore
run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore
# Check required function tool choice was set correctly
expected_tool_choice = {
@@ -734,7 +734,7 @@ def test_openai_assistants_client_prepare_options_required_function(mock_async_o
assert run_options["tool_choice"] == expected_tool_choice
def test_openai_assistants_client_prepare_options_with_file_search_tool(mock_async_openai: MagicMock) -> None:
def test_prepare_options_with_file_search_tool(mock_async_openai: MagicMock) -> None:
"""Test _prepare_options with HostedFileSearchTool."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -742,15 +742,15 @@ def test_openai_assistants_client_prepare_options_with_file_search_tool(mock_asy
# Create a HostedFileSearchTool with max_results
file_search_tool = HostedFileSearchTool(max_results=10)
chat_options = ChatOptions(
tools=[file_search_tool],
tool_choice="auto",
)
options = {
"tools": [file_search_tool],
"tool_choice": "auto",
}
messages = [ChatMessage(role=Role.USER, text="Search for information")]
# Call the method
run_options, tool_results = chat_client._prepare_options(messages, chat_options) # type: ignore
run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore
# Check file search tool was set correctly
assert "tools" in run_options
@@ -760,22 +760,22 @@ def test_openai_assistants_client_prepare_options_with_file_search_tool(mock_asy
assert run_options["tool_choice"] == "auto"
def test_openai_assistants_client_prepare_options_with_mapping_tool(mock_async_openai: MagicMock) -> None:
def test_prepare_options_with_mapping_tool(mock_async_openai: MagicMock) -> None:
"""Test _prepare_options with MutableMapping tool."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
# Create a tool as a MutableMapping (dict)
mapping_tool = {"type": "custom_tool", "parameters": {"setting": "value"}}
chat_options = ChatOptions(
tools=[mapping_tool], # type: ignore
tool_choice="auto",
)
options = {
"tools": [mapping_tool], # type: ignore
"tool_choice": "auto",
}
messages = [ChatMessage(role=Role.USER, text="Use custom tool")]
# Call the method
run_options, tool_results = chat_client._prepare_options(messages, chat_options) # type: ignore
run_options, tool_results = chat_client._prepare_options(messages, options) # type: ignore
# Check mapping tool was set correctly
assert "tools" in run_options
@@ -784,7 +784,7 @@ def test_openai_assistants_client_prepare_options_with_mapping_tool(mock_async_o
assert run_options["tool_choice"] == "auto"
def test_openai_assistants_client_prepare_options_with_system_message(mock_async_openai: MagicMock) -> None:
def test_prepare_options_with_system_message(mock_async_openai: MagicMock) -> None:
"""Test _prepare_options with system message converted to instructions."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -794,7 +794,7 @@ def test_openai_assistants_client_prepare_options_with_system_message(mock_async
]
# Call the method
run_options, tool_results = chat_client._prepare_options(messages, None) # type: ignore
run_options, tool_results = chat_client._prepare_options(messages, {}) # type: ignore
# Check that additional_messages only contains the user message
# System message should be converted to instructions (though this is handled internally)
@@ -803,7 +803,7 @@ def test_openai_assistants_client_prepare_options_with_system_message(mock_async
assert run_options["additional_messages"][0]["role"] == "user"
def test_openai_assistants_client_prepare_options_with_image_content(mock_async_openai: MagicMock) -> None:
def test_prepare_options_with_image_content(mock_async_openai: MagicMock) -> None:
"""Test _prepare_options with image content."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -813,7 +813,7 @@ def test_openai_assistants_client_prepare_options_with_image_content(mock_async_
messages = [ChatMessage(role=Role.USER, contents=[image_content])]
# Call the method
run_options, tool_results = chat_client._prepare_options(messages, None) # type: ignore
run_options, tool_results = chat_client._prepare_options(messages, {}) # type: ignore
# Check that image content was processed
assert "additional_messages" in run_options
@@ -825,7 +825,7 @@ def test_openai_assistants_client_prepare_options_with_image_content(mock_async_
assert message["content"][0]["image_url"]["url"] == "https://example.com/image.jpg"
def test_openai_assistants_client_prepare_tool_outputs_for_assistants_empty(mock_async_openai: MagicMock) -> None:
def test_prepare_tool_outputs_for_assistants_empty(mock_async_openai: MagicMock) -> None:
"""Test _prepare_tool_outputs_for_assistants with empty list."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -835,7 +835,7 @@ def test_openai_assistants_client_prepare_tool_outputs_for_assistants_empty(mock
assert tool_outputs is None
def test_openai_assistants_client_prepare_tool_outputs_for_assistants_valid(mock_async_openai: MagicMock) -> None:
def test_prepare_tool_outputs_for_assistants_valid(mock_async_openai: MagicMock) -> None:
"""Test _prepare_tool_outputs_for_assistants with valid function results."""
chat_client = create_test_openai_assistants_client(mock_async_openai)
@@ -851,7 +851,7 @@ def test_openai_assistants_client_prepare_tool_outputs_for_assistants_valid(mock
assert tool_outputs[0].get("output") == "Function executed successfully"
def test_openai_assistants_client_prepare_tool_outputs_for_assistants_mismatched_run_ids(
def test_prepare_tool_outputs_for_assistants_mismatched_run_ids(
mock_async_openai: MagicMock,
) -> None:
"""Test _prepare_tool_outputs_for_assistants with mismatched run IDs."""
@@ -872,7 +872,7 @@ def test_openai_assistants_client_prepare_tool_outputs_for_assistants_mismatched
assert tool_outputs[0].get("tool_call_id") == "call-456"
def test_openai_assistants_client_update_agent_name_and_description(mock_async_openai: MagicMock) -> None:
def test_update_agent_name_and_description(mock_async_openai: MagicMock) -> None:
"""Test _update_agent_name_and_description method updates assistant_name when not already set."""
# Test updating agent name when assistant_name is None
chat_client = create_test_openai_assistants_client(mock_async_openai, assistant_name=None)
@@ -883,7 +883,7 @@ def test_openai_assistants_client_update_agent_name_and_description(mock_async_o
assert chat_client.assistant_name == "New Assistant Name"
def test_openai_assistants_client_update_agent_name_and_description_existing(mock_async_openai: MagicMock) -> None:
def test_update_agent_name_and_description_existing(mock_async_openai: MagicMock) -> None:
"""Test _update_agent_name_and_description method doesn't override existing assistant_name."""
# Test that existing assistant_name is not overridden
chat_client = create_test_openai_assistants_client(mock_async_openai, assistant_name="Existing Assistant")
@@ -895,7 +895,7 @@ def test_openai_assistants_client_update_agent_name_and_description_existing(moc
assert chat_client.assistant_name == "Existing Assistant"
def test_openai_assistants_client_update_agent_name_and_description_none(mock_async_openai: MagicMock) -> None:
def test_update_agent_name_and_description_none(mock_async_openai: MagicMock) -> None:
"""Test _update_agent_name_and_description method with None agent_name parameter."""
# Test that None agent_name doesn't change anything
chat_client = create_test_openai_assistants_client(mock_async_openai, assistant_name=None)
@@ -916,9 +916,9 @@ def get_weather(
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_assistants_client_get_response() -> None:
async def test_get_response() -> None:
"""Test OpenAI Assistants Client response."""
async with OpenAIAssistantsClient() as openai_assistants_client:
async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client:
assert isinstance(openai_assistants_client, ChatClientProtocol)
messages: list[ChatMessage] = []
@@ -941,9 +941,9 @@ async def test_openai_assistants_client_get_response() -> None:
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_assistants_client_get_response_tools() -> None:
async def test_get_response_tools() -> None:
"""Test OpenAI Assistants Client response with tools."""
async with OpenAIAssistantsClient() as openai_assistants_client:
async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client:
assert isinstance(openai_assistants_client, ChatClientProtocol)
messages: list[ChatMessage] = []
@@ -952,8 +952,7 @@ async def test_openai_assistants_client_get_response_tools() -> None:
# Test that the client can be used to get a response
response = await openai_assistants_client.get_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
options={"tools": [get_weather], "tool_choice": "auto"},
)
assert response is not None
@@ -963,9 +962,9 @@ async def test_openai_assistants_client_get_response_tools() -> None:
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_assistants_client_streaming() -> None:
async def test_streaming() -> None:
"""Test OpenAI Assistants Client streaming response."""
async with OpenAIAssistantsClient() as openai_assistants_client:
async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client:
assert isinstance(openai_assistants_client, ChatClientProtocol)
messages: list[ChatMessage] = []
@@ -994,9 +993,9 @@ async def test_openai_assistants_client_streaming() -> None:
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_assistants_client_streaming_tools() -> None:
async def test_streaming_tools() -> None:
"""Test OpenAI Assistants Client streaming response with tools."""
async with OpenAIAssistantsClient() as openai_assistants_client:
async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client:
assert isinstance(openai_assistants_client, ChatClientProtocol)
messages: list[ChatMessage] = []
@@ -1005,8 +1004,10 @@ async def test_openai_assistants_client_streaming_tools() -> None:
# Test that the client can be used to get a response
response = openai_assistants_client.get_streaming_response(
messages=messages,
tools=[get_weather],
tool_choice="auto",
options={
"tools": [get_weather],
"tool_choice": "auto",
},
)
full_message: str = ""
async for chunk in response:
@@ -1021,10 +1022,10 @@ async def test_openai_assistants_client_streaming_tools() -> None:
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_assistants_client_with_existing_assistant() -> None:
async def test_with_existing_assistant() -> None:
"""Test OpenAI Assistants Client with existing assistant ID."""
# First create an assistant to use in the test
async with OpenAIAssistantsClient() as temp_client:
async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as temp_client:
# Get the assistant ID by triggering assistant creation
messages = [ChatMessage(role="user", text="Hello")]
await temp_client.get_response(messages=messages)
@@ -1032,7 +1033,7 @@ async def test_openai_assistants_client_with_existing_assistant() -> None:
# Now test using the existing assistant
async with OpenAIAssistantsClient(
model_id="gpt-4o-mini", assistant_id=assistant_id
model_id=INTEGRATION_TEST_MODEL, assistant_id=assistant_id
) as openai_assistants_client:
assert isinstance(openai_assistants_client, ChatClientProtocol)
assert openai_assistants_client.assistant_id == assistant_id
@@ -1050,9 +1051,9 @@ async def test_openai_assistants_client_with_existing_assistant() -> None:
@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:
async def test_file_search() -> None:
"""Test OpenAI Assistants Client response."""
async with OpenAIAssistantsClient() as openai_assistants_client:
async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client:
assert isinstance(openai_assistants_client, ChatClientProtocol)
messages: list[ChatMessage] = []
@@ -1061,8 +1062,10 @@ async def test_openai_assistants_client_file_search() -> None:
file_id, vector_store = await create_vector_store(openai_assistants_client)
response = await openai_assistants_client.get_response(
messages=messages,
tools=[HostedFileSearchTool()],
tool_resources={"file_search": {"vector_store_ids": [vector_store.vector_store_id]}},
options={
"tools": [HostedFileSearchTool()],
"tool_resources": {"file_search": {"vector_store_ids": [vector_store.vector_store_id]}},
},
)
await delete_vector_store(openai_assistants_client, file_id, vector_store.vector_store_id)
@@ -1074,9 +1077,9 @@ async def test_openai_assistants_client_file_search() -> None:
@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:
async def test_file_search_streaming() -> None:
"""Test OpenAI Assistants Client response."""
async with OpenAIAssistantsClient() as openai_assistants_client:
async with OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL) as openai_assistants_client:
assert isinstance(openai_assistants_client, ChatClientProtocol)
messages: list[ChatMessage] = []
@@ -1085,8 +1088,10 @@ async def test_openai_assistants_client_file_search_streaming() -> None:
file_id, vector_store = await create_vector_store(openai_assistants_client)
response = openai_assistants_client.get_streaming_response(
messages=messages,
tools=[HostedFileSearchTool()],
tool_resources={"file_search": {"vector_store_ids": [vector_store.vector_store_id]}},
options={
"tools": [HostedFileSearchTool()],
"tool_resources": {"file_search": {"vector_store_ids": [vector_store.vector_store_id]}},
},
)
assert response is not None
@@ -1107,7 +1112,7 @@ async def test_openai_assistants_client_file_search_streaming() -> None:
async def test_openai_assistants_agent_basic_run():
"""Test ChatAgent basic run functionality with OpenAIAssistantsClient."""
async with ChatAgent(
chat_client=OpenAIAssistantsClient(),
chat_client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
) as agent:
# Run a simple query
response = await agent.run("Hello! Please respond with 'Hello World' exactly.")
@@ -1124,7 +1129,7 @@ async def test_openai_assistants_agent_basic_run():
async def test_openai_assistants_agent_basic_run_streaming():
"""Test ChatAgent basic streaming functionality with OpenAIAssistantsClient."""
async with ChatAgent(
chat_client=OpenAIAssistantsClient(),
chat_client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
) as agent:
# Run streaming query
full_message: str = ""
@@ -1144,7 +1149,7 @@ async def test_openai_assistants_agent_basic_run_streaming():
async def test_openai_assistants_agent_thread_persistence():
"""Test ChatAgent thread persistence across runs with OpenAIAssistantsClient."""
async with ChatAgent(
chat_client=OpenAIAssistantsClient(),
chat_client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
instructions="You are a helpful assistant with good memory.",
) as agent:
# Create a new thread that will be reused
@@ -1176,7 +1181,7 @@ async def test_openai_assistants_agent_existing_thread_id():
existing_thread_id = None
async with ChatAgent(
chat_client=OpenAIAssistantsClient(),
chat_client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
instructions="You are a helpful weather agent.",
tools=[get_weather],
) as agent:
@@ -1219,7 +1224,7 @@ async def test_openai_assistants_agent_code_interpreter():
"""Test ChatAgent with code interpreter through OpenAIAssistantsClient."""
async with ChatAgent(
chat_client=OpenAIAssistantsClient(),
chat_client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
instructions="You are a helpful assistant that can write and execute Python code.",
tools=[HostedCodeInterpreterTool()],
) as agent:
@@ -1235,11 +1240,11 @@ async def test_openai_assistants_agent_code_interpreter():
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_assistants_client_agent_level_tool_persistence():
async def test_agent_level_tool_persistence():
"""Test that agent-level tools persist across multiple runs with OpenAI Assistants Client."""
async with ChatAgent(
chat_client=OpenAIAssistantsClient(),
chat_client=OpenAIAssistantsClient(model_id=INTEGRATION_TEST_MODEL),
instructions="You are a helpful assistant that uses available tools.",
tools=[get_weather], # Agent-level tool
) as agent:
@@ -1261,7 +1266,7 @@ async def test_openai_assistants_client_agent_level_tool_persistence():
# Callable API Key Tests
def test_openai_assistants_client_with_callable_api_key() -> None:
def test_with_callable_api_key() -> None:
"""Test OpenAIAssistantsClient initialization with callable API key."""
async def get_api_key() -> str:
@@ -1,25 +1,22 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import os
from typing import Annotated
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from openai import BadRequestError
from pydantic import BaseModel
from pytest import param
from agent_framework import (
AgentRunResponse,
AgentRunResponseUpdate,
ChatAgent,
ChatClientProtocol,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
DataContent,
FunctionResultContent,
HostedWebSearchTool,
TextContent,
ToolProtocol,
ai_function,
prepare_function_call_results,
@@ -170,7 +167,7 @@ async def test_content_filter_exception_handling(openai_unit_test_env: dict[str,
patch.object(client.client.chat.completions, "create", side_effect=mock_error),
pytest.raises(OpenAIContentFilterException),
):
await client._inner_get_response(messages=messages, chat_options=ChatOptions()) # type: ignore
await client._inner_get_response(messages=messages, options={}) # type: ignore
def test_unsupported_tool_handling(openai_unit_test_env: dict[str, str]) -> None:
@@ -183,12 +180,12 @@ def test_unsupported_tool_handling(openai_unit_test_env: dict[str, str]) -> None
# This should ignore the unsupported ToolProtocol and return empty list
result = client._prepare_tools_for_openai([unsupported_tool]) # type: ignore
assert result == []
assert result == {}
# Also test with a non-ToolProtocol that should be converted to dict
dict_tool = {"type": "function", "name": "test"}
result = client._prepare_tools_for_openai([dict_tool]) # type: ignore
assert result == [dict_tool]
assert result["tools"] == [dict_tool]
@ai_function
@@ -208,407 +205,6 @@ 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."""
openai_chat_client = OpenAIChatClient()
assert isinstance(openai_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change.",
)
)
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = await openai_chat_client.get_response(messages=messages)
assert response is not None
assert isinstance(response, ChatResponse)
assert "scientists" in response.text
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_chat_completion_response_params() -> None:
"""Test OpenAI chat completion responses."""
openai_chat_client = OpenAIChatClient()
assert isinstance(openai_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change.",
)
)
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = await openai_chat_client.get_response(
messages=messages, chat_options=ChatOptions(max_tokens=150, temperature=0.7, top_p=0.9)
)
assert response is not None
assert isinstance(response, ChatResponse)
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."""
openai_chat_client = OpenAIChatClient()
assert isinstance(openai_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = await openai_chat_client.get_response(
messages=messages,
tools=[get_story_text],
tool_choice="auto",
)
assert response is not None
assert isinstance(response, ChatResponse)
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."""
openai_chat_client = OpenAIChatClient()
assert isinstance(openai_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(
ChatMessage(
role="user",
text="Emily and David, two passionate scientists, met during a research expedition to Antarctica. "
"Bonded by their love for the natural world and shared curiosity, they uncovered a "
"groundbreaking phenomenon in glaciology that could potentially reshape our understanding "
"of climate change.",
)
)
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = openai_chat_client.get_streaming_response(messages=messages)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
assert chunk.message_id is not None
assert chunk.response_id is not None
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
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."""
openai_chat_client = OpenAIChatClient()
assert isinstance(openai_chat_client, ChatClientProtocol)
messages: list[ChatMessage] = []
messages.append(ChatMessage(role="user", text="who are Emily and David?"))
# Test that the client can be used to get a response
response = openai_chat_client.get_streaming_response(
messages=messages,
tools=[get_story_text],
tool_choice="auto",
)
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
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(model_id="gpt-4o-search-preview")
assert isinstance(openai_chat_client, ChatClientProtocol)
# Test that the client will use the web search tool
response = await openai_chat_client.get_response(
messages=[
ChatMessage(
role="user",
text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.",
)
],
tools=[HostedWebSearchTool()],
tool_choice="auto",
)
assert response is not None
assert isinstance(response, ChatResponse)
assert "Rumi" in response.text
assert "Mira" in response.text
assert "Zoey" in response.text
# Test that the client will use the web search tool with location
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
}
}
response = await openai_chat_client.get_response(
messages=[ChatMessage(role="user", text="What is the current weather? Do not ask for my current location.")],
tools=[HostedWebSearchTool(additional_properties=additional_properties)],
tool_choice="auto",
)
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(model_id="gpt-4o-search-preview")
assert isinstance(openai_chat_client, ChatClientProtocol)
# Test that the client will use the web search tool
response = openai_chat_client.get_streaming_response(
messages=[
ChatMessage(
role="user",
text="Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.",
)
],
tools=[HostedWebSearchTool()],
tool_choice="auto",
)
assert response is not None
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
assert "Rumi" in full_message
assert "Mira" in full_message
assert "Zoey" in full_message
# Test that the client will use the web search tool with location
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
}
}
response = openai_chat_client.get_streaming_response(
messages=[ChatMessage(role="user", text="What is the current weather? Do not ask for my current location.")],
tools=[HostedWebSearchTool(additional_properties=additional_properties)],
tool_choice="auto",
)
assert response is not None
full_message: str = ""
async for chunk in response:
assert chunk is not None
assert isinstance(chunk, ChatResponseUpdate)
for content in chunk.contents:
if isinstance(content, TextContent) and content.text:
full_message += content.text
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(model_id="gpt-4o-search-preview"),
) as agent:
# Test basic run
response = await agent.run("Hello! Please respond with 'Hello World' exactly.")
assert isinstance(response, AgentRunResponse)
assert response.text is not None
assert len(response.text) > 0
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(model_id="gpt-4o-search-preview"),
) as agent:
# Test streaming run
full_text = ""
async for chunk in agent.run_stream("Please respond with exactly: 'This is a streaming response test.'"):
assert isinstance(chunk, AgentRunResponseUpdate)
if chunk.text:
full_text += chunk.text
assert len(full_text) > 0
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(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
thread = agent.get_new_thread()
# First interaction
response1 = await agent.run("My name is Alice. Remember this.", thread=thread)
assert isinstance(response1, AgentRunResponse)
assert response1.text is not None
# Second interaction - test memory
response2 = await agent.run("What is my name?", thread=thread)
assert isinstance(response2, AgentRunResponse)
assert response2.text is not None
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."""
# First conversation - capture the thread
preserved_thread = None
async with ChatAgent(
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
thread = first_agent.get_new_thread()
first_response = await first_agent.run("My name is Alice. Remember this.", thread=thread)
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Preserve the thread for reuse
preserved_thread = thread
# Second conversation - reuse the thread in a new agent instance
if preserved_thread:
async with ChatAgent(
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
second_response = await second_agent.run("What is my name?", thread=preserved_thread)
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
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(model_id="gpt-4.1"),
instructions="You are a helpful assistant that uses available tools.",
tools=[get_weather], # Agent-level tool
) as agent:
# First run - agent-level tool should be available
first_response = await agent.run("What's the weather like in Chicago?")
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Should use the agent-level weather tool
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"])
# Second run - agent-level tool should still be available (persistence test)
second_response = await agent.run("What's the weather in Miami?")
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
# Should use the agent-level weather tool again
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."""
# Counter to track how many times the weather tool is called
call_count = 0
@ai_function
async def get_weather_with_counter(location: Annotated[str, "The location as a city name"]) -> str:
"""Get the current weather in a given location."""
nonlocal call_count
call_count += 1
return f"The weather in {location} is sunny and 72°F."
async with ChatAgent(
chat_client=OpenAIChatClient(model_id="gpt-4.1"),
instructions="You are a helpful assistant.",
) as agent:
# First run - use run-level tool
first_response = await agent.run(
"What's the weather like in Chicago?",
tools=[get_weather_with_counter], # Run-level tool
)
assert isinstance(first_response, AgentRunResponse)
assert first_response.text is not None
# Should use the run-level weather tool (call count should be 1)
assert call_count == 1
assert any(term in first_response.text.lower() for term in ["chicago", "sunny", "72"])
# Second run - run-level tool should NOT persist (key isolation test)
second_response = await agent.run("What's the weather like in Miami?")
assert isinstance(second_response, AgentRunResponse)
assert second_response.text is not None
# Should NOT use the weather tool since it was only run-level in previous call
# Call count should still be 1 (no additional calls)
assert call_count == 1
async def test_exception_message_includes_original_error_details() -> None:
"""Test that exception messages include original error details in the new format."""
client = OpenAIChatClient(model_id="test-model", api_key="test-key")
@@ -627,7 +223,7 @@ async def test_exception_message_includes_original_error_details() -> None:
patch.object(client.client.chat.completions, "create", side_effect=mock_error),
pytest.raises(ServiceResponseException) as exc_info,
):
await client._inner_get_response(messages=messages, chat_options=ChatOptions()) # type: ignore
await client._inner_get_response(messages=messages, options={}) # type: ignore
exception_message = str(exc_info.value)
assert "service failed to complete the prompt:" in exception_message
@@ -667,7 +263,7 @@ def test_chat_response_content_order_text_before_tool_calls(openai_unit_test_env
)
client = OpenAIChatClient()
response = client._parse_response_from_openai(mock_response, ChatOptions())
response = client._parse_response_from_openai(mock_response, {})
# Verify we have both text and tool call content
assert len(response.messages) == 1
@@ -894,3 +490,191 @@ def test_prepare_content_for_openai_document_file_mapping(openai_unit_test_env:
assert result["type"] == "file"
assert "filename" not in result["file"] # None filename should be omitted
# region Integration Tests
class OutputStruct(BaseModel):
"""A structured output for testing purposes."""
location: str
weather: str | None = None
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
@pytest.mark.parametrize(
"option_name,option_value,needs_validation",
[
# Simple ChatOptions - just verify they don't fail
param("temperature", 0.7, False, id="temperature"),
param("top_p", 0.9, False, id="top_p"),
param("max_tokens", 500, False, id="max_tokens"),
param("seed", 123, False, id="seed"),
param("user", "test-user-id", False, id="user"),
param("frequency_penalty", 0.5, False, id="frequency_penalty"),
param("presence_penalty", 0.3, False, id="presence_penalty"),
param("stop", ["END"], False, id="stop"),
param("allow_multiple_tool_calls", True, False, id="allow_multiple_tool_calls"),
# OpenAIChatOptions - just verify they don't fail
param("logit_bias", {"50256": -1}, False, id="logit_bias"),
param("prediction", {"type": "content", "content": "hello world"}, False, id="prediction"),
# Complex options requiring output validation
param("tools", [get_weather], True, id="tools_function"),
param("tool_choice", "auto", True, id="tool_choice_auto"),
param("tool_choice", "none", True, id="tool_choice_none"),
param("tool_choice", "required", True, id="tool_choice_required_any"),
param(
"tool_choice",
{"mode": "required", "required_function_name": "get_weather"},
True,
id="tool_choice_required",
),
param("response_format", OutputStruct, True, id="response_format_pydantic"),
param(
"response_format",
{
"type": "json_schema",
"json_schema": {
"name": "WeatherDigest",
"strict": True,
"schema": {
"title": "WeatherDigest",
"type": "object",
"properties": {
"location": {"type": "string"},
"conditions": {"type": "string"},
"temperature_c": {"type": "number"},
"advisory": {"type": "string"},
},
"required": ["location", "conditions", "temperature_c", "advisory"],
"additionalProperties": False,
},
},
},
True,
id="response_format_runtime_json_schema",
),
],
)
async def test_integration_options(
option_name: str,
option_value: Any,
needs_validation: bool,
) -> None:
"""Parametrized test covering all ChatOptions and OpenAIChatOptions.
Tests both streaming and non-streaming modes for each option to ensure
they don't cause failures. Options marked with needs_validation also
check that the feature actually works correctly.
"""
client = OpenAIChatClient()
# to ensure toolmode required does not endlessly loop
client.function_invocation_configuration.max_iterations = 1
for streaming in [False, True]:
# Prepare test message
if option_name.startswith("tools") or option_name.startswith("tool_choice"):
# Use weather-related prompt for tool tests
messages = [ChatMessage(role="user", text="What is the weather in Seattle?")]
elif option_name.startswith("response_format"):
# Use prompt that works well with structured output
messages = [ChatMessage(role="user", text="The weather in Seattle is sunny")]
messages.append(ChatMessage(role="user", text="What is the weather in Seattle?"))
else:
# Generic prompt for simple options
messages = [ChatMessage(role="user", text="Say 'Hello World' briefly.")]
# Build options dict
options: dict[str, Any] = {option_name: option_value}
# Add tools if testing tool_choice to avoid errors
if option_name.startswith("tool_choice"):
options["tools"] = [get_weather]
if streaming:
# Test streaming mode
response_gen = client.get_streaming_response(
messages=messages,
options=options,
)
output_format = option_value if option_name.startswith("response_format") else None
response = await ChatResponse.from_chat_response_generator(response_gen, output_format_type=output_format)
else:
# Test non-streaming mode
response = await client.get_response(
messages=messages,
options=options,
)
assert response is not None
assert isinstance(response, ChatResponse)
assert response.text is not None, f"No text in response for option '{option_name}'"
assert len(response.text) > 0, f"Empty response for option '{option_name}'"
# Validate based on option type
if needs_validation:
if option_name.startswith("tools") or option_name.startswith("tool_choice"):
# Should have called the weather function
text = response.text.lower()
assert "sunny" in text or "seattle" in text, f"Tool not invoked for {option_name}"
elif option_name.startswith("response_format"):
if option_value == OutputStruct:
# Should have structured output
assert response.value is not None, "No structured output"
assert isinstance(response.value, OutputStruct)
assert "seattle" in response.value.location.lower()
else:
# Runtime JSON schema
assert response.value is None, "No structured output, can't parse any json."
response_value = json.loads(response.text)
assert isinstance(response_value, dict)
assert "location" in response_value
assert "seattle" in response_value["location"].lower()
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_integration_web_search() -> None:
client = OpenAIChatClient(model_id="gpt-4o-search-preview")
for streaming in [False, True]:
content = {
"messages": "Who are the main characters of Kpop Demon Hunters? Do a web search to find the answer.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool()],
},
}
if streaming:
response = await ChatResponse.from_chat_response_generator(client.get_streaming_response(**content))
else:
response = await client.get_response(**content)
assert response is not None
assert isinstance(response, ChatResponse)
assert "Rumi" in response.text
assert "Mira" in response.text
assert "Zoey" in response.text
# Test that the client will use the web search tool with location
additional_properties = {
"user_location": {
"country": "US",
"city": "Seattle",
}
}
content = {
"messages": "What is the current weather? Do not ask for my current location.",
"options": {
"tool_choice": "auto",
"tools": [HostedWebSearchTool(additional_properties=additional_properties)],
},
}
if streaming:
response = await ChatResponse.from_chat_response_generator(client.get_streaming_response(**content))
else:
response = await client.get_response(**content)
assert response.text is not None
@@ -115,7 +115,6 @@ async def test_cmc_no_fcc_in_response(
openai_chat_completion = OpenAIChatClient()
await openai_chat_completion.get_response(
messages=chat_history,
arguments={},
)
mock_create.assert_awaited_once_with(
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
@@ -199,7 +198,7 @@ async def test_cmc_additional_properties(
chat_history.append(ChatMessage(role="user", text="hello world"))
openai_chat_completion = OpenAIChatClient()
await openai_chat_completion.get_response(messages=chat_history, additional_properties={"reasoning_effort": "low"})
await openai_chat_completion.get_response(messages=chat_history, options={"reasoning_effort": "low"})
mock_create.assert_awaited_once_with(
model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"],
stream=False,
@@ -382,8 +381,6 @@ def test_chat_response_created_at_uses_utc(openai_unit_test_env: dict[str, str])
This is a regression test for the issue where created_at was using local time
but labeling it as UTC (with 'Z' suffix).
"""
from agent_framework import ChatOptions
# Use a specific Unix timestamp: 1733011890 = 2024-12-01T00:31:30Z (UTC)
# This ensures we test that the timestamp is actually converted to UTC
utc_timestamp = 1733011890
@@ -399,7 +396,7 @@ def test_chat_response_created_at_uses_utc(openai_unit_test_env: dict[str, str])
)
client = OpenAIChatClient()
response = client._parse_response_from_openai(mock_response, ChatOptions())
response = client._parse_response_from_openai(mock_response, {})
# Verify that created_at is correctly formatted as UTC
assert response.created_at is not None
File diff suppressed because it is too large Load Diff
@@ -785,13 +785,13 @@ class TestAgentManagerConfiguration:
chat_client = MagicMock()
manager_agent = ChatAgent(chat_client=chat_client, name="Coordinator")
assert manager_agent.chat_options.response_format is None
assert manager_agent.default_options.get("response_format") is None
worker = StubAgent("worker", "response")
builder = GroupChatBuilder().set_manager(manager_agent).participants([worker])
assert manager_agent.chat_options.response_format is ManagerSelectionResponse
assert manager_agent.default_options.get("response_format") is ManagerSelectionResponse
assert builder._manager_participant is manager_agent # type: ignore[attr-defined]
async def test_set_manager_accepts_agent_manager(self) -> None:
@@ -820,13 +820,15 @@ class TestAgentManagerConfiguration:
value: str
chat_client = MagicMock()
manager_agent = ChatAgent(chat_client=chat_client, name="Coordinator", response_format=CustomResponse)
manager_agent = ChatAgent(
chat_client=chat_client, name="Coordinator", default_options={"response_format": CustomResponse}
)
worker = StubAgent("worker", "response")
with pytest.raises(ValueError, match="response_format must be ManagerSelectionResponse"):
GroupChatBuilder().set_manager(manager_agent).participants([worker])
assert manager_agent.chat_options.response_format is CustomResponse
assert manager_agent.default_options.get("response_format") is CustomResponse
class TestFactoryFunctions:
@@ -504,8 +504,8 @@ async def test_clone_chat_agent_preserves_mcp_tools() -> None:
assert hasattr(cloned_agent, "_local_mcp_tools")
assert len(cloned_agent._local_mcp_tools) == 1 # type: ignore[reportPrivateUsage]
assert cloned_agent._local_mcp_tools[0] == mock_mcp_tool # type: ignore[reportPrivateUsage]
assert cloned_agent.chat_options.tools is not None
assert len(cloned_agent.chat_options.tools) == 1
assert cloned_agent.default_options.get("tools") is not None
assert len(cloned_agent.default_options.get("tools")) == 1
async def test_return_to_previous_routing():
@@ -658,15 +658,14 @@ async def test_tool_choice_preserved_from_agent_config():
"""Verify that agent-level tool_choice configuration is preserved and not overridden."""
from unittest.mock import AsyncMock
from agent_framework import ChatResponse, ToolMode
from agent_framework import ChatResponse
# Create a mock chat client that records the tool_choice used
recorded_tool_choices: list[Any] = []
async def mock_get_response(messages: Any, **kwargs: Any) -> ChatResponse:
chat_options = kwargs.get("chat_options")
if chat_options:
recorded_tool_choices.append(chat_options.tool_choice)
async def mock_get_response(messages: Any, options: dict[str, Any] | None = None, **kwargs: Any) -> ChatResponse:
if options:
recorded_tool_choices.append(options.get("tool_choice"))
return ChatResponse(
messages=[ChatMessage(role=Role.ASSISTANT, text="Response")],
response_id="test_response",
@@ -675,11 +674,11 @@ async def test_tool_choice_preserved_from_agent_config():
mock_client = MagicMock()
mock_client.get_response = AsyncMock(side_effect=mock_get_response)
# Create agent with specific tool_choice configuration
# Create agent with specific tool_choice configuration via default_options
agent = ChatAgent(
chat_client=mock_client,
name="test_agent",
tool_choice=ToolMode(mode="required"), # type: ignore[arg-type]
default_options={"tool_choice": {"mode": "required"}},
)
# Run the agent
@@ -689,7 +688,7 @@ async def test_tool_choice_preserved_from_agent_config():
assert len(recorded_tool_choices) > 0, "No tool_choice recorded"
last_tool_choice = recorded_tool_choices[-1]
assert last_tool_choice is not None, "tool_choice should not be None"
assert str(last_tool_choice) == "required", f"Expected 'required', got {last_tool_choice}"
assert last_tool_choice == {"mode": "required"}, f"Expected 'required', got {last_tool_choice}"
async def test_handoff_builder_with_request_info():
@@ -1,6 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
import os
import sys
from collections.abc import MutableMapping
from contextvars import ContextVar
from typing import Any, Literal, TypeVar, Union
@@ -17,10 +16,7 @@ except (ImportError, RuntimeError):
# RuntimeError: .NET runtime not available or misconfigured
engine = None
if sys.version_info >= (3, 11):
from typing import overload # pragma: no cover
else:
from typing_extensions import overload # pragma: no cover
from typing import overload
logger = get_logger("agent_framework.declarative")
@@ -883,10 +883,14 @@ class EntityDiscovery:
try:
if obj_type == "agent":
# For agents, check chat_options.tools first
chat_options = getattr(obj, "chat_options", None)
if chat_options and hasattr(chat_options, "tools"):
for tool in chat_options.tools:
# For agents, check default_options.get("tools")
chat_options = getattr(obj, "default_options", None)
chat_options_tools = None
if chat_options:
chat_options_tools = chat_options.get("tools")
if chat_options_tools:
for tool in chat_options_tools:
if hasattr(tool, "__name__"):
tools.append(tool.__name__)
elif hasattr(tool, "name"):
@@ -37,17 +37,27 @@ def extract_agent_metadata(entity_object: Any) -> dict[str, Any]:
}
# Try to get instructions
if hasattr(entity_object, "chat_options") and hasattr(entity_object.chat_options, "instructions"):
metadata["instructions"] = entity_object.chat_options.instructions
if hasattr(entity_object, "default_options"):
chat_opts = entity_object.default_options
if isinstance(chat_opts, dict):
if "instructions" in chat_opts:
metadata["instructions"] = chat_opts.get("instructions")
elif hasattr(chat_opts, "instructions"):
metadata["instructions"] = chat_opts.instructions
# Try to get model - check both chat_options and chat_client
# Try to get model - check both default_options and chat_client
if hasattr(entity_object, "default_options"):
chat_opts = entity_object.default_options
if isinstance(chat_opts, dict):
if chat_opts.get("model_id"):
metadata["model"] = chat_opts.get("model_id")
elif hasattr(chat_opts, "model_id") and chat_opts.model_id:
metadata["model"] = chat_opts.model_id
if (
hasattr(entity_object, "chat_options")
and hasattr(entity_object.chat_options, "model_id")
and entity_object.chat_options.model_id
metadata["model"] is None
and hasattr(entity_object, "chat_client")
and hasattr(entity_object.chat_client, "model_id")
):
metadata["model"] = entity_object.chat_options.model_id
elif hasattr(entity_object, "chat_client") and hasattr(entity_object.chat_client, "model_id"):
metadata["model"] = entity_object.chat_client.model_id
# Try to get chat client type
+13 -5
View File
@@ -13,8 +13,9 @@ These follow the patterns established in other agent_framework packages
to avoid pytest plugin conflicts when running tests across packages.
"""
import sys
from collections.abc import AsyncIterable, MutableSequence
from typing import Any
from typing import Any, Generic
from agent_framework import (
AgentRunResponse,
@@ -24,7 +25,6 @@ from agent_framework import (
BaseChatClient,
ChatAgent,
ChatMessage,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
ConcurrentBuilder,
@@ -35,8 +35,14 @@ from agent_framework import (
TextContent,
use_chat_middleware,
)
from agent_framework._clients import TOptions_co
from agent_framework._workflows._agent_executor import AgentExecutorResponse
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
# Import real workflow event classes - NOT mocks!
from agent_framework._workflows._events import (
ExecutorCompletedEvent,
@@ -91,7 +97,7 @@ class MockChatClient:
@use_chat_middleware
class MockBaseChatClient(BaseChatClient):
class MockBaseChatClient(BaseChatClient[TOptions_co], Generic[TOptions_co]):
"""Full BaseChatClient mock with middleware support.
Use this when testing features that require the full BaseChatClient interface.
@@ -106,11 +112,12 @@ class MockBaseChatClient(BaseChatClient):
self.call_count: int = 0
self.received_messages: list[list[ChatMessage]] = []
@override
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
self.call_count += 1
@@ -119,11 +126,12 @@ class MockBaseChatClient(BaseChatClient):
return self.run_responses.pop(0)
return ChatResponse(messages=ChatMessage(role="assistant", text="Mock response from ChatAgent"))
@override
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
self.call_count += 1
@@ -2,7 +2,7 @@
import importlib.metadata
from ._foundry_local_client import FoundryLocalClient
from ._foundry_local_client import FoundryLocalChatOptions, FoundryLocalClient, FoundryLocalSettings
try:
__version__ = importlib.metadata.version(__name__)
@@ -10,6 +10,8 @@ except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"FoundryLocalChatOptions",
"FoundryLocalClient",
"FoundryLocalSettings",
"__version__",
]
@@ -1,8 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from typing import Any, ClassVar
import sys
from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import use_chat_middleware, use_function_invocation
from agent_framework import ChatOptions, use_chat_middleware, use_function_invocation
from agent_framework._pydantic import AFBaseSettings
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.observability import use_instrumentation
@@ -11,11 +12,93 @@ from foundry_local import FoundryLocalManager
from foundry_local.models import DeviceType
from openai import AsyncOpenAI
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore # pragma: no cover
__all__ = [
"FoundryLocalChatOptions",
"FoundryLocalClient",
"FoundryLocalSettings",
]
# region Foundry Local Chat Options TypedDict
class FoundryLocalChatOptions(ChatOptions, total=False):
"""Azure Foundry Local (local model deployment) chat options dict.
Extends base ChatOptions for local model inference via Foundry Local.
Foundry Local provides an OpenAI-compatible API, so most standard
OpenAI chat completion options are supported.
See: https://github.com/Azure/azure-ai-foundry-model-inference
Keys:
# Inherited from ChatOptions (supported via OpenAI-compatible API):
model_id: The model identifier or alias (e.g., 'phi-4-mini').
temperature: Sampling temperature (0-2).
top_p: Nucleus sampling parameter.
max_tokens: Maximum tokens to generate.
stop: Stop sequences.
tools: List of tools available to the model.
tool_choice: How the model should use tools.
frequency_penalty: Frequency penalty (-2.0 to 2.0).
presence_penalty: Presence penalty (-2.0 to 2.0).
seed: Random seed for reproducibility.
# Options with limited support (depends on the model):
response_format: Response format specification.
Not all local models support JSON mode.
logit_bias: Token bias dictionary.
May not be supported by all models.
# Options not supported in Foundry Local:
user: Not used locally.
store: Not applicable for local inference.
metadata: Not applicable for local inference.
# Foundry Local-specific options:
extra_body: Additional request body parameters to pass to the model.
Can be used for model-specific options not covered by standard API.
Note:
The actual options supported depend on the specific model being used.
Some models (like Phi-4) may not support all OpenAI API features.
Options not supported by the model will typically be ignored.
"""
# Foundry Local-specific options
extra_body: dict[str, Any]
"""Additional request body parameters for model-specific options."""
# ChatOptions fields not applicable for local inference
user: None # type: ignore[misc]
"""Not used for local model inference."""
store: None # type: ignore[misc]
"""Not applicable for local inference."""
FOUNDRY_LOCAL_OPTION_TRANSLATIONS: dict[str, str] = {
"model_id": "model",
}
"""Maps ChatOptions keys to OpenAI API parameter names (for compatibility)."""
TFoundryLocalChatOptions = TypeVar(
"TFoundryLocalChatOptions",
bound=TypedDict, # type: ignore[valid-type]
default="FoundryLocalChatOptions",
covariant=True,
)
# endregion
class FoundryLocalSettings(AFBaseSettings):
"""Foundry local model settings.
@@ -40,7 +123,7 @@ class FoundryLocalSettings(AFBaseSettings):
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class FoundryLocalClient(OpenAIBaseChatClient):
class FoundryLocalClient(OpenAIBaseChatClient[TFoundryLocalChatOptions], Generic[TFoundryLocalChatOptions]):
"""Foundry Local Chat completion class."""
def __init__(
@@ -125,6 +208,16 @@ class FoundryLocalClient(OpenAIBaseChatClient):
# You can also use the CLI:
`foundry model load phi-4-mini --device Auto`
# Using custom ChatOptions with type safety:
from typing import TypedDict
from agent_framework_foundry_local import FoundryLocalChatOptions
class MyOptions(FoundryLocalChatOptions, total=False):
my_custom_option: str
client: FoundryLocalClient[MyOptions] = FoundryLocalClient(model_id="phi-4-mini")
response = await client.get_response("Hello", options={"my_custom_option": "value"})
Raises:
ServiceInitializationError: If the specified model ID or alias is not found.
Sometimes a model might be available but if you have specified a device
@@ -3,18 +3,20 @@
import uuid
from typing import cast
from agent_framework._agents import ChatAgent
from agent_framework._types import AgentRunResponse, ChatMessage, Role
from agent_framework._workflows import (
from agent_framework import (
AgentExecutor,
AgentExecutorRequest,
AgentExecutorResponse,
AgentRunResponse,
ChatAgent,
ChatClientProtocol,
ChatMessage,
FunctionExecutor,
Role,
Workflow,
WorkflowBuilder,
WorkflowContext,
)
from agent_framework.openai import OpenAIChatClient
from loguru import logger
from tau2.data_model.simulation import SimulationRun, TerminationReason # type: ignore[import-untyped]
from tau2.data_model.tasks import Task # type: ignore[import-untyped]
@@ -156,7 +158,7 @@ class TaskRunner:
"""Check if user wants to stop the conversation."""
return STOP in text or TRANSFER in text or OUT_OF_SCOPE in text
def assistant_agent(self, assistant_chat_client: OpenAIChatClient) -> ChatAgent:
def assistant_agent(self, assistant_chat_client: ChatClientProtocol) -> ChatAgent:
"""Create an assistant agent.
Users can override this method to provide a custom assistant agent.
@@ -205,7 +207,7 @@ class TaskRunner:
),
)
def user_simulator(self, user_simuator_chat_client: OpenAIChatClient, task: Task) -> ChatAgent:
def user_simulator(self, user_simuator_chat_client: ChatClientProtocol, task: Task) -> ChatAgent:
"""Create a user simulator agent.
Users can override this method to provide a custom user simulator agent.
@@ -301,8 +303,8 @@ class TaskRunner:
async def run(
self,
task: Task,
assistant_chat_client: OpenAIChatClient,
user_simuator_chat_client: OpenAIChatClient,
assistant_chat_client: ChatClientProtocol,
user_simulator_chat_client: ChatClientProtocol,
) -> list[ChatMessage]:
"""Run a tau2 task using workflow-based agent orchestration.
@@ -317,18 +319,18 @@ class TaskRunner:
Args:
task: Tau2 task containing scenario, policy, and evaluation criteria
assistant_chat_client: LLM client for the assistant agent
user_simuator_chat_client: LLM client for the user simulator
user_simulator_chat_client: LLM client for the user simulator
Returns:
Complete conversation history as ChatMessage list for evaluation
"""
logger.info(f"Starting workflow agent for task {task.id}: {task.description.purpose}") # type: ignore[unused-ignore]
logger.info(f"Assistant chat client: {assistant_chat_client}")
logger.info(f"User simulator chat client: {user_simuator_chat_client}")
logger.info(f"User simulator chat client: {user_simulator_chat_client}")
# STEP 1: Create agents
assistant_agent = self.assistant_agent(assistant_chat_client)
user_simulator_agent = self.user_simulator(user_simuator_chat_client, task)
user_simulator_agent = self.user_simulator(user_simulator_chat_client, task)
# STEP 2: Create the conversation workflow
workflow = self.build_conversation_workflow(assistant_agent, user_simulator_agent)
@@ -3,22 +3,22 @@
import sys
from collections.abc import MutableSequence, Sequence
from contextlib import AbstractAsyncContextManager
from typing import Any
from typing import Any, TypedDict
from agent_framework import ChatMessage, Context, ContextProvider
from agent_framework.exceptions import ServiceInitializationError
from mem0 import AsyncMemory, AsyncMemoryClient
if sys.version_info >= (3, 11):
from typing import NotRequired, Self, TypedDict # pragma: no cover
else:
from typing_extensions import NotRequired, Self, TypedDict # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
if sys.version_info >= (3, 11):
from typing import NotRequired, Self # pragma: no cover
else:
from typing_extensions import NotRequired, Self # pragma: no cover
# Type aliases for Mem0 search response formats (v1.1 and v2; v1 is deprecated, but matches the type definition for v2)
class MemorySearchResponse_v1_1(TypedDict):
@@ -2,7 +2,7 @@
import importlib.metadata
from ._chat_client import OllamaChatClient, OllamaSettings
from ._chat_client import OllamaChatClient, OllamaChatOptions, OllamaSettings
try:
__version__ = importlib.metadata.version(__name__)
@@ -11,6 +11,7 @@ except importlib.metadata.PackageNotFoundError:
__all__ = [
"OllamaChatClient",
"OllamaChatOptions",
"OllamaSettings",
"__version__",
]
@@ -1,6 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
import json
import sys
from collections.abc import (
AsyncIterable,
Callable,
@@ -10,7 +11,7 @@ from collections.abc import (
Sequence,
)
from itertools import chain
from typing import Any, ClassVar
from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
AIFunction,
@@ -46,6 +47,229 @@ from ollama._types import ChatResponse as OllamaChatResponse
from ollama._types import Message as OllamaMessage
from pydantic import ValidationError
if sys.version_info >= (3, 13):
from typing import TypeVar
else:
from typing_extensions import TypeVar
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore[import] # pragma: no cover
__all__ = ["OllamaChatClient", "OllamaChatOptions"]
# region Ollama Chat Options TypedDict
class OllamaChatOptions(ChatOptions, total=False):
"""Ollama-specific chat options dict.
Extends base ChatOptions with Ollama-specific parameters.
Ollama passes model parameters through the `options` field.
See: https://github.com/ollama/ollama/blob/main/docs/api.md
Keys:
# Inherited from ChatOptions (mapped to Ollama options):
model_id: The model name, translates to ``model`` in Ollama API.
temperature: Sampling temperature, translates to ``options.temperature``.
top_p: Nucleus sampling, translates to ``options.top_p``.
max_tokens: Maximum tokens to generate, translates to ``options.num_predict``.
stop: Stop sequences, translates to ``options.stop``.
seed: Random seed for reproducibility, translates to ``options.seed``.
frequency_penalty: Frequency penalty, translates to ``options.frequency_penalty``.
presence_penalty: Presence penalty, translates to ``options.presence_penalty``.
tools: List of function tools.
response_format: Output format, translates to ``format``.
Use 'json' for JSON mode or a JSON schema dict for structured output.
# Options not supported in Ollama:
tool_choice: Ollama only supports auto tool choice.
allow_multiple_tool_calls: Not configurable.
user: Not supported.
store: Not supported.
logit_bias: Not supported.
metadata: Not supported.
# Ollama model-level options (placed in `options` dict):
# See: https://github.com/ollama/ollama/blob/main/docs/modelfile.mdx#valid-parameters-and-values
num_predict: Maximum number of tokens to predict (alternative to max_tokens).
top_k: Top-k sampling: limits tokens to k most likely. Higher = more diverse.
min_p: Minimum probability threshold for token selection.
typical_p: Locally typical sampling parameter (0.0-1.0).
repeat_penalty: Penalty for repeating tokens. Higher = less repetition.
repeat_last_n: Number of tokens to consider for repeat penalty.
penalize_newline: Whether to penalize newline characters.
num_ctx: Context window size (number of tokens).
num_batch: Batch size for prompt processing.
num_keep: Number of tokens to keep from initial prompt.
num_gpu: Number of layers to offload to GPU.
main_gpu: Main GPU for computation.
use_mmap: Whether to use memory-mapped files.
num_thread: Number of threads for CPU computation.
numa: Enable NUMA optimization.
# Ollama-specific top-level options:
keep_alive: How long to keep model loaded (default: '5m').
think: Whether thinking models should think before responding.
Examples:
.. code-block:: python
from agent_framework_ollama import OllamaChatOptions
# Basic usage - standard options automatically mapped
options: OllamaChatOptions = {
"temperature": 0.7,
"max_tokens": 1000,
"seed": 42,
}
# With Ollama-specific model options
options: OllamaChatOptions = {
"top_k": 40,
"num_ctx": 4096,
"keep_alive": "10m",
}
# With JSON output format
options: OllamaChatOptions = {
"response_format": "json",
}
# With structured output (JSON schema)
options: OllamaChatOptions = {
"response_format": {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
},
}
"""
# Ollama model-level options (will be placed in `options` dict)
num_predict: int
"""Maximum number of tokens to predict (equivalent to max_tokens)."""
top_k: int
"""Top-k sampling: limits tokens to k most likely. Higher = more diverse."""
min_p: float
"""Minimum probability threshold for token selection."""
typical_p: float
"""Locally typical sampling parameter (0.0-1.0)."""
repeat_penalty: float
"""Penalty for repeating tokens. Higher = less repetition."""
repeat_last_n: int
"""Number of tokens to consider for repeat penalty."""
penalize_newline: bool
"""Whether to penalize newline characters."""
num_ctx: int
"""Context window size (number of tokens)."""
num_batch: int
"""Batch size for prompt processing."""
num_keep: int
"""Number of tokens to keep from initial prompt."""
num_gpu: int
"""Number of layers to offload to GPU."""
main_gpu: int
"""Main GPU for computation."""
use_mmap: bool
"""Whether to use memory-mapped files."""
num_thread: int
"""Number of threads for CPU computation."""
numa: bool
"""Enable NUMA optimization."""
# Ollama-specific top-level options
keep_alive: str | int
"""How long to keep the model loaded in memory after request.
Can be duration string (e.g., '5m', '1h') or seconds as int.
Set to 0 to unload immediately after request."""
think: bool
"""For thinking models: whether the model should think before responding."""
# ChatOptions fields not supported in Ollama
tool_choice: None # type: ignore[misc]
"""Not supported. Ollama only supports auto tool choice."""
allow_multiple_tool_calls: None # type: ignore[misc]
"""Not supported. Not configurable in Ollama."""
user: None # type: ignore[misc]
"""Not supported in Ollama."""
store: None # type: ignore[misc]
"""Not supported in Ollama."""
logit_bias: None # type: ignore[misc]
"""Not supported in Ollama."""
metadata: None # type: ignore[misc]
"""Not supported in Ollama."""
OLLAMA_OPTION_TRANSLATIONS: dict[str, str] = {
"model_id": "model",
"response_format": "format",
}
"""Maps ChatOptions keys to Ollama API parameter names."""
# Keys that should be placed in the nested `options` dict for the Ollama API
OLLAMA_MODEL_OPTIONS: set[str] = {
# From ChatOptions (mapped to options.*)
"temperature",
"top_p",
"max_tokens", # -> num_predict
"stop",
"seed",
"frequency_penalty",
"presence_penalty",
# Ollama-specific model options
"num_predict",
"top_k",
"min_p",
"typical_p",
"repeat_penalty",
"repeat_last_n",
"penalize_newline",
"num_ctx",
"num_batch",
"num_keep",
"num_gpu",
"main_gpu",
"use_mmap",
"num_thread",
"numa",
}
# Translations for options that go into the nested `options` dict
OLLAMA_MODEL_OPTION_TRANSLATIONS: dict[str, str] = {
"max_tokens": "num_predict",
}
"""Maps ChatOptions keys to Ollama model option parameter names."""
TOllamaChatOptions = TypeVar("TOllamaChatOptions", bound=TypedDict, default="OllamaChatOptions", covariant=True) # type: ignore[valid-type]
# endregion
class OllamaSettings(AFBaseSettings):
"""Ollama settings."""
@@ -62,7 +286,7 @@ logger = get_logger("agent_framework.ollama")
@use_function_invocation
@use_instrumentation
@use_chat_middleware
class OllamaChatClient(BaseChatClient):
class OllamaChatClient(BaseChatClient[TOllamaChatOptions], Generic[TOllamaChatOptions]):
"""Ollama Chat completion class."""
OTEL_PROVIDER_NAME: ClassVar[str] = "ollama"
@@ -110,15 +334,16 @@ class OllamaChatClient(BaseChatClient):
super().__init__(**kwargs)
@override
async def _inner_get_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
# prepare
options_dict = self._prepare_options(messages, chat_options)
options_dict = self._prepare_options(messages, options)
try:
# execute
@@ -133,15 +358,16 @@ class OllamaChatClient(BaseChatClient):
# process
return self._parse_response_from_ollama(response)
@override
async def _inner_get_streaming_response(
self,
*,
messages: MutableSequence[ChatMessage],
chat_options: ChatOptions,
options: dict[str, Any],
**kwargs: Any,
) -> AsyncIterable[ChatResponseUpdate]:
# prepare
options_dict = self._prepare_options(messages, chat_options)
options_dict = self._prepare_options(messages, options)
try:
# execute
@@ -157,19 +383,37 @@ class OllamaChatClient(BaseChatClient):
async for part in response_object:
yield self._parse_streaming_response_from_ollama(part)
def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]:
# tool choice - Currently Ollama only supports auto tool choice
if chat_options.tool_choice == "required":
raise ServiceInvalidRequestError("Ollama does not support required tool choice.")
def _prepare_options(self, messages: MutableSequence[ChatMessage], options: dict[str, Any]) -> dict[str, Any]:
# Handle instructions by prepending to messages as system message
instructions = options.get("instructions")
if instructions:
from agent_framework._types import prepend_instructions_to_messages
run_options = chat_options.to_dict(
exclude={
"type",
"instructions",
"tool_choice", # Ollama does not support tool_choice configuration
"additional_properties", # handled separately
}
)
messages = prepend_instructions_to_messages(list(messages), instructions, role="system")
# Keys to exclude from processing
exclude_keys = {"instructions", "tool_choice"}
# Build run_options and model_options separately
run_options: dict[str, Any] = {}
model_options: dict[str, Any] = {}
for key, value in options.items():
if key in exclude_keys or value is None:
continue
if key in OLLAMA_MODEL_OPTIONS:
# Apply model option translations (e.g., max_tokens -> num_predict)
translated_key = OLLAMA_MODEL_OPTION_TRANSLATIONS.get(key, key)
model_options[translated_key] = value
else:
# Apply top-level translations (e.g., model_id -> model)
translated_key = OLLAMA_OPTION_TRANSLATIONS.get(key, key)
run_options[translated_key] = value
# Add model options to run_options if any
if model_options:
run_options["options"] = model_options
# messages
if messages and "messages" not in run_options:
@@ -177,12 +421,6 @@ class OllamaChatClient(BaseChatClient):
if "messages" not in run_options:
raise ServiceInvalidRequestError("Messages are required for chat completions")
# translations between ChatOptions and Ollama API
translations = {"model_id": "model"}
for old_key, new_key in translations.items():
if old_key in run_options and old_key != new_key:
run_options[new_key] = run_options.pop(old_key)
# model id
if not run_options.get("model"):
if not self.model_id:
@@ -190,15 +428,9 @@ class OllamaChatClient(BaseChatClient):
run_options["model"] = self.model_id
# tools
if chat_options.tools and (tools := self._prepare_tools_for_ollama(chat_options.tools)):
run_options["tools"] = tools
# additional properties
additional_options = {
key: value for key, value in chat_options.additional_properties.items() if value is not None
}
if additional_options:
run_options.update(additional_options)
tools = options.get("tools")
if tools and (prepared_tools := self._prepare_tools_for_ollama(tools)):
run_options["tools"] = prepared_tools
return run_options
@@ -16,6 +16,7 @@ from agent_framework import (
TextContent,
TextReasoningContent,
UriContent,
ai_function,
chat_middleware,
)
from agent_framework.exceptions import (
@@ -113,6 +114,7 @@ def mock_chat_completion_tool_call() -> OllamaChatResponse:
)
@ai_function
def hello_world(arg1: str) -> str:
return "Hello World"
@@ -199,19 +201,6 @@ async def test_empty_messages() -> None:
await ollama_chat_client.get_response(messages=[])
async def test_function_choice_required_argument() -> None:
ollama_chat_client = OllamaChatClient(
host="http://localhost:12345",
model_id="test-model",
)
with pytest.raises(ServiceInvalidRequestError):
await ollama_chat_client.get_response(
messages=[ChatMessage(text="hello world", role="user")],
tool_choice="required",
tools=[hello_world],
)
@patch.object(AsyncClient, "chat", new_callable=AsyncMock)
async def test_cmc(
mock_chat: AsyncMock,
@@ -337,7 +326,7 @@ async def test_cmc_streaming_with_tool_call(
chat_history.append(ChatMessage(text="hello world", role="user"))
ollama_client = OllamaChatClient()
result = ollama_client.get_streaming_response(messages=chat_history, tools=[hello_world])
result = ollama_client.get_streaming_response(messages=chat_history, options={"tools": [hello_world]})
chunks: list[ChatResponseUpdate] = []
async for chunk in result:
@@ -373,7 +362,9 @@ async def test_cmc_with_hosted_tool_call(
ollama_client = OllamaChatClient()
await ollama_client.get_response(
messages=chat_history,
tools=[HostedWebSearchTool(additional_properties=additional_properties)],
options={
"tools": HostedWebSearchTool(additional_properties=additional_properties),
},
)
@@ -450,7 +441,7 @@ async def test_cmc_integration_with_tool_call(
chat_history.append(ChatMessage(text="Call the hello world function and repeat what it says", role="user"))
ollama_client = OllamaChatClient()
result = await ollama_client.get_response(messages=chat_history, tools=[hello_world])
result = await ollama_client.get_response(messages=chat_history, options={"tools": [hello_world]})
assert "hello" in result.text.lower() and "world" in result.text.lower()
assert isinstance(result.messages[-2].contents[0], FunctionResultContent)
@@ -478,7 +469,7 @@ async def test_cmc_streaming_integration_with_tool_call(
ollama_client = OllamaChatClient()
result: AsyncIterable[ChatResponseUpdate] = ollama_client.get_streaming_response(
messages=chat_history, tools=[hello_world]
messages=chat_history, options={"tools": [hello_world]}
)
chunks: list[ChatResponseUpdate] = []
@@ -1,9 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unified Purview model definitions and public export surface."""
from __future__ import annotations
from collections.abc import Mapping, MutableMapping, Sequence
from datetime import datetime
from enum import Enum, Flag, auto
@@ -179,6 +175,8 @@ def translate_activity(activity: Activity) -> ProtectionScopeActivities:
# Simple value models
# --------------------------------------------------------------------------------------
TAliasSerializable = TypeVar("TAliasSerializable", bound="_AliasSerializable")
class _AliasSerializable(SerializationMixin):
"""Base class adding alias mapping + pydantic-compat helpers.
@@ -232,7 +230,7 @@ class _AliasSerializable(SerializationMixin):
return json.dumps(self.model_dump(by_alias=by_alias, exclude_none=exclude_none, **kwargs))
@classmethod
def model_validate(cls, value: MutableMapping[str, Any]) -> _AliasSerializable: # type: ignore[name-defined]
def model_validate(cls: type[TAliasSerializable], value: MutableMapping[str, Any]) -> TAliasSerializable: # type: ignore[name-defined]
return cls(**value)
# ------------------------------------------------------------------
@@ -37,7 +37,7 @@ class TestPurviewChatPolicyMiddleware:
chat_options = MagicMock()
chat_options.model = "test-model"
return ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], options=chat_options
)
async def test_initialization(self, middleware: PurviewChatPolicyMiddleware) -> None:
@@ -110,7 +110,7 @@ class TestPurviewChatPolicyMiddleware:
streaming_context = ChatContext(
chat_client=chat_client,
messages=[ChatMessage(role=Role.USER, text="Hello")],
chat_options=chat_options,
options=chat_options,
is_streaming=True,
)
with patch.object(middleware._processor, "process_messages", return_value=(False, "user-123")) as mock_proc:
@@ -189,7 +189,7 @@ class TestPurviewChatPolicyMiddleware:
chat_options = MagicMock()
chat_options.model = "test-model"
context = ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], options=chat_options
)
async def mock_process_messages(*args, **kwargs):
@@ -215,7 +215,7 @@ class TestPurviewChatPolicyMiddleware:
chat_options = MagicMock()
chat_options.model = "test-model"
context = ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], options=chat_options
)
async def mock_process_messages(*args, **kwargs):
@@ -257,7 +257,7 @@ class TestPurviewChatPolicyMiddleware:
chat_options = MagicMock()
chat_options.model = "test-model"
context = ChatContext(
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], chat_options=chat_options
chat_client=chat_client, messages=[ChatMessage(role=Role.USER, text="Hello")], options=chat_options
)
async def mock_process_messages(*args, **kwargs):
@@ -289,8 +289,10 @@ class WeatherChatKitServer(ChatKitServer[dict[str, Any]]):
# Use the chat client directly for a quick, lightweight call
response = await self.weather_agent.chat_client.get_response(
messages=title_prompt,
temperature=0.3,
max_tokens=20,
options={
"temperature": 0.3,
"max_tokens": 20,
},
)
if response.messages and response.messages[-1].text:
@@ -3,7 +3,7 @@
import asyncio
from agent_framework import HostedMCPTool, HostedWebSearchTool, TextReasoningContent, UsageContent
from agent_framework.anthropic import AnthropicClient
from agent_framework.anthropic import AnthropicChatOptions, AnthropicClient
"""
Anthropic Chat Agent Example
@@ -15,9 +15,9 @@ This sample demonstrates using Anthropic with:
"""
async def streaming_example() -> None:
async def main() -> None:
"""Example of streaming response (get results as they are generated)."""
agent = AnthropicClient().create_agent(
agent = AnthropicClient[AnthropicChatOptions]().create_agent(
name="DocsAgent",
instructions="You are a helpful agent for both Microsoft docs questions and general questions.",
tools=[
@@ -27,10 +27,12 @@ async def streaming_example() -> None:
),
HostedWebSearchTool(),
],
# anthropic needs a value for the max_tokens parameter
# we set it to 1024, but you can override like this:
max_tokens=20000,
additional_chat_options={"thinking": {"type": "enabled", "budget_tokens": 10000}},
default_options={
# anthropic needs a value for the max_tokens parameter
# we set it to 1024, but you can override like this:
"max_tokens": 20000,
"thinking": {"type": "enabled", "budget_tokens": 10000},
},
)
query = "Can you compare Python decorators with C# attributes?"
@@ -48,11 +50,5 @@ async def streaming_example() -> None:
print("\n")
async def main() -> None:
print("=== Anthropic Example ===")
await streaming_example()
if __name__ == "__main__":
asyncio.run(main())
@@ -38,10 +38,12 @@ async def main() -> None:
),
HostedWebSearchTool(),
],
# anthropic needs a value for the max_tokens parameter
# we set it to 1024, but you can override like this:
max_tokens=20000,
additional_chat_options={"thinking": {"type": "enabled", "budget_tokens": 10000}},
default_options={
# anthropic needs a value for the max_tokens parameter
# we set it to 1024, but you can override like this:
"max_tokens": 20000,
"thinking": {"type": "enabled", "budget_tokens": 10000},
},
)
query = "Can you compare Python decorators with C# attributes?"
@@ -5,7 +5,7 @@ import logging
from pathlib import Path
from agent_framework import HostedCodeInterpreterTool, HostedFileContent
from agent_framework.anthropic import AnthropicClient
from agent_framework.anthropic import AnthropicChatOptions, AnthropicClient
logger = logging.getLogger(__name__)
"""
@@ -22,7 +22,7 @@ This sample demonstrates using Anthropic with:
async def main() -> None:
"""Example of streaming response (get results as they are generated)."""
client = AnthropicClient(additional_beta_flags=["skills-2025-10-02"])
client = AnthropicClient[AnthropicChatOptions](additional_beta_flags=["skills-2025-10-02"])
# List Anthropic-managed Skills
skills = await client.anthropic_client.beta.skills.list(source="anthropic", betas=["skills-2025-10-02"])
@@ -35,8 +35,8 @@ async def main() -> None:
name="DocsAgent",
instructions="You are a helpful agent for creating powerpoint presentations.",
tools=HostedCodeInterpreterTool(),
max_tokens=20000,
additional_chat_options={
default_options={
"max_tokens": 20000,
"thinking": {"type": "enabled", "budget_tokens": 10000},
"container": {"skills": [{"type": "anthropic", "skill_id": "pptx", "version": "latest"}]},
},
@@ -44,7 +44,7 @@ async def main() -> None:
result = await agent.run(
query,
# These additional options are required for image generation
additional_chat_options={
options={
"extra_headers": {"x-ms-oai-image-generation-deployment": "gpt-image-1-mini"},
},
)
@@ -46,7 +46,7 @@ async def main() -> None:
result = await agent.run(
query,
# Specify type to use as response
additional_chat_options={
options={
"response_format": {
"type": "json_schema",
"json_schema": {

Some files were not shown because too many files have changed in this diff Show More