fix: strip empty {} entries from anyOf/oneOf in strict JSON schema (#5130)#5134
fix: strip empty {} entries from anyOf/oneOf in strict JSON schema (#5130)#5134guoyangzhen wants to merge 2 commits intolivekit:mainfrom
Conversation
When a Pydantic model field is typed as Union[Literal[...], Any] (common in
codegen clients like Fern for forward-compatible 'open enums'), model_json_schema()
produces an anyOf containing a bare {} entry. OpenAI's strict mode rejects schemas
containing {} because it lacks a type field.
This fix strips empty schema objects from anyOf and oneOf arrays before recursing,
making the schema strict-mode compatible. Removing {} is semantically neutral since
it is the JSON Schema identity element for anyOf (matches anything).
Fixes livekit#5130
|
|
| any_of = [v for v in any_of if v != {}] | ||
| json_schema["anyOf"] = [ | ||
| _ensure_strict_json_schema(variant, path=(*path, "anyOf", str(i)), root=root) | ||
| for i, variant in enumerate(any_of) |
There was a problem hiding this comment.
🟡 Single-element anyOf/oneOf not unwrapped after stripping empty {} entries
After stripping empty {} entries from anyOf/oneOf, if only one variant remains, the code leaves a redundant single-element wrapper (e.g., "anyOf": [{"type": "null"}] instead of just {"type": "null"}). This is confirmed reachable with Union[Any, None] (produces anyOf: [{}, {"type": "null"}] → stripped to anyOf: [{"type": "null"}]) and Union[str, Any] (produces anyOf: [{"type": "string"}, {}] → stripped to anyOf: [{"type": "string"}]). The code already handles single-element allOf by unwrapping at _strict.py:103-107, but the new stripping logic for anyOf/oneOf doesn't include analogous unwrapping. Additionally, if all variants are {} (extremely unlikely), the result would be anyOf: [] which is invalid JSON Schema.
(Refers to lines 84-88)
Prompt for agents
In livekit-agents/livekit/agents/llm/_strict.py, after stripping empty {} entries from anyOf (lines 84-88) and oneOf (lines 94-98), add handling for the case where the filtered list has 0 or 1 elements, similar to the existing allOf handling at lines 103-107.
For anyOf (around line 84):
1. After filtering (any_of = [v for v in any_of if v != {}]), check len(any_of).
2. If len(any_of) == 0: remove the anyOf key from json_schema entirely (json_schema.pop("anyOf", None)).
3. If len(any_of) == 1: unwrap the single element by updating json_schema with the processed single variant and removing the anyOf key, similar to the allOf single-element pattern at lines 103-107.
4. Otherwise (len >= 2): proceed with the existing logic.
Apply the same pattern to oneOf (around line 94).
Was this helpful? React with 👍 or 👎 to provide feedback.
| import enum | ||
| from typing import Literal | ||
| import json | ||
| import typing |
There was a problem hiding this comment.
🟡 Unused import typing in test file violates CONTRIBUTING.md lint rule
import typing on line 3 is unused — typing is never referenced by name in the file (all needed symbols are imported via from typing import Any, Literal, Union). Running ruff check confirms the F401 violation. CONTRIBUTING.md mandates running ruff check --fix before committing, which would remove this import.
| import typing |
Was this helpful? React with 👍 or 👎 to provide feedback.
|
Hey, lgtm, can you fix the ruff CI? |
|
Continued here: #5137 |
Summary
Fixes #5130
When a Pydantic model field is typed as
Union[Literal["a", "b"], Any](common pattern in codegen clients like Fern for forward-compatible "open enums"),model_json_schema()produces ananyOfcontaining a bare{}entry:OpenAI's strict mode rejects schemas containing
{}because it lacks a type field. This causes anyFunctionToolwhose signature includes a model with aUnion[..., Any]field to fail at the API level.Root Cause
_ensure_strict_json_schemarecurses intoanyOf/oneOfvariants but doesn't strip or transform empty{}entries. Pydantic generates these forAnytypes in unions.Fix
Strip empty schema objects (
{}) fromanyOfandoneOfarrays before recursing.{}is the JSON Schema identity element foranyOf(matches anything), so removing it is semantically neutral and makes the schema strict-mode compatible.Changes
livekit-agents/livekit/agents/llm/_strict.py: Filter out{}entries before processinganyOf/oneOfvariantstests/test_tools.py: AddTestEmptySchemaStrippingtest class with test cases for:Union[Literal, Any]fieldVerification