fix(llm): allow tools in JSON-object mode
This commit is contained in:
parent
b6249b127e
commit
88e56540db
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue