Skip to content

Commit 2ba5b76

Browse files
committed
fix(llm): preserve response format nullability
1 parent d36f2b7 commit 2ba5b76

3 files changed

Lines changed: 74 additions & 18 deletions

File tree

livekit-agents/livekit/agents/llm/_strict.py

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,48 @@
77
_T = TypeVar("_T")
88

99

10-
def to_strict_json_schema(model: type[BaseModel] | TypeAdapter[Any]) -> dict[str, Any]:
10+
def _model_json_schema(model: type[BaseModel] | TypeAdapter[Any]) -> dict[str, Any]:
1111
if isinstance(model, TypeAdapter):
12-
schema = model.json_schema()
13-
else:
14-
schema = model.model_json_schema()
12+
return model.json_schema()
13+
return model.model_json_schema()
14+
15+
16+
def to_strict_json_schema(model: type[BaseModel] | TypeAdapter[Any]) -> dict[str, Any]:
17+
schema = _model_json_schema(model)
18+
19+
return _ensure_strict_json_schema(schema, path=(), root=schema)
20+
21+
22+
def to_strict_tool_json_schema(model: type[BaseModel] | TypeAdapter[Any]) -> dict[str, Any]:
23+
schema = _model_json_schema(model)
24+
_make_defaults_nullable(schema)
1525

1626
return _ensure_strict_json_schema(schema, path=(), root=schema)
1727

1828

29+
def _make_defaults_nullable(json_schema: object) -> None:
30+
"""Encode defaulted tool arguments as nullable fields for strict schemas.
31+
32+
OpenAI strict schemas require every property and do not support JSON Schema defaults, so
33+
``null`` represents "use the Python default". Tool argument preparation replaces that value
34+
before validation. Response formats intentionally skip this conversion because their output
35+
is validated directly against the declared Pydantic nullability.
36+
"""
37+
if isinstance(json_schema, dict):
38+
if "default" in json_schema:
39+
typ = json_schema.get("type")
40+
if isinstance(typ, str):
41+
json_schema["type"] = [typ, "null"]
42+
elif isinstance(typ, list) and "null" not in typ:
43+
json_schema["type"] = [*typ, "null"]
44+
45+
for value in json_schema.values():
46+
_make_defaults_nullable(value)
47+
elif isinstance(json_schema, list):
48+
for value in json_schema:
49+
_make_defaults_nullable(value)
50+
51+
1952
# from https://platform.openai.com/docs/guides/function-calling?api-mode=responses&strict-mode=disabled#strict-mode
2053
# Strict mode
2154
# Setting strict to true will ensure function calls reliably adhere to the function schema,
@@ -118,19 +151,6 @@ def _ensure_strict_json_schema(
118151
if "default" in json_schema:
119152
json_schema.pop("default", None)
120153

121-
# Treat any parameter with a default value as optional. If the parameter’s type doesn't
122-
# support None, the default will be used instead.
123-
t = json_schema.get("type")
124-
if isinstance(t, str):
125-
json_schema["type"] = [t, "null"]
126-
127-
elif isinstance(t, list):
128-
types = t.copy()
129-
if "null" not in types:
130-
types.append("null")
131-
132-
json_schema["type"] = types
133-
134154
json_schema.pop("title", None)
135155
json_schema.pop("discriminator", None)
136156

livekit-agents/livekit/agents/llm/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ def build_strict_openai_schema(
239239
"""strict mode tool description"""
240240
model = function_arguments_to_pydantic_model(function_tool)
241241
info = function_tool.info
242-
schema = _strict.to_strict_json_schema(model)
242+
schema = _strict.to_strict_tool_json_schema(model)
243243

244244
return {
245245
"type": "function",

tests/test_response_format.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
from pydantic import BaseModel
5+
6+
from livekit.agents.llm import function_tool
7+
from livekit.agents.llm.utils import build_strict_openai_schema, to_openai_response_format
8+
9+
pytestmark = pytest.mark.unit
10+
11+
12+
class _ResponseFormat(BaseModel):
13+
label: str = ""
14+
description: str | None = None
15+
16+
17+
def test_response_format_preserves_pydantic_nullability() -> None:
18+
response_format = to_openai_response_format(_ResponseFormat)
19+
schema = response_format["json_schema"]["schema"]
20+
21+
assert schema["required"] == ["label", "description"]
22+
assert schema["properties"]["label"]["type"] == "string"
23+
assert schema["properties"]["description"]["type"] == ["string", "null"]
24+
assert "default" not in schema["properties"]["label"]
25+
assert "default" not in schema["properties"]["description"]
26+
27+
28+
def test_strict_tool_schema_keeps_defaulted_arguments_nullable() -> None:
29+
@function_tool
30+
async def lookup(label: str = "") -> str:
31+
"""Look up an item by label."""
32+
return label
33+
34+
schema = build_strict_openai_schema(lookup)["function"]["parameters"]
35+
36+
assert schema["properties"]["label"]["type"] == ["string", "null"]

0 commit comments

Comments
 (0)