fix(fireworks): strip pattern, title, default:null from tool param schemas

Fireworks AI rejects tool parameter schemas containing `pattern`, `title`,
or `default: null`, returning 400 errors:

- `pattern`: "Conflict in schema definitions for key 'pattern'" — occurs
  when LiteLLM's Anthropic→OpenAI translation injects a second `pattern`
  on a field that already has one from the client (e.g. Claude Code).
  Reported by a Fireworks customer using Claude Code + LiteLLM + kimi-k3.
- `title` / `default: null`: "JSON Schema not supported: could not
  understand the instance" — auto-emitted by Pydantic models from MCP
  servers. See #27821 and #28149.

The existing `_transform_tools` only popped `strict` and inlined legacy
`$ref` defs. The Anthropic path already strips these keywords
(`filter_anthropic_output_schema`), but the Fireworks path had no
equivalent recursive sanitizer.

Adds `FireworksAIConfig._sanitize_tool_schema` — a static recursive
helper that walks `properties`, `items`, `anyOf`/`allOf`/`oneOf`, and
`$defs` to strip:
- `pattern` (always removed — Fireworks compiles it into a constrained
  decoding grammar and conflicts arise when multiple patterns land on
  the same field)
- `title` (always removed)
- `default` (only when value is `None`; non-null defaults preserved)

Non-null defaults, enums, descriptions, and all other keywords are
preserved. The sanitizer is Fireworks-only, called from
`_transform_tools` after the existing `unpack_legacy_defs` call.

20 new unit + integration tests covering: top-level removal, nested
properties, array items, anyOf/allOf/oneOf, $defs, deeply nested
schemas, field preservation, non-null default preservation, no-op on
clean schemas, non-function tools, and tools without parameters.

Closes #27821, #28149.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Zara Thomas 2026-08-19 08:41:58 +01:00
parent c696fdfb05
commit 635c3b34ca
2 changed files with 417 additions and 0 deletions

View file

@ -454,8 +454,48 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig):
params = function.get("parameters")
if isinstance(params, dict):
unpack_legacy_defs(params)
self._sanitize_tool_schema(params)
return tools
# JSON Schema keywords Fireworks rejects in tool parameter schemas.
# ``pattern``: Fireworks compiles it into a constrained-decoding grammar.
# When a caller (e.g. LiteLLM's Anthropic→OpenAI translation) injects a
# second ``pattern`` on the same field, Fireworks returns
# "Conflict in schema definitions for key 'pattern'".
# ``title``: auto-emitted by Pydantic; Fireworks returns
# "JSON Schema not supported: could not understand the instance".
# ``default: null``: same 400 as ``title``; non-null defaults are preserved.
_FIREWORKS_TOOL_SCHEMA_STRIP_KEYS: Final = frozenset({"pattern", "title"})
@staticmethod
def _sanitize_tool_schema(schema: Any) -> None:
"""Recursively strip JSON Schema keywords Fireworks rejects from tool
parameter schemas, in place.
Walks ``properties``, ``items``, ``anyOf``/``allOf``/``oneOf``, and
``$defs``. Removes ``pattern`` and ``title`` everywhere, and ``default``
only when its value is ``None``. Non-null defaults, enums, and all other
keywords are preserved.
"""
if not isinstance(schema, dict):
return
for key in list(schema):
if key in FireworksAIConfig._FIREWORKS_TOOL_SCHEMA_STRIP_KEYS:
schema.pop(key, None)
elif key == "default" and schema[key] is None:
schema.pop(key, None)
elif key == "properties" and isinstance(schema[key], dict):
for prop in schema[key].values():
FireworksAIConfig._sanitize_tool_schema(prop)
elif key == "items" and isinstance(schema[key], dict):
FireworksAIConfig._sanitize_tool_schema(schema[key])
elif key == "$defs" and isinstance(schema[key], dict):
for defn in schema[key].values():
FireworksAIConfig._sanitize_tool_schema(defn)
elif key in ("anyOf", "allOf", "oneOf") and isinstance(schema[key], list):
for item in schema[key]:
FireworksAIConfig._sanitize_tool_schema(item)
def _transform_messages_helper(
self, messages: list[AllMessageValues], model: str, litellm_params: dict
) -> list[AllMessageValues]:

View file

