Skip to content

fix: strip empty {} entries from anyOf/oneOf in strict JSON schema (#5130)#5134

Closed
guoyangzhen wants to merge 2 commits intolivekit:mainfrom
guoyangzhen:fix/strict-schema-empty-anyof
Closed

fix: strip empty {} entries from anyOf/oneOf in strict JSON schema (#5130)#5134
guoyangzhen wants to merge 2 commits intolivekit:mainfrom
guoyangzhen:fix/strict-schema-empty-anyof

Conversation

@guoyangzhen
Copy link
Contributor

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 an anyOf containing a bare {} entry:

"anyOf": [
    {"enum": ["a", "b"], "type": "string"},
    {},
    {"type": "null"}
]

OpenAI's strict mode rejects schemas containing {} because it lacks a type field. This causes any FunctionTool whose signature includes a model with a Union[..., Any] field to fail at the API level.

Root Cause

_ensure_strict_json_schema recurses into anyOf/oneOf variants but doesn't strip or transform empty {} entries. Pydantic generates these for Any types in unions.

Fix

Strip empty schema objects ({}) from anyOf and oneOf arrays before recursing. {} is the JSON Schema identity element for anyOf (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 processing anyOf/oneOf variants
  • tests/test_tools.py: Add TestEmptySchemaStripping test class with test cases for:
    • Single model with Union[Literal, Any] field
    • Nested model containing open enum model
    • Validation that output schema contains no empty objects

Verification

from pydantic import BaseModel
from typing import Literal, Union, Any
from livekit.agents.llm._strict import to_strict_json_schema

class OpenEnum(BaseModel):
    preference: Union[Literal["a", "b"], Any, None] = None

schema = to_strict_json_schema(OpenEnum)
# Before fix: anyOf contains {} at index 1 → OpenAI rejects
# After fix: {} stripped → schema is strict-mode compatible

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
@CLAassistant
Copy link

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment on lines +84 to 87
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)
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 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).
Open in Devin Review

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

import enum
from typing import Literal
import json
import typing
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 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.

Suggested change
import typing
Open in Devin Review

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

@theomonnom
Copy link
Member

Hey, lgtm, can you fix the ruff CI?

@theomonnom
Copy link
Member

Continued here: #5137

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_ensure_strict_json_schema should handle empty {} entries in anyOf/oneOf

3 participants