diff --git a/src/llm/backends/openai.py b/src/llm/backends/openai.py index d8a2fc60..2ab35846 100644 --- a/src/llm/backends/openai.py +++ b/src/llm/backends/openai.py @@ -30,12 +30,14 @@ logger = logging.getLogger(__name__) # does not hold a reference to the keyed BaseModel so that when a dynamically created # type is no longer referenced it becomes eligible for garbage collection. This avoids a # memory leak. -_json_object_instruction_cache: weakref.WeakKeyDictionary[type[BaseModel], str] = ( - weakref.WeakKeyDictionary() -) +_json_object_instruction_cache: weakref.WeakKeyDictionary[ + type[BaseModel], dict[bool, str] +] = weakref.WeakKeyDictionary() -def _json_object_instruction(response_format: type[BaseModel]) -> str: +def _json_object_instruction( + response_format: type[BaseModel], *, tools_present: bool = False +) -> str: """Schema-injection instruction for json_object mode. The JSON schema is static per response_format class, so cache the serialized @@ -43,17 +45,25 @@ def _json_object_instruction(response_format: type[BaseModel]) -> str: it every call. """ cached = _json_object_instruction_cache.get(response_format) - if cached is not None: - return cached + if cached is not None and tools_present in cached: + return cached[tools_present] # Some OpenAI-compatible providers enforce this JSON-object precondition with # a case-sensitive substring check, so include lowercase "json" explicitly. + requirement = ( + "If not responding with a tool call, respond with" + if tools_present + else "You must respond with" + ) instruction = ( - "You must respond with a single JSON object (json) that conforms " - "exactly to the following JSON schema. Do not include any text, " - "markdown, or code fences outside the JSON object.\n\nJSON schema:\n" + f"{requirement} a single JSON object (json) that conforms exactly to " + "the following JSON schema. Do not include any text, markdown, or code " + "fences outside the JSON object.\n\nJSON schema:\n" f"{json.dumps(response_format.model_json_schema())}" ) - _json_object_instruction_cache[response_format] = instruction + if cached is None: + cached = {} + _json_object_instruction_cache[response_format] = cached + cached[tools_present] = instruction return instruction @@ -536,7 +546,9 @@ class OpenAIBackend: isinstance(response_format, type) and self._structured_output_mode(extra_params) == "json_object" ): - schema_instruction = _json_object_instruction(response_format) + schema_instruction = _json_object_instruction( + response_format, tools_present=bool(tools) + ) instructions = "\n\n".join( part for part in (instructions, schema_instruction) if part ) @@ -699,8 +711,9 @@ class OpenAIBackend: } ) else: + block_name = block_type or "" raise ValidationException( - f"Unsupported Responses content block type: {block_type or ''}" + f"Unsupported Responses content block type: {block_name}" ) elif content is not None: raise ValidationException( diff --git a/tests/llm/test_backends/test_openai.py b/tests/llm/test_backends/test_openai.py index 60fb0bcb..630a3fe9 100644 --- a/tests/llm/test_backends/test_openai.py +++ b/tests/llm/test_backends/test_openai.py @@ -1701,6 +1701,27 @@ def test_responses_json_object_mode_injects_schema_instructions() -> None: assert "answer" in params["instructions"] +def test_responses_json_object_mode_keeps_tool_calls_available() -> None: + backend = OpenAIBackend(Mock()) + params = backend._build_responses_params( # pyright: ignore[reportPrivateUsage] + model="gpt-5.6-luna", + messages=[{"role": "user", "content": "Search, then answer."}], + max_tokens=100, + tools=[AGENT_TOOL], + tool_choice="auto", + response_format=_StructuredResponse, + thinking_effort=None, + extra_params={ + "api_mode": "responses", + "structured_output_mode": "json_object", + }, + ) + + assert params["text"]["format"] == {"type": "json_object"} + assert params["tools"][0]["name"] == "search" + assert "If not responding with a tool call" in params["instructions"] + + def test_openai_backend_responses_mode_can_omit_max_output_tokens() -> None: backend = OpenAIBackend(Mock()) params = backend._build_responses_params( # pyright: ignore[reportPrivateUsage]