You can not select more than 25 topics Topics must start with a chinese character,a letter or number, can include dashes ('-') and can be up to 35 characters long.

test_openai_model_client.py 129 kB

feat: Add thought process handling in tool calls and expose ThoughtEvent through stream in AgentChat (#5500) Resolves #5192 Test ```python import asyncio import os from random import randint from typing import List from autogen_core.tools import BaseTool, FunctionTool from autogen_ext.models.openai import OpenAIChatCompletionClient from autogen_agentchat.agents import AssistantAgent from autogen_agentchat.ui import Console async def get_current_time(city: str) -> str: return f"The current time in {city} is {randint(0, 23)}:{randint(0, 59)}." tools: List[BaseTool] = [ FunctionTool( get_current_time, name="get_current_time", description="Get current time for a city.", ), ] model_client = OpenAIChatCompletionClient( model="anthropic/claude-3.5-haiku-20241022", base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"], model_info={ "family": "claude-3.5-haiku", "function_calling": True, "vision": False, "json_output": False, } ) agent = AssistantAgent( name="Agent", model_client=model_client, tools=tools, system_message= "You are an assistant with some tools that can be used to answer some questions", ) async def main() -> None: await Console(agent.run_stream(task="What is current time of Paris and Toronto?")) asyncio.run(main()) ``` ``` ---------- user ---------- What is current time of Paris and Toronto? ---------- Agent ---------- I'll help you find the current time for Paris and Toronto by using the get_current_time function for each city. ---------- Agent ---------- [FunctionCall(id='toolu_01NwP3fNAwcYKn1x656Dq9xW', arguments='{"city": "Paris"}', name='get_current_time'), FunctionCall(id='toolu_018d4cWSy3TxXhjgmLYFrfRt', arguments='{"city": "Toronto"}', name='get_current_time')] ---------- Agent ---------- [FunctionExecutionResult(content='The current time in Paris is 1:10.', call_id='toolu_01NwP3fNAwcYKn1x656Dq9xW', is_error=False), FunctionExecutionResult(content='The current time in Toronto is 7:28.', call_id='toolu_018d4cWSy3TxXhjgmLYFrfRt', is_error=False)] ---------- Agent ---------- The current time in Paris is 1:10. The current time in Toronto is 7:28. ``` --------- Co-authored-by: Jack Gerrits <jackgerrits@users.noreply.github.com>
1 year ago
fix: Update SKChatCompletionAdapter message conversion (#5749) <!-- Thank you for your contribution! Please review https://microsoft.github.io/autogen/docs/Contribute before opening a pull request. --> <!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. --> ## Why are these changes needed? <!-- Please give a short summary of the change and the problem this solves. --> The PR introduces two changes. The first change is adding a name attribute to `FunctionExecutionResult`. The motivation is that semantic kernel requires it for their function result interface and it seemed like a easy modification as `FunctionExecutionResult` is always created in the context of a `FunctionCall` which will contain the name. I'm unsure if there was a motivation to keep it out but this change makes it easier to trace which tool the result refers to and also increases api compatibility with SK. The second change is an update to how messages are mapped from autogen to semantic kernel, which includes an update/fix in the processing of function results. ## Related issue number <!-- For example: "Closes #1234" --> Related to #5675 but wont fix the underlying issue of anthropic requiring tools during AssistantAgent reflection. ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [ ] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ ] I've made sure all auto checks have passed. --------- Co-authored-by: Leonardo Pinheiro <lpinheiro@microsoft.com>
1 year ago
feat: Add thought process handling in tool calls and expose ThoughtEvent through stream in AgentChat (#5500) Resolves #5192 Test ```python import asyncio import os from random import randint from typing import List from autogen_core.tools import BaseTool, FunctionTool from autogen_ext.models.openai import OpenAIChatCompletionClient from autogen_agentchat.agents import AssistantAgent from autogen_agentchat.ui import Console async def get_current_time(city: str) -> str: return f"The current time in {city} is {randint(0, 23)}:{randint(0, 59)}." tools: List[BaseTool] = [ FunctionTool( get_current_time, name="get_current_time", description="Get current time for a city.", ), ] model_client = OpenAIChatCompletionClient( model="anthropic/claude-3.5-haiku-20241022", base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"], model_info={ "family": "claude-3.5-haiku", "function_calling": True, "vision": False, "json_output": False, } ) agent = AssistantAgent( name="Agent", model_client=model_client, tools=tools, system_message= "You are an assistant with some tools that can be used to answer some questions", ) async def main() -> None: await Console(agent.run_stream(task="What is current time of Paris and Toronto?")) asyncio.run(main()) ``` ``` ---------- user ---------- What is current time of Paris and Toronto? ---------- Agent ---------- I'll help you find the current time for Paris and Toronto by using the get_current_time function for each city. ---------- Agent ---------- [FunctionCall(id='toolu_01NwP3fNAwcYKn1x656Dq9xW', arguments='{"city": "Paris"}', name='get_current_time'), FunctionCall(id='toolu_018d4cWSy3TxXhjgmLYFrfRt', arguments='{"city": "Toronto"}', name='get_current_time')] ---------- Agent ---------- [FunctionExecutionResult(content='The current time in Paris is 1:10.', call_id='toolu_01NwP3fNAwcYKn1x656Dq9xW', is_error=False), FunctionExecutionResult(content='The current time in Toronto is 7:28.', call_id='toolu_018d4cWSy3TxXhjgmLYFrfRt', is_error=False)] ---------- Agent ---------- The current time in Paris is 1:10. The current time in Toronto is 7:28. ``` --------- Co-authored-by: Jack Gerrits <jackgerrits@users.noreply.github.com>
1 year ago
feat: Add thought process handling in tool calls and expose ThoughtEvent through stream in AgentChat (#5500) Resolves #5192 Test ```python import asyncio import os from random import randint from typing import List from autogen_core.tools import BaseTool, FunctionTool from autogen_ext.models.openai import OpenAIChatCompletionClient from autogen_agentchat.agents import AssistantAgent from autogen_agentchat.ui import Console async def get_current_time(city: str) -> str: return f"The current time in {city} is {randint(0, 23)}:{randint(0, 59)}." tools: List[BaseTool] = [ FunctionTool( get_current_time, name="get_current_time", description="Get current time for a city.", ), ] model_client = OpenAIChatCompletionClient( model="anthropic/claude-3.5-haiku-20241022", base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"], model_info={ "family": "claude-3.5-haiku", "function_calling": True, "vision": False, "json_output": False, } ) agent = AssistantAgent( name="Agent", model_client=model_client, tools=tools, system_message= "You are an assistant with some tools that can be used to answer some questions", ) async def main() -> None: await Console(agent.run_stream(task="What is current time of Paris and Toronto?")) asyncio.run(main()) ``` ``` ---------- user ---------- What is current time of Paris and Toronto? ---------- Agent ---------- I'll help you find the current time for Paris and Toronto by using the get_current_time function for each city. ---------- Agent ---------- [FunctionCall(id='toolu_01NwP3fNAwcYKn1x656Dq9xW', arguments='{"city": "Paris"}', name='get_current_time'), FunctionCall(id='toolu_018d4cWSy3TxXhjgmLYFrfRt', arguments='{"city": "Toronto"}', name='get_current_time')] ---------- Agent ---------- [FunctionExecutionResult(content='The current time in Paris is 1:10.', call_id='toolu_01NwP3fNAwcYKn1x656Dq9xW', is_error=False), FunctionExecutionResult(content='The current time in Toronto is 7:28.', call_id='toolu_018d4cWSy3TxXhjgmLYFrfRt', is_error=False)] ---------- Agent ---------- The current time in Paris is 1:10. The current time in Toronto is 7:28. ``` --------- Co-authored-by: Jack Gerrits <jackgerrits@users.noreply.github.com>
1 year ago
feat: Add thought process handling in tool calls and expose ThoughtEvent through stream in AgentChat (#5500) Resolves #5192 Test ```python import asyncio import os from random import randint from typing import List from autogen_core.tools import BaseTool, FunctionTool from autogen_ext.models.openai import OpenAIChatCompletionClient from autogen_agentchat.agents import AssistantAgent from autogen_agentchat.ui import Console async def get_current_time(city: str) -> str: return f"The current time in {city} is {randint(0, 23)}:{randint(0, 59)}." tools: List[BaseTool] = [ FunctionTool( get_current_time, name="get_current_time", description="Get current time for a city.", ), ] model_client = OpenAIChatCompletionClient( model="anthropic/claude-3.5-haiku-20241022", base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"], model_info={ "family": "claude-3.5-haiku", "function_calling": True, "vision": False, "json_output": False, } ) agent = AssistantAgent( name="Agent", model_client=model_client, tools=tools, system_message= "You are an assistant with some tools that can be used to answer some questions", ) async def main() -> None: await Console(agent.run_stream(task="What is current time of Paris and Toronto?")) asyncio.run(main()) ``` ``` ---------- user ---------- What is current time of Paris and Toronto? ---------- Agent ---------- I'll help you find the current time for Paris and Toronto by using the get_current_time function for each city. ---------- Agent ---------- [FunctionCall(id='toolu_01NwP3fNAwcYKn1x656Dq9xW', arguments='{"city": "Paris"}', name='get_current_time'), FunctionCall(id='toolu_018d4cWSy3TxXhjgmLYFrfRt', arguments='{"city": "Toronto"}', name='get_current_time')] ---------- Agent ---------- [FunctionExecutionResult(content='The current time in Paris is 1:10.', call_id='toolu_01NwP3fNAwcYKn1x656Dq9xW', is_error=False), FunctionExecutionResult(content='The current time in Toronto is 7:28.', call_id='toolu_018d4cWSy3TxXhjgmLYFrfRt', is_error=False)] ---------- Agent ---------- The current time in Paris is 1:10. The current time in Toronto is 7:28. ``` --------- Co-authored-by: Jack Gerrits <jackgerrits@users.noreply.github.com>
1 year ago
[BugFix][Refactor] Modular Transformer Pipeline and Fix Gemini/Anthropic Empty Content Handling (#6063) ## Why are these changes needed? This change addresses a compatibility issue when using Google Gemini models with AutoGen. Specifically, Gemini returns a 400 INVALID_ARGUMENT error when receiving a response with an empty "text" parameter. The root cause is that Gemini does not accept empty string values (e.g., "") as valid inputs in the history of the conversation. To fix this, if the content field is falsy (e.g., None, "", etc.), it is explicitly replaced with a single whitespace (" "), which prevents the Gemini model from rejecting the request. - **Gemini API compatibility:** Gemini models reject empty assistant messages (e.g., `""`), causing runtime errors. This PR ensures such messages are safely replaced with whitespace where appropriate. - **Avoiding regressions:** Applying the empty content workaround **only to Gemini**, and **only to valid message types**, avoids breaking OpenAI or other models. - **Reducing duplication:** Previously, message transformation logic was scattered and repeated across different message types and models. Modularizing this pipeline removes that redundancy. - **Improved maintainability:** With future model variants likely to introduce more constraints, this modular structure makes it easier to adapt transformations without writing ad-hoc code each time. - **Testing for correctness:** The new structure is verified with tests, ensuring the bug fix is effective and non-intrusive. ## Summary This PR introduces a **modular transformer pipeline** for message conversion and **fixes a Gemini-specific bug** related to empty assistant message content. ### Key Changes - **[Refactor]** Extracted message transformation logic into a unified pipeline to: - Reduce code duplication - Improve maintainability - Simplify debugging and extension for future model-specific logic - **[BugFix]** Gemini models do not accept empty assistant message content. - Introduced `_set_empty_to_whitespace` transformer to replace empty strings with `" "` only where needed - Applied it **only** to `"text"` and `"thought"` message types, not to `"tools"` to avoid serialization errors - **Improved structure for model-specific handling** - Transformer functions are now grouped and conditionally applied based on message type and model family - This design makes it easier to support future models or combinations (e.g., Gemini + R1) - **Test coverage added** - Added dedicated tests to verify that empty assistant content causes errors for Gemini - Ensured the fix resolves the issue without affecting OpenAI models --- ## Motivation Originally, Gemini-compatible endpoints would fail when receiving assistant messages with empty content (`""`). This issue required special handling without introducing brittle, ad-hoc patches. In addressing this, I also saw an opportunity to **modularize** the message transformation logic across models. This improves clarity, avoids duplication, and simplifies future adaptations (e.g., different constraints across model families). --- ## 📘 AutoGen Modular Message Transformer: Design & Usage Guide This document introduces the **new modular transformer system** used in AutoGen for converting `LLMMessage` instances to SDK-specific message formats (e.g., OpenAI-style `ChatCompletionMessageParam`). The design improves **reusability, extensibility**, and **maintainability** across different model families. --- ### 🚀 Overview Instead of scattering model-specific message conversion logic across the codebase, the new design introduces: - Modular transformer **functions** for each message type - Per-model **transformer maps** (e.g., for OpenAI-compatible models) - Optional **conditional transformers** for multimodal/text hybrid models - Clear separation between **message adaptation logic** and **SDK-specific builder** (e.g., `ChatCompletionUserMessageParam`) --- ### 🧱 1. Define Transform Functions Each transformer function takes: - `LLMMessage`: a structured AutoGen message - `context: dict`: metadata passed through the builder pipeline And returns: - A dictionary of keyword arguments for the target message constructor (e.g., `{"content": ..., "name": ..., "role": ...}`) ```python def _set_thought_as_content_gemini(message: LLMMessage, context: Dict[str, Any]) -> Dict[str, str | None]: assert isinstance(message, AssistantMessage) return {"content": message.thought or " "} ``` --- ### 🪢 2. Compose Transformer Pipelines Multiple transformer functions are composed into a pipeline using `build_transformer_func()`: ```python base_user_transformer_funcs: List[Callable[[LLMMessage, Dict[str, Any]], Dict[str, Any]]] = [ _assert_valid_name, _set_name, _set_role("user"), ] user_transformer = build_transformer_func( funcs=base_user_transformer_funcs, message_param_func=ChatCompletionUserMessageParam ) ``` - The `message_param_func` is the actual constructor for the target message class (usually from the SDK). - The pipeline is **ordered** — each function adds or overrides keys in the builder kwargs. --- ### 🗂️ 3. Register Transformer Map Each model family maintains a `TransformerMap`, which maps `LLMMessage` types to transformers: ```python __BASE_TRANSFORMER_MAP: TransformerMap = { SystemMessage: system_transformer, UserMessage: user_transformer, AssistantMessage: assistant_transformer, } register_transformer("openai", model_name_or_family, __BASE_TRANSFORMER_MAP) ``` - `"openai"` is currently required (as only OpenAI-compatible format is supported now). - Registration ensures AutoGen knows how to transform each message type for that model. --- ### 🔁 4. Conditional Transformers (Optional) When message construction depends on runtime conditions (e.g., `"text"` vs. `"multimodal"`), use: ```python conditional_transformer = build_conditional_transformer_func( funcs_map=user_transformer_funcs_claude, message_param_func_map=user_transformer_constructors, condition_func=user_condition, ) ``` Where: - `funcs_map`: maps condition label → list of transformer functions ```python user_transformer_funcs_claude = { "text": text_transformers + [_set_empty_to_whitespace], "multimodal": multimodal_transformers + [_set_empty_to_whitespace], } ``` - `message_param_func_map`: maps condition label → message builder ```python user_transformer_constructors = { "text": ChatCompletionUserMessageParam, "multimodal": ChatCompletionUserMessageParam, } ``` - `condition_func`: determines which transformer to apply at runtime ```python def user_condition(message: LLMMessage, context: Dict[str, Any]) -> str: if isinstance(message.content, str): return "text" return "multimodal" ``` --- ### 🧪 Example Flow ```python llm_message = AssistantMessage(name="a", thought="let’s go") model_family = "openai" model_name = "claude-3-opus" transformer = get_transformer(model_family, model_name, type(llm_message)) sdk_message = transformer(llm_message, context={}) ``` --- ### 🎯 Design Benefits | Feature | Benefit | |--------|---------| | 🧱 Function-based modular design | Easy to compose and test | | 🧩 Per-model registry | Clean separation across model families | | ⚖️ Conditional support | Allows multimodal / dynamic adaptation | | 🔄 Reuse-friendly | Shared logic (e.g., `_set_name`) is DRY | | 📦 SDK-specific | Keeps message adaptation aligned to builder interface | --- ### 🔮 Future Direction - Support more SDKs and formats by introducing new message_param_func - Global registry integration (currently `"openai"`-scoped) - Class-based transformer variant if complexity grows --- ## Related issue number Closes #5762 ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [x] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ v ] I've made sure all auto checks have passed. --------- Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
1 year ago
fix: Update SKChatCompletionAdapter message conversion (#5749) <!-- Thank you for your contribution! Please review https://microsoft.github.io/autogen/docs/Contribute before opening a pull request. --> <!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. --> ## Why are these changes needed? <!-- Please give a short summary of the change and the problem this solves. --> The PR introduces two changes. The first change is adding a name attribute to `FunctionExecutionResult`. The motivation is that semantic kernel requires it for their function result interface and it seemed like a easy modification as `FunctionExecutionResult` is always created in the context of a `FunctionCall` which will contain the name. I'm unsure if there was a motivation to keep it out but this change makes it easier to trace which tool the result refers to and also increases api compatibility with SK. The second change is an update to how messages are mapped from autogen to semantic kernel, which includes an update/fix in the processing of function results. ## Related issue number <!-- For example: "Closes #1234" --> Related to #5675 but wont fix the underlying issue of anthropic requiring tools during AssistantAgent reflection. ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [ ] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ ] I've made sure all auto checks have passed. --------- Co-authored-by: Leonardo Pinheiro <lpinheiro@microsoft.com>
1 year ago
fix: Update SKChatCompletionAdapter message conversion (#5749) <!-- Thank you for your contribution! Please review https://microsoft.github.io/autogen/docs/Contribute before opening a pull request. --> <!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. --> ## Why are these changes needed? <!-- Please give a short summary of the change and the problem this solves. --> The PR introduces two changes. The first change is adding a name attribute to `FunctionExecutionResult`. The motivation is that semantic kernel requires it for their function result interface and it seemed like a easy modification as `FunctionExecutionResult` is always created in the context of a `FunctionCall` which will contain the name. I'm unsure if there was a motivation to keep it out but this change makes it easier to trace which tool the result refers to and also increases api compatibility with SK. The second change is an update to how messages are mapped from autogen to semantic kernel, which includes an update/fix in the processing of function results. ## Related issue number <!-- For example: "Closes #1234" --> Related to #5675 but wont fix the underlying issue of anthropic requiring tools during AssistantAgent reflection. ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [ ] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ ] I've made sure all auto checks have passed. --------- Co-authored-by: Leonardo Pinheiro <lpinheiro@microsoft.com>
1 year ago
fix: Update SKChatCompletionAdapter message conversion (#5749) <!-- Thank you for your contribution! Please review https://microsoft.github.io/autogen/docs/Contribute before opening a pull request. --> <!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. --> ## Why are these changes needed? <!-- Please give a short summary of the change and the problem this solves. --> The PR introduces two changes. The first change is adding a name attribute to `FunctionExecutionResult`. The motivation is that semantic kernel requires it for their function result interface and it seemed like a easy modification as `FunctionExecutionResult` is always created in the context of a `FunctionCall` which will contain the name. I'm unsure if there was a motivation to keep it out but this change makes it easier to trace which tool the result refers to and also increases api compatibility with SK. The second change is an update to how messages are mapped from autogen to semantic kernel, which includes an update/fix in the processing of function results. ## Related issue number <!-- For example: "Closes #1234" --> Related to #5675 but wont fix the underlying issue of anthropic requiring tools during AssistantAgent reflection. ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [ ] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ ] I've made sure all auto checks have passed. --------- Co-authored-by: Leonardo Pinheiro <lpinheiro@microsoft.com>
1 year ago
fix: Update SKChatCompletionAdapter message conversion (#5749) <!-- Thank you for your contribution! Please review https://microsoft.github.io/autogen/docs/Contribute before opening a pull request. --> <!-- Please add a reviewer to the assignee section when you create a PR. If you don't have the access to it, we will shortly find a reviewer and assign them to your PR. --> ## Why are these changes needed? <!-- Please give a short summary of the change and the problem this solves. --> The PR introduces two changes. The first change is adding a name attribute to `FunctionExecutionResult`. The motivation is that semantic kernel requires it for their function result interface and it seemed like a easy modification as `FunctionExecutionResult` is always created in the context of a `FunctionCall` which will contain the name. I'm unsure if there was a motivation to keep it out but this change makes it easier to trace which tool the result refers to and also increases api compatibility with SK. The second change is an update to how messages are mapped from autogen to semantic kernel, which includes an update/fix in the processing of function results. ## Related issue number <!-- For example: "Closes #1234" --> Related to #5675 but wont fix the underlying issue of anthropic requiring tools during AssistantAgent reflection. ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [ ] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ ] I've made sure all auto checks have passed. --------- Co-authored-by: Leonardo Pinheiro <lpinheiro@microsoft.com>
1 year ago
[BugFix][Refactor] Modular Transformer Pipeline and Fix Gemini/Anthropic Empty Content Handling (#6063) ## Why are these changes needed? This change addresses a compatibility issue when using Google Gemini models with AutoGen. Specifically, Gemini returns a 400 INVALID_ARGUMENT error when receiving a response with an empty "text" parameter. The root cause is that Gemini does not accept empty string values (e.g., "") as valid inputs in the history of the conversation. To fix this, if the content field is falsy (e.g., None, "", etc.), it is explicitly replaced with a single whitespace (" "), which prevents the Gemini model from rejecting the request. - **Gemini API compatibility:** Gemini models reject empty assistant messages (e.g., `""`), causing runtime errors. This PR ensures such messages are safely replaced with whitespace where appropriate. - **Avoiding regressions:** Applying the empty content workaround **only to Gemini**, and **only to valid message types**, avoids breaking OpenAI or other models. - **Reducing duplication:** Previously, message transformation logic was scattered and repeated across different message types and models. Modularizing this pipeline removes that redundancy. - **Improved maintainability:** With future model variants likely to introduce more constraints, this modular structure makes it easier to adapt transformations without writing ad-hoc code each time. - **Testing for correctness:** The new structure is verified with tests, ensuring the bug fix is effective and non-intrusive. ## Summary This PR introduces a **modular transformer pipeline** for message conversion and **fixes a Gemini-specific bug** related to empty assistant message content. ### Key Changes - **[Refactor]** Extracted message transformation logic into a unified pipeline to: - Reduce code duplication - Improve maintainability - Simplify debugging and extension for future model-specific logic - **[BugFix]** Gemini models do not accept empty assistant message content. - Introduced `_set_empty_to_whitespace` transformer to replace empty strings with `" "` only where needed - Applied it **only** to `"text"` and `"thought"` message types, not to `"tools"` to avoid serialization errors - **Improved structure for model-specific handling** - Transformer functions are now grouped and conditionally applied based on message type and model family - This design makes it easier to support future models or combinations (e.g., Gemini + R1) - **Test coverage added** - Added dedicated tests to verify that empty assistant content causes errors for Gemini - Ensured the fix resolves the issue without affecting OpenAI models --- ## Motivation Originally, Gemini-compatible endpoints would fail when receiving assistant messages with empty content (`""`). This issue required special handling without introducing brittle, ad-hoc patches. In addressing this, I also saw an opportunity to **modularize** the message transformation logic across models. This improves clarity, avoids duplication, and simplifies future adaptations (e.g., different constraints across model families). --- ## 📘 AutoGen Modular Message Transformer: Design & Usage Guide This document introduces the **new modular transformer system** used in AutoGen for converting `LLMMessage` instances to SDK-specific message formats (e.g., OpenAI-style `ChatCompletionMessageParam`). The design improves **reusability, extensibility**, and **maintainability** across different model families. --- ### 🚀 Overview Instead of scattering model-specific message conversion logic across the codebase, the new design introduces: - Modular transformer **functions** for each message type - Per-model **transformer maps** (e.g., for OpenAI-compatible models) - Optional **conditional transformers** for multimodal/text hybrid models - Clear separation between **message adaptation logic** and **SDK-specific builder** (e.g., `ChatCompletionUserMessageParam`) --- ### 🧱 1. Define Transform Functions Each transformer function takes: - `LLMMessage`: a structured AutoGen message - `context: dict`: metadata passed through the builder pipeline And returns: - A dictionary of keyword arguments for the target message constructor (e.g., `{"content": ..., "name": ..., "role": ...}`) ```python def _set_thought_as_content_gemini(message: LLMMessage, context: Dict[str, Any]) -> Dict[str, str | None]: assert isinstance(message, AssistantMessage) return {"content": message.thought or " "} ``` --- ### 🪢 2. Compose Transformer Pipelines Multiple transformer functions are composed into a pipeline using `build_transformer_func()`: ```python base_user_transformer_funcs: List[Callable[[LLMMessage, Dict[str, Any]], Dict[str, Any]]] = [ _assert_valid_name, _set_name, _set_role("user"), ] user_transformer = build_transformer_func( funcs=base_user_transformer_funcs, message_param_func=ChatCompletionUserMessageParam ) ``` - The `message_param_func` is the actual constructor for the target message class (usually from the SDK). - The pipeline is **ordered** — each function adds or overrides keys in the builder kwargs. --- ### 🗂️ 3. Register Transformer Map Each model family maintains a `TransformerMap`, which maps `LLMMessage` types to transformers: ```python __BASE_TRANSFORMER_MAP: TransformerMap = { SystemMessage: system_transformer, UserMessage: user_transformer, AssistantMessage: assistant_transformer, } register_transformer("openai", model_name_or_family, __BASE_TRANSFORMER_MAP) ``` - `"openai"` is currently required (as only OpenAI-compatible format is supported now). - Registration ensures AutoGen knows how to transform each message type for that model. --- ### 🔁 4. Conditional Transformers (Optional) When message construction depends on runtime conditions (e.g., `"text"` vs. `"multimodal"`), use: ```python conditional_transformer = build_conditional_transformer_func( funcs_map=user_transformer_funcs_claude, message_param_func_map=user_transformer_constructors, condition_func=user_condition, ) ``` Where: - `funcs_map`: maps condition label → list of transformer functions ```python user_transformer_funcs_claude = { "text": text_transformers + [_set_empty_to_whitespace], "multimodal": multimodal_transformers + [_set_empty_to_whitespace], } ``` - `message_param_func_map`: maps condition label → message builder ```python user_transformer_constructors = { "text": ChatCompletionUserMessageParam, "multimodal": ChatCompletionUserMessageParam, } ``` - `condition_func`: determines which transformer to apply at runtime ```python def user_condition(message: LLMMessage, context: Dict[str, Any]) -> str: if isinstance(message.content, str): return "text" return "multimodal" ``` --- ### 🧪 Example Flow ```python llm_message = AssistantMessage(name="a", thought="let’s go") model_family = "openai" model_name = "claude-3-opus" transformer = get_transformer(model_family, model_name, type(llm_message)) sdk_message = transformer(llm_message, context={}) ``` --- ### 🎯 Design Benefits | Feature | Benefit | |--------|---------| | 🧱 Function-based modular design | Easy to compose and test | | 🧩 Per-model registry | Clean separation across model families | | ⚖️ Conditional support | Allows multimodal / dynamic adaptation | | 🔄 Reuse-friendly | Shared logic (e.g., `_set_name`) is DRY | | 📦 SDK-specific | Keeps message adaptation aligned to builder interface | --- ### 🔮 Future Direction - Support more SDKs and formats by introducing new message_param_func - Global registry integration (currently `"openai"`-scoped) - Class-based transformer variant if complexity grows --- ## Related issue number Closes #5762 ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [x] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ v ] I've made sure all auto checks have passed. --------- Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
1 year ago
[BugFix][Refactor] Modular Transformer Pipeline and Fix Gemini/Anthropic Empty Content Handling (#6063) ## Why are these changes needed? This change addresses a compatibility issue when using Google Gemini models with AutoGen. Specifically, Gemini returns a 400 INVALID_ARGUMENT error when receiving a response with an empty "text" parameter. The root cause is that Gemini does not accept empty string values (e.g., "") as valid inputs in the history of the conversation. To fix this, if the content field is falsy (e.g., None, "", etc.), it is explicitly replaced with a single whitespace (" "), which prevents the Gemini model from rejecting the request. - **Gemini API compatibility:** Gemini models reject empty assistant messages (e.g., `""`), causing runtime errors. This PR ensures such messages are safely replaced with whitespace where appropriate. - **Avoiding regressions:** Applying the empty content workaround **only to Gemini**, and **only to valid message types**, avoids breaking OpenAI or other models. - **Reducing duplication:** Previously, message transformation logic was scattered and repeated across different message types and models. Modularizing this pipeline removes that redundancy. - **Improved maintainability:** With future model variants likely to introduce more constraints, this modular structure makes it easier to adapt transformations without writing ad-hoc code each time. - **Testing for correctness:** The new structure is verified with tests, ensuring the bug fix is effective and non-intrusive. ## Summary This PR introduces a **modular transformer pipeline** for message conversion and **fixes a Gemini-specific bug** related to empty assistant message content. ### Key Changes - **[Refactor]** Extracted message transformation logic into a unified pipeline to: - Reduce code duplication - Improve maintainability - Simplify debugging and extension for future model-specific logic - **[BugFix]** Gemini models do not accept empty assistant message content. - Introduced `_set_empty_to_whitespace` transformer to replace empty strings with `" "` only where needed - Applied it **only** to `"text"` and `"thought"` message types, not to `"tools"` to avoid serialization errors - **Improved structure for model-specific handling** - Transformer functions are now grouped and conditionally applied based on message type and model family - This design makes it easier to support future models or combinations (e.g., Gemini + R1) - **Test coverage added** - Added dedicated tests to verify that empty assistant content causes errors for Gemini - Ensured the fix resolves the issue without affecting OpenAI models --- ## Motivation Originally, Gemini-compatible endpoints would fail when receiving assistant messages with empty content (`""`). This issue required special handling without introducing brittle, ad-hoc patches. In addressing this, I also saw an opportunity to **modularize** the message transformation logic across models. This improves clarity, avoids duplication, and simplifies future adaptations (e.g., different constraints across model families). --- ## 📘 AutoGen Modular Message Transformer: Design & Usage Guide This document introduces the **new modular transformer system** used in AutoGen for converting `LLMMessage` instances to SDK-specific message formats (e.g., OpenAI-style `ChatCompletionMessageParam`). The design improves **reusability, extensibility**, and **maintainability** across different model families. --- ### 🚀 Overview Instead of scattering model-specific message conversion logic across the codebase, the new design introduces: - Modular transformer **functions** for each message type - Per-model **transformer maps** (e.g., for OpenAI-compatible models) - Optional **conditional transformers** for multimodal/text hybrid models - Clear separation between **message adaptation logic** and **SDK-specific builder** (e.g., `ChatCompletionUserMessageParam`) --- ### 🧱 1. Define Transform Functions Each transformer function takes: - `LLMMessage`: a structured AutoGen message - `context: dict`: metadata passed through the builder pipeline And returns: - A dictionary of keyword arguments for the target message constructor (e.g., `{"content": ..., "name": ..., "role": ...}`) ```python def _set_thought_as_content_gemini(message: LLMMessage, context: Dict[str, Any]) -> Dict[str, str | None]: assert isinstance(message, AssistantMessage) return {"content": message.thought or " "} ``` --- ### 🪢 2. Compose Transformer Pipelines Multiple transformer functions are composed into a pipeline using `build_transformer_func()`: ```python base_user_transformer_funcs: List[Callable[[LLMMessage, Dict[str, Any]], Dict[str, Any]]] = [ _assert_valid_name, _set_name, _set_role("user"), ] user_transformer = build_transformer_func( funcs=base_user_transformer_funcs, message_param_func=ChatCompletionUserMessageParam ) ``` - The `message_param_func` is the actual constructor for the target message class (usually from the SDK). - The pipeline is **ordered** — each function adds or overrides keys in the builder kwargs. --- ### 🗂️ 3. Register Transformer Map Each model family maintains a `TransformerMap`, which maps `LLMMessage` types to transformers: ```python __BASE_TRANSFORMER_MAP: TransformerMap = { SystemMessage: system_transformer, UserMessage: user_transformer, AssistantMessage: assistant_transformer, } register_transformer("openai", model_name_or_family, __BASE_TRANSFORMER_MAP) ``` - `"openai"` is currently required (as only OpenAI-compatible format is supported now). - Registration ensures AutoGen knows how to transform each message type for that model. --- ### 🔁 4. Conditional Transformers (Optional) When message construction depends on runtime conditions (e.g., `"text"` vs. `"multimodal"`), use: ```python conditional_transformer = build_conditional_transformer_func( funcs_map=user_transformer_funcs_claude, message_param_func_map=user_transformer_constructors, condition_func=user_condition, ) ``` Where: - `funcs_map`: maps condition label → list of transformer functions ```python user_transformer_funcs_claude = { "text": text_transformers + [_set_empty_to_whitespace], "multimodal": multimodal_transformers + [_set_empty_to_whitespace], } ``` - `message_param_func_map`: maps condition label → message builder ```python user_transformer_constructors = { "text": ChatCompletionUserMessageParam, "multimodal": ChatCompletionUserMessageParam, } ``` - `condition_func`: determines which transformer to apply at runtime ```python def user_condition(message: LLMMessage, context: Dict[str, Any]) -> str: if isinstance(message.content, str): return "text" return "multimodal" ``` --- ### 🧪 Example Flow ```python llm_message = AssistantMessage(name="a", thought="let’s go") model_family = "openai" model_name = "claude-3-opus" transformer = get_transformer(model_family, model_name, type(llm_message)) sdk_message = transformer(llm_message, context={}) ``` --- ### 🎯 Design Benefits | Feature | Benefit | |--------|---------| | 🧱 Function-based modular design | Easy to compose and test | | 🧩 Per-model registry | Clean separation across model families | | ⚖️ Conditional support | Allows multimodal / dynamic adaptation | | 🔄 Reuse-friendly | Shared logic (e.g., `_set_name`) is DRY | | 📦 SDK-specific | Keeps message adaptation aligned to builder interface | --- ### 🔮 Future Direction - Support more SDKs and formats by introducing new message_param_func - Global registry integration (currently `"openai"`-scoped) - Class-based transformer variant if complexity grows --- ## Related issue number Closes #5762 ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [x] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ v ] I've made sure all auto checks have passed. --------- Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
1 year ago
[BugFix][Refactor] Modular Transformer Pipeline and Fix Gemini/Anthropic Empty Content Handling (#6063) ## Why are these changes needed? This change addresses a compatibility issue when using Google Gemini models with AutoGen. Specifically, Gemini returns a 400 INVALID_ARGUMENT error when receiving a response with an empty "text" parameter. The root cause is that Gemini does not accept empty string values (e.g., "") as valid inputs in the history of the conversation. To fix this, if the content field is falsy (e.g., None, "", etc.), it is explicitly replaced with a single whitespace (" "), which prevents the Gemini model from rejecting the request. - **Gemini API compatibility:** Gemini models reject empty assistant messages (e.g., `""`), causing runtime errors. This PR ensures such messages are safely replaced with whitespace where appropriate. - **Avoiding regressions:** Applying the empty content workaround **only to Gemini**, and **only to valid message types**, avoids breaking OpenAI or other models. - **Reducing duplication:** Previously, message transformation logic was scattered and repeated across different message types and models. Modularizing this pipeline removes that redundancy. - **Improved maintainability:** With future model variants likely to introduce more constraints, this modular structure makes it easier to adapt transformations without writing ad-hoc code each time. - **Testing for correctness:** The new structure is verified with tests, ensuring the bug fix is effective and non-intrusive. ## Summary This PR introduces a **modular transformer pipeline** for message conversion and **fixes a Gemini-specific bug** related to empty assistant message content. ### Key Changes - **[Refactor]** Extracted message transformation logic into a unified pipeline to: - Reduce code duplication - Improve maintainability - Simplify debugging and extension for future model-specific logic - **[BugFix]** Gemini models do not accept empty assistant message content. - Introduced `_set_empty_to_whitespace` transformer to replace empty strings with `" "` only where needed - Applied it **only** to `"text"` and `"thought"` message types, not to `"tools"` to avoid serialization errors - **Improved structure for model-specific handling** - Transformer functions are now grouped and conditionally applied based on message type and model family - This design makes it easier to support future models or combinations (e.g., Gemini + R1) - **Test coverage added** - Added dedicated tests to verify that empty assistant content causes errors for Gemini - Ensured the fix resolves the issue without affecting OpenAI models --- ## Motivation Originally, Gemini-compatible endpoints would fail when receiving assistant messages with empty content (`""`). This issue required special handling without introducing brittle, ad-hoc patches. In addressing this, I also saw an opportunity to **modularize** the message transformation logic across models. This improves clarity, avoids duplication, and simplifies future adaptations (e.g., different constraints across model families). --- ## 📘 AutoGen Modular Message Transformer: Design & Usage Guide This document introduces the **new modular transformer system** used in AutoGen for converting `LLMMessage` instances to SDK-specific message formats (e.g., OpenAI-style `ChatCompletionMessageParam`). The design improves **reusability, extensibility**, and **maintainability** across different model families. --- ### 🚀 Overview Instead of scattering model-specific message conversion logic across the codebase, the new design introduces: - Modular transformer **functions** for each message type - Per-model **transformer maps** (e.g., for OpenAI-compatible models) - Optional **conditional transformers** for multimodal/text hybrid models - Clear separation between **message adaptation logic** and **SDK-specific builder** (e.g., `ChatCompletionUserMessageParam`) --- ### 🧱 1. Define Transform Functions Each transformer function takes: - `LLMMessage`: a structured AutoGen message - `context: dict`: metadata passed through the builder pipeline And returns: - A dictionary of keyword arguments for the target message constructor (e.g., `{"content": ..., "name": ..., "role": ...}`) ```python def _set_thought_as_content_gemini(message: LLMMessage, context: Dict[str, Any]) -> Dict[str, str | None]: assert isinstance(message, AssistantMessage) return {"content": message.thought or " "} ``` --- ### 🪢 2. Compose Transformer Pipelines Multiple transformer functions are composed into a pipeline using `build_transformer_func()`: ```python base_user_transformer_funcs: List[Callable[[LLMMessage, Dict[str, Any]], Dict[str, Any]]] = [ _assert_valid_name, _set_name, _set_role("user"), ] user_transformer = build_transformer_func( funcs=base_user_transformer_funcs, message_param_func=ChatCompletionUserMessageParam ) ``` - The `message_param_func` is the actual constructor for the target message class (usually from the SDK). - The pipeline is **ordered** — each function adds or overrides keys in the builder kwargs. --- ### 🗂️ 3. Register Transformer Map Each model family maintains a `TransformerMap`, which maps `LLMMessage` types to transformers: ```python __BASE_TRANSFORMER_MAP: TransformerMap = { SystemMessage: system_transformer, UserMessage: user_transformer, AssistantMessage: assistant_transformer, } register_transformer("openai", model_name_or_family, __BASE_TRANSFORMER_MAP) ``` - `"openai"` is currently required (as only OpenAI-compatible format is supported now). - Registration ensures AutoGen knows how to transform each message type for that model. --- ### 🔁 4. Conditional Transformers (Optional) When message construction depends on runtime conditions (e.g., `"text"` vs. `"multimodal"`), use: ```python conditional_transformer = build_conditional_transformer_func( funcs_map=user_transformer_funcs_claude, message_param_func_map=user_transformer_constructors, condition_func=user_condition, ) ``` Where: - `funcs_map`: maps condition label → list of transformer functions ```python user_transformer_funcs_claude = { "text": text_transformers + [_set_empty_to_whitespace], "multimodal": multimodal_transformers + [_set_empty_to_whitespace], } ``` - `message_param_func_map`: maps condition label → message builder ```python user_transformer_constructors = { "text": ChatCompletionUserMessageParam, "multimodal": ChatCompletionUserMessageParam, } ``` - `condition_func`: determines which transformer to apply at runtime ```python def user_condition(message: LLMMessage, context: Dict[str, Any]) -> str: if isinstance(message.content, str): return "text" return "multimodal" ``` --- ### 🧪 Example Flow ```python llm_message = AssistantMessage(name="a", thought="let’s go") model_family = "openai" model_name = "claude-3-opus" transformer = get_transformer(model_family, model_name, type(llm_message)) sdk_message = transformer(llm_message, context={}) ``` --- ### 🎯 Design Benefits | Feature | Benefit | |--------|---------| | 🧱 Function-based modular design | Easy to compose and test | | 🧩 Per-model registry | Clean separation across model families | | ⚖️ Conditional support | Allows multimodal / dynamic adaptation | | 🔄 Reuse-friendly | Shared logic (e.g., `_set_name`) is DRY | | 📦 SDK-specific | Keeps message adaptation aligned to builder interface | --- ### 🔮 Future Direction - Support more SDKs and formats by introducing new message_param_func - Global registry integration (currently `"openai"`-scoped) - Class-based transformer variant if complexity grows --- ## Related issue number Closes #5762 ## Checks - [ ] I've included any doc changes needed for <https://microsoft.github.io/autogen/>. See <https://github.com/microsoft/autogen/blob/main/CONTRIBUTING.md> to build and test documentation locally. - [x] I've added tests (if relevant) corresponding to the changes introduced in this PR. - [ v ] I've made sure all auto checks have passed. --------- Co-authored-by: Eric Zhu <ekzhu@users.noreply.github.com>
1 year ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364
  1. import asyncio
  2. import json
  3. import logging
  4. import os
  5. from typing import Annotated, Any, AsyncGenerator, Dict, List, Literal, Tuple, TypeVar, get_args
  6. from unittest.mock import AsyncMock, MagicMock
  7. import httpx
  8. import pytest
  9. from autogen_agentchat.agents import AssistantAgent
  10. from autogen_agentchat.messages import MultiModalMessage
  11. from autogen_core import CancellationToken, FunctionCall, Image
  12. from autogen_core.models import (
  13. AssistantMessage,
  14. CreateResult,
  15. FunctionExecutionResult,
  16. FunctionExecutionResultMessage,
  17. LLMMessage,
  18. ModelInfo,
  19. RequestUsage,
  20. SystemMessage,
  21. UserMessage,
  22. )
  23. from autogen_core.models._model_client import ModelFamily
  24. from autogen_core.tools import BaseTool, FunctionTool
  25. from autogen_ext.models.openai import AzureOpenAIChatCompletionClient, OpenAIChatCompletionClient
  26. from autogen_ext.models.openai._model_info import resolve_model
  27. from autogen_ext.models.openai._openai_client import (
  28. BaseOpenAIChatCompletionClient,
  29. calculate_vision_tokens,
  30. convert_tools,
  31. to_oai_type,
  32. )
  33. from autogen_ext.models.openai._transformation import TransformerMap, get_transformer
  34. from autogen_ext.models.openai._transformation.registry import _find_model_family # pyright: ignore[reportPrivateUsage]
  35. from openai.lib.streaming.chat import AsyncChatCompletionStreamManager
  36. from openai.resources.chat.completions import AsyncCompletions
  37. from openai.types.chat.chat_completion import ChatCompletion, Choice
  38. from openai.types.chat.chat_completion_chunk import (
  39. ChatCompletionChunk,
  40. ChoiceDelta,
  41. ChoiceDeltaToolCall,
  42. ChoiceDeltaToolCallFunction,
  43. )
  44. from openai.types.chat.chat_completion_chunk import (
  45. Choice as ChunkChoice,
  46. )
  47. from openai.types.chat.chat_completion_message import ChatCompletionMessage
  48. from openai.types.chat.chat_completion_message_function_tool_call import (
  49. ChatCompletionMessageFunctionToolCall as _FuncToolCall,
  50. )
  51. from openai.types.chat.chat_completion_message_function_tool_call import Function as _TypedFunction # type: ignore
  52. from openai.types.chat.parsed_chat_completion import (
  53. ParsedChatCompletion,
  54. ParsedChatCompletionMessage,
  55. ParsedChoice,
  56. )
  57. from openai.types.chat.parsed_function_tool_call import ParsedFunction, ParsedFunctionToolCall
  58. from openai.types.completion_usage import CompletionUsage
  59. from pydantic import BaseModel, Field
  60. # Provide a constructible alias for tests compatible with OpenAI 1.99 types
  61. ChatCompletionMessageToolCall = _FuncToolCall # type: ignore[assignment]
  62. # Helper to satisfy type checker with OpenAI 1.99 types
  63. # Construct the function payload using the typed helper
  64. def Function(*, name: str, arguments: str) -> _TypedFunction: # type: ignore[override]
  65. return _TypedFunction(name=name, arguments=arguments)
  66. ResponseFormatT = TypeVar("ResponseFormatT", bound=BaseModel)
  67. def _pass_function(input: str) -> str:
  68. return "pass"
  69. async def _fail_function(input: str) -> str:
  70. return "fail"
  71. async def _echo_function(input: str) -> str:
  72. return input
  73. class MyResult(BaseModel):
  74. result: str = Field(description="The other description.")
  75. class MyArgs(BaseModel):
  76. query: str = Field(description="The description.")
  77. class MockChunkDefinition(BaseModel):
  78. # defining elements for diffentiating mocking chunks
  79. chunk_choice: ChunkChoice
  80. usage: CompletionUsage | None
  81. class MockChunkEvent(BaseModel):
  82. type: Literal["chunk"]
  83. chunk: ChatCompletionChunk
  84. async def _mock_create_stream(*args: Any, **kwargs: Any) -> AsyncGenerator[ChatCompletionChunk, None]:
  85. model = resolve_model(kwargs.get("model", "gpt-4.1-nano"))
  86. mock_chunks_content = ["Hello", " Another Hello", " Yet Another Hello"]
  87. # The openai api implementations (OpenAI and Litellm) stream chunks of tokens
  88. # with content as string, and then at the end a token with stop set and finally if
  89. # usage requested with `"stream_options": {"include_usage": True}` a chunk with the usage data
  90. mock_chunks = [
  91. # generate the list of mock chunk content
  92. MockChunkDefinition(
  93. chunk_choice=ChunkChoice(
  94. finish_reason=None,
  95. index=0,
  96. delta=ChoiceDelta(
  97. content=mock_chunk_content,
  98. role="assistant",
  99. ),
  100. ),
  101. usage=None,
  102. )
  103. for mock_chunk_content in mock_chunks_content
  104. ] + [
  105. # generate the stop chunk
  106. MockChunkDefinition(
  107. chunk_choice=ChunkChoice(
  108. finish_reason="stop",
  109. index=0,
  110. delta=ChoiceDelta(
  111. content=None,
  112. role="assistant",
  113. ),
  114. ),
  115. usage=None,
  116. )
  117. ]
  118. # generate the usage chunk if configured
  119. if kwargs.get("stream_options", {}).get("include_usage") is True:
  120. mock_chunks = mock_chunks + [
  121. # ---- API differences
  122. # OPENAI API does NOT create a choice
  123. # LITELLM (proxy) DOES create a choice
  124. # Not simulating all the API options, just implementing the LITELLM variant
  125. MockChunkDefinition(
  126. chunk_choice=ChunkChoice(
  127. finish_reason=None,
  128. index=0,
  129. delta=ChoiceDelta(
  130. content=None,
  131. role="assistant",
  132. ),
  133. ),
  134. usage=CompletionUsage(prompt_tokens=3, completion_tokens=3, total_tokens=6),
  135. )
  136. ]
  137. elif kwargs.get("stream_options", {}).get("include_usage") is False:
  138. pass
  139. else:
  140. pass
  141. for mock_chunk in mock_chunks:
  142. await asyncio.sleep(0.1)
  143. yield ChatCompletionChunk(
  144. id="id",
  145. choices=[mock_chunk.chunk_choice],
  146. created=0,
  147. model=model,
  148. object="chat.completion.chunk",
  149. usage=mock_chunk.usage,
  150. )
  151. async def _mock_create(*args: Any, **kwargs: Any) -> ChatCompletion | AsyncGenerator[ChatCompletionChunk, None]:
  152. stream = kwargs.get("stream", False)
  153. model = resolve_model(kwargs.get("model", "gpt-4.1-nano"))
  154. if not stream:
  155. await asyncio.sleep(0.1)
  156. return ChatCompletion(
  157. id="id",
  158. choices=[
  159. Choice(finish_reason="stop", index=0, message=ChatCompletionMessage(content="Hello", role="assistant"))
  160. ],
  161. created=0,
  162. model=model,
  163. object="chat.completion",
  164. usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
  165. )
  166. else:
  167. return _mock_create_stream(*args, **kwargs)
  168. @pytest.mark.asyncio
  169. async def test_openai_chat_completion_client() -> None:
  170. client = OpenAIChatCompletionClient(model="gpt-4.1-nano", api_key="api_key")
  171. assert client
  172. @pytest.mark.asyncio
  173. async def test_openai_chat_completion_client_with_gemini_model() -> None:
  174. client = OpenAIChatCompletionClient(model="gemini-1.5-flash", api_key="api_key")
  175. assert client
  176. @pytest.mark.asyncio
  177. async def test_openai_chat_completion_client_serialization() -> None:
  178. client = OpenAIChatCompletionClient(model="gpt-4.1-nano", api_key="sk-password")
  179. assert client
  180. config = client.dump_component()
  181. assert config
  182. assert "sk-password" not in str(config)
  183. serialized_config = config.model_dump_json()
  184. assert serialized_config
  185. assert "sk-password" not in serialized_config
  186. client2 = OpenAIChatCompletionClient.load_component(config)
  187. assert client2
  188. @pytest.mark.asyncio
  189. async def test_openai_chat_completion_client_raise_on_unknown_model() -> None:
  190. with pytest.raises(ValueError, match="model_info is required"):
  191. _ = OpenAIChatCompletionClient(model="unknown", api_key="api_key")
  192. @pytest.mark.asyncio
  193. async def test_custom_model_with_capabilities() -> None:
  194. with pytest.raises(ValueError, match="model_info is required"):
  195. client = OpenAIChatCompletionClient(model="dummy_model", base_url="https://api.dummy.com/v0", api_key="api_key")
  196. client = OpenAIChatCompletionClient(
  197. model="dummy_model",
  198. base_url="https://api.dummy.com/v0",
  199. api_key="api_key",
  200. model_info={
  201. "vision": False,
  202. "function_calling": False,
  203. "json_output": False,
  204. "family": ModelFamily.UNKNOWN,
  205. "structured_output": False,
  206. },
  207. )
  208. assert client
  209. @pytest.mark.asyncio
  210. async def test_azure_openai_chat_completion_client() -> None:
  211. client = AzureOpenAIChatCompletionClient(
  212. azure_deployment="gpt-4o-1",
  213. model="gpt-4o",
  214. api_key="api_key",
  215. api_version="2020-08-04",
  216. azure_endpoint="https://dummy.com",
  217. model_info={
  218. "vision": True,
  219. "function_calling": True,
  220. "json_output": True,
  221. "family": ModelFamily.GPT_4O,
  222. "structured_output": True,
  223. },
  224. )
  225. assert client
  226. @pytest.mark.asyncio
  227. async def test_openai_chat_completion_client_create(
  228. monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
  229. ) -> None:
  230. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  231. with caplog.at_level(logging.INFO):
  232. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  233. result = await client.create(messages=[UserMessage(content="Hello", source="user")])
  234. assert result.content == "Hello"
  235. assert "LLMCall" in caplog.text and "Hello" in caplog.text
  236. @pytest.mark.asyncio
  237. async def test_openai_chat_completion_client_create_stream_with_usage(
  238. monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
  239. ) -> None:
  240. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  241. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  242. chunks: List[str | CreateResult] = []
  243. # Check that include_usage works when set via create_args
  244. with caplog.at_level(logging.INFO):
  245. async for chunk in client.create_stream(
  246. messages=[UserMessage(content="Hello", source="user")],
  247. # include_usage not the default of the OPENAI API and must be explicitly set
  248. extra_create_args={"stream_options": {"include_usage": True}},
  249. ):
  250. chunks.append(chunk)
  251. assert "LLMStreamStart" in caplog.text
  252. assert "LLMStreamEnd" in caplog.text
  253. assert chunks[0] == "Hello"
  254. assert chunks[1] == " Another Hello"
  255. assert chunks[2] == " Yet Another Hello"
  256. assert isinstance(chunks[-1], CreateResult)
  257. assert isinstance(chunks[-1].content, str)
  258. assert chunks[-1].content == "Hello Another Hello Yet Another Hello"
  259. assert chunks[-1].content in caplog.text
  260. assert chunks[-1].usage == RequestUsage(prompt_tokens=3, completion_tokens=3)
  261. chunks = []
  262. # Check that include_usage works when set via include_usage flag
  263. with caplog.at_level(logging.INFO):
  264. async for chunk in client.create_stream(
  265. messages=[UserMessage(content="Hello", source="user")],
  266. include_usage=True,
  267. ):
  268. chunks.append(chunk)
  269. assert "LLMStreamStart" in caplog.text
  270. assert "LLMStreamEnd" in caplog.text
  271. assert chunks[0] == "Hello"
  272. assert chunks[1] == " Another Hello"
  273. assert chunks[2] == " Yet Another Hello"
  274. assert isinstance(chunks[-1], CreateResult)
  275. assert isinstance(chunks[-1].content, str)
  276. assert chunks[-1].content == "Hello Another Hello Yet Another Hello"
  277. assert chunks[-1].content in caplog.text
  278. assert chunks[-1].usage == RequestUsage(prompt_tokens=3, completion_tokens=3)
  279. chunks = []
  280. # Check that setting both flags to different values raises an exception
  281. with pytest.raises(ValueError):
  282. async for chunk in client.create_stream(
  283. messages=[UserMessage(content="Hello", source="user")],
  284. extra_create_args={"stream_options": {"include_usage": False}},
  285. include_usage=True,
  286. ):
  287. chunks.append(chunk)
  288. @pytest.mark.asyncio
  289. async def test_openai_chat_completion_client_create_stream_no_usage_default(monkeypatch: pytest.MonkeyPatch) -> None:
  290. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  291. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  292. chunks: List[str | CreateResult] = []
  293. async for chunk in client.create_stream(
  294. messages=[UserMessage(content="Hello", source="user")],
  295. # include_usage not the default of the OPENAI APIis ,
  296. # it can be explicitly set
  297. # or just not declared which is the default
  298. # extra_create_args={"stream_options": {"include_usage": False}},
  299. ):
  300. chunks.append(chunk)
  301. assert chunks[0] == "Hello"
  302. assert chunks[1] == " Another Hello"
  303. assert chunks[2] == " Yet Another Hello"
  304. assert isinstance(chunks[-1], CreateResult)
  305. assert chunks[-1].content == "Hello Another Hello Yet Another Hello"
  306. assert chunks[-1].usage == RequestUsage(prompt_tokens=0, completion_tokens=0)
  307. @pytest.mark.asyncio
  308. async def test_openai_chat_completion_client_create_stream_no_usage_explicit(monkeypatch: pytest.MonkeyPatch) -> None:
  309. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  310. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  311. chunks: List[str | CreateResult] = []
  312. async for chunk in client.create_stream(
  313. messages=[UserMessage(content="Hello", source="user")],
  314. # include_usage is not the default of the OPENAI API ,
  315. # it can be explicitly set
  316. # or just not declared which is the default
  317. extra_create_args={"stream_options": {"include_usage": False}},
  318. ):
  319. chunks.append(chunk)
  320. assert chunks[0] == "Hello"
  321. assert chunks[1] == " Another Hello"
  322. assert chunks[2] == " Yet Another Hello"
  323. @pytest.mark.asyncio
  324. async def test_openai_chat_completion_client_none_usage(monkeypatch: pytest.MonkeyPatch) -> None:
  325. """Test that completion_tokens and prompt_tokens handle None usage correctly.
  326. This test addresses issue #6352 where result.usage could be None,
  327. causing TypeError in logging when trying to access completion_tokens.
  328. """
  329. async def _mock_create_with_none_usage(*args: Any, **kwargs: Any) -> ChatCompletion:
  330. await asyncio.sleep(0.1)
  331. # Create a ChatCompletion with None usage (which can happen in some API scenarios)
  332. return ChatCompletion(
  333. id="id",
  334. choices=[
  335. Choice(finish_reason="stop", index=0, message=ChatCompletionMessage(content="Hello", role="assistant"))
  336. ],
  337. created=0,
  338. model="gpt-4o",
  339. object="chat.completion",
  340. usage=None, # This is the scenario from the issue
  341. )
  342. monkeypatch.setattr(AsyncCompletions, "create", _mock_create_with_none_usage)
  343. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  344. # This should not raise a TypeError
  345. result = await client.create(messages=[UserMessage(content="Hello", source="user")])
  346. # Verify that the usage is correctly set to 0 when usage is None
  347. assert result.usage.prompt_tokens == 0
  348. assert result.usage.completion_tokens == 0
  349. @pytest.mark.asyncio
  350. async def test_openai_chat_completion_client_create_cancel(monkeypatch: pytest.MonkeyPatch) -> None:
  351. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  352. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  353. cancellation_token = CancellationToken()
  354. task = asyncio.create_task(
  355. client.create(messages=[UserMessage(content="Hello", source="user")], cancellation_token=cancellation_token)
  356. )
  357. cancellation_token.cancel()
  358. with pytest.raises(asyncio.CancelledError):
  359. await task
  360. @pytest.mark.asyncio
  361. async def test_openai_chat_completion_client_create_stream_cancel(monkeypatch: pytest.MonkeyPatch) -> None:
  362. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  363. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  364. cancellation_token = CancellationToken()
  365. stream = client.create_stream(
  366. messages=[UserMessage(content="Hello", source="user")], cancellation_token=cancellation_token
  367. )
  368. assert await anext(stream)
  369. cancellation_token.cancel()
  370. with pytest.raises(asyncio.CancelledError):
  371. async for _ in stream:
  372. pass
  373. @pytest.mark.asyncio
  374. async def test_openai_chat_completion_client_count_tokens(monkeypatch: pytest.MonkeyPatch) -> None:
  375. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="api_key")
  376. messages: List[LLMMessage] = [
  377. SystemMessage(content="Hello"),
  378. UserMessage(content="Hello", source="user"),
  379. AssistantMessage(content="Hello", source="assistant"),
  380. UserMessage(
  381. content=[
  382. "str1",
  383. Image.from_base64(
  384. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
  385. ),
  386. ],
  387. source="user",
  388. ),
  389. FunctionExecutionResultMessage(
  390. content=[FunctionExecutionResult(content="Hello", call_id="1", is_error=False, name="tool1")]
  391. ),
  392. ]
  393. def tool1(test: str, test2: str) -> str:
  394. return test + test2
  395. def tool2(test1: int, test2: List[int]) -> str:
  396. return str(test1) + str(test2)
  397. tools = [FunctionTool(tool1, description="example tool 1"), FunctionTool(tool2, description="example tool 2")]
  398. mockcalculate_vision_tokens = MagicMock()
  399. monkeypatch.setattr("autogen_ext.models.openai._openai_client.calculate_vision_tokens", mockcalculate_vision_tokens)
  400. num_tokens = client.count_tokens(messages, tools=tools)
  401. assert num_tokens
  402. # Check that calculate_vision_tokens was called
  403. mockcalculate_vision_tokens.assert_called_once()
  404. remaining_tokens = client.remaining_tokens(messages, tools=tools)
  405. assert remaining_tokens
  406. @pytest.mark.parametrize(
  407. "mock_size, expected_num_tokens",
  408. [
  409. ((1, 1), 255),
  410. ((512, 512), 255),
  411. ((2048, 512), 765),
  412. ((2048, 2048), 765),
  413. ((512, 1024), 425),
  414. ],
  415. )
  416. def test_openai_count_image_tokens(mock_size: Tuple[int, int], expected_num_tokens: int) -> None:
  417. # Step 1: Mock the Image class with only the 'image' attribute
  418. mock_image_attr = MagicMock()
  419. mock_image_attr.size = mock_size
  420. mock_image = MagicMock()
  421. mock_image.image = mock_image_attr
  422. # Directly call calculate_vision_tokens and check the result
  423. calculated_tokens = calculate_vision_tokens(mock_image, detail="auto")
  424. assert calculated_tokens == expected_num_tokens
  425. def test_convert_tools_accepts_both_func_tool_and_schema() -> None:
  426. def my_function(arg: str, other: Annotated[int, "int arg"], nonrequired: int = 5) -> MyResult:
  427. return MyResult(result="test")
  428. tool = FunctionTool(my_function, description="Function tool.")
  429. schema = tool.schema
  430. converted_tool_schema = convert_tools([tool, schema])
  431. assert len(converted_tool_schema) == 2
  432. assert converted_tool_schema[0] == converted_tool_schema[1]
  433. def test_convert_tools_accepts_both_tool_and_schema() -> None:
  434. class MyTool(BaseTool[MyArgs, MyResult]):
  435. def __init__(self) -> None:
  436. super().__init__(
  437. args_type=MyArgs,
  438. return_type=MyResult,
  439. name="TestTool",
  440. description="Description of test tool.",
  441. )
  442. async def run(self, args: MyArgs, cancellation_token: CancellationToken) -> MyResult:
  443. return MyResult(result="value")
  444. tool = MyTool()
  445. schema = tool.schema
  446. converted_tool_schema = convert_tools([tool, schema])
  447. assert len(converted_tool_schema) == 2
  448. assert converted_tool_schema[0] == converted_tool_schema[1]
  449. @pytest.mark.asyncio
  450. async def test_json_mode(monkeypatch: pytest.MonkeyPatch) -> None:
  451. model = "gpt-4.1-nano-2025-04-14"
  452. called_args = {}
  453. async def _mock_create(*args: Any, **kwargs: Any) -> ChatCompletion:
  454. # Capture the arguments passed to the function
  455. called_args["kwargs"] = kwargs
  456. return ChatCompletion(
  457. id="id1",
  458. choices=[
  459. Choice(
  460. finish_reason="stop",
  461. index=0,
  462. message=ChatCompletionMessage(
  463. content=json.dumps({"thoughts": "happy", "response": "happy"}),
  464. role="assistant",
  465. ),
  466. )
  467. ],
  468. created=0,
  469. model=model,
  470. object="chat.completion",
  471. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  472. )
  473. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  474. model_client = OpenAIChatCompletionClient(model=model, api_key="")
  475. # Test that the openai client was called with the correct response format.
  476. create_result = await model_client.create(
  477. messages=[UserMessage(content="I am happy.", source="user")], json_output=True
  478. )
  479. assert isinstance(create_result.content, str)
  480. response = json.loads(create_result.content)
  481. assert response["thoughts"] == "happy"
  482. assert response["response"] == "happy"
  483. assert called_args["kwargs"]["response_format"] == {"type": "json_object"}
  484. # Make sure that the response format is set to json_object when json_output is True, regardless of the extra_create_args.
  485. create_result = await model_client.create(
  486. messages=[UserMessage(content="I am happy.", source="user")],
  487. json_output=True,
  488. extra_create_args={"response_format": "json_object"},
  489. )
  490. assert isinstance(create_result.content, str)
  491. response = json.loads(create_result.content)
  492. assert response["thoughts"] == "happy"
  493. assert response["response"] == "happy"
  494. assert called_args["kwargs"]["response_format"] == {"type": "json_object"}
  495. create_result = await model_client.create(
  496. messages=[UserMessage(content="I am happy.", source="user")],
  497. json_output=True,
  498. extra_create_args={"response_format": "text"},
  499. )
  500. assert isinstance(create_result.content, str)
  501. response = json.loads(create_result.content)
  502. assert response["thoughts"] == "happy"
  503. assert response["response"] == "happy"
  504. # Check that the openai client was called with the correct response format.
  505. assert called_args["kwargs"]["response_format"] == {"type": "json_object"}
  506. # Make sure when json_output is set to False, the response format is always set to text.
  507. create_result = await model_client.create(
  508. messages=[UserMessage(content="I am happy.", source="user")],
  509. json_output=False,
  510. extra_create_args={"response_format": "text"},
  511. )
  512. assert called_args["kwargs"]["response_format"] == {"type": "text"}
  513. create_result = await model_client.create(
  514. messages=[UserMessage(content="I am happy.", source="user")],
  515. json_output=False,
  516. extra_create_args={"response_format": "json_object"},
  517. )
  518. assert called_args["kwargs"]["response_format"] == {"type": "text"}
  519. # Make sure when response_format is set it is used when json_output is not set.
  520. create_result = await model_client.create(
  521. messages=[UserMessage(content="I am happy.", source="user")],
  522. extra_create_args={"response_format": {"type": "json_object"}},
  523. )
  524. assert isinstance(create_result.content, str)
  525. response = json.loads(create_result.content)
  526. assert response["thoughts"] == "happy"
  527. assert response["response"] == "happy"
  528. assert called_args["kwargs"]["response_format"] == {"type": "json_object"}
  529. @pytest.mark.asyncio
  530. async def test_structured_output_using_response_format(monkeypatch: pytest.MonkeyPatch) -> None:
  531. class AgentResponse(BaseModel):
  532. thoughts: str
  533. response: Literal["happy", "sad", "neutral"]
  534. model = "gpt-4.1-nano-2025-04-14"
  535. called_args = {}
  536. async def _mock_create(*args: Any, **kwargs: Any) -> ChatCompletion:
  537. # Capture the arguments passed to the function
  538. called_args["kwargs"] = kwargs
  539. return ChatCompletion(
  540. id="id1",
  541. choices=[
  542. Choice(
  543. finish_reason="stop",
  544. index=0,
  545. message=ChatCompletionMessage(
  546. content=json.dumps({"thoughts": "happy", "response": "happy"}),
  547. role="assistant",
  548. ),
  549. )
  550. ],
  551. created=0,
  552. model=model,
  553. object="chat.completion",
  554. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  555. )
  556. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  557. # Scenario 1: response_format is set to constructor.
  558. model_client = OpenAIChatCompletionClient(
  559. model=model,
  560. api_key="",
  561. response_format={
  562. "type": "json_schema",
  563. "json_schema": {
  564. "name": "test",
  565. "description": "test",
  566. "schema": AgentResponse.model_json_schema(),
  567. },
  568. },
  569. )
  570. create_result = await model_client.create(
  571. messages=[UserMessage(content="I am happy.", source="user")],
  572. )
  573. assert isinstance(create_result.content, str)
  574. response = json.loads(create_result.content)
  575. assert response["thoughts"] == "happy"
  576. assert response["response"] == "happy"
  577. assert called_args["kwargs"]["response_format"]["type"] == "json_schema"
  578. # Test the response format can be serailized and deserialized.
  579. config = model_client.dump_component()
  580. assert config
  581. loaded_client = OpenAIChatCompletionClient.load_component(config)
  582. create_result = await loaded_client.create(
  583. messages=[UserMessage(content="I am happy.", source="user")],
  584. )
  585. assert isinstance(create_result.content, str)
  586. response = json.loads(create_result.content)
  587. assert response["thoughts"] == "happy"
  588. assert response["response"] == "happy"
  589. assert called_args["kwargs"]["response_format"]["type"] == "json_schema"
  590. # Scenario 2: response_format is set to a extra_create_args.
  591. model_client = OpenAIChatCompletionClient(model=model, api_key="")
  592. create_result = await model_client.create(
  593. messages=[UserMessage(content="I am happy.", source="user")],
  594. extra_create_args={
  595. "response_format": {
  596. "type": "json_schema",
  597. "json_schema": {
  598. "name": "test",
  599. "description": "test",
  600. "schema": AgentResponse.model_json_schema(),
  601. },
  602. }
  603. },
  604. )
  605. assert isinstance(create_result.content, str)
  606. response = json.loads(create_result.content)
  607. assert response["thoughts"] == "happy"
  608. assert response["response"] == "happy"
  609. assert called_args["kwargs"]["response_format"]["type"] == "json_schema"
  610. @pytest.mark.asyncio
  611. async def test_structured_output(monkeypatch: pytest.MonkeyPatch) -> None:
  612. class AgentResponse(BaseModel):
  613. thoughts: str
  614. response: Literal["happy", "sad", "neutral"]
  615. model = "gpt-4.1-nano-2025-04-14"
  616. async def _mock_parse(*args: Any, **kwargs: Any) -> ParsedChatCompletion[AgentResponse]:
  617. return ParsedChatCompletion(
  618. id="id1",
  619. choices=[
  620. ParsedChoice(
  621. finish_reason="stop",
  622. index=0,
  623. message=ParsedChatCompletionMessage(
  624. content=json.dumps(
  625. {
  626. "thoughts": "The user explicitly states that they are happy without any indication of sadness or neutrality.",
  627. "response": "happy",
  628. }
  629. ),
  630. role="assistant",
  631. ),
  632. )
  633. ],
  634. created=0,
  635. model=model,
  636. object="chat.completion",
  637. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  638. )
  639. monkeypatch.setattr(AsyncCompletions, "parse", _mock_parse)
  640. model_client = OpenAIChatCompletionClient(
  641. model=model,
  642. api_key="",
  643. )
  644. # Test that the openai client was called with the correct response format.
  645. create_result = await model_client.create(
  646. messages=[UserMessage(content="I am happy.", source="user")], json_output=AgentResponse
  647. )
  648. assert isinstance(create_result.content, str)
  649. response = AgentResponse.model_validate(json.loads(create_result.content))
  650. assert (
  651. response.thoughts
  652. == "The user explicitly states that they are happy without any indication of sadness or neutrality."
  653. )
  654. assert response.response == "happy"
  655. # Test that a warning will be raise if response_format is set to a dict.
  656. with pytest.warns(
  657. UserWarning,
  658. match="response_format is found in extra_create_args while json_output is set to a Pydantic model class.",
  659. ):
  660. create_result = await model_client.create(
  661. messages=[UserMessage(content="I am happy.", source="user")],
  662. json_output=AgentResponse,
  663. extra_create_args={"response_format": {"type": "json_object"}},
  664. )
  665. # Test that a warning will be raised if response_format is set to a pydantic model.
  666. with pytest.warns(
  667. DeprecationWarning,
  668. match="Using response_format to specify the BaseModel for structured output type will be deprecated.",
  669. ):
  670. create_result = await model_client.create(
  671. messages=[UserMessage(content="I am happy.", source="user")],
  672. extra_create_args={"response_format": AgentResponse},
  673. )
  674. # Test that a ValueError will be raised if response_format and json_output are set to a pydantic model.
  675. with pytest.raises(
  676. ValueError, match="response_format and json_output cannot be set to a Pydantic model class at the same time."
  677. ):
  678. create_result = await model_client.create(
  679. messages=[UserMessage(content="I am happy.", source="user")],
  680. json_output=AgentResponse,
  681. extra_create_args={"response_format": AgentResponse},
  682. )
  683. @pytest.mark.asyncio
  684. async def test_structured_output_with_tool_calls(monkeypatch: pytest.MonkeyPatch) -> None:
  685. class AgentResponse(BaseModel):
  686. thoughts: str
  687. response: Literal["happy", "sad", "neutral"]
  688. model = "gpt-4.1-nano-2025-04-14"
  689. async def _mock_parse(*args: Any, **kwargs: Any) -> ParsedChatCompletion[AgentResponse]:
  690. return ParsedChatCompletion(
  691. id="id1",
  692. choices=[
  693. ParsedChoice(
  694. finish_reason="tool_calls",
  695. index=0,
  696. message=ParsedChatCompletionMessage(
  697. content=json.dumps(
  698. {
  699. "thoughts": "The user explicitly states that they are happy without any indication of sadness or neutrality.",
  700. "response": "happy",
  701. }
  702. ),
  703. role="assistant",
  704. tool_calls=[
  705. ParsedFunctionToolCall(
  706. id="1",
  707. type="function",
  708. function=ParsedFunction(
  709. name="_pass_function",
  710. arguments=json.dumps({"input": "happy"}),
  711. ),
  712. )
  713. ],
  714. ),
  715. )
  716. ],
  717. created=0,
  718. model=model,
  719. object="chat.completion",
  720. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  721. )
  722. monkeypatch.setattr(AsyncCompletions, "parse", _mock_parse)
  723. model_client = OpenAIChatCompletionClient(
  724. model=model,
  725. api_key="",
  726. )
  727. # Test that the openai client was called with the correct response format.
  728. create_result = await model_client.create(
  729. messages=[UserMessage(content="I am happy.", source="user")], json_output=AgentResponse
  730. )
  731. assert isinstance(create_result.content, list)
  732. assert len(create_result.content) == 1
  733. assert create_result.content[0] == FunctionCall(
  734. id="1", name="_pass_function", arguments=json.dumps({"input": "happy"})
  735. )
  736. assert isinstance(create_result.thought, str)
  737. response = AgentResponse.model_validate(json.loads(create_result.thought))
  738. assert (
  739. response.thoughts
  740. == "The user explicitly states that they are happy without any indication of sadness or neutrality."
  741. )
  742. assert response.response == "happy"
  743. @pytest.mark.asyncio
  744. async def test_structured_output_with_streaming(monkeypatch: pytest.MonkeyPatch) -> None:
  745. class AgentResponse(BaseModel):
  746. thoughts: str
  747. response: Literal["happy", "sad", "neutral"]
  748. raw_content = json.dumps(
  749. {
  750. "thoughts": "The user explicitly states that they are happy without any indication of sadness or neutrality.",
  751. "response": "happy",
  752. }
  753. )
  754. chunked_content = [raw_content[i : i + 5] for i in range(0, len(raw_content), 5)]
  755. assert "".join(chunked_content) == raw_content
  756. model = "gpt-4.1-nano-2025-04-14"
  757. mock_chunk_events = [
  758. MockChunkEvent(
  759. type="chunk",
  760. chunk=ChatCompletionChunk(
  761. id="id",
  762. choices=[
  763. ChunkChoice(
  764. finish_reason=None,
  765. index=0,
  766. delta=ChoiceDelta(
  767. content=mock_chunk_content,
  768. role="assistant",
  769. ),
  770. )
  771. ],
  772. created=0,
  773. model=model,
  774. object="chat.completion.chunk",
  775. usage=None,
  776. ),
  777. )
  778. for mock_chunk_content in chunked_content
  779. ]
  780. async def _mock_create_stream(*args: Any) -> AsyncGenerator[MockChunkEvent, None]:
  781. async def _stream() -> AsyncGenerator[MockChunkEvent, None]:
  782. for mock_chunk_event in mock_chunk_events:
  783. await asyncio.sleep(0.1)
  784. yield mock_chunk_event
  785. return _stream()
  786. # Mock the context manager __aenter__ method which returns the stream.
  787. monkeypatch.setattr(AsyncChatCompletionStreamManager, "__aenter__", _mock_create_stream)
  788. model_client = OpenAIChatCompletionClient(
  789. model=model,
  790. api_key="",
  791. )
  792. # Test that the openai client was called with the correct response format.
  793. chunks: List[str | CreateResult] = []
  794. async for chunk in model_client.create_stream(
  795. messages=[UserMessage(content="I am happy.", source="user")], json_output=AgentResponse
  796. ):
  797. chunks.append(chunk)
  798. assert len(chunks) > 0
  799. assert isinstance(chunks[-1], CreateResult)
  800. assert isinstance(chunks[-1].content, str)
  801. response = AgentResponse.model_validate(json.loads(chunks[-1].content))
  802. assert (
  803. response.thoughts
  804. == "The user explicitly states that they are happy without any indication of sadness or neutrality."
  805. )
  806. assert response.response == "happy"
  807. @pytest.mark.asyncio
  808. async def test_structured_output_with_streaming_tool_calls(monkeypatch: pytest.MonkeyPatch) -> None:
  809. class AgentResponse(BaseModel):
  810. thoughts: str
  811. response: Literal["happy", "sad", "neutral"]
  812. raw_content = json.dumps(
  813. {
  814. "thoughts": "The user explicitly states that they are happy without any indication of sadness or neutrality.",
  815. "response": "happy",
  816. }
  817. )
  818. chunked_content = [raw_content[i : i + 5] for i in range(0, len(raw_content), 5)]
  819. assert "".join(chunked_content) == raw_content
  820. model = "gpt-4.1-nano-2025-04-14"
  821. # generate the list of mock chunk content
  822. mock_chunk_events = [
  823. MockChunkEvent(
  824. type="chunk",
  825. chunk=ChatCompletionChunk(
  826. id="id",
  827. choices=[
  828. ChunkChoice(
  829. finish_reason=None,
  830. index=0,
  831. delta=ChoiceDelta(
  832. content=mock_chunk_content,
  833. role="assistant",
  834. ),
  835. )
  836. ],
  837. created=0,
  838. model=model,
  839. object="chat.completion.chunk",
  840. usage=None,
  841. ),
  842. )
  843. for mock_chunk_content in chunked_content
  844. ]
  845. # add the tool call chunk.
  846. mock_chunk_events += [
  847. MockChunkEvent(
  848. type="chunk",
  849. chunk=ChatCompletionChunk(
  850. id="id",
  851. choices=[
  852. ChunkChoice(
  853. finish_reason="tool_calls",
  854. index=0,
  855. delta=ChoiceDelta(
  856. content=None,
  857. role="assistant",
  858. tool_calls=[
  859. ChoiceDeltaToolCall(
  860. id="1",
  861. index=0,
  862. type="function",
  863. function=ChoiceDeltaToolCallFunction(
  864. name="_pass_function",
  865. arguments=json.dumps({"input": "happy"}),
  866. ),
  867. )
  868. ],
  869. ),
  870. )
  871. ],
  872. created=0,
  873. model=model,
  874. object="chat.completion.chunk",
  875. usage=None,
  876. ),
  877. )
  878. ]
  879. async def _mock_create_stream(*args: Any) -> AsyncGenerator[MockChunkEvent, None]:
  880. async def _stream() -> AsyncGenerator[MockChunkEvent, None]:
  881. for mock_chunk_event in mock_chunk_events:
  882. await asyncio.sleep(0.1)
  883. yield mock_chunk_event
  884. return _stream()
  885. # Mock the context manager __aenter__ method which returns the stream.
  886. monkeypatch.setattr(AsyncChatCompletionStreamManager, "__aenter__", _mock_create_stream)
  887. model_client = OpenAIChatCompletionClient(
  888. model=model,
  889. api_key="",
  890. )
  891. # Test that the openai client was called with the correct response format.
  892. chunks: List[str | CreateResult] = []
  893. async for chunk in model_client.create_stream(
  894. messages=[UserMessage(content="I am happy.", source="user")], json_output=AgentResponse
  895. ):
  896. chunks.append(chunk)
  897. assert len(chunks) > 0
  898. assert isinstance(chunks[-1], CreateResult)
  899. assert isinstance(chunks[-1].content, list)
  900. assert len(chunks[-1].content) == 1
  901. assert chunks[-1].content[0] == FunctionCall(
  902. id="1", name="_pass_function", arguments=json.dumps({"input": "happy"})
  903. )
  904. assert isinstance(chunks[-1].thought, str)
  905. response = AgentResponse.model_validate(json.loads(chunks[-1].thought))
  906. assert (
  907. response.thoughts
  908. == "The user explicitly states that they are happy without any indication of sadness or neutrality."
  909. )
  910. assert response.response == "happy"
  911. @pytest.mark.asyncio
  912. async def test_r1_reasoning_content(monkeypatch: pytest.MonkeyPatch) -> None:
  913. """Test handling of reasoning_content in R1 model. Testing create without streaming."""
  914. async def _mock_create(*args: Any, **kwargs: Any) -> ChatCompletion:
  915. return ChatCompletion(
  916. id="test_id",
  917. model="r1",
  918. object="chat.completion",
  919. created=1234567890,
  920. choices=[
  921. Choice(
  922. index=0,
  923. message=ChatCompletionMessage(
  924. role="assistant",
  925. content="This is the main content",
  926. # The reasoning content is included in model_extra for hosted R1 models.
  927. reasoning_content="This is the reasoning content", # type: ignore
  928. ),
  929. finish_reason="stop",
  930. )
  931. ],
  932. usage=CompletionUsage(
  933. prompt_tokens=10,
  934. completion_tokens=10,
  935. total_tokens=20,
  936. ),
  937. )
  938. # Patch the client creation
  939. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  940. # Create the client
  941. model_client = OpenAIChatCompletionClient(
  942. model="r1",
  943. api_key="",
  944. model_info={
  945. "family": ModelFamily.R1,
  946. "vision": False,
  947. "function_calling": False,
  948. "json_output": False,
  949. "structured_output": False,
  950. },
  951. )
  952. # Test the create method
  953. result = await model_client.create([UserMessage(content="Test message", source="user")])
  954. # Verify that the content and thought are as expected
  955. assert result.content == "This is the main content"
  956. assert result.thought == "This is the reasoning content"
  957. @pytest.mark.asyncio
  958. async def test_r1_reasoning_content_streaming(monkeypatch: pytest.MonkeyPatch) -> None:
  959. """Test that reasoning_content in model_extra is correctly extracted and streamed."""
  960. async def _mock_create_stream(*args: Any, **kwargs: Any) -> AsyncGenerator[ChatCompletionChunk, None]:
  961. contentChunks = [None, None, "This is the main content"]
  962. reasoningChunks = ["This is the reasoning content 1", "This is the reasoning content 2", None]
  963. for i in range(len(contentChunks)):
  964. await asyncio.sleep(0.1)
  965. yield ChatCompletionChunk(
  966. id="id",
  967. choices=[
  968. ChunkChoice(
  969. finish_reason="stop" if i == len(contentChunks) - 1 else None,
  970. index=0,
  971. delta=ChoiceDelta(
  972. content=contentChunks[i],
  973. # The reasoning content is included in model_extra for hosted R1 models.
  974. reasoning_content=reasoningChunks[i], # type: ignore
  975. role="assistant",
  976. ),
  977. ),
  978. ],
  979. created=0,
  980. model="r1",
  981. object="chat.completion.chunk",
  982. usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
  983. )
  984. async def _mock_create(*args: Any, **kwargs: Any) -> AsyncGenerator[ChatCompletionChunk, None]:
  985. return _mock_create_stream(*args, **kwargs)
  986. # Patch the client creation
  987. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  988. # Create the client
  989. model_client = OpenAIChatCompletionClient(
  990. model="r1",
  991. api_key="",
  992. model_info={
  993. "family": ModelFamily.R1,
  994. "vision": False,
  995. "function_calling": False,
  996. "json_output": False,
  997. "structured_output": False,
  998. },
  999. )
  1000. # Test the create_stream method
  1001. chunks: List[str | CreateResult] = []
  1002. async for chunk in model_client.create_stream(messages=[UserMessage(content="Hello", source="user")]):
  1003. chunks.append(chunk)
  1004. # Verify that the chunks first stream the reasoning content and then the main content
  1005. # Then verify that the final result has the correct content and thought
  1006. assert len(chunks) == 5
  1007. assert chunks[0] == "<think>This is the reasoning content 1"
  1008. assert chunks[1] == "This is the reasoning content 2"
  1009. assert chunks[2] == "</think>"
  1010. assert chunks[3] == "This is the main content"
  1011. assert isinstance(chunks[4], CreateResult)
  1012. assert chunks[4].content == "This is the main content"
  1013. assert chunks[4].thought == "This is the reasoning content 1This is the reasoning content 2"
  1014. @pytest.mark.asyncio
  1015. async def test_r1_think_field(monkeypatch: pytest.MonkeyPatch) -> None:
  1016. async def _mock_create_stream(*args: Any, **kwargs: Any) -> AsyncGenerator[ChatCompletionChunk, None]:
  1017. chunks = ["<think> Hello</think>", " Another Hello", " Yet Another Hello"]
  1018. for i, chunk in enumerate(chunks):
  1019. await asyncio.sleep(0.1)
  1020. yield ChatCompletionChunk(
  1021. id="id",
  1022. choices=[
  1023. ChunkChoice(
  1024. finish_reason="stop" if i == len(chunks) - 1 else None,
  1025. index=0,
  1026. delta=ChoiceDelta(
  1027. content=chunk,
  1028. role="assistant",
  1029. ),
  1030. ),
  1031. ],
  1032. created=0,
  1033. model="r1",
  1034. object="chat.completion.chunk",
  1035. usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
  1036. )
  1037. async def _mock_create(*args: Any, **kwargs: Any) -> ChatCompletion | AsyncGenerator[ChatCompletionChunk, None]:
  1038. stream = kwargs.get("stream", False)
  1039. if not stream:
  1040. await asyncio.sleep(0.1)
  1041. return ChatCompletion(
  1042. id="id",
  1043. choices=[
  1044. Choice(
  1045. finish_reason="stop",
  1046. index=0,
  1047. message=ChatCompletionMessage(
  1048. content="<think> Hello</think> Another Hello Yet Another Hello", role="assistant"
  1049. ),
  1050. )
  1051. ],
  1052. created=0,
  1053. model="r1",
  1054. object="chat.completion",
  1055. usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
  1056. )
  1057. else:
  1058. return _mock_create_stream(*args, **kwargs)
  1059. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  1060. model_client = OpenAIChatCompletionClient(
  1061. model="r1",
  1062. api_key="",
  1063. model_info={
  1064. "family": ModelFamily.R1,
  1065. "vision": False,
  1066. "function_calling": False,
  1067. "json_output": False,
  1068. "structured_output": False,
  1069. },
  1070. )
  1071. # Successful completion with think field.
  1072. create_result = await model_client.create(messages=[UserMessage(content="I am happy.", source="user")])
  1073. assert create_result.content == "Another Hello Yet Another Hello"
  1074. assert create_result.finish_reason == "stop"
  1075. assert not create_result.cached
  1076. assert create_result.thought == "Hello"
  1077. # Stream completion with think field.
  1078. chunks: List[str | CreateResult] = []
  1079. async for chunk in model_client.create_stream(messages=[UserMessage(content="Hello", source="user")]):
  1080. chunks.append(chunk)
  1081. assert len(chunks) > 0
  1082. assert isinstance(chunks[-1], CreateResult)
  1083. assert chunks[-1].content == "Another Hello Yet Another Hello"
  1084. assert chunks[-1].thought == "Hello"
  1085. assert not chunks[-1].cached
  1086. @pytest.mark.asyncio
  1087. async def test_r1_think_field_not_present(monkeypatch: pytest.MonkeyPatch) -> None:
  1088. async def _mock_create_stream(*args: Any, **kwargs: Any) -> AsyncGenerator[ChatCompletionChunk, None]:
  1089. chunks = ["Hello", " Another Hello", " Yet Another Hello"]
  1090. for i, chunk in enumerate(chunks):
  1091. await asyncio.sleep(0.1)
  1092. yield ChatCompletionChunk(
  1093. id="id",
  1094. choices=[
  1095. ChunkChoice(
  1096. finish_reason="stop" if i == len(chunks) - 1 else None,
  1097. index=0,
  1098. delta=ChoiceDelta(
  1099. content=chunk,
  1100. role="assistant",
  1101. ),
  1102. ),
  1103. ],
  1104. created=0,
  1105. model="r1",
  1106. object="chat.completion.chunk",
  1107. usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
  1108. )
  1109. async def _mock_create(*args: Any, **kwargs: Any) -> ChatCompletion | AsyncGenerator[ChatCompletionChunk, None]:
  1110. stream = kwargs.get("stream", False)
  1111. if not stream:
  1112. await asyncio.sleep(0.1)
  1113. return ChatCompletion(
  1114. id="id",
  1115. choices=[
  1116. Choice(
  1117. finish_reason="stop",
  1118. index=0,
  1119. message=ChatCompletionMessage(
  1120. content="Hello Another Hello Yet Another Hello", role="assistant"
  1121. ),
  1122. )
  1123. ],
  1124. created=0,
  1125. model="r1",
  1126. object="chat.completion",
  1127. usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
  1128. )
  1129. else:
  1130. return _mock_create_stream(*args, **kwargs)
  1131. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  1132. model_client = OpenAIChatCompletionClient(
  1133. model="r1",
  1134. api_key="",
  1135. model_info={
  1136. "family": ModelFamily.R1,
  1137. "vision": False,
  1138. "function_calling": False,
  1139. "json_output": False,
  1140. "structured_output": False,
  1141. },
  1142. )
  1143. # Warning completion when think field is not present.
  1144. with pytest.warns(UserWarning, match="Could not find <think>..</think> field in model response content."):
  1145. create_result = await model_client.create(messages=[UserMessage(content="I am happy.", source="user")])
  1146. assert create_result.content == "Hello Another Hello Yet Another Hello"
  1147. assert create_result.finish_reason == "stop"
  1148. assert not create_result.cached
  1149. assert create_result.thought is None
  1150. # Stream completion with think field.
  1151. with pytest.warns(UserWarning, match="Could not find <think>..</think> field in model response content."):
  1152. chunks: List[str | CreateResult] = []
  1153. async for chunk in model_client.create_stream(messages=[UserMessage(content="Hello", source="user")]):
  1154. chunks.append(chunk)
  1155. assert len(chunks) > 0
  1156. assert isinstance(chunks[-1], CreateResult)
  1157. assert chunks[-1].content == "Hello Another Hello Yet Another Hello"
  1158. assert chunks[-1].thought is None
  1159. assert not chunks[-1].cached
  1160. @pytest.mark.asyncio
  1161. async def test_tool_calling(monkeypatch: pytest.MonkeyPatch) -> None:
  1162. model = "gpt-4.1-nano-2025-04-14"
  1163. chat_completions = [
  1164. # Successful completion, single tool call
  1165. ChatCompletion(
  1166. id="id1",
  1167. choices=[
  1168. Choice(
  1169. finish_reason="tool_calls",
  1170. index=0,
  1171. message=ChatCompletionMessage(
  1172. content=None,
  1173. tool_calls=[
  1174. ChatCompletionMessageToolCall(
  1175. id="1",
  1176. type="function",
  1177. function=Function(
  1178. name="_pass_function",
  1179. arguments=json.dumps({"input": "task"}),
  1180. ),
  1181. )
  1182. ],
  1183. role="assistant",
  1184. ),
  1185. )
  1186. ],
  1187. created=0,
  1188. model=model,
  1189. object="chat.completion",
  1190. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  1191. ),
  1192. # Successful completion, parallel tool calls
  1193. ChatCompletion(
  1194. id="id2",
  1195. choices=[
  1196. Choice(
  1197. finish_reason="tool_calls",
  1198. index=0,
  1199. message=ChatCompletionMessage(
  1200. content=None,
  1201. tool_calls=[
  1202. ChatCompletionMessageToolCall(
  1203. id="1",
  1204. type="function",
  1205. function=Function(
  1206. name="_pass_function",
  1207. arguments=json.dumps({"input": "task"}),
  1208. ),
  1209. ),
  1210. ChatCompletionMessageToolCall(
  1211. id="2",
  1212. type="function",
  1213. function=Function(
  1214. name="_fail_function",
  1215. arguments=json.dumps({"input": "task"}),
  1216. ),
  1217. ),
  1218. ChatCompletionMessageToolCall(
  1219. id="3",
  1220. type="function",
  1221. function=Function(
  1222. name="_echo_function",
  1223. arguments=json.dumps({"input": "task"}),
  1224. ),
  1225. ),
  1226. ],
  1227. role="assistant",
  1228. ),
  1229. )
  1230. ],
  1231. created=0,
  1232. model=model,
  1233. object="chat.completion",
  1234. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  1235. ),
  1236. # Warning completion when finish reason is not tool_calls.
  1237. ChatCompletion(
  1238. id="id3",
  1239. choices=[
  1240. Choice(
  1241. finish_reason="stop",
  1242. index=0,
  1243. message=ChatCompletionMessage(
  1244. content=None,
  1245. tool_calls=[
  1246. ChatCompletionMessageToolCall(
  1247. id="1",
  1248. type="function",
  1249. function=Function(
  1250. name="_pass_function",
  1251. arguments=json.dumps({"input": "task"}),
  1252. ),
  1253. )
  1254. ],
  1255. role="assistant",
  1256. ),
  1257. )
  1258. ],
  1259. created=0,
  1260. model=model,
  1261. object="chat.completion",
  1262. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  1263. ),
  1264. # Thought field is populated when content is not None.
  1265. ChatCompletion(
  1266. id="id4",
  1267. choices=[
  1268. Choice(
  1269. finish_reason="tool_calls",
  1270. index=0,
  1271. message=ChatCompletionMessage(
  1272. content="I should make a tool call.",
  1273. tool_calls=[
  1274. ChatCompletionMessageToolCall(
  1275. id="1",
  1276. type="function",
  1277. function=Function(
  1278. name="_pass_function",
  1279. arguments=json.dumps({"input": "task"}),
  1280. ),
  1281. )
  1282. ],
  1283. role="assistant",
  1284. ),
  1285. )
  1286. ],
  1287. created=0,
  1288. model=model,
  1289. object="chat.completion",
  1290. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  1291. ),
  1292. # Should not be returning tool calls when the tool_calls are empty
  1293. ChatCompletion(
  1294. id="id5",
  1295. choices=[
  1296. Choice(
  1297. finish_reason="stop",
  1298. index=0,
  1299. message=ChatCompletionMessage(
  1300. content="I should make a tool call.",
  1301. tool_calls=[],
  1302. role="assistant",
  1303. ),
  1304. )
  1305. ],
  1306. created=0,
  1307. model=model,
  1308. object="chat.completion",
  1309. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  1310. ),
  1311. # Should raise warning when function arguments is not a string.
  1312. ChatCompletion(
  1313. id="id6",
  1314. choices=[
  1315. Choice(
  1316. finish_reason="tool_calls",
  1317. index=0,
  1318. message=ChatCompletionMessage(
  1319. content=None,
  1320. tool_calls=[
  1321. ChatCompletionMessageToolCall(
  1322. id="1",
  1323. type="function",
  1324. function=Function.construct(name="_pass_function", arguments={"input": "task"}), # type: ignore
  1325. )
  1326. ],
  1327. role="assistant",
  1328. ),
  1329. )
  1330. ],
  1331. created=0,
  1332. model=model,
  1333. object="chat.completion",
  1334. usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=0),
  1335. ),
  1336. ]
  1337. class _MockChatCompletion:
  1338. def __init__(self, completions: List[ChatCompletion]):
  1339. self.completions = list(completions)
  1340. self.calls: List[Dict[str, Any]] = []
  1341. async def mock_create(
  1342. self, *args: Any, **kwargs: Any
  1343. ) -> ChatCompletion | AsyncGenerator[ChatCompletionChunk, None]:
  1344. if kwargs.get("stream", False):
  1345. raise NotImplementedError("Streaming not supported in this test.")
  1346. self.calls.append(kwargs)
  1347. return self.completions.pop(0)
  1348. mock = _MockChatCompletion(chat_completions)
  1349. monkeypatch.setattr(AsyncCompletions, "create", mock.mock_create)
  1350. pass_tool = FunctionTool(_pass_function, description="pass tool.")
  1351. fail_tool = FunctionTool(_fail_function, description="fail tool.")
  1352. echo_tool = FunctionTool(_echo_function, description="echo tool.")
  1353. model_client = OpenAIChatCompletionClient(model=model, api_key="")
  1354. # Single tool call
  1355. create_result = await model_client.create(messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool])
  1356. assert create_result.content == [FunctionCall(id="1", arguments=r'{"input": "task"}', name="_pass_function")]
  1357. # Verify that the tool schema was passed to the model client.
  1358. kwargs = mock.calls[0]
  1359. assert kwargs["tools"] == [{"function": pass_tool.schema, "type": "function"}]
  1360. # Verify finish reason
  1361. assert create_result.finish_reason == "function_calls"
  1362. # Parallel tool calls
  1363. create_result = await model_client.create(
  1364. messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool, fail_tool, echo_tool]
  1365. )
  1366. assert create_result.content == [
  1367. FunctionCall(id="1", arguments=r'{"input": "task"}', name="_pass_function"),
  1368. FunctionCall(id="2", arguments=r'{"input": "task"}', name="_fail_function"),
  1369. FunctionCall(id="3", arguments=r'{"input": "task"}', name="_echo_function"),
  1370. ]
  1371. # Verify that the tool schema was passed to the model client.
  1372. kwargs = mock.calls[1]
  1373. assert kwargs["tools"] == [
  1374. {"function": pass_tool.schema, "type": "function"},
  1375. {"function": fail_tool.schema, "type": "function"},
  1376. {"function": echo_tool.schema, "type": "function"},
  1377. ]
  1378. # Verify finish reason
  1379. assert create_result.finish_reason == "function_calls"
  1380. # Warning completion when finish reason is not tool_calls.
  1381. with pytest.warns(UserWarning, match="Finish reason mismatch"):
  1382. create_result = await model_client.create(
  1383. messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool]
  1384. )
  1385. assert create_result.content == [FunctionCall(id="1", arguments=r'{"input": "task"}', name="_pass_function")]
  1386. assert create_result.finish_reason == "function_calls"
  1387. # Thought field is populated when content is not None.
  1388. create_result = await model_client.create(messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool])
  1389. assert create_result.content == [FunctionCall(id="1", arguments=r'{"input": "task"}', name="_pass_function")]
  1390. assert create_result.finish_reason == "function_calls"
  1391. assert create_result.thought == "I should make a tool call."
  1392. # Should not be returning tool calls when the tool_calls are empty
  1393. create_result = await model_client.create(messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool])
  1394. assert create_result.content == "I should make a tool call."
  1395. assert create_result.finish_reason == "stop"
  1396. # Should raise warning when function arguments is not a string.
  1397. with pytest.warns(UserWarning, match="Tool call function arguments field is not a string"):
  1398. create_result = await model_client.create(
  1399. messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool]
  1400. )
  1401. assert create_result.content == [FunctionCall(id="1", arguments=r'{"input": "task"}', name="_pass_function")]
  1402. assert create_result.finish_reason == "function_calls"
  1403. @pytest.mark.asyncio
  1404. async def test_tool_calling_with_stream(monkeypatch: pytest.MonkeyPatch) -> None:
  1405. async def _mock_create_stream(*args: Any, **kwargs: Any) -> AsyncGenerator[ChatCompletionChunk, None]:
  1406. model = resolve_model(kwargs.get("model", "gpt-4o"))
  1407. mock_chunks_content = ["Hello", " Another Hello", " Yet Another Hello"]
  1408. mock_chunks = [
  1409. # generate the list of mock chunk content
  1410. MockChunkDefinition(
  1411. chunk_choice=ChunkChoice(
  1412. finish_reason=None,
  1413. index=0,
  1414. delta=ChoiceDelta(
  1415. content=mock_chunk_content,
  1416. role="assistant",
  1417. ),
  1418. ),
  1419. usage=None,
  1420. )
  1421. for mock_chunk_content in mock_chunks_content
  1422. ] + [
  1423. # generate the function call chunk
  1424. MockChunkDefinition(
  1425. chunk_choice=ChunkChoice(
  1426. finish_reason="tool_calls",
  1427. index=0,
  1428. delta=ChoiceDelta(
  1429. content=None,
  1430. role="assistant",
  1431. tool_calls=[
  1432. ChoiceDeltaToolCall(
  1433. index=0,
  1434. id="1",
  1435. type="function",
  1436. function=ChoiceDeltaToolCallFunction(
  1437. name="_pass_function",
  1438. arguments=json.dumps({"input": "task"}),
  1439. ),
  1440. )
  1441. ],
  1442. ),
  1443. ),
  1444. usage=None,
  1445. )
  1446. ]
  1447. for mock_chunk in mock_chunks:
  1448. await asyncio.sleep(0.1)
  1449. yield ChatCompletionChunk(
  1450. id="id",
  1451. choices=[mock_chunk.chunk_choice],
  1452. created=0,
  1453. model=model,
  1454. object="chat.completion.chunk",
  1455. usage=mock_chunk.usage,
  1456. )
  1457. async def _mock_create(*args: Any, **kwargs: Any) -> ChatCompletion | AsyncGenerator[ChatCompletionChunk, None]:
  1458. stream = kwargs.get("stream", False)
  1459. if not stream:
  1460. raise ValueError("Stream is not False")
  1461. else:
  1462. return _mock_create_stream(*args, **kwargs)
  1463. monkeypatch.setattr(AsyncCompletions, "create", _mock_create)
  1464. model_client = OpenAIChatCompletionClient(model="gpt-4o", api_key="")
  1465. pass_tool = FunctionTool(_pass_function, description="pass tool.")
  1466. stream = model_client.create_stream(messages=[UserMessage(content="Hello", source="user")], tools=[pass_tool])
  1467. chunks: List[str | CreateResult] = []
  1468. async for chunk in stream:
  1469. chunks.append(chunk)
  1470. assert chunks[0] == "Hello"
  1471. assert chunks[1] == " Another Hello"
  1472. assert chunks[2] == " Yet Another Hello"
  1473. assert isinstance(chunks[-1], CreateResult)
  1474. assert chunks[-1].content == [FunctionCall(id="1", arguments=r'{"input": "task"}', name="_pass_function")]
  1475. assert chunks[-1].finish_reason == "function_calls"
  1476. assert chunks[-1].thought == "Hello Another Hello Yet Another Hello"
  1477. @pytest.mark.asyncio
  1478. async def test_tool_calls_assistant_message_content_field(monkeypatch: pytest.MonkeyPatch) -> None:
  1479. """Test that AssistantMessage with tool calls includes required content field.
  1480. This test addresses the issue where AssistantMessage with tool calls but no thought
  1481. was missing the required 'content' field, causing OpenAI API UnprocessableEntityError(422).
  1482. """
  1483. # Create a tool call for testing
  1484. tool_calls = [
  1485. FunctionCall(id="call_1", name="increment_number", arguments='{"number": 5}'),
  1486. FunctionCall(id="call_2", name="increment_number", arguments='{"number": 6}'),
  1487. ]
  1488. # Mock response for tool calls
  1489. chat_completion = ChatCompletion(
  1490. id="id1",
  1491. choices=[
  1492. Choice(
  1493. finish_reason="stop",
  1494. index=0,
  1495. message=ChatCompletionMessage(
  1496. role="assistant",
  1497. content="Done",
  1498. ),
  1499. )
  1500. ],
  1501. created=1234567890,
  1502. model="gpt-4o",
  1503. object="chat.completion",
  1504. usage=CompletionUsage(completion_tokens=10, prompt_tokens=5, total_tokens=15),
  1505. )
  1506. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="test")
  1507. mock_create = AsyncMock(return_value=chat_completion)
  1508. # Test AssistantMessage with tool calls but no thought
  1509. assistant_message_no_thought = AssistantMessage(
  1510. content=tool_calls,
  1511. source="assistant",
  1512. thought=None, # No thought - this was causing the issue
  1513. )
  1514. with monkeypatch.context() as mp:
  1515. mp.setattr(client._client.chat.completions, "create", mock_create) # type: ignore[reportPrivateUsage]
  1516. await client.create(
  1517. messages=[
  1518. UserMessage(content="Please increment these numbers", source="user"),
  1519. assistant_message_no_thought,
  1520. ]
  1521. )
  1522. # Verify the API was called and check the messages sent
  1523. mock_create.assert_called_once()
  1524. call_args = mock_create.call_args
  1525. # Extract the messages from the API call
  1526. messages = call_args.kwargs["messages"]
  1527. # Find the assistant message in the API call
  1528. assistant_messages = [msg for msg in messages if msg["role"] == "assistant"]
  1529. assert len(assistant_messages) == 1
  1530. assistant_msg = assistant_messages[0]
  1531. # Verify all required fields are present
  1532. assert "role" in assistant_msg
  1533. assert "tool_calls" in assistant_msg
  1534. assert "content" in assistant_msg # This was missing before the fix
  1535. # Verify field values
  1536. assert assistant_msg["role"] == "assistant"
  1537. assert assistant_msg["content"] is None # Should be null for tools without thought
  1538. assert len(assistant_msg["tool_calls"]) == 2
  1539. # Test AssistantMessage with tool calls AND thought
  1540. assistant_message_with_thought = AssistantMessage(
  1541. content=tool_calls, source="assistant", thought="I need to increment these numbers."
  1542. )
  1543. mock_create.reset_mock() # Reset for second test
  1544. with monkeypatch.context() as mp:
  1545. mp.setattr(client._client.chat.completions, "create", mock_create) # type: ignore[reportPrivateUsage]
  1546. await client.create(
  1547. messages=[
  1548. UserMessage(content="Please increment these numbers", source="user"),
  1549. assistant_message_with_thought,
  1550. ]
  1551. )
  1552. # Verify the API was called for the second test
  1553. mock_create.assert_called_once()
  1554. call_args = mock_create.call_args
  1555. # Extract the messages from the API call
  1556. messages = call_args.kwargs["messages"]
  1557. # Find the assistant message in the API call
  1558. assistant_messages = [msg for msg in messages if msg["role"] == "assistant"]
  1559. assert len(assistant_messages) == 1
  1560. assistant_msg_with_thought = assistant_messages[0]
  1561. # Should have both tool_calls and content with thought text
  1562. assert "role" in assistant_msg_with_thought
  1563. assert "tool_calls" in assistant_msg_with_thought
  1564. assert "content" in assistant_msg_with_thought
  1565. assert assistant_msg_with_thought["role"] == "assistant"
  1566. assert assistant_msg_with_thought["content"] == "I need to increment these numbers."
  1567. assert len(assistant_msg_with_thought["tool_calls"]) == 2
  1568. @pytest.fixture()
  1569. def openai_client(request: pytest.FixtureRequest) -> OpenAIChatCompletionClient:
  1570. model = request.node.callspec.params["model"] # type: ignore
  1571. assert isinstance(model, str)
  1572. if model.startswith("gemini"):
  1573. api_key = os.getenv("GEMINI_API_KEY")
  1574. if not api_key:
  1575. pytest.skip("GEMINI_API_KEY not found in environment variables")
  1576. elif model.startswith("claude"):
  1577. api_key = os.getenv("ANTHROPIC_API_KEY")
  1578. if not api_key:
  1579. pytest.skip("ANTHROPIC_API_KEY not found in environment variables")
  1580. else:
  1581. api_key = os.getenv("OPENAI_API_KEY")
  1582. if not api_key:
  1583. pytest.skip("OPENAI_API_KEY not found in environment variables")
  1584. model_client = OpenAIChatCompletionClient(
  1585. model=model,
  1586. api_key=api_key,
  1587. )
  1588. return model_client
  1589. @pytest.mark.asyncio
  1590. @pytest.mark.parametrize(
  1591. "model",
  1592. ["gpt-4.1-nano", "gemini-1.5-flash", "claude-3-5-haiku-20241022"],
  1593. )
  1594. async def test_model_client_basic_completion(model: str, openai_client: OpenAIChatCompletionClient) -> None:
  1595. # Test basic completion
  1596. create_result = await openai_client.create(
  1597. messages=[
  1598. SystemMessage(content="You are a helpful assistant."),
  1599. UserMessage(content="Explain to me how AI works.", source="user"),
  1600. ]
  1601. )
  1602. assert isinstance(create_result.content, str)
  1603. assert len(create_result.content) > 0
  1604. @pytest.mark.asyncio
  1605. @pytest.mark.parametrize(
  1606. "model",
  1607. ["gpt-4.1-nano", "gemini-1.5-flash", "claude-3-5-haiku-20241022"],
  1608. )
  1609. async def test_model_client_with_function_calling(model: str, openai_client: OpenAIChatCompletionClient) -> None:
  1610. # Test tool calling
  1611. pass_tool = FunctionTool(_pass_function, name="pass_tool", description="pass session.")
  1612. fail_tool = FunctionTool(_fail_function, name="fail_tool", description="fail session.")
  1613. messages: List[LLMMessage] = [
  1614. UserMessage(content="Call the pass tool with input 'task' summarize the result.", source="user")
  1615. ]
  1616. create_result = await openai_client.create(messages=messages, tools=[pass_tool, fail_tool])
  1617. assert isinstance(create_result.content, list)
  1618. assert len(create_result.content) == 1
  1619. assert isinstance(create_result.content[0], FunctionCall)
  1620. assert create_result.content[0].name == "pass_tool"
  1621. assert json.loads(create_result.content[0].arguments) == {"input": "task"}
  1622. assert create_result.finish_reason == "function_calls"
  1623. assert create_result.usage is not None
  1624. # Test reflection on tool call response.
  1625. messages.append(AssistantMessage(content=create_result.content, source="assistant"))
  1626. messages.append(
  1627. FunctionExecutionResultMessage(
  1628. content=[
  1629. FunctionExecutionResult(
  1630. content="passed",
  1631. call_id=create_result.content[0].id,
  1632. is_error=False,
  1633. name=create_result.content[0].name,
  1634. )
  1635. ]
  1636. )
  1637. )
  1638. create_result = await openai_client.create(messages=messages)
  1639. assert isinstance(create_result.content, str)
  1640. assert len(create_result.content) > 0
  1641. # Test parallel tool calling
  1642. messages = [
  1643. UserMessage(
  1644. content="Call both the pass tool with input 'task' and the fail tool also with input 'task' and summarize the result",
  1645. source="user",
  1646. )
  1647. ]
  1648. create_result = await openai_client.create(messages=messages, tools=[pass_tool, fail_tool])
  1649. assert isinstance(create_result.content, list)
  1650. assert len(create_result.content) == 2
  1651. assert isinstance(create_result.content[0], FunctionCall)
  1652. assert create_result.content[0].name == "pass_tool"
  1653. assert json.loads(create_result.content[0].arguments) == {"input": "task"}
  1654. assert isinstance(create_result.content[1], FunctionCall)
  1655. assert create_result.content[1].name == "fail_tool"
  1656. assert json.loads(create_result.content[1].arguments) == {"input": "task"}
  1657. assert create_result.finish_reason == "function_calls"
  1658. assert create_result.usage is not None
  1659. # Test reflection on parallel tool call response.
  1660. messages.append(AssistantMessage(content=create_result.content, source="assistant"))
  1661. messages.append(
  1662. FunctionExecutionResultMessage(
  1663. content=[
  1664. FunctionExecutionResult(
  1665. content="passed", call_id=create_result.content[0].id, is_error=False, name="pass_tool"
  1666. ),
  1667. FunctionExecutionResult(
  1668. content="failed", call_id=create_result.content[1].id, is_error=True, name="fail_tool"
  1669. ),
  1670. ]
  1671. )
  1672. )
  1673. create_result = await openai_client.create(messages=messages)
  1674. assert isinstance(create_result.content, str)
  1675. assert len(create_result.content) > 0
  1676. @pytest.mark.asyncio
  1677. @pytest.mark.parametrize(
  1678. "model",
  1679. ["gpt-4.1-nano", "gemini-1.5-flash"],
  1680. )
  1681. async def test_openai_structured_output_using_response_format(
  1682. model: str, openai_client: OpenAIChatCompletionClient
  1683. ) -> None:
  1684. class AgentResponse(BaseModel):
  1685. thoughts: str
  1686. response: Literal["happy", "sad", "neutral"]
  1687. create_result = await openai_client.create(
  1688. messages=[UserMessage(content="I am happy.", source="user")],
  1689. extra_create_args={
  1690. "response_format": {
  1691. "type": "json_schema",
  1692. "json_schema": {
  1693. "name": "AgentResponse",
  1694. "description": "Agent response",
  1695. "schema": AgentResponse.model_json_schema(),
  1696. },
  1697. }
  1698. },
  1699. )
  1700. assert isinstance(create_result.content, str)
  1701. assert len(create_result.content) > 0
  1702. response = AgentResponse.model_validate(json.loads(create_result.content))
  1703. assert response.thoughts
  1704. assert response.response in ["happy", "sad", "neutral"]
  1705. @pytest.mark.asyncio
  1706. @pytest.mark.parametrize(
  1707. "model",
  1708. ["gpt-4.1-nano", "gemini-1.5-flash"],
  1709. )
  1710. async def test_openai_structured_output(model: str, openai_client: OpenAIChatCompletionClient) -> None:
  1711. class AgentResponse(BaseModel):
  1712. thoughts: str
  1713. response: Literal["happy", "sad", "neutral"]
  1714. # Test that the openai client was called with the correct response format.
  1715. create_result = await openai_client.create(
  1716. messages=[UserMessage(content="I am happy.", source="user")], json_output=AgentResponse
  1717. )
  1718. assert isinstance(create_result.content, str)
  1719. response = AgentResponse.model_validate(json.loads(create_result.content))
  1720. assert response.thoughts
  1721. assert response.response in ["happy", "sad", "neutral"]
  1722. @pytest.mark.asyncio
  1723. @pytest.mark.parametrize(
  1724. "model",
  1725. ["gpt-4.1-nano", "gemini-1.5-flash"],
  1726. )
  1727. async def test_openai_structured_output_with_streaming(model: str, openai_client: OpenAIChatCompletionClient) -> None:
  1728. class AgentResponse(BaseModel):
  1729. thoughts: str
  1730. response: Literal["happy", "sad", "neutral"]
  1731. # Test that the openai client was called with the correct response format.
  1732. stream = openai_client.create_stream(
  1733. messages=[UserMessage(content="I am happy.", source="user")], json_output=AgentResponse
  1734. )
  1735. chunks: List[str | CreateResult] = []
  1736. async for chunk in stream:
  1737. chunks.append(chunk)
  1738. assert len(chunks) > 0
  1739. assert isinstance(chunks[-1], CreateResult)
  1740. assert isinstance(chunks[-1].content, str)
  1741. response = AgentResponse.model_validate(json.loads(chunks[-1].content))
  1742. assert response.thoughts
  1743. assert response.response in ["happy", "sad", "neutral"]
  1744. @pytest.mark.asyncio
  1745. @pytest.mark.parametrize(
  1746. "model",
  1747. [
  1748. "gpt-4.1-nano",
  1749. # "gemini-1.5-flash", # Gemini models do not support structured output with tool calls from model client.
  1750. ],
  1751. )
  1752. async def test_openai_structured_output_with_tool_calls(model: str, openai_client: OpenAIChatCompletionClient) -> None:
  1753. class AgentResponse(BaseModel):
  1754. thoughts: str
  1755. response: Literal["happy", "sad", "neutral"]
  1756. def sentiment_analysis(text: str) -> str:
  1757. """Given a text, return the sentiment."""
  1758. return "happy" if "happy" in text else "sad" if "sad" in text else "neutral"
  1759. tool = FunctionTool(sentiment_analysis, description="Sentiment Analysis", strict=True)
  1760. extra_create_args = {"tool_choice": "required"}
  1761. response1 = await openai_client.create(
  1762. messages=[
  1763. SystemMessage(content="Analyze input text sentiment using the tool provided."),
  1764. UserMessage(content="I am happy.", source="user"),
  1765. ],
  1766. tools=[tool],
  1767. extra_create_args=extra_create_args,
  1768. json_output=AgentResponse,
  1769. )
  1770. assert isinstance(response1.content, list)
  1771. assert len(response1.content) == 1
  1772. assert isinstance(response1.content[0], FunctionCall)
  1773. assert response1.content[0].name == "sentiment_analysis"
  1774. assert json.loads(response1.content[0].arguments) == {"text": "I am happy."}
  1775. assert response1.finish_reason == "function_calls"
  1776. response2 = await openai_client.create(
  1777. messages=[
  1778. SystemMessage(content="Analyze input text sentiment using the tool provided."),
  1779. UserMessage(content="I am happy.", source="user"),
  1780. AssistantMessage(content=response1.content, source="assistant"),
  1781. FunctionExecutionResultMessage(
  1782. content=[
  1783. FunctionExecutionResult(
  1784. content="happy", call_id=response1.content[0].id, is_error=False, name=tool.name
  1785. )
  1786. ]
  1787. ),
  1788. ],
  1789. json_output=AgentResponse,
  1790. )
  1791. assert isinstance(response2.content, str)
  1792. parsed_response = AgentResponse.model_validate(json.loads(response2.content))
  1793. assert parsed_response.thoughts
  1794. assert parsed_response.response in ["happy", "sad", "neutral"]
  1795. @pytest.mark.asyncio
  1796. @pytest.mark.parametrize(
  1797. "model",
  1798. [
  1799. "gpt-4.1-nano",
  1800. # "gemini-1.5-flash", # Gemini models do not support structured output with tool calls from model client.
  1801. ],
  1802. )
  1803. async def test_openai_structured_output_with_streaming_tool_calls(
  1804. model: str, openai_client: OpenAIChatCompletionClient
  1805. ) -> None:
  1806. class AgentResponse(BaseModel):
  1807. thoughts: str
  1808. response: Literal["happy", "sad", "neutral"]
  1809. def sentiment_analysis(text: str) -> str:
  1810. """Given a text, return the sentiment."""
  1811. return "happy" if "happy" in text else "sad" if "sad" in text else "neutral"
  1812. tool = FunctionTool(sentiment_analysis, description="Sentiment Analysis", strict=True)
  1813. extra_create_args = {"tool_choice": "required"}
  1814. chunks1: List[str | CreateResult] = []
  1815. stream1 = openai_client.create_stream(
  1816. messages=[
  1817. SystemMessage(content="Analyze input text sentiment using the tool provided."),
  1818. UserMessage(content="I am happy.", source="user"),
  1819. ],
  1820. tools=[tool],
  1821. extra_create_args=extra_create_args,
  1822. json_output=AgentResponse,
  1823. )
  1824. async for chunk in stream1:
  1825. chunks1.append(chunk)
  1826. assert len(chunks1) > 0
  1827. create_result1 = chunks1[-1]
  1828. assert isinstance(create_result1, CreateResult)
  1829. assert isinstance(create_result1.content, list)
  1830. assert len(create_result1.content) == 1
  1831. assert isinstance(create_result1.content[0], FunctionCall)
  1832. assert create_result1.content[0].name == "sentiment_analysis"
  1833. assert json.loads(create_result1.content[0].arguments) == {"text": "I am happy."}
  1834. assert create_result1.finish_reason == "function_calls"
  1835. stream2 = openai_client.create_stream(
  1836. messages=[
  1837. SystemMessage(content="Analyze input text sentiment using the tool provided."),
  1838. UserMessage(content="I am happy.", source="user"),
  1839. AssistantMessage(content=create_result1.content, source="assistant"),
  1840. FunctionExecutionResultMessage(
  1841. content=[
  1842. FunctionExecutionResult(
  1843. content="happy", call_id=create_result1.content[0].id, is_error=False, name=tool.name
  1844. )
  1845. ]
  1846. ),
  1847. ],
  1848. json_output=AgentResponse,
  1849. )
  1850. chunks2: List[str | CreateResult] = []
  1851. async for chunk in stream2:
  1852. chunks2.append(chunk)
  1853. assert len(chunks2) > 0
  1854. create_result2 = chunks2[-1]
  1855. assert isinstance(create_result2, CreateResult)
  1856. assert isinstance(create_result2.content, str)
  1857. parsed_response = AgentResponse.model_validate(json.loads(create_result2.content))
  1858. assert parsed_response.thoughts
  1859. assert parsed_response.response in ["happy", "sad", "neutral"]
  1860. @pytest.mark.asyncio
  1861. async def test_hugging_face() -> None:
  1862. api_key = os.getenv("HF_TOKEN")
  1863. if not api_key:
  1864. pytest.skip("HF_TOKEN not found in environment variables")
  1865. model_client = OpenAIChatCompletionClient(
  1866. model="microsoft/Phi-3.5-mini-instruct",
  1867. api_key=api_key,
  1868. base_url="https://api-inference.huggingface.co/v1/",
  1869. model_info={
  1870. "function_calling": False,
  1871. "json_output": False,
  1872. "vision": False,
  1873. "family": ModelFamily.UNKNOWN,
  1874. "structured_output": False,
  1875. },
  1876. )
  1877. # Test basic completion
  1878. create_result = await model_client.create(
  1879. messages=[
  1880. SystemMessage(content="You are a helpful assistant."),
  1881. UserMessage(content="Explain to me how AI works.", source="user"),
  1882. ]
  1883. )
  1884. assert isinstance(create_result.content, str)
  1885. assert len(create_result.content) > 0
  1886. @pytest.mark.asyncio
  1887. async def test_ollama() -> None:
  1888. model = "deepseek-r1:1.5b"
  1889. model_info: ModelInfo = {
  1890. "function_calling": False,
  1891. "json_output": False,
  1892. "vision": False,
  1893. "family": ModelFamily.R1,
  1894. "structured_output": False,
  1895. }
  1896. # Check if the model is running locally.
  1897. try:
  1898. async with httpx.AsyncClient() as client:
  1899. response = await client.get(f"http://localhost:11434/v1/models/{model}")
  1900. response.raise_for_status()
  1901. except httpx.HTTPStatusError as e:
  1902. pytest.skip(f"{model} model is not running locally: {e}")
  1903. except httpx.ConnectError as e:
  1904. pytest.skip(f"Ollama is not running locally: {e}")
  1905. model_client = OpenAIChatCompletionClient(
  1906. model=model,
  1907. api_key="placeholder",
  1908. base_url="http://localhost:11434/v1",
  1909. model_info=model_info,
  1910. )
  1911. # Test basic completion with the Ollama deepseek-r1:1.5b model.
  1912. create_result = await model_client.create(
  1913. messages=[
  1914. UserMessage(
  1915. content="Taking two balls from a bag of 10 green balls and 20 red balls, "
  1916. "what is the probability of getting a green and a red balls?",
  1917. source="user",
  1918. ),
  1919. ]
  1920. )
  1921. assert isinstance(create_result.content, str)
  1922. assert len(create_result.content) > 0
  1923. assert create_result.finish_reason == "stop"
  1924. assert create_result.usage is not None
  1925. if model_info["family"] == ModelFamily.R1:
  1926. assert create_result.thought is not None
  1927. # Test streaming completion with the Ollama deepseek-r1:1.5b model.
  1928. chunks: List[str | CreateResult] = []
  1929. async for chunk in model_client.create_stream(
  1930. messages=[
  1931. UserMessage(
  1932. content="Taking two balls from a bag of 10 green balls and 20 red balls, "
  1933. "what is the probability of getting a green and a red balls?",
  1934. source="user",
  1935. ),
  1936. ]
  1937. ):
  1938. chunks.append(chunk)
  1939. assert len(chunks) > 0
  1940. assert isinstance(chunks[-1], CreateResult)
  1941. assert chunks[-1].finish_reason == "stop"
  1942. assert len(chunks[-1].content) > 0
  1943. assert chunks[-1].usage is not None
  1944. if model_info["family"] == ModelFamily.R1:
  1945. assert chunks[-1].thought is not None
  1946. @pytest.mark.asyncio
  1947. async def test_add_name_prefixes(monkeypatch: pytest.MonkeyPatch) -> None:
  1948. sys_message = SystemMessage(content="You are a helpful AI agent, and you answer questions in a friendly way.")
  1949. assistant_message = AssistantMessage(content="Hello, how can I help you?", source="Assistant")
  1950. user_text_message = UserMessage(content="Hello, I am from Seattle.", source="Adam")
  1951. user_mm_message = UserMessage(
  1952. content=[
  1953. "Here is a postcard from Seattle:",
  1954. Image.from_base64(
  1955. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
  1956. ),
  1957. ],
  1958. source="Adam",
  1959. )
  1960. # Default conversion
  1961. oai_sys = to_oai_type(sys_message)[0]
  1962. oai_asst = to_oai_type(assistant_message)[0]
  1963. oai_text = to_oai_type(user_text_message)[0]
  1964. oai_mm = to_oai_type(user_mm_message)[0]
  1965. converted_sys = to_oai_type(sys_message, prepend_name=True)[0]
  1966. converted_asst = to_oai_type(assistant_message, prepend_name=True)[0]
  1967. converted_text = to_oai_type(user_text_message, prepend_name=True)[0]
  1968. converted_mm = to_oai_type(user_mm_message, prepend_name=True)[0]
  1969. # Invariants
  1970. assert "content" in oai_sys
  1971. assert "content" in oai_asst
  1972. assert "content" in oai_text
  1973. assert "content" in oai_mm
  1974. assert "content" in converted_sys
  1975. assert "content" in converted_asst
  1976. assert "content" in converted_text
  1977. assert "content" in converted_mm
  1978. assert oai_sys["role"] == converted_sys["role"]
  1979. assert oai_sys["content"] == converted_sys["content"]
  1980. assert oai_asst["role"] == converted_asst["role"]
  1981. assert oai_asst["content"] == converted_asst["content"]
  1982. assert oai_text["role"] == converted_text["role"]
  1983. assert oai_mm["role"] == converted_mm["role"]
  1984. assert isinstance(oai_mm["content"], list)
  1985. assert isinstance(converted_mm["content"], list)
  1986. assert len(oai_mm["content"]) == len(converted_mm["content"])
  1987. assert "text" in converted_mm["content"][0]
  1988. assert "text" in oai_mm["content"][0]
  1989. # Name prepended
  1990. assert str(converted_text["content"]) == "Adam said:\n" + str(oai_text["content"])
  1991. assert str(converted_mm["content"][0]["text"]) == "Adam said:\n" + str(oai_mm["content"][0]["text"])
  1992. @pytest.mark.asyncio
  1993. @pytest.mark.parametrize(
  1994. "model",
  1995. [
  1996. "gpt-4.1-nano",
  1997. "gemini-1.5-flash",
  1998. "claude-3-5-haiku-20241022",
  1999. ],
  2000. )
  2001. async def test_muliple_system_message(model: str, openai_client: OpenAIChatCompletionClient) -> None:
  2002. """Test multiple system messages in a single request."""
  2003. # Test multiple system messages
  2004. messages: List[LLMMessage] = [
  2005. SystemMessage(content="When you say anything Start with 'FOO'"),
  2006. SystemMessage(content="When you say anything End with 'BAR'"),
  2007. UserMessage(content="Just say '.'", source="user"),
  2008. ]
  2009. result = await openai_client.create(messages=messages)
  2010. result_content = result.content
  2011. assert isinstance(result_content, str)
  2012. result_content = result_content.strip()
  2013. assert result_content[:3] == "FOO"
  2014. assert result_content[-3:] == "BAR"
  2015. @pytest.mark.asyncio
  2016. async def test_system_message_merge_with_continuous_system_messages_models() -> None:
  2017. """Tests that system messages are merged correctly for Gemini models."""
  2018. # Create a mock client
  2019. mock_client = MagicMock()
  2020. client = BaseOpenAIChatCompletionClient(
  2021. client=mock_client,
  2022. create_args={"model": "gemini-1.5-flash"},
  2023. model_info={
  2024. "vision": False,
  2025. "function_calling": False,
  2026. "json_output": False,
  2027. "family": "unknown",
  2028. "structured_output": False,
  2029. "multiple_system_messages": False,
  2030. },
  2031. )
  2032. # Create two system messages
  2033. messages: List[LLMMessage] = [
  2034. SystemMessage(content="I am system message 1"),
  2035. SystemMessage(content="I am system message 2"),
  2036. UserMessage(content="Hello", source="user"),
  2037. ]
  2038. # Process the messages
  2039. # pylint: disable=protected-access
  2040. # The method is protected, but we need to test it
  2041. create_params = client._process_create_args( # pyright: ignore[reportPrivateUsage]
  2042. messages=messages,
  2043. tools=[],
  2044. json_output=None,
  2045. extra_create_args={},
  2046. tool_choice="none",
  2047. )
  2048. # Extract the actual messages from the result
  2049. oai_messages = create_params.messages
  2050. # Check that there is only one system message and it contains the merged content
  2051. system_messages = [msg for msg in oai_messages if msg["role"] == "system"]
  2052. assert len(system_messages) == 1
  2053. assert system_messages[0]["content"] == "I am system message 1\nI am system message 2"
  2054. # Check that the user message is preserved
  2055. user_messages = [msg for msg in oai_messages if msg["role"] == "user"]
  2056. assert len(user_messages) == 1
  2057. assert user_messages[0]["content"] == "Hello"
  2058. @pytest.mark.asyncio
  2059. async def test_system_message_merge_with_non_continuous_messages() -> None:
  2060. """Tests that an error is raised when non-continuous system messages are provided."""
  2061. # Create a mock client
  2062. mock_client = MagicMock()
  2063. client = BaseOpenAIChatCompletionClient(
  2064. client=mock_client,
  2065. create_args={"model": "gemini-1.5-flash"},
  2066. model_info={
  2067. "vision": False,
  2068. "function_calling": False,
  2069. "json_output": False,
  2070. "family": "unknown",
  2071. "structured_output": False,
  2072. "multiple_system_messages": False,
  2073. },
  2074. )
  2075. # Create non-continuous system messages
  2076. messages: List[LLMMessage] = [
  2077. SystemMessage(content="I am system message 1"),
  2078. UserMessage(content="Hello", source="user"),
  2079. SystemMessage(content="I am system message 2"),
  2080. ]
  2081. # Process should raise ValueError
  2082. with pytest.raises(ValueError, match="Multiple and Not continuous system messages are not supported"):
  2083. # pylint: disable=protected-access
  2084. # The method is protected, but we need to test it
  2085. client._process_create_args( # pyright: ignore[reportPrivateUsage]
  2086. messages=messages,
  2087. tools=[],
  2088. json_output=None,
  2089. extra_create_args={},
  2090. tool_choice="none",
  2091. )
  2092. @pytest.mark.asyncio
  2093. async def test_system_message_not_merged_for_multiple_system_messages_true() -> None:
  2094. """Tests that system messages aren't modified for non-Gemini models."""
  2095. # Create a mock client
  2096. mock_client = MagicMock()
  2097. client = BaseOpenAIChatCompletionClient(
  2098. client=mock_client,
  2099. create_args={"model": "gpt-4.1-nano"},
  2100. model_info={
  2101. "vision": False,
  2102. "function_calling": False,
  2103. "json_output": False,
  2104. "family": "unknown",
  2105. "structured_output": False,
  2106. "multiple_system_messages": True,
  2107. },
  2108. )
  2109. # Create two system messages
  2110. messages: List[LLMMessage] = [
  2111. SystemMessage(content="I am system message 1"),
  2112. SystemMessage(content="I am system message 2"),
  2113. UserMessage(content="Hello", source="user"),
  2114. ]
  2115. # Process the messages
  2116. # pylint: disable=protected-access
  2117. # The method is protected, but we need to test it
  2118. create_params = client._process_create_args( # pyright: ignore[reportPrivateUsage]
  2119. messages=messages,
  2120. tools=[],
  2121. json_output=None,
  2122. extra_create_args={},
  2123. tool_choice="none",
  2124. )
  2125. # Extract the actual messages from the result
  2126. oai_messages = create_params.messages
  2127. # Check that there are two system messages preserved
  2128. system_messages = [msg for msg in oai_messages if msg["role"] == "system"]
  2129. assert len(system_messages) == 2
  2130. assert system_messages[0]["content"] == "I am system message 1"
  2131. assert system_messages[1]["content"] == "I am system message 2"
  2132. @pytest.mark.asyncio
  2133. async def test_no_system_messages_for_gemini_model() -> None:
  2134. """Tests behavior when no system messages are provided to a Gemini model."""
  2135. # Create a mock client
  2136. mock_client = MagicMock()
  2137. client = BaseOpenAIChatCompletionClient(
  2138. client=mock_client,
  2139. create_args={"model": "gemini-1.5-flash"},
  2140. model_info={
  2141. "vision": False,
  2142. "function_calling": False,
  2143. "json_output": False,
  2144. "family": "unknown",
  2145. "structured_output": False,
  2146. },
  2147. )
  2148. # Create messages with no system message
  2149. messages: List[LLMMessage] = [
  2150. UserMessage(content="Hello", source="user"),
  2151. AssistantMessage(content="Hi there", source="assistant"),
  2152. ]
  2153. # Process the messages
  2154. # pylint: disable=protected-access
  2155. # The method is protected, but we need to test it
  2156. create_params = client._process_create_args( # pyright: ignore[reportPrivateUsage]
  2157. messages=messages,
  2158. tools=[],
  2159. json_output=None,
  2160. extra_create_args={},
  2161. tool_choice="none",
  2162. )
  2163. # Extract the actual messages from the result
  2164. oai_messages = create_params.messages
  2165. # Check that there are no system messages
  2166. system_messages = [msg for msg in oai_messages if msg["role"] == "system"]
  2167. assert len(system_messages) == 0
  2168. # Check that other messages are preserved
  2169. user_messages = [msg for msg in oai_messages if msg["role"] == "user"]
  2170. assistant_messages = [msg for msg in oai_messages if msg["role"] == "assistant"]
  2171. assert len(user_messages) == 1
  2172. assert len(assistant_messages) == 1
  2173. @pytest.mark.asyncio
  2174. async def test_single_system_message_for_gemini_model() -> None:
  2175. """Tests that a single system message is preserved for Gemini models."""
  2176. # Create a mock client
  2177. mock_client = MagicMock()
  2178. client = BaseOpenAIChatCompletionClient(
  2179. client=mock_client,
  2180. create_args={"model": "gemini-1.5-flash"},
  2181. model_info={
  2182. "vision": False,
  2183. "function_calling": False,
  2184. "json_output": False,
  2185. "family": "unknown",
  2186. "structured_output": False,
  2187. },
  2188. )
  2189. # Create messages with a single system message
  2190. messages: List[LLMMessage] = [
  2191. SystemMessage(content="I am the only system message"),
  2192. UserMessage(content="Hello", source="user"),
  2193. ]
  2194. # Process the messages
  2195. # pylint: disable=protected-access
  2196. # The method is protected, but we need to test it
  2197. create_params = client._process_create_args( # pyright: ignore[reportPrivateUsage]
  2198. messages=messages,
  2199. tools=[],
  2200. json_output=None,
  2201. extra_create_args={},
  2202. tool_choice="auto",
  2203. )
  2204. # Extract the actual messages from the result
  2205. oai_messages = create_params.messages
  2206. # Check that there is exactly one system message with the correct content
  2207. system_messages = [msg for msg in oai_messages if msg["role"] == "system"]
  2208. assert len(system_messages) == 1
  2209. assert system_messages[0]["content"] == "I am the only system message"
  2210. def noop(input: str) -> str:
  2211. return "done"
  2212. @pytest.mark.asyncio
  2213. @pytest.mark.parametrize("model", ["gemini-1.5-flash"])
  2214. async def test_empty_assistant_content_with_gemini(model: str, openai_client: OpenAIChatCompletionClient) -> None:
  2215. # Test tool calling
  2216. tool = FunctionTool(noop, name="noop", description="No-op tool")
  2217. messages: List[LLMMessage] = [UserMessage(content="Call noop", source="user")]
  2218. result = await openai_client.create(messages=messages, tools=[tool])
  2219. assert isinstance(result.content, list)
  2220. tool_call = result.content[0]
  2221. assert isinstance(tool_call, FunctionCall)
  2222. # reply with empty string as thought (== content)
  2223. messages.append(AssistantMessage(content=result.content, thought="", source="assistant"))
  2224. messages.append(
  2225. FunctionExecutionResultMessage(
  2226. content=[
  2227. FunctionExecutionResult(
  2228. content="done",
  2229. call_id=tool_call.id,
  2230. is_error=False,
  2231. name=tool_call.name,
  2232. )
  2233. ]
  2234. )
  2235. )
  2236. # This will crash if _set_empty_to_whitespace is not applied to "thought"
  2237. result = await openai_client.create(messages=messages)
  2238. assert isinstance(result.content, str)
  2239. assert result.content.strip() != "" or result.content == " "
  2240. @pytest.mark.asyncio
  2241. @pytest.mark.parametrize(
  2242. "model",
  2243. [
  2244. "gpt-4.1-nano",
  2245. "gemini-1.5-flash",
  2246. "claude-3-5-haiku-20241022",
  2247. ],
  2248. )
  2249. async def test_empty_assistant_content_string_with_some_model(
  2250. model: str, openai_client: OpenAIChatCompletionClient
  2251. ) -> None:
  2252. # message: assistant is response empty content
  2253. messages: list[LLMMessage] = [
  2254. UserMessage(content="Say something", source="user"),
  2255. AssistantMessage(content="test", source="assistant"),
  2256. UserMessage(content="", source="user"),
  2257. ]
  2258. # This will crash if _set_empty_to_whitespace is not applied to "content"
  2259. result = await openai_client.create(messages=messages)
  2260. assert isinstance(result.content, str)
  2261. def test_openai_model_registry_find_well() -> None:
  2262. model = "gpt-4o"
  2263. client1 = OpenAIChatCompletionClient(model=model, api_key="test")
  2264. client2 = OpenAIChatCompletionClient(
  2265. model=model,
  2266. model_info={
  2267. "vision": False,
  2268. "function_calling": False,
  2269. "json_output": False,
  2270. "structured_output": False,
  2271. "family": ModelFamily.UNKNOWN,
  2272. },
  2273. api_key="test",
  2274. )
  2275. def get_regitered_transformer(client: OpenAIChatCompletionClient) -> TransformerMap:
  2276. model_name = client._create_args["model"] # pyright: ignore[reportPrivateUsage]
  2277. model_family = client.model_info["family"]
  2278. return get_transformer("openai", model_name, model_family)
  2279. assert get_regitered_transformer(client1) == get_regitered_transformer(client2)
  2280. @pytest.mark.asyncio
  2281. @pytest.mark.parametrize(
  2282. "model",
  2283. [
  2284. "gpt-4.1-nano",
  2285. ],
  2286. )
  2287. async def test_openai_model_unknown_message_type(model: str, openai_client: OpenAIChatCompletionClient) -> None:
  2288. class WrongMessage:
  2289. content = "foo"
  2290. source = "bar"
  2291. messages: List[WrongMessage] = [WrongMessage()]
  2292. with pytest.raises(ValueError, match="Unknown message type"):
  2293. await openai_client.create(messages=messages) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
  2294. @pytest.mark.asyncio
  2295. @pytest.mark.parametrize(
  2296. "model",
  2297. [
  2298. "claude-3-5-haiku-20241022",
  2299. ],
  2300. )
  2301. async def test_claude_trailing_whitespace_at_last_assistant_content(
  2302. model: str, openai_client: OpenAIChatCompletionClient
  2303. ) -> None:
  2304. messages: list[LLMMessage] = [
  2305. UserMessage(content="foo", source="user"),
  2306. UserMessage(content="bar", source="user"),
  2307. AssistantMessage(content="foobar ", source="assistant"),
  2308. ]
  2309. result = await openai_client.create(messages=messages)
  2310. assert isinstance(result.content, str)
  2311. def test_rstrip_railing_whitespace_at_last_assistant_content() -> None:
  2312. messages: list[LLMMessage] = [
  2313. UserMessage(content="foo", source="user"),
  2314. UserMessage(content="bar", source="user"),
  2315. AssistantMessage(content="foobar ", source="assistant"),
  2316. ]
  2317. # This will crash if _rstrip_railing_whitespace_at_last_assistant_content is not applied to "content"
  2318. dummy_client = OpenAIChatCompletionClient(model="claude-3-5-haiku-20241022", api_key="dummy-key")
  2319. result = dummy_client._rstrip_last_assistant_message(messages) # pyright: ignore[reportPrivateUsage]
  2320. assert isinstance(result[-1].content, str)
  2321. assert result[-1].content == "foobar"
  2322. def test_find_model_family() -> None:
  2323. assert _find_model_family("openai", "gpt-4") == ModelFamily.GPT_4
  2324. assert _find_model_family("openai", "gpt-4-latest") == ModelFamily.GPT_4
  2325. assert _find_model_family("openai", "gpt-4o") == ModelFamily.GPT_4O
  2326. assert _find_model_family("openai", "gemini-2.0-flash") == ModelFamily.GEMINI_2_0_FLASH
  2327. assert _find_model_family("openai", "claude-3-5-haiku-20241022") == ModelFamily.CLAUDE_3_5_HAIKU
  2328. assert _find_model_family("openai", "error") == ModelFamily.UNKNOWN
  2329. @pytest.mark.asyncio
  2330. @pytest.mark.parametrize(
  2331. "model",
  2332. [
  2333. "gpt-4.1-nano",
  2334. "gemini-1.5-flash",
  2335. "claude-3-5-haiku-20241022",
  2336. ],
  2337. )
  2338. async def test_multimodal_message_test(
  2339. model: str, openai_client: OpenAIChatCompletionClient, monkeypatch: pytest.MonkeyPatch
  2340. ) -> None:
  2341. # Test that the multimodal message is converted to the correct format
  2342. img = Image.from_base64(
  2343. "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"
  2344. )
  2345. multi_modal_message = MultiModalMessage(content=["Can you describe the content of this image?", img], source="user")
  2346. ocr_agent = AssistantAgent(
  2347. name="ocr_agent", model_client=openai_client, system_message="""You are a helpful agent."""
  2348. )
  2349. _ = await ocr_agent.run(task=multi_modal_message)
  2350. @pytest.mark.asyncio
  2351. async def test_mistral_remove_name() -> None:
  2352. # Test that the name pramaeter is removed from the message
  2353. # when the model is Mistral
  2354. message = UserMessage(content="foo", source="user")
  2355. params = to_oai_type(message, prepend_name=False, model="mistral-7b", model_family=ModelFamily.MISTRAL)
  2356. assert ("name" in params[0]) is False
  2357. # when the model is gpt-4o, the name parameter is not removed
  2358. params = to_oai_type(message, prepend_name=False, model="gpt-4o", model_family=ModelFamily.GPT_4O)
  2359. assert ("name" in params[0]) is True
  2360. @pytest.mark.asyncio
  2361. async def test_include_name_in_message() -> None:
  2362. """Test that include_name_in_message parameter controls the name field."""
  2363. # Test with UserMessage
  2364. user_message = UserMessage(content="Hello, I am from Seattle.", source="Adam")
  2365. # Test with include_name_in_message=True (default)
  2366. result_with_name = to_oai_type(user_message, include_name_in_message=True)[0]
  2367. assert "name" in result_with_name
  2368. assert result_with_name["name"] == "Adam" # type: ignore[typeddict-item]
  2369. assert result_with_name["role"] == "user"
  2370. assert result_with_name["content"] == "Hello, I am from Seattle."
  2371. # Test with include_name_in_message=False
  2372. result_without_name = to_oai_type(user_message, include_name_in_message=False)[0]
  2373. assert "name" not in result_without_name
  2374. assert result_without_name["role"] == "user"
  2375. assert result_without_name["content"] == "Hello, I am from Seattle."
  2376. # Test with AssistantMessage (should not have name field regardless)
  2377. assistant_message = AssistantMessage(content="Hello, how can I help you?", source="Assistant")
  2378. # Test with include_name_in_message=True
  2379. result_assistant_with_name = to_oai_type(assistant_message, include_name_in_message=True)[0]
  2380. assert "name" not in result_assistant_with_name
  2381. assert result_assistant_with_name["role"] == "assistant"
  2382. # Test with include_name_in_message=False
  2383. result_assistant_without_name = to_oai_type(assistant_message, include_name_in_message=False)[0]
  2384. assert "name" not in result_assistant_without_name
  2385. assert result_assistant_without_name["role"] == "assistant"
  2386. # Test with SystemMessage (should not have name field regardless)
  2387. system_message = SystemMessage(content="You are a helpful assistant.")
  2388. result_system_with_name = to_oai_type(system_message, include_name_in_message=True)[0]
  2389. result_system_without_name = to_oai_type(system_message, include_name_in_message=False)[0]
  2390. assert "name" not in result_system_with_name
  2391. assert "name" not in result_system_without_name
  2392. assert result_system_with_name["role"] == "system"
  2393. assert result_system_without_name["role"] == "system"
  2394. # Test default behavior (should include name when parameter not specified)
  2395. result_default = to_oai_type(user_message)[0] # include_name_in_message defaults to True
  2396. assert "name" in result_default
  2397. assert result_default["name"] == "Adam" # type: ignore[typeddict-item]
  2398. @pytest.mark.asyncio
  2399. async def test_include_name_with_different_models() -> None:
  2400. """Test that include_name_in_message works with different model families."""
  2401. user_message = UserMessage(content="Hello", source="User")
  2402. # Test with GPT-4o model (normally includes name)
  2403. result_gpt4o_with_name = to_oai_type(
  2404. user_message, model="gpt-4o", model_family=ModelFamily.GPT_4O, include_name_in_message=True
  2405. )[0]
  2406. result_gpt4o_without_name = to_oai_type(
  2407. user_message, model="gpt-4o", model_family=ModelFamily.GPT_4O, include_name_in_message=False
  2408. )[0]
  2409. assert "name" in result_gpt4o_with_name
  2410. assert "name" not in result_gpt4o_without_name
  2411. # Test with Mistral model (normally excludes name, but should still respect the parameter)
  2412. result_mistral_with_name = to_oai_type(
  2413. user_message, model="mistral-7b", model_family=ModelFamily.MISTRAL, include_name_in_message=True
  2414. )[0]
  2415. result_mistral_without_name = to_oai_type(
  2416. user_message, model="mistral-7b", model_family=ModelFamily.MISTRAL, include_name_in_message=False
  2417. )[0]
  2418. # Note: Mistral transformers are specifically built without _set_name, so they won't have name regardless
  2419. # But our parameter still controls the behavior consistently
  2420. assert "name" not in result_mistral_with_name # Mistral design excludes names
  2421. assert "name" not in result_mistral_without_name
  2422. # Test with unknown model (uses default transformer)
  2423. result_unknown_with_name = to_oai_type(
  2424. user_message, model="some-custom-model", model_family=ModelFamily.UNKNOWN, include_name_in_message=True
  2425. )[0]
  2426. result_unknown_without_name = to_oai_type(
  2427. user_message, model="some-custom-model", model_family=ModelFamily.UNKNOWN, include_name_in_message=False
  2428. )[0]
  2429. assert "name" in result_unknown_with_name
  2430. assert "name" not in result_unknown_without_name
  2431. @pytest.mark.asyncio
  2432. async def test_mock_tool_choice_specific_tool(monkeypatch: pytest.MonkeyPatch) -> None:
  2433. """Test tool_choice parameter with a specific tool using mocks."""
  2434. def _pass_function(input: str) -> str:
  2435. """Simple passthrough function."""
  2436. return f"Processed: {input}"
  2437. def _add_numbers(a: int, b: int) -> int:
  2438. """Add two numbers together."""
  2439. return a + b
  2440. model = "gpt-4o"
  2441. # Mock successful completion with specific tool call
  2442. chat_completion = ChatCompletion(
  2443. id="id1",
  2444. choices=[
  2445. Choice(
  2446. finish_reason="tool_calls",
  2447. index=0,
  2448. message=ChatCompletionMessage(
  2449. role="assistant",
  2450. content=None,
  2451. tool_calls=[
  2452. ChatCompletionMessageToolCall(
  2453. id="1",
  2454. type="function",
  2455. function=Function(
  2456. name="_pass_function",
  2457. arguments=json.dumps({"input": "hello"}),
  2458. ),
  2459. )
  2460. ],
  2461. ),
  2462. )
  2463. ],
  2464. created=1234567890,
  2465. model=model,
  2466. object="chat.completion",
  2467. usage=CompletionUsage(completion_tokens=10, prompt_tokens=5, total_tokens=15),
  2468. )
  2469. client = OpenAIChatCompletionClient(model=model, api_key="test")
  2470. # Define tools
  2471. pass_tool = FunctionTool(_pass_function, description="Process input text", name="_pass_function")
  2472. add_tool = FunctionTool(_add_numbers, description="Add two numbers together", name="_add_numbers")
  2473. # Create mock for the chat completions create method
  2474. mock_create = AsyncMock(return_value=chat_completion)
  2475. with monkeypatch.context() as mp:
  2476. mp.setattr(client._client.chat.completions, "create", mock_create) # type: ignore[reportPrivateUsage]
  2477. _ = await client.create(
  2478. messages=[UserMessage(content="Process 'hello'", source="user")],
  2479. tools=[pass_tool, add_tool],
  2480. tool_choice=pass_tool, # Force use of specific tool
  2481. )
  2482. # Verify the correct API call was made
  2483. mock_create.assert_called_once()
  2484. call_args = mock_create.call_args
  2485. # Check that tool_choice was set correctly
  2486. assert "tool_choice" in call_args.kwargs
  2487. assert call_args.kwargs["tool_choice"] == {"type": "function", "function": {"name": "_pass_function"}}
  2488. @pytest.mark.asyncio
  2489. async def test_mock_tool_choice_auto(monkeypatch: pytest.MonkeyPatch) -> None:
  2490. """Test tool_choice parameter with 'auto' setting using mocks."""
  2491. def _pass_function(input: str) -> str:
  2492. """Simple passthrough function."""
  2493. return f"Processed: {input}"
  2494. def _add_numbers(a: int, b: int) -> int:
  2495. """Add two numbers together."""
  2496. return a + b
  2497. model = "gpt-4o"
  2498. # Mock successful completion
  2499. chat_completion = ChatCompletion(
  2500. id="id1",
  2501. choices=[
  2502. Choice(
  2503. finish_reason="tool_calls",
  2504. index=0,
  2505. message=ChatCompletionMessage(
  2506. role="assistant",
  2507. content=None,
  2508. tool_calls=[
  2509. ChatCompletionMessageToolCall(
  2510. id="1",
  2511. type="function",
  2512. function=Function(
  2513. name="_add_numbers",
  2514. arguments=json.dumps({"a": 1, "b": 2}),
  2515. ),
  2516. )
  2517. ],
  2518. ),
  2519. )
  2520. ],
  2521. created=1234567890,
  2522. model=model,
  2523. object="chat.completion",
  2524. usage=CompletionUsage(completion_tokens=10, prompt_tokens=5, total_tokens=15),
  2525. )
  2526. client = OpenAIChatCompletionClient(model=model, api_key="test")
  2527. # Define tools
  2528. pass_tool = FunctionTool(_pass_function, description="Process input text", name="_pass_function")
  2529. add_tool = FunctionTool(_add_numbers, description="Add two numbers together", name="_add_numbers")
  2530. # Create mock for the chat completions create method
  2531. mock_create = AsyncMock(return_value=chat_completion)
  2532. with monkeypatch.context() as mp:
  2533. mp.setattr(client._client.chat.completions, "create", mock_create) # type: ignore[reportPrivateUsage]
  2534. await client.create(
  2535. messages=[UserMessage(content="Add 1 and 2", source="user")],
  2536. tools=[pass_tool, add_tool],
  2537. tool_choice="auto", # Let model choose
  2538. )
  2539. # Verify the correct API call was made
  2540. mock_create.assert_called_once()
  2541. call_args = mock_create.call_args
  2542. # Check that tool_choice was set correctly
  2543. assert "tool_choice" in call_args.kwargs
  2544. assert call_args.kwargs["tool_choice"] == "auto"
  2545. @pytest.mark.asyncio
  2546. async def test_mock_tool_choice_none(monkeypatch: pytest.MonkeyPatch) -> None:
  2547. """Test tool_choice parameter with None setting using mocks."""
  2548. def _pass_function(input: str) -> str:
  2549. """Simple passthrough function."""
  2550. return f"Processed: {input}"
  2551. model = "gpt-4o"
  2552. # Mock successful completion
  2553. chat_completion = ChatCompletion(
  2554. id="id1",
  2555. choices=[
  2556. Choice(
  2557. finish_reason="stop",
  2558. index=0,
  2559. message=ChatCompletionMessage(
  2560. role="assistant",
  2561. content="I can help you with that!",
  2562. tool_calls=None,
  2563. ),
  2564. )
  2565. ],
  2566. created=1234567890,
  2567. model=model,
  2568. object="chat.completion",
  2569. usage=CompletionUsage(completion_tokens=10, prompt_tokens=5, total_tokens=15),
  2570. )
  2571. client = OpenAIChatCompletionClient(model=model, api_key="test")
  2572. # Define tools
  2573. pass_tool = FunctionTool(_pass_function, description="Process input text", name="_pass_function")
  2574. # Create mock for the chat completions create method
  2575. mock_create = AsyncMock(return_value=chat_completion)
  2576. with monkeypatch.context() as mp:
  2577. mp.setattr(client._client.chat.completions, "create", mock_create) # type: ignore[reportPrivateUsage]
  2578. await client.create(
  2579. messages=[UserMessage(content="Hello there", source="user")],
  2580. tools=[pass_tool],
  2581. tool_choice="none",
  2582. )
  2583. # Verify the correct API call was made
  2584. mock_create.assert_called_once()
  2585. call_args = mock_create.call_args
  2586. # Check that tool_choice was set to "none" (disabling tool usage)
  2587. assert "tool_choice" in call_args.kwargs
  2588. assert call_args.kwargs["tool_choice"] == "none"
  2589. @pytest.mark.asyncio
  2590. async def test_mock_tool_choice_validation_error() -> None:
  2591. """Test tool_choice validation with invalid tool reference."""
  2592. def _pass_function(input: str) -> str:
  2593. """Simple passthrough function."""
  2594. return f"Processed: {input}"
  2595. def _add_numbers(a: int, b: int) -> int:
  2596. """Add two numbers together."""
  2597. return a + b
  2598. def _different_function(text: str) -> str:
  2599. """Different function."""
  2600. return text
  2601. client = OpenAIChatCompletionClient(model="gpt-4o", api_key="test")
  2602. # Define tools
  2603. pass_tool = FunctionTool(_pass_function, description="Process input text", name="_pass_function")
  2604. add_tool = FunctionTool(_add_numbers, description="Add two numbers together", name="_add_numbers")
  2605. different_tool = FunctionTool(_different_function, description="Different tool", name="_different_function")
  2606. messages = [UserMessage(content="Hello there", source="user")]
  2607. # Test with a tool that's not in the tools list
  2608. with pytest.raises(
  2609. ValueError, match="tool_choice references '_different_function' but it's not in the provided tools"
  2610. ):
  2611. await client.create(
  2612. messages=messages,
  2613. tools=[pass_tool, add_tool],
  2614. tool_choice=different_tool, # This tool is not in the tools list
  2615. )
  2616. @pytest.mark.asyncio
  2617. async def test_mock_tool_choice_required(monkeypatch: pytest.MonkeyPatch) -> None:
  2618. """Test tool_choice parameter with 'required' setting using mocks."""
  2619. def _pass_function(input: str) -> str:
  2620. """Simple passthrough function."""
  2621. return f"Processed: {input}"
  2622. def _add_numbers(a: int, b: int) -> int:
  2623. """Add two numbers together."""
  2624. return a + b
  2625. model = "gpt-4o"
  2626. # Mock successful completion with tool calls (required forces tool usage)
  2627. chat_completion = ChatCompletion(
  2628. id="id1",
  2629. choices=[
  2630. Choice(
  2631. finish_reason="tool_calls",
  2632. index=0,
  2633. message=ChatCompletionMessage(
  2634. role="assistant",
  2635. content=None,
  2636. tool_calls=[
  2637. ChatCompletionMessageToolCall(
  2638. id="1",
  2639. type="function",
  2640. function=Function(
  2641. name="_pass_function",
  2642. arguments=json.dumps({"input": "hello"}),
  2643. ),
  2644. )
  2645. ],
  2646. ),
  2647. )
  2648. ],
  2649. created=1234567890,
  2650. model=model,
  2651. object="chat.completion",
  2652. usage=CompletionUsage(completion_tokens=10, prompt_tokens=5, total_tokens=15),
  2653. )
  2654. client = OpenAIChatCompletionClient(model=model, api_key="test")
  2655. # Define tools
  2656. pass_tool = FunctionTool(_pass_function, description="Process input text", name="_pass_function")
  2657. add_tool = FunctionTool(_add_numbers, description="Add two numbers together", name="_add_numbers")
  2658. # Create mock for the chat completions create method
  2659. mock_create = AsyncMock(return_value=chat_completion)
  2660. with monkeypatch.context() as mp:
  2661. mp.setattr(client._client.chat.completions, "create", mock_create) # type: ignore[reportPrivateUsage]
  2662. await client.create(
  2663. messages=[UserMessage(content="Process some text", source="user")],
  2664. tools=[pass_tool, add_tool],
  2665. tool_choice="required", # Force tool usage
  2666. )
  2667. # Verify the correct API call was made
  2668. mock_create.assert_called_once()
  2669. call_args = mock_create.call_args
  2670. # Check that tool_choice was set correctly
  2671. assert "tool_choice" in call_args.kwargs
  2672. assert call_args.kwargs["tool_choice"] == "required"
  2673. # Integration tests for tool_choice using the actual OpenAI API
  2674. @pytest.mark.asyncio
  2675. async def test_openai_tool_choice_specific_tool_integration() -> None:
  2676. """Test tool_choice parameter with a specific tool using the actual OpenAI API."""
  2677. api_key = os.getenv("OPENAI_API_KEY")
  2678. if not api_key:
  2679. pytest.skip("OPENAI_API_KEY not found in environment variables")
  2680. def _pass_function(input: str) -> str:
  2681. """Simple passthrough function."""
  2682. return f"Processed: {input}"
  2683. def _add_numbers(a: int, b: int) -> int:
  2684. """Add two numbers together."""
  2685. return a + b
  2686. model = "gpt-4o-mini"
  2687. client = OpenAIChatCompletionClient(model=model, api_key=api_key)
  2688. # Define tools
  2689. pass_tool = FunctionTool(_pass_function, description="Process input text", name="_pass_function")
  2690. add_tool = FunctionTool(_add_numbers, description="Add two numbers together", name="_add_numbers")
  2691. # Test forcing use of specific tool
  2692. result = await client.create(
  2693. messages=[UserMessage(content="Process the word 'hello'", source="user")],
  2694. tools=[pass_tool, add_tool],
  2695. tool_choice=pass_tool, # Force use of specific tool
  2696. )
  2697. assert isinstance(result.content, list)
  2698. assert len(result.content) == 1
  2699. assert isinstance(result.content[0], FunctionCall)
  2700. assert result.content[0].name == "_pass_function"
  2701. assert result.finish_reason == "function_calls"
  2702. assert result.usage is not None
  2703. @pytest.mark.asyncio
  2704. async def test_openai_tool_choice_auto_integration() -> None:
  2705. """Test tool_choice parameter with 'auto' setting using the actual OpenAI API."""
  2706. api_key = os.getenv("OPENAI_API_KEY")
  2707. if not api_key:
  2708. pytest.skip("OPENAI_API_KEY not found in environment variables")
  2709. def _pass_function(input: str) -> str:
  2710. """Simple passthrough function."""
  2711. return f"Processed: {input}"
  2712. def _add_numbers(a: int, b: int) -> int:
  2713. """Add two numbers together."""
  2714. return a + b
  2715. model = "gpt-4o-mini"
  2716. client = OpenAIChatCompletionClient(model=model, api_key=api_key)
  2717. # Define tools
  2718. pass_tool = FunctionTool(_pass_function, description="Process input text", name="_pass_function")
  2719. add_tool = FunctionTool(_add_numbers, description="Add two numbers together", name="_add_numbers")
  2720. # Test auto tool choice - model should choose to use add_numbers for math
  2721. result = await client.create(
  2722. messages=[UserMessage(content="What is 15 plus 27?", source="user")],
  2723. tools=[pass_tool, add_tool],
  2724. tool_choice="auto", # Let model choose
  2725. )
  2726. assert isinstance(result.content, list)
  2727. assert len(result.content) == 1
  2728. assert isinstance(result.content[0], FunctionCall)
  2729. assert result.content[0].name == "_add_numbers"
  2730. assert result.finish_reason == "function_calls"
  2731. assert result.usage is not None
  2732. # Parse arguments to verify correct values
  2733. args = json.loads(result.content[0].arguments)
  2734. assert args["a"] == 15
  2735. assert args["b"] == 27
  2736. @pytest.mark.asyncio
  2737. async def test_openai_tool_choice_none_integration() -> None:
  2738. """Test tool_choice parameter with 'none' setting using the actual OpenAI API."""
  2739. api_key = os.getenv("OPENAI_API_KEY")
  2740. if not api_key:
  2741. pytest.skip("OPENAI_API_KEY not found in environment variables")
  2742. def _pass_function(input: str) -> str:
  2743. """Simple passthrough function."""
  2744. return f"Processed: {input}"
  2745. model = "gpt-4o-mini"
  2746. client = OpenAIChatCompletionClient(model=model, api_key=api_key)
  2747. # Define tools
  2748. pass_tool = FunctionTool(_pass_function, description="Process input text", name="_pass_function")
  2749. # Test none tool choice - model should not use any tools
  2750. result = await client.create(
  2751. messages=[UserMessage(content="Hello there, how are you?", source="user")],
  2752. tools=[pass_tool],
  2753. tool_choice="none", # Disable tool usage
  2754. )
  2755. assert isinstance(result.content, str)
  2756. assert len(result.content) > 0
  2757. assert result.finish_reason == "stop"
  2758. assert result.usage is not None
  2759. @pytest.mark.asyncio
  2760. async def test_openai_tool_choice_required_integration() -> None:
  2761. """Test tool_choice parameter with 'required' setting using the actual OpenAI API."""
  2762. api_key = os.getenv("OPENAI_API_KEY")
  2763. if not api_key:
  2764. pytest.skip("OPENAI_API_KEY not found in environment variables")
  2765. def _pass_function(input: str) -> str:
  2766. """Simple passthrough function."""
  2767. return f"Processed: {input}"
  2768. def _add_numbers(a: int, b: int) -> int:
  2769. """Add two numbers together."""
  2770. return a + b
  2771. model = "gpt-4o-mini"
  2772. client = OpenAIChatCompletionClient(model=model, api_key=api_key)
  2773. # Define tools
  2774. pass_tool = FunctionTool(_pass_function, description="Process input text", name="_pass_function")
  2775. add_tool = FunctionTool(_add_numbers, description="Add two numbers together", name="_add_numbers")
  2776. # Test required tool choice - model must use a tool even for general conversation
  2777. result = await client.create(
  2778. messages=[UserMessage(content="Say hello to me", source="user")],
  2779. tools=[pass_tool, add_tool],
  2780. tool_choice="required", # Force tool usage
  2781. )
  2782. assert isinstance(result.content, list)
  2783. assert len(result.content) == 1
  2784. assert isinstance(result.content[0], FunctionCall)
  2785. assert result.content[0].name in ["_pass_function", "_add_numbers"]
  2786. assert result.finish_reason == "function_calls"
  2787. assert result.usage is not None
  2788. @pytest.mark.asyncio
  2789. async def test_openai_tool_choice_validation_error_integration() -> None:
  2790. """Test tool_choice validation with invalid tool reference using the actual OpenAI API."""
  2791. api_key = os.getenv("OPENAI_API_KEY")
  2792. if not api_key:
  2793. pytest.skip("OPENAI_API_KEY not found in environment variables")
  2794. def _pass_function(input: str) -> str:
  2795. """Simple passthrough function."""
  2796. return f"Processed: {input}"
  2797. def _add_numbers(a: int, b: int) -> int:
  2798. """Add two numbers together."""
  2799. return a + b
  2800. def _different_function(text: str) -> str:
  2801. """Different function."""
  2802. return text
  2803. model = "gpt-4o-mini"
  2804. client = OpenAIChatCompletionClient(model=model, api_key=api_key)
  2805. # Define tools
  2806. pass_tool = FunctionTool(_pass_function, description="Process input text", name="_pass_function")
  2807. add_tool = FunctionTool(_add_numbers, description="Add two numbers together", name="_add_numbers")
  2808. different_tool = FunctionTool(_different_function, description="Different tool", name="_different_function")
  2809. messages = [UserMessage(content="Hello there", source="user")]
  2810. # Test with a tool that's not in the tools list
  2811. with pytest.raises(
  2812. ValueError, match="tool_choice references '_different_function' but it's not in the provided tools"
  2813. ):
  2814. await client.create(
  2815. messages=messages,
  2816. tools=[pass_tool, add_tool],
  2817. tool_choice=different_tool, # This tool is not in the tools list
  2818. )
  2819. # GPT-5 model tests
  2820. def test_gpt5_model_resolution() -> None:
  2821. """Test that GPT-5 models resolve correctly."""
  2822. assert resolve_model("gpt-5") == "gpt-5-2025-08-07"
  2823. assert resolve_model("gpt-5-mini") == "gpt-5-mini-2025-08-07"
  2824. assert resolve_model("gpt-5-nano") == "gpt-5-nano-2025-08-07"
  2825. def test_gpt5_model_info() -> None:
  2826. """Test that GPT-5 models have correct capabilities."""
  2827. from autogen_ext.models.openai._model_info import get_info
  2828. gpt5_info = get_info("gpt-5")
  2829. assert gpt5_info["vision"] is True
  2830. assert gpt5_info["function_calling"] is True
  2831. assert gpt5_info["json_output"] is True
  2832. assert gpt5_info["family"] == ModelFamily.GPT_5
  2833. assert gpt5_info["structured_output"] is True
  2834. assert gpt5_info.get("multiple_system_messages", False) is True
  2835. gpt5_mini_info = get_info("gpt-5-mini")
  2836. assert gpt5_mini_info["family"] == ModelFamily.GPT_5_MINI
  2837. gpt5_nano_info = get_info("gpt-5-nano")
  2838. assert gpt5_nano_info["family"] == ModelFamily.GPT_5_NANO
  2839. def test_gpt5_client_creation() -> None:
  2840. """Test that GPT-5 client can be created with new parameters."""
  2841. client = OpenAIChatCompletionClient(
  2842. model="gpt-5",
  2843. api_key="test-key",
  2844. )
  2845. assert client.model_info["family"] == ModelFamily.GPT_5
  2846. @pytest.mark.asyncio
  2847. async def test_gpt5_reasoning_effort_parameter() -> None:
  2848. """Test that reasoning_effort parameter is properly handled."""
  2849. # Mock the OpenAI client to avoid actual API calls
  2850. import unittest.mock
  2851. with unittest.mock.patch(
  2852. "autogen_ext.models.openai._openai_client._openai_client_from_config"
  2853. ) as mock_client_factory:
  2854. mock_client = unittest.mock.AsyncMock()
  2855. mock_client_factory.return_value = mock_client
  2856. # Mock the completion response
  2857. mock_response = unittest.mock.MagicMock()
  2858. mock_response.choices = [unittest.mock.MagicMock()]
  2859. mock_response.choices[0].message.content = "Test response"
  2860. mock_response.choices[0].message.tool_calls = None
  2861. mock_response.choices[0].message.function_call = None
  2862. mock_response.choices[0].message.model_extra = None # Add this to fix the validation error
  2863. mock_response.choices[0].finish_reason = "stop"
  2864. mock_response.choices[0].logprobs = None # Add this to avoid potential issues
  2865. mock_response.usage.prompt_tokens = 10
  2866. mock_response.usage.completion_tokens = 5
  2867. mock_response.model = "gpt-5-2025-08-07"
  2868. mock_client.chat.completions.create.return_value = mock_response
  2869. client = OpenAIChatCompletionClient(
  2870. model="gpt-5",
  2871. api_key="test-key",
  2872. )
  2873. messages = [UserMessage(content="Test message", source="user")]
  2874. # Test with reasoning_effort parameter
  2875. await client.create(messages, reasoning_effort="minimal", verbosity="low")
  2876. # Verify the client was called with the correct parameters
  2877. call_args = mock_client.chat.completions.create.call_args
  2878. assert "reasoning_effort" in call_args.kwargs
  2879. assert call_args.kwargs["reasoning_effort"] == "minimal"
  2880. assert "verbosity" in call_args.kwargs
  2881. assert call_args.kwargs["verbosity"] == "low"
  2882. def test_gpt5_model_families() -> None:
  2883. """Test that GPT-5 model families are properly defined."""
  2884. assert ModelFamily.GPT_5 == "gpt-5"
  2885. assert ModelFamily.GPT_5_MINI == "gpt-5-mini"
  2886. assert ModelFamily.GPT_5_NANO == "gpt-5-nano"
  2887. # Check that they're included in the ANY type
  2888. any_args = get_args(ModelFamily.ANY)
  2889. assert "gpt-5" in any_args
  2890. assert "gpt-5-mini" in any_args
  2891. assert "gpt-5-nano" in any_args
  2892. # TODO: add integration tests for Azure OpenAI using AAD token.