From 635c3b34ca1e19a39f2181763725eef1c08457a4 Mon Sep 17 00:00:00 2001 From: Zara Thomas Date: Wed, 19 Aug 2026 08:41:58 +0100 Subject: [PATCH 1/4] fix(fireworks): strip pattern, title, default:null from tool param schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../llms/fireworks_ai/chat/transformation.py | 40 ++ .../chat/test_fireworks_schema_sanitize.py | 377 ++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_schema_sanitize.py diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index e64237da978..5de471eba59 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -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]: diff --git a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_schema_sanitize.py b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_schema_sanitize.py new file mode 100644 index 00000000000..13e92c8f2a5 --- /dev/null +++ b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_schema_sanitize.py @@ -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"] From daf12e19a71e4c368d262ed729ac8b1942600ef4 Mon Sep 17 00:00:00 2001 From: Zara Thomas Date: Wed, 19 Aug 2026 08:47:19 +0100 Subject: [PATCH 2/4] lint: combine if branches to satisfy ruff SIM114 Co-authored-by: Cursor --- litellm/llms/fireworks_ai/chat/transformation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index 5de471eba59..3cbe8a8d9dc 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -480,9 +480,9 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig): 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: + if key in FireworksAIConfig._FIREWORKS_TOOL_SCHEMA_STRIP_KEYS or ( + 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(): From 4f18a08ea7ed5835468542510a21d373c8b03738 Mon Sep 17 00:00:00 2001 From: Zara Thomas Date: Wed, 19 Aug 2026 09:26:15 +0100 Subject: [PATCH 3/4] address greptile feedback: expand sanitizer traversal, use object type - Traverse propertyNames, prefixItems, and definitions subschemas (Greptile P1: propertyNames could retain rejected keywords) - Use `object` instead of `Any` for the sanitizer parameter (Greptile P2: reduces untyped boundary) - Combine anyOf/allOf/oneOf/prefixItems branches (ruff SIM114) - Add tests for propertyNames, prefixItems, and definitions traversal Co-authored-by: Cursor --- .../llms/fireworks_ai/chat/transformation.py | 13 +++--- .../chat/test_fireworks_schema_sanitize.py | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index 3cbe8a8d9dc..c4a9c85dbe2 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -468,12 +468,13 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig): _FIREWORKS_TOOL_SCHEMA_STRIP_KEYS: Final = frozenset({"pattern", "title"}) @staticmethod - def _sanitize_tool_schema(schema: Any) -> None: + def _sanitize_tool_schema(schema: object) -> 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`` + Walks ``properties``, ``items``, ``anyOf``/``allOf``/``oneOf``, + ``$defs``, ``definitions``, ``prefixItems``, and ``propertyNames``. + Removes ``pattern`` and ``title`` everywhere, and ``default`` only when its value is ``None``. Non-null defaults, enums, and all other keywords are preserved. """ @@ -489,12 +490,14 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig): 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): + elif key in ("$defs", "definitions") 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): + elif key in ("anyOf", "allOf", "oneOf", "prefixItems") and isinstance(schema[key], list): for item in schema[key]: FireworksAIConfig._sanitize_tool_schema(item) + elif key == "propertyNames" and isinstance(schema[key], dict): + FireworksAIConfig._sanitize_tool_schema(schema[key]) def _transform_messages_helper( self, messages: list[AllMessageValues], model: str, litellm_params: dict diff --git a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_schema_sanitize.py b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_schema_sanitize.py index 13e92c8f2a5..4f55143440e 100644 --- a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_schema_sanitize.py +++ b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_schema_sanitize.py @@ -228,6 +228,51 @@ def test_sanitize_deeply_nested(): assert "pattern" not in deep +def test_sanitize_recurses_into_propertyNames(): + """propertyNames subschema must be traversed (Greptile P1).""" + config = FireworksAIConfig() + schema = { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-z]+$", + "title": "Prop Name", + }, + } + config._sanitize_tool_schema(schema) + assert "pattern" not in schema["propertyNames"] + assert "title" not in schema["propertyNames"] + + +def test_sanitize_recurses_into_prefixItems(): + """prefixItems array must be traversed.""" + config = FireworksAIConfig() + schema = { + "type": "array", + "prefixItems": [ + {"type": "string", "pattern": "^[A-Z]+$"}, + {"type": "string", "title": "Second"}, + ], + } + config._sanitize_tool_schema(schema) + assert "pattern" not in schema["prefixItems"][0] + assert "title" not in schema["prefixItems"][1] + + +def test_sanitize_recurses_into_definitions(): + """Legacy draft-04 definitions must be traversed.""" + config = FireworksAIConfig() + schema = { + "type": "object", + "definitions": { + "Foo": {"type": "string", "pattern": "^[a-z]+$", "title": "Foo"}, + }, + } + config._sanitize_tool_schema(schema) + assert "pattern" not in schema["definitions"]["Foo"] + assert "title" not in schema["definitions"]["Foo"] + + # --------------------------------------------------------------------------- # _transform_tools integration tests # --------------------------------------------------------------------------- From 219ebc266d97c9ca39def5e72e16acce79f58b52 Mon Sep 17 00:00:00 2001 From: Zara Thomas Date: Wed, 19 Aug 2026 09:35:37 +0100 Subject: [PATCH 4/4] style: ruff format Co-authored-by: Cursor --- litellm/llms/fireworks_ai/chat/transformation.py | 4 +--- .../llms/fireworks_ai/chat/test_fireworks_schema_sanitize.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index c4a9c85dbe2..2833c8f6f40 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -481,9 +481,7 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig): if not isinstance(schema, dict): return for key in list(schema): - if key in FireworksAIConfig._FIREWORKS_TOOL_SCHEMA_STRIP_KEYS or ( - key == "default" and schema[key] is None - ): + if key in FireworksAIConfig._FIREWORKS_TOOL_SCHEMA_STRIP_KEYS or (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(): diff --git a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_schema_sanitize.py b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_schema_sanitize.py index 4f55143440e..fa4ca2b4cb7 100644 --- a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_schema_sanitize.py +++ b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_schema_sanitize.py @@ -3,9 +3,7 @@ import sys import pytest -sys.path.insert( - 0, os.path.abspath("../../../../..") -) # Adds the parent directory to the system path +sys.path.insert(0, os.path.abspath("../../../../..")) # Adds the parent directory to the system path from litellm.llms.fireworks_ai.chat.transformation import FireworksAIConfig