mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
@@ -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
|
||||
|
||||
+2
-1
@@ -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
-2
@@ -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)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
|
||||
@@ -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, "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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__",
|
||||
]
|
||||
|
||||
+96
-3
@@ -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
Reference in New Issue
Block a user