Compare commits

...

8 Commits

Author SHA1 Message Date
lorenzejay
5b00b5f837 regen cassette 2026-02-25 15:49:34 -08:00
lorenzejay
120f2ef6e7 feat: implement strip_openai_specific_schema_fields utility
* Added a new utility function to remove OpenAI-specific fields from schemas, ensuring compatibility with Gemini and Bedrock APIs.
* Updated BedrockCompletion and GeminiCompletion classes to utilize this function for cleaning parameters before API calls.
* Introduced tests to validate the functionality of the new utility, ensuring it correctly strips incompatible fields while preserving required properties.
2026-02-25 15:26:43 -08:00
Greyson LaLonde
168b650226 Merge branch 'main' into lg-preserve-null-types-tools 2026-02-25 17:14:34 -05:00
Greyson LaLonde
d675a26899 Merge branch 'main' into lg-preserve-null-types-tools 2026-02-25 16:51:05 -05:00
Greyson LaLonde
c57400bff7 Merge branch 'main' into lg-preserve-null-types-tools 2026-02-25 11:56:21 -05:00
Lucas Gomide
a70af77893 Update lib/crewai/src/crewai/utilities/pydantic_schema_utils.py
Co-authored-by: Gabe Milani <gabriel@crewai.com>
2026-02-25 10:04:10 -05:00
Greyson LaLonde
18acf6dffd Merge branch 'main' into lg-preserve-null-types-tools 2026-02-25 07:59:17 -05:00
Lucas Gomide
e6c11a539f fix: preserve null types in tool parameter schemas for LLM
Tool parameter schemas were stripping null from optional fields via
generate_model_description, forcing the LLM to provide non-null values
for fields.
Adds strip_null_types parameter to generate_model_description and passes False when generating tool
schemas, so optional fields keep anyOf: [{type: T}, {type: null}]
2026-02-24 10:06:44 -03:00
8 changed files with 680 additions and 4 deletions

View File

@@ -1948,6 +1948,9 @@ class BedrockCompletion(BaseLLM):
) -> list[ConverseToolTypeDef]:
"""Convert CrewAI tools to Converse API format following AWS specification."""
from crewai.llms.providers.utils.common import safe_tool_conversion
from crewai.utilities.pydantic_schema_utils import (
strip_openai_specific_schema_fields,
)
converse_tools: list[ConverseToolTypeDef] = []
@@ -1961,6 +1964,7 @@ class BedrockCompletion(BaseLLM):
}
if parameters and isinstance(parameters, dict):
parameters = strip_openai_specific_schema_fields(parameters)
input_schema: ToolInputSchema = {"json": parameters}
tool_spec["inputSchema"] = input_schema

View File

