fix(openai): drop tool schema regex patterns OpenAI's validator cannot compile (#40485)

* fix(openai): drop tool schema regex patterns OpenAI's validator cannot compile

OpenAI validates function tool parameters with jsonschema's format checker,
which compiles every pattern with Python re. Claude Code's Artifact tool ships
an ECMA-262 pattern with \p{..} Unicode property escapes, so any OpenAI target
behind /v1/messages, /v1/responses or /v1/chat/completions 400s with
"Invalid schema for function 'Artifact': '...' is not a 'regex'" for every
model family. Drop only the patterns Python re rejects, keep the rest, at the
same seams that already flatten top-level combinators.

* fix(openai): walk only schema positions, iteratively, and drop regexes for every openai deployment

Review round: the regex sanitizer now walks JSON Schema applicator positions
only (properties, items, prefixItems, combinators, $defs, additionalProperties
and the rest), so a pattern key inside default, examples, const or a vendor
extension is data and stays. It also drops patternProperties keys Python re
cannot compile, which OpenAI checks the same way. The walk is level-order and
rebuilt deepest level first instead of recursive, so the code-quality recursion
gate passes and there is no depth cap below what a JSON parser admits. On the
chat wire an openai deployment with a custom api_base now drops such regexes
too, since that base is usually a proxy in front of the same validator, while
the lossier combinator flattening stays limited to api.openai.com hosts.
This commit is contained in:
tin-berri 2026-09-09 22:08:30 -07:00 committed by GitHub
parent c14e782810
commit 13837d319d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 532 additions and 76 deletions

View file

