Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions lib/crewai/src/crewai/llms/providers/openai/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,16 +723,19 @@ def _prepare_responses_params(
if response_model or self.response_format:
format_model = response_model or self.response_format
if isinstance(format_model, type) and issubclass(format_model, BaseModel):
schema_output = generate_model_description(format_model)
json_schema = schema_output.get("json_schema", {})
params["text"] = {
"format": {
"type": "json_schema",
"name": json_schema.get("name", format_model.__name__),
"strict": json_schema.get("strict", True),
"schema": json_schema.get("schema", {}),
if self._supports_structured_output():
schema_output = generate_model_description(format_model)
json_schema = schema_output.get("json_schema", {})
params["text"] = {
"format": {
"type": "json_schema",
"name": json_schema.get("name", format_model.__name__),
"strict": json_schema.get("strict", True),
"schema": json_schema.get("schema", {}),
}
}
}
else:
params["text"] = {"format": {"type": "json_object"}}
elif isinstance(format_model, dict):
params["text"] = {"format": format_model}

Expand Down Expand Up @@ -1573,9 +1576,12 @@ def _prepare_completion_params(
if isinstance(self.response_format, type) and issubclass(
self.response_format, BaseModel
):
params["response_format"] = generate_model_description(
self.response_format
)
if self._supports_structured_output():
params["response_format"] = generate_model_description(
self.response_format
)
else:
params["response_format"] = {"type": "json_object"}
Comment on lines +1579 to +1584

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

OpenAI chat completions response_format json_object requires messages contain the word json

💡 Result:

Yes, when using the OpenAI Chat Completions API with response_format set to {"type": "json_object"}, it is a strict requirement that your messages (which includes the system, user, or assistant messages) contain the word "json" (or "JSON") [1][2][3]. If this requirement is not met, the API will return a 400 error stating that the messages must contain the word "json" in some form [4][5][6][3]. This safeguard exists because, without an explicit instruction to produce JSON, the model may fail to output a valid JSON object, potentially resulting in an infinite stream of whitespace or other malformed outputs [2][6][3]. To satisfy this requirement, you should include a clear instruction in your messages—typically in the system prompt—such as "You are a helpful assistant designed to output JSON" or "Respond in JSON format" [1][2][3]. Note that while this "JSON mode" (json_object) is a common way to generate JSON, OpenAI now recommends using Structured Outputs (type: "json_schema") for applications that require guaranteed adherence to a specific schema [2][7][8][3]. Structured Outputs also inherently instruct the model to produce valid JSON, making the manual inclusion of the word "json" in the prompt less of a primary constraint compared to JSON mode [2][7].

Citations:


json_object fallback may trigger an OpenAI API error if the prompt lacks "json"

Using response_format={"type": "json_object"} with the Chat Completions API strictly requires the word "json" to appear in the messages. If CrewAI's generated prompts do not explicitly include this term, the API will reject the request with a 400 error, replacing the original integration failure with a validation error.

When self._supports_structured_output() is false, ensure the system prompt is augmented to include a "Respond in JSON" instruction.
file: lib/crewai/src/crewai/llms/providers/openai/completion.py

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/llms/providers/openai/completion.py` around lines 1579
- 1584, The json_object fallback in the OpenAI completion flow can fail unless
the prompt explicitly contains “json”. Update the request-building logic in the
completion path around self._supports_structured_output() so that when
response_format is set to {"type": "json_object"}, the system prompt/messages
are augmented with a clear “Respond in JSON” instruction before sending the
request. Keep the structured-output branch using
generate_model_description(self.response_format) unchanged, and ensure the
fallback path consistently injects the JSON hint wherever the final messages are
assembled.

elif isinstance(self.response_format, dict):
params["response_format"] = self.response_format

Expand Down Expand Up @@ -2377,6 +2383,20 @@ async def _ahandle_streaming_completion(
response_id=stream_response_id,
)

def _supports_structured_output(self) -> bool:
"""Whether the model supports json_schema response_format type."""
model_lower = self.model.lower() if self.model else ""

unsupported_prefixes = ("o1-preview", "o1-mini", "gpt-4-base", "gpt-3.5", "gpt-35")
if model_lower.startswith(unsupported_prefixes):
return False

supported_prefixes = ("gpt-4o", "gpt-4-turbo", "gpt-4.1", "gpt-5", "o1", "o3", "o4")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Does OpenAI gpt-4-turbo support json_schema structured outputs response_format

💡 Result:

No, OpenAI's gpt-4-turbo does not support the json_schema structured outputs response_format [1][2][3]. While Structured Outputs as a capability is generally available in the API, its support is divided into two distinct methods [1][2]: 1. Structured Outputs via Function Calling: This method is supported on a wide range of models, including gpt-4-turbo, gpt-4, and gpt-3.5-turbo, for all models that already supported function calling [2][3]. 2. Structured Outputs via response_format (using json_schema): This specific implementation is restricted to newer models, starting with gpt-4o-2024-08-06 and gpt-4o-mini-2024-07-18 [1][2][3]. If you are using gpt-4-turbo, you cannot use the { "type": "json_schema",... } response format directly [1]. Instead, for gpt-4-turbo, you may use JSON mode by setting response_format to { "type": "json_object" } (which ensures valid JSON but does not enforce a specific schema) or continue using function calling if you require strict schema adherence [1][3].

Citations:


Remove gpt-4-turbo from supported_prefixes to prevent API errors.

OpenAI's gpt-4-turbo does not support the json_schema response format; it only supports json_object or function calling for structured outputs. The json_schema capability is restricted to gpt-4o-2024-08-06+, gpt-4o-mini, and the o-series models. Keeping gpt-4-turbo in this list causes the code to attempt unsupported API calls.

Original Snippet
        supported_prefixes = ("gpt-4o", "gpt-4-turbo", "gpt-4.1", "gpt-5", "o1", "o3", "o4")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/llms/providers/openai/completion.py` at line 2394,
Remove gpt-4-turbo from the supported_prefixes tuple in the OpenAI completion
logic so structured-output detection no longer treats it as json_schema-capable.
Update the prefix check in the completion.py path that builds supported_prefixes
for response-format handling, keeping only the models that actually support
json_schema (such as gpt-4o, gpt-4.1, gpt-5, and the o-series prefixes).

if model_lower.startswith(supported_prefixes):
return True

return True

Comment on lines +2386 to +2399

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the response_model -> beta.parse path is unguarded by _supports_structured_output
rg -nP -C3 'beta\.chat\.completions\.(parse|stream)\(' lib/crewai/src/crewai/llms/providers/openai/completion.py
# Confirm litellm compat helper is available to reuse
rg -nP 'supports_response_schema' lib/crewai/src/crewai/llm.py

Repository: crewAIInc/crewAI

Length of output: 1648


Guard structured output with a capability check and remove dead allowlist

The supported_prefixes block never changes the outcome because the function defaults to True; it only denies specific prefixes. This leaves unknown models (e.g., Deepseek V4 Pro) enabling json_schema, which triggers response_format type is unavailable now on non‑compliant endpoints.

Use litellm.utils.supports_response_schema to determine capability, and gate the beta.parse/beta.stream paths by that result. If the helper isn’t available, fall back to a conservative denylist.

Suggested changes:

  • Rewrite _supports_structured_output in lib/crewai/src/crewai/llms/providers/openai/completion.py to rely on litellm’s check:
Replace lines 2386–2399
    def _supports_structured_output(self) -> bool:
        """Whether the model supports json_schema response_format type."""
        global supports_response_schema

        if not _ensure_litellm() or supports_response_schema is None:
            # Fallback: deny known incompatible prefixes
            model_lower = self.model.lower() if self.model else ""
            unsupported_prefixes = ("o1-preview", "o1-mini", "gpt-4-base", "gpt-3.5", "gpt-35")
            return not model_lower.startswith(unsupported_prefixes)

        return bool(supports_response_schema(self.model))
  • Guard the beta.parse and beta.stream calls in _handle_completion and _ahandle_completion so they only use response_model when capability is supported. Otherwise, fall back to a plain response_format={"type": "text"} or omit structured output.
Apply guards at the call sites

For each occurrence around lines 1649–1651, 1922–1923, and 2070–2073:

  • If _supports_structured_output() is False, do not pass response_model to beta.parse/beta.stream; instead, pass response_format={"type": "text"} or re-route to a non-structured path.
  • Ensure the response_format parameter reflects the selected path to avoid sending json_schema to unsupported endpoints.

This prevents mis-routed json_schema payloads and aligns behavior with model capabilities across custom endpoints like Deepseek.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/llms/providers/openai/completion.py` around lines 2386
- 2399, The structured-output check in _supports_structured_output is
effectively allow-by-default, which lets unsupported models reach
beta.parse/beta.stream with json_schema. Update _supports_structured_output in
completion.py to use litellm.utils.supports_response_schema when available, and
fall back to a conservative denylist only if litellm support is unavailable.
Then gate the beta.parse and beta.stream call sites in _handle_completion and
_ahandle_completion on that helper so response_model is only passed when
structured output is truly supported; otherwise use a non-structured
response_format or plain text path.

def supports_function_calling(self) -> bool:
"""Check if the model supports function calling."""
return not self.is_o1_model
Expand Down
52 changes: 52 additions & 0 deletions lib/crewai/tests/llms/openai/test_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -2017,3 +2017,55 @@ def test_openai_no_detail_fields_omitted():
assert usage["completion_tokens"] == 30
assert "cached_prompt_tokens" not in usage
assert "reasoning_tokens" not in usage


from pydantic import BaseModel


class SampleOutput(BaseModel):
name: str
value: int


def test_structured_output_json_schema_for_supported_models():
"""Test that json_schema response_format is used for models that support it."""
llm = LLM(model="gpt-4o", response_format=SampleOutput, api_key="test-key")
params = llm._prepare_completion_params(messages=[{"role": "user", "content": "test"}])
assert params["response_format"]["type"] == "json_schema"
assert "json_schema" in params["response_format"]


def test_structured_output_fallback_for_unsupported_models():
"""Test that json_object fallback is used for models that don't support json_schema."""
llm = LLM(model="o1-preview", response_format=SampleOutput, api_key="test-key")
params = llm._prepare_completion_params(messages=[{"role": "user", "content": "test"}])
assert params["response_format"]["type"] == "json_object"


def test_structured_output_fallback_for_o1_mini():
"""Test that o1-mini falls back to json_object."""
llm = LLM(model="o1-mini", response_format=SampleOutput, api_key="test-key")
params = llm._prepare_completion_params(messages=[{"role": "user", "content": "test"}])
assert params["response_format"]["type"] == "json_object"


def test_structured_output_json_schema_for_o3_mini():
"""Test that o3-mini supports json_schema (it's a newer reasoning model)."""
llm = LLM(model="o3-mini", response_format=SampleOutput, api_key="test-key")
params = llm._prepare_completion_params(messages=[{"role": "user", "content": "test"}])
assert params["response_format"]["type"] == "json_schema"


def test_structured_output_json_schema_for_gpt4_turbo():
"""Test that gpt-4-turbo supports json_schema."""
llm = LLM(model="gpt-4-turbo", response_format=SampleOutput, api_key="test-key")
params = llm._prepare_completion_params(messages=[{"role": "user", "content": "test"}])
assert params["response_format"]["type"] == "json_schema"


def test_dict_response_format_passed_through():
"""Test that dict response_format is passed through unchanged."""
custom_format = {"type": "json_object"}
llm = LLM(model="o1-preview", response_format=custom_format, api_key="test-key")
params = llm._prepare_completion_params(messages=[{"role": "user", "content": "test"}])
assert params["response_format"] == custom_format