@@ -529,10 +529,16 @@ class GeminiCompletion(BaseLLM):
gemini_tools = []
from crewai.llms.providers.utils.common import safe_tool_conversion
from crewai.utilities.pydantic_schema_utils import (
strip_openai_specific_schema_fields,
)
for tool in tools:
name, description, parameters = safe_tool_conversion(tool, "Gemini")
if parameters:
parameters = strip_openai_specific_schema_fields(parameters)
function_declaration = types.FunctionDeclaration(
name=name,
description=description,

View File

@@ -168,7 +168,9 @@ def convert_tools_to_openai_schema(
parameters: dict[str, Any] = {}
if hasattr(tool, "args_schema") and tool.args_schema is not None:
try:
schema_output = generate_model_description(tool.args_schema)
schema_output = generate_model_description(
tool.args_schema, strip_null_types=False
)
parameters = schema_output.get("json_schema", {}).get("schema", {})
# Remove title and description from schema root as they're redundant
parameters.pop("title", None)

View File

@@ -417,7 +417,40 @@ def strip_null_from_types(schema: dict[str, Any]) -> dict[str, Any]:
return schema
def generate_model_description(model: type[BaseModel]) -> ModelDescription:
def strip_openai_specific_schema_fields(schema: dict[str, Any]) -> dict[str, Any]:
"""Remove OpenAI strict-mode fields that are incompatible with other providers.
OpenAI strict mode requires ``additionalProperties: false`` on every object
and all properties listed in ``required``. Gemini and Bedrock reject these
fields with schema validation errors, so we strip them before handing off to
non-OpenAI providers.
Specifically removes (recursively):
- ``additionalProperties`` — not supported by Gemini / Bedrock tool schemas
Args:
schema: JSON schema dictionary (mutated in place and returned).
Returns:
The modified schema.
"""
if isinstance(schema, dict):
schema.pop("additionalProperties", None)
for value in schema.values():
if isinstance(value, dict):
strip_openai_specific_schema_fields(value)
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
strip_openai_specific_schema_fields(item)
return schema
def generate_model_description(
model: type[BaseModel],
*,
strip_null_types: bool = True,
) -> ModelDescription:
"""Generate JSON schema description of a Pydantic model.
This function takes a Pydantic model class and returns its JSON schema,
@@ -426,6 +459,9 @@ def generate_model_description(model: type[BaseModel]) -> ModelDescription:
Args:
model: A Pydantic model class.
strip_null_types: When ``True`` (default), remove ``null`` from
``anyOf`` / ``type`` arrays. Set to ``False`` to allow sending ``null`` for
optional fields.
Returns:
A ModelDescription with JSON schema representation of the model.
@@ -442,7 +478,9 @@ def generate_model_description(model: type[BaseModel]) -> ModelDescription:
json_schema = fix_discriminator_mappings(json_schema)
json_schema = convert_oneof_to_anyof(json_schema)
json_schema = ensure_all_properties_required(json_schema)
json_schema = strip_null_from_types(json_schema)
if strip_null_types:
json_schema = strip_null_from_types(json_schema)
return {
"type": "json_schema",

View File

@@ -559,6 +559,39 @@ class TestAnthropicNativeToolCalling:
_assert_tools_overlapped()
_MCP_OPTIONAL_FIELDS_TOOL_DEFS = [
{
"name": "filtered_search",
"description": (
"Search a knowledge base. Pass null for any filter you don't need."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query string",
},
"filter_type": {
"anyOf": [
{"type": "string", "enum": ["news", "blog", "docs"]},
{"type": "null"},
],
"default": None,
"description": "Optional content-type filter",
},
"page_id": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": None,
"description": "Optional page UUID",
},
},
"required": ["query"],
},
}
]
# =============================================================================
# Google/Gemini Provider Tests
# =============================================================================
@@ -796,6 +829,9 @@ class TestAzureNativeToolCalling:
_assert_tools_overlapped()
# =============================================================================
# Bedrock Provider Tests
# =============================================================================
@@ -929,6 +965,135 @@ class TestNativeToolCallingBehavior:
assert hasattr(llm, "supports_function_calling")
assert llm.supports_function_calling() is True
@pytest.mark.vcr()
def test_gemini_mcp_tool_with_optional_fields_no_schema_error(self) -> None:
"""MCP tools with Optional fields must not cause Gemini schema validation errors.
Regression test for https://github.com/crewAIInc/crewAI/issues/4472.
Before the fix, generate_model_description() added additionalProperties:false
to the tool schema. Gemini rejects this field, raising:
"value at properties.<field> must be a list"
With the fix, strip_openai_specific_schema_fields() removes it before the
schema is sent to the Gemini API.
To record the cassette:
RUN_LIVE_GEMINI_TESTS=true GOOGLE_API_KEY=<key> \\
uv run pytest tests/agents/test_native_tool_calling.py \\
::TestGeminiNativeToolCalling::test_gemini_mcp_tool_with_optional_fields_no_schema_error \\
--record-mode=once -v
"""
from unittest.mock import AsyncMock, patch
from crewai.mcp.config import MCPServerStdio
stdio_config = MCPServerStdio(command="python", args=["fake_mcp_server.py"])
agent = Agent(
role="Knowledge Researcher",
goal="Search a knowledge base and return relevant results",
backstory="You are a helpful researcher who uses the filtered_search tool.",
mcps=[stdio_config],
llm=LLM(model="gemini/gemini-2.5-flash"),
verbose=False,
max_iter=3,
)
with patch("crewai.agent.core.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.list_tools = AsyncMock(
return_value=_MCP_OPTIONAL_FIELDS_TOOL_DEFS
)
mock_client.connected = False
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client.call_tool = AsyncMock(
return_value="Search results: Introduction to agents, Tool integration guide"
)
mock_client_class.return_value = mock_client
task = Task(
description=(
"Search the knowledge base for information about CrewAI agents. "
"No filters needed — just a general search."
),
expected_output="A brief summary of relevant search results.",
agent=agent,
)
crew = Crew(agents=[agent], tasks=[task])
result = crew.kickoff()
assert result is not None
assert result.raw is not None
@pytest.mark.vcr()
def test_bedrock_mcp_tool_with_optional_fields_no_schema_error(self) -> None:
"""MCP tools with Optional fields must not cause Bedrock schema validation errors.
Regression test for https://github.com/crewAIInc/crewAI/issues/4472.
Before the fix, generate_model_description() added additionalProperties:false
to the tool schema. The Bedrock Converse API rejects this field, raising:
"JSON schema is invalid"
With the fix, strip_openai_specific_schema_fields() removes it before the
schema is sent to Bedrock.
Note: MCPClient is mocked — no Bedrock credentials are needed to run this
test. The LLM call is replayed from the VCR cassette.
To record the cassette:
AWS_ACCESS_KEY_ID=<key> AWS_SECRET_ACCESS_KEY=<secret> AWS_DEFAULT_REGION=us-east-1 \\
uv run pytest tests/agents/test_native_tool_calling.py \\
::TestNativeToolCallingBehavior::test_bedrock_mcp_tool_with_optional_fields_no_schema_error \\
--record-mode=once -v
"""
from unittest.mock import AsyncMock, patch
from crewai.mcp.config import MCPServerStdio
stdio_config = MCPServerStdio(command="python", args=["fake_mcp_server.py"])
agent = Agent(
role="Knowledge Researcher",
goal="Search a knowledge base and return relevant results",
backstory="You are a helpful researcher who uses the filtered_search tool.",
mcps=[stdio_config],
llm=LLM(model="bedrock/anthropic.claude-3-haiku-20240307-v1:0"),
verbose=False,
max_iter=3,
)
with patch("crewai.agent.core.MCPClient") as mock_client_class:
mock_client = AsyncMock()
mock_client.list_tools = AsyncMock(
return_value=_MCP_OPTIONAL_FIELDS_TOOL_DEFS
)
mock_client.connected = False
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
mock_client.call_tool = AsyncMock(
return_value="Search results: Introduction to agents, Tool integration guide"
)
mock_client_class.return_value = mock_client
task = Task(
description=(
"Search the knowledge base for information about CrewAI agents. "
"No filters needed — just a general search."
),
expected_output="A brief summary of relevant search results.",
agent=agent,
)
crew = Crew(agents=[agent], tasks=[task])
result = crew.kickoff()
assert result is not None
assert result.raw is not None
# =============================================================================
# Token Usage Tests

View File

@@ -0,0 +1,128 @@
interactions:
- request:
body: '{"messages": [{"role": "user", "content": [{"text": "\nCurrent Task: Search
the knowledge base for information about CrewAI agents. No filters needed \u2014
just a general search.\n\nThis is the expected criteria for your final answer:
A brief summary of relevant search results.\nyou MUST return the actual complete
content as the final answer, not a summary."}]}], "inferenceConfig": {"stopSequences":
["\nObservation:"]}, "system": [{"text": "You are Knowledge Researcher. You
are a helpful researcher who uses the filtered_search tool.\nYour personal goal
is: Search a knowledge base and return relevant results"}], "toolConfig": {"tools":
[{"toolSpec": {"name": "python_fake_mcp_server_py_filtered_search", "description":
"Search a knowledge base. Pass null for any filter you don''t need.", "inputSchema":
{"json": {"properties": {"query": {"description": "Search query string", "title":
"Query", "type": "string"}, "filter_type": {"anyOf": [{"type": "string"}, {"type":
"object"}, {"type": "null"}], "default": null, "description": "Optional content-type
filter", "title": "Filter Type"}, "page_id": {"anyOf": [{"type": "string"},
{"type": "object"}, {"type": "null"}], "default": null, "description": "Optional
page UUID", "title": "Page Id"}}, "required": ["query", "filter_type", "page_id"],
"type": "object"}}}}]}}'
headers:
Content-Length:
- '1324'
Content-Type:
- !!binary |
YXBwbGljYXRpb24vanNvbg==
User-Agent:
- X-USER-AGENT-XXX
amz-sdk-invocation-id:
- AMZ-SDK-INVOCATION-ID-XXX
amz-sdk-request:
- !!binary |
YXR0ZW1wdD0x
authorization:
- AUTHORIZATION-XXX
x-amz-date:
- X-AMZ-DATE-XXX
method: POST
uri: https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/converse
response:
body:
string: '{"metrics":{"latencyMs":1866},"output":{"message":{"content":[{"text":"Understood.
Here is the search query to find information about CrewAI agents in the knowledge
base:"},{"toolUse":{"input":{"query":"CrewAI agents","filter_type":null,"page_id":null},"name":"python_fake_mcp_server_py_filtered_search","toolUseId":"tooluse_DH3PzO6KYfG8Zn9tGJZrvL"}}],"role":"assistant"}},"stopReason":"tool_use","usage":{"inputTokens":572,"outputTokens":129,"serverToolUsage":{},"totalTokens":701}}'
headers:
Connection:
- keep-alive
Content-Length:
- '483'
Content-Type:
- application/json
Date:
- Wed, 25 Feb 2026 23:49:21 GMT
x-amzn-RequestId:
- X-AMZN-REQUESTID-XXX
status:
code: 200
message: OK
- request:
body: '{"messages": [{"role": "user", "content": [{"text": "\nCurrent Task: Search
the knowledge base for information about CrewAI agents. No filters needed \u2014
just a general search.\n\nThis is the expected criteria for your final answer:
A brief summary of relevant search results.\nyou MUST return the actual complete
content as the final answer, not a summary."}]}, {"role": "assistant", "content":
[{"toolUse": {"toolUseId": "tooluse_DH3PzO6KYfG8Zn9tGJZrvL", "name": "python_fake_mcp_server_py_filtered_search",
"input": {}}}]}, {"role": "user", "content": [{"toolResult": {"toolUseId": "tooluse_DH3PzO6KYfG8Zn9tGJZrvL",
"content": [{"text": "Search results: Introduction to agents, Tool integration
guide"}]}}]}, {"role": "user", "content": [{"text": "Analyze the tool result.
If requirements are met, provide the Final Answer. Otherwise, call the next
tool. Deliver only the answer without meta-commentary."}]}], "inferenceConfig":
{"stopSequences": ["\nObservation:"]}, "system": [{"text": "You are Knowledge
Researcher. You are a helpful researcher who uses the filtered_search tool.\nYour
personal goal is: Search a knowledge base and return relevant results"}], "toolConfig":
{"tools": [{"toolSpec": {"name": "python_fake_mcp_server_py_filtered_search",
"description": "Search a knowledge base. Pass null for any filter you don''t
need.", "inputSchema": {"json": {"properties": {"query": {"description": "Search
query string", "title": "Query", "type": "string"}, "filter_type": {"anyOf":
[{"type": "string"}, {"type": "object"}, {"type": "null"}], "default": null,
"description": "Optional content-type filter", "title": "Filter Type"}, "page_id":
{"anyOf": [{"type": "string"}, {"type": "object"}, {"type": "null"}], "default":
null, "description": "Optional page UUID", "title": "Page Id"}}, "required":
["query", "filter_type", "page_id"], "type": "object"}}}}]}}'
headers:
Content-Length:
- '1873'
Content-Type:
- !!binary |
YXBwbGljYXRpb24vanNvbg==
User-Agent:
- X-USER-AGENT-XXX
amz-sdk-invocation-id:
- AMZ-SDK-INVOCATION-ID-XXX
amz-sdk-request:
- !!binary |
YXR0ZW1wdD0x
authorization:
- AUTHORIZATION-XXX
x-amz-date:
- X-AMZ-DATE-XXX
method: POST
uri: https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1%3A0/converse
response:
body:
string: '{"metrics":{"latencyMs":2324},"output":{"message":{"content":[{"text":"<result>\nIntroduction
to agents:\nCrewAI agents are autonomous software entities that can perform
a variety of tasks within a larger system or application. They are designed
to be modular, scalable, and highly configurable, allowing them to be integrated
into a wide range of use cases. Agents can handle tasks such as data processing,
decision-making, and event-driven automation.\n\nTool integration guide:\nThe
CrewAI tool integration guide provides detailed instructions on how to integrate
CrewAI agents into your existing software systems and workflows. It covers
topics such as agent deployment, configuration, communication protocols, and
monitoring/reporting. The guide includes code samples and best practices to
ensure seamless integration.\n</result>"}],"role":"assistant"}},"stopReason":"end_turn","usage":{"inputTokens":680,"outputTokens":154,"serverToolUsage":{},"totalTokens":834}}'
headers:
Connection:
- keep-alive
Content-Length:
- '969'
Content-Type:
- application/json
Date:
- Wed, 25 Feb 2026 23:49:24 GMT
x-amzn-RequestId:
- X-AMZN-REQUESTID-XXX
status:
code: 200
message: OK
version: 1

View File

@@ -0,0 +1,161 @@
interactions:
- request:
body: '{"contents": [{"parts": [{"text": "\nCurrent Task: Search the knowledge
base for information about CrewAI agents. No filters needed \u2014 just a general
search.\n\nThis is the expected criteria for your final answer: A brief summary
of relevant search results.\nyou MUST return the actual complete content as
the final answer, not a summary."}], "role": "user"}], "systemInstruction":
{"parts": [{"text": "You are Knowledge Researcher. You are a helpful researcher
who uses the filtered_search tool.\nYour personal goal is: Search a knowledge
base and return relevant results"}], "role": "user"}, "tools": [{"functionDeclarations":
[{"description": "Search a knowledge base. Pass null for any filter you don''t
need.", "name": "python_fake_mcp_server_py_filtered_search", "parameters_json_schema":
{"properties": {"query": {"description": "Search query string", "title": "Query",
"type": "string"}, "filter_type": {"anyOf": [{"type": "string"}, {"type": "object"},
{"type": "null"}], "default": null, "description": "Optional content-type filter",
"title": "Filter Type"}, "page_id": {"anyOf": [{"type": "string"}, {"type":
"object"}, {"type": "null"}], "default": null, "description": "Optional page
UUID", "title": "Page Id"}}, "required": ["query", "filter_type", "page_id"],
"type": "object"}}]}], "generationConfig": {"stopSequences": ["\nObservation:"]}}'
headers:
User-Agent:
- X-USER-AGENT-XXX
accept:
- '*/*'
accept-encoding:
- ACCEPT-ENCODING-XXX
connection:
- keep-alive
content-length:
- '1360'
content-type:
- application/json
host:
- generativelanguage.googleapis.com
x-goog-api-client:
- google-genai-sdk/1.49.0 gl-python/3.13.3
x-goog-api-key:
- X-GOOG-API-KEY-XXX
method: POST
uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent
response:
body:
string: "{\n \"candidates\": [\n {\n \"content\": {\n \"parts\":
[\n {\n \"functionCall\": {\n \"name\": \"python_fake_mcp_server_py_filtered_search\",\n
\ \"args\": {\n \"query\": \"CrewAI agents\"\n
\ }\n },\n \"thoughtSignature\": \"CsMEAb4+9vuUI8bbZhI2K5IFCJz+FZPQ+EP/o+hvgBgIAImbCgAE3lyB3kNCn3FcFFSBSPP0KKDiEqq/J0tZjsSmNolNSZr2qAc2kLpzDkI26PhKOk4X3WQ9JEUuaCopvsFYd4mBgZqLaK8PAASQQX8zFD5X54TCk6RFtood4o82kouQbXPCUZAKiC9V/tkz3Qqe5M+K7W69MKzsKGiQakO5I5UfIKc/KD69rROuf/ZUw5oU+8+0qQVhvp8HuPWyQW815eyVZidwIUfFRaswRzZp48PefjNWKhf/1NcVM5fy+RLq5M88FQZ9zhcY9wjV3H66G439VHPCH1SekLJ7A7se/60Sj7/oXfzl93FZ9kb4RMA2PsEJSFqb+EDVDhEmkRxsqCERSoEm7vSVut7AEJiO4UG3+5986MjV52IOpgdTZxWwUoJbQ9xKH7fov6rEDLa1ExSYLmqvnPJbZEhfR39vVmTM105qIlzFSVOBekOsqc6VCSX5P0iwmlxGib7YTRYFaqyY2ZuEWp4Xw+m82MjBIFRZPHW80tSG285VSTFUx6dxkMsfC1XT0TzqjwmeV+xSQ9fvuu8hM6EO5XN+vHIJSC2WPrN+Hc0WIaZb8Ol1q4BITA7jxAp8RLg7PD4FI7VzzMzyDMdf7kffSVMRGOdaNC8zGfM2KbSP3J2QA/InmM9bUawKsOz+nMLsjTs2fTp2nvMmNRePWlNkKOxV0DUfxCQRPtQOZk/OKd9U6GpCd/omHtGkk8hWcgYwhxQxXsNOqnIb\"\n
\ }\n ],\n \"role\": \"model\"\n },\n \"finishReason\":
\"STOP\",\n \"index\": 0,\n \"finishMessage\": \"Model generated
function call(s).\"\n }\n ],\n \"usageMetadata\": {\n \"promptTokenCount\":
203,\n \"candidatesTokenCount\": 28,\n \"totalTokenCount\": 361,\n \"promptTokensDetails\":
[\n {\n \"modality\": \"TEXT\",\n \"tokenCount\": 203\n
\ }\n ],\n \"thoughtsTokenCount\": 130\n },\n \"modelVersion\":
\"gemini-2.5-flash\",\n \"responseId\": \"bICfafeTH_T5-8YP8rnE6Qo\"\n}\n"
headers:
Alt-Svc:
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-Type:
- application/json; charset=UTF-8
Date:
- Wed, 25 Feb 2026 23:06:21 GMT
Server:
- scaffolding on HTTPServer2
Server-Timing:
- gfet4t7; dur=1210
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- X-CONTENT-TYPE-XXX
X-Frame-Options:
- X-FRAME-OPTIONS-XXX
X-XSS-Protection:
- '0'
status:
code: 200
message: OK
- request:
body: '{"contents": [{"parts": [{"text": "\nCurrent Task: Search the knowledge
base for information about CrewAI agents. No filters needed \u2014 just a general
search.\n\nThis is the expected criteria for your final answer: A brief summary
of relevant search results.\nyou MUST return the actual complete content as
the final answer, not a summary."}], "role": "user"}, {"parts": [{"functionCall":
{"args": {"query": "CrewAI agents"}, "name": "python_fake_mcp_server_py_filtered_search"}}],
"role": "model"}, {"parts": [{"functionResponse": {"name": "python_fake_mcp_server_py_filtered_search",
"response": {"result": "Search results: Introduction to agents, Tool integration
guide"}}}], "role": "user"}, {"parts": [{"text": "Analyze the tool result. If
requirements are met, provide the Final Answer. Otherwise, call the next tool.
Deliver only the answer without meta-commentary."}], "role": "user"}], "systemInstruction":
{"parts": [{"text": "You are Knowledge Researcher. You are a helpful researcher
who uses the filtered_search tool.\nYour personal goal is: Search a knowledge
base and return relevant results"}], "role": "user"}, "tools": [{"functionDeclarations":
[{"description": "Search a knowledge base. Pass null for any filter you don''t
need.", "name": "python_fake_mcp_server_py_filtered_search", "parameters_json_schema":
{"properties": {"query": {"description": "Search query string", "title": "Query",
"type": "string"}, "filter_type": {"anyOf": [{"type": "string"}, {"type": "object"},
{"type": "null"}], "default": null, "description": "Optional content-type filter",
"title": "Filter Type"}, "page_id": {"anyOf": [{"type": "string"}, {"type":
"object"}, {"type": "null"}], "default": null, "description": "Optional page
UUID", "title": "Page Id"}}, "required": ["query", "filter_type", "page_id"],
"type": "object"}}]}], "generationConfig": {"stopSequences": ["\nObservation:"]}}'
headers:
User-Agent:
- X-USER-AGENT-XXX
accept:
- '*/*'
accept-encoding:
- ACCEPT-ENCODING-XXX
connection:
- keep-alive
content-length:
- '1893'
content-type:
- application/json
host:
- generativelanguage.googleapis.com
x-goog-api-client:
- google-genai-sdk/1.49.0 gl-python/3.13.3
x-goog-api-key:
- X-GOOG-API-KEY-XXX
method: POST
uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent
response:
body:
string: "{\n \"candidates\": [\n {\n \"content\": {\n \"parts\":
[\n {\n \"text\": \"Search results: Introduction to agents,
Tool integration guide\"\n }\n ],\n \"role\": \"model\"\n
\ },\n \"finishReason\": \"STOP\",\n \"index\": 0\n }\n ],\n
\ \"usageMetadata\": {\n \"promptTokenCount\": 299,\n \"candidatesTokenCount\":
10,\n \"totalTokenCount\": 309,\n \"promptTokensDetails\": [\n {\n
\ \"modality\": \"TEXT\",\n \"tokenCount\": 299\n }\n ]\n
\ },\n \"modelVersion\": \"gemini-2.5-flash\",\n \"responseId\": \"bYCfacPnLsD4-8YP6p64sQs\"\n}\n"
headers:
Alt-Svc:
- h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
Content-Type:
- application/json; charset=UTF-8
Date:
- Wed, 25 Feb 2026 23:06:22 GMT
Server:
- scaffolding on HTTPServer2
Server-Timing:
- gfet4t7; dur=380
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- X-CONTENT-TYPE-XXX
X-Frame-Options:
- X-FRAME-OPTIONS-XXX
X-XSS-Protection:
- '0'
status:
code: 200
message: OK
version: 1

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from typing import Any
from typing import Any, Literal, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -235,6 +235,178 @@ def _make_mock_i18n() -> MagicMock:
}.get(key, "")
return mock_i18n
class MCPStyleInput(BaseModel):
"""Input schema mimicking an MCP tool with optional fields."""
query: str = Field(description="Search query")
filter_type: Optional[Literal["internal", "user"]] = Field(
default=None, description="Filter type"
)
page_id: Optional[str] = Field(
default=None, description="Page UUID"
)
class MCPStyleTool(BaseTool):
"""A tool mimicking MCP tool schemas with optional fields."""
name: str = "mcp_search"
description: str = "Search with optional filters"
args_schema: type[BaseModel] = MCPStyleInput
def _run(self, **kwargs: Any) -> str:
return "result"
class TestOptionalFieldsPreserveNull:
"""Tests that optional tool fields preserve null in the schema."""
def test_optional_string_allows_null(self) -> None:
"""Optional[str] fields should include null in the schema so the LLM
can send null instead of being forced to guess a value."""
tools = [MCPStyleTool()]
schemas, _ = convert_tools_to_openai_schema(tools)
params = schemas[0]["function"]["parameters"]
page_id_prop = params["properties"]["page_id"]
assert "anyOf" in page_id_prop
type_options = [opt.get("type") for opt in page_id_prop["anyOf"]]
assert "string" in type_options
assert "null" in type_options
def test_optional_literal_allows_null(self) -> None:
"""Optional[Literal[...]] fields should include null."""
tools = [MCPStyleTool()]
schemas, _ = convert_tools_to_openai_schema(tools)
params = schemas[0]["function"]["parameters"]
filter_prop = params["properties"]["filter_type"]
assert "anyOf" in filter_prop
has_null = any(opt.get("type") == "null" for opt in filter_prop["anyOf"])
assert has_null
def test_required_field_stays_non_null(self) -> None:
"""Required fields without Optional should NOT have null."""
tools = [MCPStyleTool()]
schemas, _ = convert_tools_to_openai_schema(tools)
params = schemas[0]["function"]["parameters"]
query_prop = params["properties"]["query"]
assert query_prop.get("type") == "string"
assert "anyOf" not in query_prop
def test_all_fields_in_required_for_strict_mode(self) -> None:
"""All fields (including optional) must be in required for strict mode."""
tools = [MCPStyleTool()]
schemas, _ = convert_tools_to_openai_schema(tools)
params = schemas[0]["function"]["parameters"]
assert "query" in params["required"]
assert "filter_type" in params["required"]
assert "page_id" in params["required"]
class TestStripOpenAISpecificSchemaFields:
"""Tests for strip_openai_specific_schema_fields.
All tests call the real schema pipeline with real tool instances —
no mocking of internal functions.
"""
def test_removes_additional_properties_at_root(self) -> None:
"""additionalProperties must be absent after stripping."""
from crewai.utilities.pydantic_schema_utils import (
strip_openai_specific_schema_fields,
)
tools = [MCPStyleTool()]
schemas, _ = convert_tools_to_openai_schema(tools)
params = schemas[0]["function"]["parameters"]
# Confirm it's present in the OpenAI-format schema
assert params.get("additionalProperties") is False
clean = strip_openai_specific_schema_fields(params)
assert "additionalProperties" not in clean
def test_removes_additional_properties_recursively(self) -> None:
"""Nested objects must also have additionalProperties removed."""
from crewai.utilities.pydantic_schema_utils import (
strip_openai_specific_schema_fields,
)
class NestedInput(BaseModel):
config: dict[str, Any] = Field(default_factory=dict)
query: str = Field(description="query")
class NestedTool(BaseTool):
name: str = "nested_tool"
description: str = "tool with nested object"
args_schema: type[BaseModel] = NestedInput
def _run(self, **kwargs: Any) -> str:
return "ok"
schemas, _ = convert_tools_to_openai_schema([NestedTool()])
params = schemas[0]["function"]["parameters"]
clean = strip_openai_specific_schema_fields(params)
def has_additional_properties(obj: Any) -> bool:
if isinstance(obj, dict):
if "additionalProperties" in obj:
return True
return any(has_additional_properties(v) for v in obj.values())
if isinstance(obj, list):
return any(has_additional_properties(i) for i in obj)
return False
assert not has_additional_properties(clean)
def test_preserves_required_and_properties(self) -> None:
"""required and properties must survive the strip."""
from crewai.utilities.pydantic_schema_utils import (
strip_openai_specific_schema_fields,
)
tools = [MCPStyleTool()]
schemas, _ = convert_tools_to_openai_schema(tools)
params = schemas[0]["function"]["parameters"]
clean = strip_openai_specific_schema_fields(params)
assert "properties" in clean
assert "required" in clean
assert "query" in clean["properties"]
def test_gemini_bedrock_path_extracts_clean_parameters(self) -> None:
"""extract_tool_info returns the same parameters dict that
strip_openai_specific_schema_fields will clean — verifying the
full Gemini/Bedrock conversion pipeline produces a valid schema."""
from crewai.llms.providers.utils.common import extract_tool_info
from crewai.utilities.pydantic_schema_utils import (
strip_openai_specific_schema_fields,
)
tools = [MCPStyleTool()]
schemas, _ = convert_tools_to_openai_schema(tools)
# extract_tool_info is what Gemini/Bedrock call internally
_name, _desc, parameters = extract_tool_info(schemas[0])
assert parameters.get("additionalProperties") is False, (
"Raw OpenAI schema should have additionalProperties=false"
)
clean = strip_openai_specific_schema_fields(parameters)
assert "additionalProperties" not in clean
# null types preserved for optional fields
assert any(
opt.get("type") == "null"
for opt in clean["properties"]["page_id"].get("anyOf", [])
)
class TestSummarizeMessages:
"""Tests for summarize_messages function."""