@ -6,8 +6,8 @@ import io
import json
import mimetypes
import re
from collections.abc import Iterable, Iterator, Mapping, Sequence
from itertools import groupby
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from itertools import groupby, islice
from os import PathLike
from pathlib import Path
from types import MappingProxyType
@ -1320,17 +1320,128 @@ def flatten_top_level_schema_combinators(schema: Mapping[str, object]) -> Mappin
return _flatten_schema_against_root(schema, schema, frozenset(), 0, {}) # mutable-ok: fresh per-call $ref memo
def tool_with_flattened_parameters(tool: Mapping[str, object]) -> Mapping[str, object]:
_SUBSCHEMA_KEYWORDS: Final = frozenset(
{
"additionalItems",
"additionalProperties",
"contains",
"else",
"if",
"items",
"not",
"propertyNames",
"then",
"unevaluatedItems",
"unevaluatedProperties",
}
)
_SUBSCHEMA_LIST_KEYWORDS: Final = frozenset({"allOf", "anyOf", "items", "oneOf", "prefixItems"})
_SUBSCHEMA_MAP_KEYWORDS: Final = frozenset(
{"$defs", "definitions", "dependentSchemas", "patternProperties", "properties"}
)
_MAX_SCHEMA_NESTING: Final = 1024
def drop_non_python_regex_patterns(schema: Mapping[str, object]) -> Mapping[str, object]:
"""Drop every regex in a schema position that Python's ``re`` cannot compile.
OpenAI validates tool ``parameters`` against the 2020-12 metaschema with
``jsonschema``'s format checker, which hands each ``pattern`` value and each
``patternProperties`` key to ``re.compile``, so a regex written for an
ECMA-262 engine (Unicode property escapes such as ``\\p{Cc}``, as in Claude
Code's ``Artifact`` tool) is refused with "'...' is not a 'regex'" by every
model family on both the chat and Responses wires. Only schema positions are
walked (properties, items, combinators, ``$defs`` and the other applicators),
so a ``pattern`` key inside ``default``, ``examples``, ``const`` or vendor
extensions is data and stays. Outside strict mode the keyword is only a
hint, so dropping it costs the model a constraint and the caller nothing.
Compilable regexes and everything else pass through, the input is never
mutated, and the same object comes back when nothing was dropped. The walk
is level-order rather than recursive, rebuilt deepest level first, and stops
at more schema levels than a JSON parser admits, so a cyclic schema built in
code cannot spin it.
"""
rebuilt: dict[int, Mapping[str, object]] = {} # mutable-ok: per-call memo of rewritten nodes, deepest level first
for level in reversed(tuple(islice(_schema_levels(schema), _MAX_SCHEMA_NESTING))):
rebuilt.update(
(id(node), rewritten)
for node in level
if (rewritten := _node_without_non_python_regex(node, rebuilt)) is not node
)
return rebuilt.get(id(schema), schema)
def _schema_levels(schema: Mapping[str, object]) -> Iterator[tuple[Mapping[str, object], ...]]:
frontier: tuple[Mapping[str, object], ...] = (schema,) # rebind-ok: level-order cursor, one level a round
while frontier:
yield frontier
frontier = tuple(child for node in frontier for child in _subschemas(node))
def _subschemas(node: Mapping[str, object]) -> Iterator[Mapping[str, object]]:
for key, value in node.items():
if key in _SUBSCHEMA_MAP_KEYWORDS and isinstance(value, dict):
yield from (sub for sub in value.values() if isinstance(sub, dict))
elif key in _SUBSCHEMA_LIST_KEYWORDS and isinstance(value, list):
yield from (sub for sub in value if isinstance(sub, dict))
elif key in _SUBSCHEMA_KEYWORDS and isinstance(value, dict):
yield value
def _node_without_non_python_regex(
node: Mapping[str, object], rebuilt: Mapping[int, Mapping[str, object]]
) -> Mapping[str, object]:
kept: Final = { # mutable-ok: tool parameters are JSON dicts
key: _keyword_value_rebuilt(key, value, rebuilt)
for key, value in node.items()
if key != "pattern" or not isinstance(value, str) or _is_python_regex(value)
}
return node if len(kept) == len(node) and all(kept[key] is node[key] for key in kept) else kept
def _keyword_value_rebuilt(key: str, value: object, rebuilt: Mapping[int, Mapping[str, object]]) -> object:
if key in _SUBSCHEMA_MAP_KEYWORDS and isinstance(value, dict):
kept: Final = { # mutable-ok: tool parameters are JSON dicts
name: rebuilt.get(id(sub), sub)
for name, sub in value.items()
if key != "patternProperties" or not isinstance(name, str) or _is_python_regex(name)
}
return value if len(kept) == len(value) and all(kept[name] is value[name] for name in kept) else kept
if key in _SUBSCHEMA_LIST_KEYWORDS and isinstance(value, list):
items: Final = [rebuilt.get(id(sub), sub) for sub in value] # mutable-ok: tool parameters are JSON lists
return value if all(new is old for new, old in zip(items, value, strict=True)) else items
if key in _SUBSCHEMA_KEYWORDS and isinstance(value, dict):
return rebuilt.get(id(value), value)
return value
def _is_python_regex(pattern: str) -> bool:
try:
re.compile(pattern)
except (re.error, RecursionError):
return False
return True
def flatten_combinators_and_drop_non_python_regex_patterns(schema: Mapping[str, object]) -> Mapping[str, object]:
return flatten_top_level_schema_combinators(drop_non_python_regex_patterns(schema))
def tool_with_sanitized_parameters(
tool: Mapping[str, object],
sanitize: Callable[[Mapping[str, object]], Mapping[str, object]],
) -> Mapping[str, object]:
function: Final = tool.get("function")
if not isinstance(function, dict):
return tool
parameters: Final = function.get("parameters")
if not isinstance(parameters, dict):
return tool
flattened: Final = flatten_top_level_schema_combinators(parameters)
if flattened is parameters:
sanitized: Final = sanitize(parameters)
if sanitized is parameters:
return tool
return {**tool, "function": {**function, "parameters": flattened}} # mutable-ok: request tools are JSON dicts
return {**tool, "function": {**function, "parameters": sanitized}} # mutable-ok: request tools are JSON dicts
def _get_image_mime_type_from_url(url: str) -> str | None:

View file

