mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
c696fdfb05
commit
635c3b34ca
2 changed files with 417 additions and 0 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
Loading…
Add table
Reference in a new issue