@ -0,0 +1,377 @@
import os
import sys
import pytest
sys.path.insert(
0, os.path.abspath("../../../../..")
) # Adds the parent directory to the system path
from litellm.llms.fireworks_ai.chat.transformation import FireworksAIConfig
@pytest.fixture(autouse=True)
def force_local_model_cost(monkeypatch):
"""Force local model cost map usage for all tests in this file."""
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
import litellm
from litellm.litellm_core_utils.get_model_cost_map import get_model_cost_map
litellm.model_cost = get_model_cost_map(url=litellm.model_cost_map_url)
# ---------------------------------------------------------------------------
# _sanitize_tool_schema unit tests
# ---------------------------------------------------------------------------
def test_sanitize_strips_pattern():
"""pattern is stripped from every string property to avoid
"Conflict in schema definitions for key 'pattern'" 400s."""
config = FireworksAIConfig()
schema = {
"type": "object",
"properties": {
"name": {"type": "string", "pattern": "^[^\\n\\r]*$"},
},
}
config._sanitize_tool_schema(schema)
assert "pattern" not in schema["properties"]["name"]
assert schema["properties"]["name"]["type"] == "string"
def test_sanitize_strips_title():
"""title (auto-emitted by Pydantic) is stripped everywhere."""
config = FireworksAIConfig()
schema = {
"type": "object",
"properties": {
"page_size": {"type": "integer", "title": "Page Size"},
},
}
config._sanitize_tool_schema(schema)
assert "title" not in schema["properties"]["page_size"]
def test_sanitize_strips_default_null():
"""default: null is stripped; non-null defaults are preserved."""
config = FireworksAIConfig()
schema = {
"type": "object",
"properties": {
"a": {"type": "integer", "default": None},
"b": {"type": "integer", "default": 10},
"c": {"type": "boolean", "default": False},
},
}
config._sanitize_tool_schema(schema)
assert "default" not in schema["properties"]["a"]
assert schema["properties"]["b"]["default"] == 10
# False is not None — must survive
assert schema["properties"]["c"]["default"] is False
def test_sanitize_recurses_into_nested_properties():
config = FireworksAIConfig()
schema = {
"type": "object",
"properties": {
"outer": {
"type": "object",
"properties": {
"inner": {"type": "string", "pattern": "^[a-z]+$", "title": "Inner"},
},
},
},
}
config._sanitize_tool_schema(schema)
inner = schema["properties"]["outer"]["properties"]["inner"]
assert "pattern" not in inner
assert "title" not in inner
def test_sanitize_recurses_into_array_items():
config = FireworksAIConfig()
schema = {
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {"type": "string", "pattern": "^[A-Z]+$"},
},
},
}
config._sanitize_tool_schema(schema)
assert "pattern" not in schema["properties"]["tags"]["items"]
def test_sanitize_recurses_into_anyof():
config = FireworksAIConfig()
schema = {
"type": "object",
"properties": {
"val": {
"anyOf": [
{"type": "string", "pattern": "^[0-9]+$"},
{"type": "integer"},
],
},
},
}
config._sanitize_tool_schema(schema)
assert "pattern" not in schema["properties"]["val"]["anyOf"][0]
def test_sanitize_recurses_into_allof():
config = FireworksAIConfig()
schema = {
"allOf": [
{"type": "string", "pattern": "^[a-z]+$"},
{"title": "Foo"},
],
}
config._sanitize_tool_schema(schema)
assert "pattern" not in schema["allOf"][0]
assert "title" not in schema["allOf"][1]
def test_sanitize_recurses_into_oneof():
config = FireworksAIConfig()
schema = {
"oneOf": [
{"type": "string", "pattern": "^[a-z]+$"},
{"type": "string", "pattern": "^[A-Z]+$"},
],
}
config._sanitize_tool_schema(schema)
for item in schema["oneOf"]:
assert "pattern" not in item
def test_sanitize_recurses_into_dollar_defs():
config = FireworksAIConfig()
schema = {
"type": "object",
"properties": {"a": {"$ref": "#/$defs/A"}},
"$defs": {
"A": {"type": "string", "pattern": "^[a-z]+$", "title": "A"},
},
}
config._sanitize_tool_schema(schema)
assert "pattern" not in schema["$defs"]["A"]
assert "title" not in schema["$defs"]["A"]
def test_sanitize_preserves_other_fields():
config = FireworksAIConfig()
schema = {
"type": "object",
"properties": {
"color": {
"type": "string",
"enum": ["red", "green", "blue"],
"description": "Pick a color",
"pattern": "^[a-z]+$",
},
},
"required": ["color"],
"additionalProperties": False,
}
config._sanitize_tool_schema(schema)
prop = schema["properties"]["color"]
assert prop["enum"] == ["red", "green", "blue"]
assert prop["description"] == "Pick a color"
assert "pattern" not in prop
assert schema["required"] == ["color"]
assert schema["additionalProperties"] is False
def test_sanitize_noop_on_non_dict():
config = FireworksAIConfig()
# Should not raise
config._sanitize_tool_schema(None)
config._sanitize_tool_schema("string")
config._sanitize_tool_schema(42)
config._sanitize_tool_schema([])
def test_sanitize_handles_empty_schema():
config = FireworksAIConfig()
schema = {}
config._sanitize_tool_schema(schema)
assert schema == {}
def test_sanitize_deeply_nested():
config = FireworksAIConfig()
schema = {
"type": "object",
"properties": {
"a": {
"type": "object",
"properties": {
"b": {
"type": "array",
"items": {
"type": "object",
"properties": {
"c": {"type": "string", "pattern": "^[a-z]+$"},
},
},
},
},
},
},
}
config._sanitize_tool_schema(schema)
deep = schema["properties"]["a"]["properties"]["b"]["items"]["properties"]["c"]
assert "pattern" not in deep
# ---------------------------------------------------------------------------
# _transform_tools integration tests
# ---------------------------------------------------------------------------
def test_transform_tools_strips_pattern_from_params():
"""End-to-end: _transform_tools must strip pattern from tool parameters."""
config = FireworksAIConfig()
tools = [
{
"type": "function",
"function": {
"name": "search",
"description": "Search things",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"pattern": "^[^\\n\\r]*$",
"title": "Query",
},
},
"required": ["query"],
},
},
}
]
out = config._transform_tools(tools)
params = out[0]["function"]["parameters"]
assert "pattern" not in params["properties"]["query"]
assert "title" not in params["properties"]["query"]
assert params["properties"]["query"]["type"] == "string"
def test_transform_tools_strips_default_null_from_params():
"""End-to-end: _transform_tools must strip default: null."""
config = FireworksAIConfig()
tools = [
{
"type": "function",
"function": {
"name": "list_items",
"parameters": {
"type": "object",
"properties": {
"page_size": {"type": "integer", "default": None, "title": "Page Size"},
},
},
},
}
]
out = config._transform_tools(tools)
prop = out[0]["function"]["parameters"]["properties"]["page_size"]
assert "default" not in prop
assert "title" not in prop
def test_transform_tools_preserves_non_null_default():
config = FireworksAIConfig()
tools = [
{
"type": "function",
"function": {
"name": "get_config",
"parameters": {
"type": "object",
"properties": {
"retries": {"type": "integer", "default": 3},
},
},
},
}
]
out = config._transform_tools(tools)
assert out[0]["function"]["parameters"]["properties"]["retries"]["default"] == 3
def test_transform_tools_noop_on_clean_schema():
config = FireworksAIConfig()
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string", "description": "City name"},
},
"required": ["location"],
},
},
}
]
out = config._transform_tools(tools)
assert out[0]["function"]["parameters"] == tools[0]["function"]["parameters"]
def test_transform_tools_skips_non_function_tools_sanitization():
"""Non-function tools must pass through untouched."""
config = FireworksAIConfig()
non_function_tool = {
"type": "code_interpreter",
"code_interpreter": {"some": "config"},
}
out = config._transform_tools([non_function_tool])
assert out[0] == non_function_tool
def test_transform_tools_handles_tools_without_parameters():
config = FireworksAIConfig()
tools = [
{
"type": "function",
"function": {
"name": "no_params_tool",
"description": "A tool with no parameters field",
},
}
]
# Should not raise
out = config._transform_tools(tools)
assert out[0]["function"]["name"] == "no_params_tool"
def test_transform_tools_strips_strict_and_pattern_together():
"""Both strict pop and pattern strip happen in the same pass."""
config = FireworksAIConfig()
tools = [
{
"type": "function",
"function": {
"name": "strict_tool",
"strict": True,
"parameters": {
"type": "object",
"properties": {
"x": {"type": "string", "pattern": "^[a-z]+$"},
},
},
},
}
]
out = config._transform_tools(tools)
assert "strict" not in out[0]["function"]
assert "pattern" not in out[0]["function"]["parameters"]["properties"]["x"]