@ -7,8 +7,9 @@ from httpx._models import Headers, Response
import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_tool_reference_parts_from_tool_messages,
flatten_combinators_and_drop_non_python_regex_patterns,
hoist_images_from_tool_messages,
tool_with_flattened_parameters,
tool_with_sanitized_parameters,
)
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_azure_openai_messages,
@ -39,14 +40,17 @@ else:
_NO_TOOLS_UPDATE: Final[Mapping[str, object]] = MappingProxyType({})
def flattened_tools_update(optional_params: Mapping[str, object]) -> Mapping[str, object]:
def sanitized_tools_update(optional_params: Mapping[str, object]) -> Mapping[str, object]:
tools: Final = optional_params.get("tools")
if not isinstance(tools, list):
return _NO_TOOLS_UPDATE
flattened: Final = [ # mutable-ok: request tools are a JSON list
tool_with_flattened_parameters(tool) if isinstance(tool, dict) else tool for tool in tools
sanitized: Final = [ # mutable-ok: request tools are a JSON list
tool_with_sanitized_parameters(tool, flatten_combinators_and_drop_non_python_regex_patterns)
if isinstance(tool, dict)
else tool
for tool in tools
]
return MappingProxyType({"tools": flattened})
return MappingProxyType({"tools": sanitized})
class AzureOpenAIConfig(BaseConfig):
@ -278,7 +282,7 @@ class AzureOpenAIConfig(BaseConfig):
"model": model,
"messages": azure_messages,
**optional_params,
**flattened_tools_update(optional_params),
**sanitized_tools_update(optional_params),
}
def transform_response(

View file

@ -20,7 +20,7 @@ from litellm.types.llms.openai import AllMessageValues
from litellm.utils import get_model_info, supports_reasoning
from ...openai.chat.o_series_transformation import OpenAIOSeriesConfig
from .gpt_transformation import flattened_tools_update
from .gpt_transformation import sanitized_tools_update
class AzureOpenAIO1Config(OpenAIOSeriesConfig):
@ -111,6 +111,6 @@ class AzureOpenAIO1Config(OpenAIOSeriesConfig):
model = model.replace("o_series/", "") # handle o_series/my-random-deployment-name
flattened_params: Final = { # mutable-ok: transform_request's contract takes a plain JSON params dict
**optional_params,
**flattened_tools_update(optional_params),
**sanitized_tools_update(optional_params),
}
return super().transform_request(model, messages, flattened_params, litellm_params, headers)

View file

@ -19,10 +19,12 @@ from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response impo
_should_convert_tool_call_to_json_mode,
)
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_non_python_regex_patterns,
drop_tool_reference_parts_from_tool_messages,
flatten_combinators_and_drop_non_python_regex_patterns,
get_tool_call_names,
hoist_images_from_tool_messages,
tool_with_flattened_parameters,
tool_with_sanitized_parameters,
)
from litellm.litellm_core_utils.prompt_templates.image_handling import (
async_convert_url_to_base64,
@ -432,7 +434,7 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
custom_llm_provider, api_base
)
def _flattened_tools_update_for_openai(
def _sanitized_tools_update_for_openai(
self,
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
@ -440,22 +442,26 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
"""
OpenAI's chat completions validator rejects tool `parameters` carrying
'oneOf'/'anyOf'/'allOf'/'enum'/'const'/'not' at the top level for every
model family, unlike the Responses API, where GPT-5+ accepts them.
model family, unlike the Responses API, where GPT-5+ accepts them, and
a `pattern` Python's `re` cannot compile for every model family on both.
A custom api_base on the `openai` provider is usually a proxy in front of
the same validator, so regexes are dropped there too, while the lossier
combinator flattening stays limited to api.openai.com hosts.
"""
tools: Final = optional_params.get("tools")
if not isinstance(tools, list):
return _NO_TOOLS_UPDATE
provider: Final = litellm_params.get("custom_llm_provider")
raw_api_base: Final = litellm_params.get("api_base")
if not self._targets_openai_hosted_endpoint(
provider if isinstance(provider, str) else None,
raw_api_base if isinstance(raw_api_base, str) else None,
):
if not isinstance(tools, list) or provider != "openai":
return _NO_TOOLS_UPDATE
flattened: Final = [ # mutable-ok: request tools are a JSON list
tool_with_flattened_parameters(tool) if isinstance(tool, dict) else tool for tool in tools
raw_api_base: Final = litellm_params.get("api_base")
sanitize: Final = (
flatten_combinators_and_drop_non_python_regex_patterns
if self._targets_openai_hosted_endpoint(provider, raw_api_base if isinstance(raw_api_base, str) else None)
else drop_non_python_regex_patterns
)
sanitized: Final = [ # mutable-ok: request tools are a JSON list
tool_with_sanitized_parameters(tool, sanitize) if isinstance(tool, dict) else tool for tool in tools
]
return MappingProxyType({"tools": flattened})
return MappingProxyType({"tools": sanitized})
def transform_request(
self,
@ -489,7 +495,7 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
"model": model,
"messages": messages,
**optional_params,
**self._flattened_tools_update_for_openai(optional_params, litellm_params),
**self._sanitized_tools_update_for_openai(optional_params, litellm_params),
}
async def async_transform_request(
@ -521,7 +527,7 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig):
"model": model,
"messages": transformed_messages,
**optional_params,
**self._flattened_tools_update_for_openai(optional_params, litellm_params),
**self._sanitized_tools_update_for_openai(optional_params, litellm_params),
}
else:
## allow for any object specific behaviour to be handled

View file

@ -1,4 +1,4 @@
from collections.abc import Mapping, Sequence
from collections.abc import Callable, Mapping, Sequence
from functools import lru_cache
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Protocol, cast, get_type_hints
@ -15,6 +15,10 @@ from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
_safe_convert_created_field,
)
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_non_python_regex_patterns,
flatten_combinators_and_drop_non_python_regex_patterns,
)
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
@ -40,7 +44,7 @@ else:
_NO_TOOL_UPDATE: Final[Mapping[str, object]] = MappingProxyType({})
_MODEL_FAMILIES_REJECTING_TOP_LEVEL_SCHEMA_COMBINATORS: Final = ("gpt-4", "gpt-3.5", "chatgpt-4o", "o1", "o3", "o4")
_PROVIDERS_WITH_COMBINATOR_REJECTING_VALIDATOR: Final = frozenset({LlmProviders.AZURE, LlmProviders.OPENAI})
_PROVIDERS_WITH_OPENAI_SCHEMA_VALIDATOR: Final = frozenset({LlmProviders.AZURE, LlmProviders.OPENAI})
_PROVIDERS_VALIDATING_TOOL_CALL_ITEM_IDS: Final = frozenset({LlmProviders.AZURE, LlmProviders.OPENAI})
@ -293,7 +297,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
model=model, input=validated_input, tools=tools
)
object_schema_tools: Final = self._tools_with_object_parameters(model=model, tools=stripped_tools)
sanitized_tools: Final = self._flatten_tool_schema_combinators_for_openai(
sanitized_tools: Final = self._sanitized_tool_schemas_for_openai(
model=model, tools=object_schema_tools, litellm_params=litellm_params
)
return self._drop_foreign_tool_call_item_ids(stripped_input), sanitized_tools
@ -378,35 +382,35 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
return item
return {key: value for key, value in item.items() if key != "id"} # mutable-ok: outgoing JSON request item
def _flatten_tool_schema_combinators_for_openai(
def _sanitized_tool_schemas_for_openai(
self,
model: str,
tools: Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None,
litellm_params: GenericLiteLLMParams,
) -> Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None:
"""Flatten top-level schema combinators only where OpenAI's validator rejects them.
"""Rewrite tool schemas only where OpenAI's validator rejects them.
OpenAI-compatible backends reusing this config (and the ChatGPT backend
Codex talks to natively) accept them, and so do GPT-5 and later models,
which also call tools better with the union intact. Codex wraps MCP tools
inside namespace entries, so nested ``tools`` arrays are walked too.
Azure OpenAI shares the validator but names deployments arbitrarily, so
the router's declared ``model_info.base_model`` wins over the deployment
name and an unrecognized name without one is left untouched.
Every model family refuses a ``pattern`` Python's ``re`` cannot compile,
while top-level schema combinators are flattened only for the families
whose validator rejects them: OpenAI-compatible backends reusing this
config (and the ChatGPT backend Codex talks to natively) accept them,
and so do GPT-5 and later models, which also call tools better with the
union intact. Codex wraps MCP tools inside namespace entries, so nested
``tools`` arrays are walked too. Azure OpenAI shares the validator but
names deployments arbitrarily, so the router's declared
``model_info.base_model`` wins over the deployment name and an
unrecognized name without one keeps its combinators.
"""
if tools is None or self.custom_llm_provider not in _PROVIDERS_WITH_COMBINATOR_REJECTING_VALIDATOR:
if tools is None or self.custom_llm_provider not in _PROVIDERS_WITH_OPENAI_SCHEMA_VALIDATOR:
return tools
gate_model: Final = self._combinator_gate_model(model=model, litellm_params=litellm_params)
if not self._rejects_top_level_schema_combinators(gate_model):
return tools
flattened: Final = [ # mutable-ok: request tools are a JSON list
self._flattened_tool_or_passthrough(tool) for tool in tools
]
return cast("Sequence[ALL_RESPONSES_API_TOOL_PARAMS]", flattened) # cast-ok: spread keeps each tool's shape
@staticmethod
def _flattened_tool_or_passthrough(tool: object) -> object:
return OpenAIResponsesAPIConfig._flattened_tool_entry(tool) if isinstance(tool, dict) else tool
sanitize: Final = (
flatten_combinators_and_drop_non_python_regex_patterns
if self._rejects_top_level_schema_combinators(gate_model)
else drop_non_python_regex_patterns
)
sanitized: Final = self._sanitized_tools(tools, sanitize)
return cast("Sequence[ALL_RESPONSES_API_TOOL_PARAMS]", sanitized) # cast-ok: spread keeps each tool's shape
@staticmethod
def _rejects_top_level_schema_combinators(model: str) -> bool:
@ -421,35 +425,42 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
return base_model if isinstance(base_model, str) and base_model else model
@staticmethod
def _flattened_tool_entry(
def _sanitized_tool_entry(
entry: Mapping[str, object],
) -> dict[str, object]: # mutable-ok: request tools are JSON dicts
from litellm.litellm_core_utils.prompt_templates.common_utils import (
flatten_top_level_schema_combinators,
)
sanitize: Callable[[Mapping[str, object]], Mapping[str, object]],
) -> Mapping[str, object]:
parameters: Final = entry.get("parameters")
nested_tools: Final = entry.get("tools")
sanitized_parameters: Final = sanitize(parameters) if isinstance(parameters, dict) else parameters
sanitized_nested_tools: Final = (
OpenAIResponsesAPIConfig._sanitized_tools(nested_tools, sanitize)
if isinstance(nested_tools, list)
else nested_tools
)
parameters_update: Final = (
MappingProxyType({"parameters": flatten_top_level_schema_combinators(parameters)})
if isinstance(parameters, dict)
MappingProxyType({"parameters": sanitized_parameters})
if sanitized_parameters is not parameters
else _NO_TOOL_UPDATE
)
tools_update: Final = (
MappingProxyType({"tools": OpenAIResponsesAPIConfig._flattened_nested_tools(nested_tools)})
if isinstance(nested_tools, list)
MappingProxyType({"tools": sanitized_nested_tools})
if sanitized_nested_tools is not nested_tools
else _NO_TOOL_UPDATE
)
if not parameters_update and not tools_update:
return entry
return {**entry, **parameters_update, **tools_update} # mutable-ok: request tools are JSON dicts
@staticmethod
def _flattened_nested_tools(
nested_tools: Sequence[object],
) -> list[object]: # mutable-ok: namespace tools are a JSON list
return [ # mutable-ok: namespace tools are a JSON list
OpenAIResponsesAPIConfig._flattened_tool_entry(item) if isinstance(item, dict) else item
for item in nested_tools
def _sanitized_tools(
tools: Sequence[object],
sanitize: Callable[[Mapping[str, object]], Mapping[str, object]],
) -> Sequence[object]:
sanitized: Final = [ # mutable-ok: request tools are a JSON list
OpenAIResponsesAPIConfig._sanitized_tool_entry(item, sanitize) if isinstance(item, dict) else item
for item in tools
]
return tools if all(new is old for new, old in zip(sanitized, tools, strict=True)) else sanitized
def _validate_input_param(self, input: str | ResponseInputParam) -> str | ResponseInputParam:
"""

View file

@ -2,11 +2,12 @@ import copy
import functools
import json
import os
import sys
from typing import Final
from unittest.mock import MagicMock, patch
import pytest
from litellm.litellm_core_utils.prompt_templates.common_utils import (
ENCRYPTED_REASONING_SIGNATURE_PREFIX,
TOOL_RESULT_IMAGE_BOUNDARY,
@ -25,6 +26,8 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
update_messages_with_model_file_ids,
)
_ARTIFACT_FIELD_PATTERN: Final = r'^(?!__.*__$)[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}"\\./[\]]{1,200}$'
def test_get_format_from_file_id():
unified_file_id = "litellm_proxy:application/pdf;unified_id,cbbe3534-8bf8-4386-af00-f5f6b7e370bf"
@ -1442,7 +1445,7 @@ class TestFlattenTopLevelSchemaCombinators:
assert schema == snapshot
class TestToolWithFlattenedParameters:
class TestToolWithSanitizedParameters:
def _anyof_tool(self):
return {
"type": "function",
@ -1469,11 +1472,12 @@ class TestToolWithFlattenedParameters:
def test_flattens_anyof_parameters_into_new_tool(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
tool_with_flattened_parameters,
flatten_combinators_and_drop_non_python_regex_patterns,
tool_with_sanitized_parameters,
)
tool = self._anyof_tool()
result = tool_with_flattened_parameters(tool)
result = tool_with_sanitized_parameters(tool, flatten_combinators_and_drop_non_python_regex_patterns)
assert result is not tool
parameters = result["function"]["parameters"]
@ -1486,7 +1490,8 @@ class TestToolWithFlattenedParameters:
def test_clean_parameters_return_the_same_tool_object(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
tool_with_flattened_parameters,
flatten_combinators_and_drop_non_python_regex_patterns,
tool_with_sanitized_parameters,
)
tool = {
@ -1497,7 +1502,23 @@ class TestToolWithFlattenedParameters:
},
}
assert tool_with_flattened_parameters(tool) is tool
assert tool_with_sanitized_parameters(tool, flatten_combinators_and_drop_non_python_regex_patterns) is tool
def test_pattern_only_sanitizer_drops_the_regex_and_keeps_the_union(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_non_python_regex_patterns,
tool_with_sanitized_parameters,
)
tool = self._anyof_tool()
tool["function"]["parameters"]["properties"]["id"]["pattern"] = _ARTIFACT_FIELD_PATTERN
result = tool_with_sanitized_parameters(tool, drop_non_python_regex_patterns)
parameters = result["function"]["parameters"]
assert parameters["properties"]["id"] == {"type": "string"}
assert parameters["anyOf"] == self._anyof_tool()["function"]["parameters"]["anyOf"]
assert tool["function"]["parameters"]["properties"]["id"]["pattern"] == _ARTIFACT_FIELD_PATTERN
@pytest.mark.parametrize(
"tool",
@ -1510,10 +1531,127 @@ class TestToolWithFlattenedParameters:
)
def test_non_dict_function_or_parameters_return_the_same_tool_object(self, tool):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
tool_with_flattened_parameters,
flatten_combinators_and_drop_non_python_regex_patterns,
tool_with_sanitized_parameters,
)
assert tool_with_flattened_parameters(tool) is tool
assert tool_with_sanitized_parameters(tool, flatten_combinators_and_drop_non_python_regex_patterns) is tool
class TestDropNonPythonRegexPatterns:
"""Claude Code's Artifact tool declares ECMA-262 ``\\p{..}`` escapes that OpenAI's
validator, which compiles ``pattern`` values and ``patternProperties`` keys with
Python ``re``, refuses as "not a 'regex'"."""
def _schema(self, pattern):
return {
"type": "object",
"properties": {
"field": {"type": "string", "pattern": pattern},
"writes": {
"type": "array",
"items": {"properties": {"doc_id": {"type": "string", "pattern": pattern}}},
},
"query": {"anyOf": [{"type": "string", "pattern": pattern}, {"type": "null"}]},
"pair": {"type": "array", "prefixItems": [{"type": "string", "pattern": pattern}]},
"extra": {"type": "object", "additionalProperties": {"type": "string", "pattern": pattern}},
"tagged": {
"type": "object",
"patternProperties": {pattern: {"type": "string"}, "^x_": {"type": "integer"}},
},
},
"$defs": {"segment": {"type": "string", "pattern": pattern}},
"required": ["field"],
}
def test_drops_every_regex_python_re_rejects_from_every_schema_position(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_non_python_regex_patterns,
)
schema = self._schema(_ARTIFACT_FIELD_PATTERN)
result = drop_non_python_regex_patterns(schema)
assert '"pattern"' not in json.dumps(result)
properties = result["properties"]
assert properties["field"] == {"type": "string"}
assert properties["writes"]["items"]["properties"]["doc_id"] == {"type": "string"}
assert properties["query"]["anyOf"] == [{"type": "string"}, {"type": "null"}]
assert properties["pair"]["prefixItems"] == [{"type": "string"}]
assert properties["extra"]["additionalProperties"] == {"type": "string"}
assert properties["tagged"]["patternProperties"] == {"^x_": {"type": "integer"}}
assert result["$defs"]["segment"] == {"type": "string"}
assert result["required"] == ["field"]
assert schema == self._schema(_ARTIFACT_FIELD_PATTERN)
def test_keeps_regexes_python_re_compiles_and_returns_the_same_object(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_non_python_regex_patterns,
)
schema = self._schema(r'^(?!__.*__$)[^"\\./[\]]{1,200}$')
assert drop_non_python_regex_patterns(schema) is schema
def test_pattern_keys_inside_data_positions_are_not_regexes(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_non_python_regex_patterns,
)
schema = {
"type": "object",
"properties": {
"pattern": {"type": "string"},
"template": {"type": "object", "default": {"pattern": _ARTIFACT_FIELD_PATTERN}},
"samples": {"type": "array", "examples": [{"pattern": _ARTIFACT_FIELD_PATTERN}]},
"fixed": {"const": {"pattern": _ARTIFACT_FIELD_PATTERN}},
"vendor": {"type": "string", "x-litellm": {"pattern": _ARTIFACT_FIELD_PATTERN}},
},
"required": ["pattern"],
}
assert drop_non_python_regex_patterns(schema) is schema
def test_regex_nested_past_what_python_re_can_parse_is_dropped_not_raised(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_non_python_regex_patterns,
)
schema = {
"type": "object",
"properties": {"deep": {"type": "string", "pattern": "(" * 2000 + "a" + ")" * 2000}},
}
assert drop_non_python_regex_patterns(schema)["properties"]["deep"] == {"type": "string"}
def test_walks_schemas_deeper_than_the_interpreter_recursion_limit(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_non_python_regex_patterns,
)
depth = sys.getrecursionlimit()
leaf = {"type": "string", "pattern": _ARTIFACT_FIELD_PATTERN}
schema = functools.reduce(
lambda inner, _: {"type": "object", "properties": {"child": inner}}, range(depth), leaf
)
result = drop_non_python_regex_patterns(schema)
assert functools.reduce(lambda node, _: node["properties"]["child"], range(depth), result) == {"type": "string"}
assert functools.reduce(lambda node, _: node["properties"]["child"], range(depth), schema) is leaf
def test_leaves_levels_past_the_json_nesting_limit_alone(self):
from litellm.litellm_core_utils.prompt_templates.common_utils import (
drop_non_python_regex_patterns,
)
leaf = {"type": "string", "pattern": _ARTIFACT_FIELD_PATTERN}
schema = functools.reduce(
lambda inner, _: {"type": "object", "properties": {"child": inner}}, range(1100), leaf
)
assert drop_non_python_regex_patterns(schema) is schema
class TestRequestContainsImageContent:

View file

@ -198,6 +198,9 @@ def test_azure_gpt_5_takes_the_reasoning_path() -> None:
assert "reasoning_effort" in supported
_ARTIFACT_FIELD_PATTERN: Final = r'^(?!__.*__$)[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}"\\./[\]]{1,200}$'
class TestAzureToolSchemaCombinatorFlattening:
"""
Regression tests for LIT-6510: Azure's chat completions validator rejects
@ -259,6 +262,26 @@ class TestAzureToolSchemaCombinatorFlattening:
self._transform(AzureOpenAIConfig(), "gpt-4o", [tool])
assert tool == self._anyof_tool()
def test_transform_request_drops_non_python_regex_pattern(self):
tool = {
"type": "function",
"function": {
"name": "Artifact",
"parameters": {
"type": "object",
"properties": {"field": {"type": "string", "pattern": _ARTIFACT_FIELD_PATTERN}},
},
},
}
request = self._transform(AzureOpenAIConfig(), "gpt-4o", [tool])
assert request["tools"][0]["function"]["parameters"] == {
"type": "object",
"properties": {"field": {"type": "string"}},
}
assert tool["function"]["parameters"]["properties"]["field"]["pattern"] == _ARTIFACT_FIELD_PATTERN
def test_clean_object_schema_passes_through_as_same_object(self):
tool = {
"type": "function",

View file

@ -1,4 +1,5 @@
from copy import deepcopy
from typing import Final
from unittest.mock import MagicMock, patch
import pytest
@ -243,6 +244,9 @@ def test_provider_config_manager_o_series_selection():
assert not isinstance(default_config, AzureOpenAIOSeriesResponsesAPIConfig)
_ARTIFACT_FIELD_PATTERN: Final = r'^(?!__.*__$)[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}"\\./[\]]{1,200}$'
class TestAzureResponsesAPIConfig:
def setup_method(self):
self.config = AzureOpenAIResponsesAPIConfig()
@ -599,6 +603,31 @@ class TestAzureResponsesAPIConfig:
assert result["tools"][0] is tool
assert "anyOf" in result["tools"][0]["parameters"]
def test_azure_drops_non_python_regex_pattern_while_keeping_gpt5_combinators(self):
tool = {
"type": "function",
"name": "Artifact",
"parameters": {
"type": "object",
"anyOf": [{"properties": {"field": {"type": "string", "pattern": _ARTIFACT_FIELD_PATTERN}}}],
"properties": {"field": {"type": "string", "pattern": _ARTIFACT_FIELD_PATTERN}},
},
}
result = self.config.transform_responses_api_request(
model="my-eastus-deployment",
input="hi",
response_api_optional_request_params={"tools": [tool]},
litellm_params=GenericLiteLLMParams(model_info={"base_model": "azure/gpt-5.4-mini"}),
headers={},
)
assert result["tools"][0]["parameters"] == {
"type": "object",
"anyOf": [{"properties": {"field": {"type": "string"}}}],
"properties": {"field": {"type": "string"}},
}
def test_azure_keeps_combinators_for_unrecognized_deployment_without_base_model(self):
tool = self._anyof_tool()

View file

@ -4,6 +4,7 @@ Tests for OpenAI GPT transformation (litellm/llms/openai/chat/gpt_transformation
import pytest
from typing import Final
import litellm
@ -1168,6 +1169,9 @@ class TestOpenAIPromptCacheBreakpointChatPath:
assert "prompt_cache_options" not in request
_ARTIFACT_FIELD_PATTERN: Final = r'^(?!__.*__$)[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}"\\./[\]]{1,200}$'
class TestToolSchemaCombinatorFlatteningForOpenAI:
"""
Regression tests for LIT-6488: OpenAI's chat completions validator rejects
@ -1281,3 +1285,50 @@ class TestToolSchemaCombinatorFlatteningForOpenAI:
parameters = request["tools"][0]["function"]["parameters"]
assert "anyOf" not in parameters
assert set(parameters["properties"]) == {"id", "enabled", "schedule"}
@staticmethod
def _artifact_tool():
return {
"type": "function",
"function": {
"name": "Artifact",
"parameters": {
"type": "object",
"properties": {"field": {"type": "string", "pattern": _ARTIFACT_FIELD_PATTERN}},
"required": ["field"],
},
},
}
def test_drops_non_python_regex_pattern_for_hosted_openai(self):
tool = self._artifact_tool()
request = self._transform(self.config, "gpt-4o", {"custom_llm_provider": "openai", "api_base": None}, [tool])
assert request["tools"][0]["function"]["parameters"] == {
"type": "object",
"properties": {"field": {"type": "string"}},
"required": ["field"],
}
assert tool == self._artifact_tool()
def test_custom_api_base_drops_non_python_regex_pattern_but_keeps_union(self):
tool = self._anyof_tool()
tool["function"]["parameters"]["properties"]["id"]["pattern"] = _ARTIFACT_FIELD_PATTERN
request = self._transform(
self.config, "gpt-4o", {"custom_llm_provider": "openai", "api_base": "http://localhost:8000/v1"}, [tool]
)
parameters = request["tools"][0]["function"]["parameters"]
assert parameters["properties"]["id"] == {"type": "string"}
assert parameters["anyOf"] == self._anyof_tool()["function"]["parameters"]["anyOf"]
def test_non_openai_provider_keeps_non_python_regex_pattern(self):
tool = self._artifact_tool()
request = self._transform(
self.config, "some-oss-model", {"custom_llm_provider": "groq", "api_base": None}, [tool]
)
assert request["tools"][0] is tool

View file

@ -1,5 +1,6 @@
import json
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
@ -20,6 +21,8 @@ from litellm.types.llms.openai import (
)
from litellm.types.router import GenericLiteLLMParams
_ARTIFACT_FIELD_PATTERN: Final = r'^(?!__.*__$)[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}"\\./[\]]{1,200}$'
class TestOpenAIResponsesAPIConfig:
def setup_method(self):
@ -2022,6 +2025,86 @@ class TestFlattenToolSchemaCombinatorsWiring:
assert "anyOf" not in result["tools"][1]["parameters"]
class TestToolSchemaRegexPatternWiring:
"""Claude Code's Artifact tool reaches /v1/responses (the /v1/messages bridge) with an
ECMA-262 ``pattern``; OpenAI compiles patterns with Python ``re`` and 400s
"'...' is not a 'regex'" for every model family, so the keyword is dropped.
"""
def _artifact_tool(self):
return {
"type": "function",
"name": "Artifact",
"parameters": {
"type": "object",
"properties": {
"field": {"type": "string", "pattern": _ARTIFACT_FIELD_PATTERN},
"doc_id": {"type": "string", "pattern": r"^(?!\.\.?(?:/|$))[A-Za-z0-9_\-.~:@+]{1,200}$"},
},
"required": ["field"],
},
}
@pytest.mark.parametrize("model", ["gpt-5.6", "gpt-4o", "o3"])
def test_openai_drops_only_the_pattern_python_re_rejects_for_every_family(self, model):
tool = self._artifact_tool()
result = OpenAIResponsesAPIConfig().transform_responses_api_request(
model=model,
input="hi",
response_api_optional_request_params={"tools": [tool]},
litellm_params=GenericLiteLLMParams(),
headers={},
)
properties = result["tools"][0]["parameters"]["properties"]
assert properties["field"] == {"type": "string"}
assert properties["doc_id"] == tool["parameters"]["properties"]["doc_id"]
assert result["tools"][0]["parameters"]["required"] == ["field"]
assert tool["parameters"]["properties"]["field"]["pattern"] == _ARTIFACT_FIELD_PATTERN
assert json.loads(json.dumps(result["tools"])) == result["tools"]
def test_openai_drops_patterns_inside_codex_namespace_tools(self):
namespace = {"type": "namespace", "name": "mcp__claude", "tools": [self._artifact_tool()]}
result = OpenAIResponsesAPIConfig().transform_responses_api_request(
model="gpt-5.6",
input="hi",
response_api_optional_request_params={"tools": [namespace]},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert result["tools"][0]["tools"][0]["parameters"]["properties"]["field"] == {"type": "string"}
def test_openai_compact_request_drops_patterns(self):
_, data = OpenAIResponsesAPIConfig().transform_compact_response_api_request(
model="gpt-5.6",
input="hi",
response_api_optional_request_params={"tools": [self._artifact_tool()]},
api_base="https://api.openai.com/v1/responses",
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert data["tools"][0]["parameters"]["properties"]["field"] == {"type": "string"}
def test_non_openai_subclass_keeps_patterns(self):
from litellm.llms.hosted_vllm.responses.transformation import HostedVLLMResponsesAPIConfig
tool = self._artifact_tool()
result = HostedVLLMResponsesAPIConfig().transform_responses_api_request(
model="hosted_vllm/qwen",
input="hi",
response_api_optional_request_params={"tools": [tool]},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert result["tools"][0] is tool
class TestReasoningFollowsModelSupport:
"""Responses API clients like Codex send `reasoning` on every request, and OpenAI 400s it
on non-reasoning models like gpt-4o. drop_params must strip it there, the same way the