fix(llm): allow tools in JSON-object mode

This commit is contained in:
Elkim 2026-08-23 09:08:14 +02:00
parent b6249b127e
commit 88e56540db
2 changed files with 46 additions and 12 deletions

View File

@ -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 "<missing>"
raise ValidationException(
f"Unsupported Responses content block type: {block_type or '<missing>'}"
f"Unsupported Responses content block type: {block_name}"
)
elif content is not None:
raise ValidationException(

View File

@ -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]