fix(tools): strip default from $ref nodes in tool schemas

Fireworks-hosted Kimi rejects tool requests when nullable MCP/Pydantic
schemas collapse to {"$ref": "...", "default": null}. Strip that sibling
during global schema sanitization so gateway and CLI calls succeed again.
This commit is contained in:
Brooklyn Nicholson 2026-06-12 00:30:51 -05:00
parent c574170050
commit b2d151abe2
2 changed files with 104 additions and 0 deletions

View file

@ -201,6 +201,72 @@ def test_items_sanitized_in_array_schema():
assert items == {"type": "object", "properties": {}}
def test_ref_with_default_sibling_stripped():
"""Strict backends reject ``default`` alongside ``$ref``."""
tools = [_tool("t", {
"type": "object",
"properties": {
"payload": {"$ref": "#/$defs/Payload", "default": None},
},
"$defs": {
"Payload": {
"type": "object",
"properties": {"q": {"type": "string"}},
},
},
})]
out = sanitize_tool_schemas(tools)
payload = out[0]["function"]["parameters"]["properties"]["payload"]
assert payload == {"$ref": "#/$defs/Payload"}
def test_nullable_union_collapse_does_not_leave_default_on_ref():
"""Nullable anyOf collapse must not attach ``default`` to a ``$ref`` branch."""
tools = [_tool("t", {
"type": "object",
"properties": {
"input": {
"anyOf": [
{"$ref": "#/$defs/Payload"},
{"type": "null"},
],
"default": None,
},
},
"$defs": {
"Payload": {
"type": "object",
"properties": {"q": {"type": "string"}},
},
},
})]
out = sanitize_tool_schemas(tools)
prop = out[0]["function"]["parameters"]["properties"]["input"]
assert prop["$ref"] == "#/$defs/Payload"
assert "default" not in prop
assert prop.get("nullable") is True
def test_ref_description_preserved():
"""Annotation siblings that strict backends allow should survive."""
tools = [_tool("t", {
"type": "object",
"properties": {
"payload": {
"$ref": "#/$defs/Payload",
"description": "The payload",
},
},
"$defs": {
"Payload": {"type": "object", "properties": {}},
},
})]
out = sanitize_tool_schemas(tools)
payload = out[0]["function"]["parameters"]["properties"]["payload"]
assert payload["description"] == "The payload"
assert payload["$ref"] == "#/$defs/Payload"
def test_empty_tools_list_returns_empty():
assert sanitize_tool_schemas([]) == []

View file

@ -21,6 +21,12 @@ The failure modes we've seen in the wild:
optional fields (common Pydantic/MCP shape). Anthropic rejects these at
the top of ``input_schema``; collapse them to the non-null branch.
* Unconstrained ``additionalProperties`` on objects with empty properties.
* ``default`` (and other annotation keywords) alongside ``$ref`` strict
backends (Fireworks-hosted Kimi, JSON Schema draft-07 validators) reject
sibling keywords at the same level as ``$ref``. Common MCP/Pydantic shape
after nullable-union collapse::
{"$ref": "#/$defs/Foo", "default": null}
This module walks the final tool schema tree (after MCP-level normalization
and any per-tool dynamic rebuilds) and fixes the known-hostile constructs
@ -90,6 +96,35 @@ def _sanitize_single_tool(tool: dict) -> dict:
fn["parameters"] = _strip_top_level_combinators(
fn["parameters"], path=fn.get("name", "<tool>")
)
fn["parameters"] = _strip_ref_siblings(fn["parameters"])
return out
# Sibling keywords strict JSON Schema validators reject alongside ``$ref``.
_REF_FORBIDDEN_SIBLINGS = frozenset({"default"})
def _strip_ref_siblings(node: Any) -> Any:
"""Drop forbidden sibling keywords from nodes that carry ``$ref``.
Fireworks (and other draft-07-strict backends) fail tool requests with::
JSON Schema not supported: keyword(s) ['default'] not allowed at
the same level as $ref.
Nullable-union collapse and MCP ingestion can leave ``default`` on a
``$ref`` node; strip it recursively.
"""
if isinstance(node, list):
return [_strip_ref_siblings(item) for item in node]
if not isinstance(node, dict):
return node
out = {key: _strip_ref_siblings(value) for key, value in node.items()}
if "$ref" in out:
for key in _REF_FORBIDDEN_SIBLINGS:
if key in out:
out.pop(key, None)
return out
@ -185,6 +220,9 @@ def strip_nullable_unions(
replacement.setdefault("nullable", True)
for meta_key in ("title", "description", "default", "examples"):
if meta_key in stripped and meta_key not in replacement:
# ``default`` is illegal alongside ``$ref`` on strict backends.
if meta_key == "default" and "$ref" in replacement:
continue
replacement[meta_key] = stripped[meta_key]
return strip_nullable_unions(replacement, keep_nullable_hint=keep_nullable_hint)
return stripped