Skip to content
Merged
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
35 changes: 20 additions & 15 deletions livekit-agents/livekit/agents/llm/_strict.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,21 +75,26 @@ def _ensure_strict_json_schema(
if is_dict(items):
json_schema["items"] = _ensure_strict_json_schema(items, path=(*path, "items"), root=root)

# unions
any_of = json_schema.get("anyOf")
if is_list(any_of):
json_schema["anyOf"] = [
_ensure_strict_json_schema(variant, path=(*path, "anyOf", str(i)), root=root)
for i, variant in enumerate(any_of)
]

# unions (oneOf)
one_of = json_schema.get("oneOf")
if is_list(one_of):
json_schema["oneOf"] = [
_ensure_strict_json_schema(variant, path=(*path, "oneOf", str(i)), root=root)
for i, variant in enumerate(one_of)
]
# unions (anyOf / oneOf)
# Strip empty schema objects ({}) — they are JSON Schema's identity element
# for anyOf (match anything) and cause OpenAI strict mode to reject the schema.
# Common when Union[..., Any] or ForwardRef patterns produce bare {} entries.
for union_key in ("anyOf", "oneOf"):
variants = json_schema.get(union_key)
if is_list(variants):
variants = [v for v in variants if v != {}]
if len(variants) == 1:
json_schema.update(
_ensure_strict_json_schema(variants[0], path=(*path, union_key, "0"), root=root)
)
json_schema.pop(union_key, None)
Comment on lines +87 to +90
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 Single-variant anyOf/oneOf unwrapping loses inner union when it has the same key

When stripping {} entries from anyOf/oneOf leaves exactly one variant, the code at livekit-agents/livekit/agents/llm/_strict.py:87-90 calls json_schema.update(processed_variant) then json_schema.pop(union_key, None). If the processed variant itself contains the same union key (e.g., an inner anyOf inside an outer anyOf), the update overwrites json_schema[union_key] with the inner union's variants, and then pop unconditionally removes it — silently dropping all type information.

Reproduction and expected behavior

Input schema: {"anyOf": [{"anyOf": [{"type": "string"}, {"type": "integer"}]}, {}]}

Expected output: {"anyOf": [{"type": "string"}, {"type": "integer"}]}

Actual output: {} — all type information is lost.

The fix is to swap the order: call pop before update, so the outer union key is removed first, and then the inner union (if present) is correctly merged in.

Suggested change
json_schema.update(
_ensure_strict_json_schema(variants[0], path=(*path, union_key, "0"), root=root)
)
json_schema.pop(union_key, None)
json_schema.pop(union_key, None)
json_schema.update(
_ensure_strict_json_schema(variants[0], path=(*path, union_key, "0"), root=root)
)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

elif len(variants) >= 2:
json_schema[union_key] = [
_ensure_strict_json_schema(variant, path=(*path, union_key, str(i)), root=root)
for i, variant in enumerate(variants)
]
else:
json_schema.pop(union_key, None)

# intersections
all_of = json_schema.get("allOf")
Expand Down
52 changes: 51 additions & 1 deletion tests/test_tools.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import enum
from typing import Literal
import json
from typing import Any, Literal

import pytest
from pydantic import BaseModel, Field
Expand Down Expand Up @@ -447,3 +448,52 @@ def test_non_nullable_enum_excludes_null(self):
status = schema["properties"]["status"]
assert None not in status["enum"], f"enum should not contain None: {status}"
assert "null" not in status.get("type", []), f"type should not contain 'null': {status}"


class _OpenEnumModel(BaseModel):
"""Simulates a codegen'd "open enum" pattern (e.g. Fern Python SDK).
Union[Literal["a", "b"], Any] produces an anyOf with a bare {} entry."""

preference: Literal["a", "b"] | Any | None = None


class _NestedOpenEnumModel(BaseModel):
items: list[_OpenEnumModel]


def _has_empty_schema(schema: object) -> bool:
"""Recursively check if any dict in the schema tree is an empty {}."""
if isinstance(schema, dict):
if schema == {}:
return True
return any(_has_empty_schema(v) for v in schema.values())
if isinstance(schema, list):
return any(_has_empty_schema(v) for v in schema)
return False


class TestEmptySchemaStripping:
"""Test that empty {} entries are stripped from anyOf/oneOf."""

def test_open_enum_strips_empty_anyof(self):
schema = to_strict_json_schema(_OpenEnumModel)
assert not _has_empty_schema(schema), (
f"schema should not contain empty {{}}: {json.dumps(schema, indent=2)}"
)

def test_nested_open_enum_strips_empty_anyof(self):
schema = to_strict_json_schema(_NestedOpenEnumModel)
assert not _has_empty_schema(schema), (
f"nested schema should not contain empty {{}}: {json.dumps(schema, indent=2)}"
)

def test_single_variant_after_strip_is_unwrapped(self):
"""When stripping {} leaves a single variant, anyOf should be unwrapped."""
schema = to_strict_json_schema(_OpenEnumModel)
pref = schema["properties"]["preference"]
# After stripping {}, the union should be simplified (no single-element anyOf)
any_of = pref.get("anyOf")
if any_of is not None:
assert len(any_of) != 1, (
f"single-element anyOf should be unwrapped: {json.dumps(pref, indent=2)}"
)
Loading