Files
hermes-agent/agent/moonshot_schema.py
chelsealong dc0915ce35 fix(moonshot): stop stamping a synthetic type on if/then/else nodes
_repair_schema didn't recurse into if/then/else, so a bare conditional
wrapper (e.g. an allOf branch expressing "when goal is set, mode must be
one of ...") fell through _fill_missing_type's default and was stamped
with type: "string". That corrupts the schema: the wrapper's actual
instance is an object, so Moonshot (and any strict validator) now
rejects every real argument against it.

if/then/else are added to the recursed node keys, and _fill_missing_type
leaves a bare conditional node untyped instead of defaulting to "string"
since it constrains the parent instance rather than describing its own
shape.
2026-09-21 13:14:22 -07:00

184 lines
7.6 KiB
Python

"""Translate OpenAI-style tool schemas to Moonshot's (Kimi) stricter JSON Schema subset.
Violations fail with HTTP 400 "tools.function.parameters is not a valid moonshot
flavored json schema". Rules: (1) every property schema carries a ``type``;
(2) with ``anyOf``, ``type`` belongs on the children, never the parent; (3) enum
arrays under scalar types may not contain null / empty string; (4) every object
schema carries a ``required`` array, even an empty one. The ``#/definitions/`` →
``#/$defs/`` rewrite lives in ``tools/mcp_tool`` so it applies to all providers.
"""
from __future__ import annotations
import copy
from typing import Any, Dict, List
# Values are maps of name → schema: recurse into the values, but the map itself
# is not a schema and gets no repairs.
_SCHEMA_MAP_KEYS = frozenset({"properties", "patternProperties", "$defs", "definitions"})
# Values are lists of schemas.
_SCHEMA_LIST_KEYS = frozenset({"anyOf", "oneOf", "allOf", "prefixItems"})
# Values are a single nested schema (additionalProperties may also be a bool).
_SCHEMA_NODE_KEYS = frozenset(
{"items", "contains", "not", "additionalProperties", "propertyNames", "if", "then", "else"}
)
_SCALAR_TYPES = frozenset({"string", "integer", "number", "boolean"})
# bool before int: bool is an int subclass.
_ENUM_SAMPLE_TYPES = ((bool, "boolean"), (int, "integer"), (float, "number"))
def _empty_object_schema() -> Dict[str, Any]:
return {"type": "object", "properties": {}, "required": []}
def _repair_schema(node: Any) -> Any:
"""Recursively apply the Moonshot repairs to a schema node."""
if isinstance(node, list):
return [_repair_schema(item) for item in node]
if not isinstance(node, dict):
return node
repaired: Dict[str, Any] = {}
for key, value in node.items():
if key in _SCHEMA_MAP_KEYS and isinstance(value, dict):
repaired[key] = {sub_key: _repair_schema(sub_val) for sub_key, sub_val in value.items()}
elif (key in _SCHEMA_LIST_KEYS and isinstance(value, list)) or (
key in _SCHEMA_NODE_KEYS and isinstance(value, dict)
):
repaired[key] = _repair_schema(value)
else:
repaired[key] = value
# Rule 2, plus: Moonshot rejects null-type branches inside anyOf. Drop
# them; a single surviving branch is promoted into this node and falls
# through to rules 1/3/4, otherwise the pruned anyOf is returned as-is.
if "anyOf" in repaired and isinstance(repaired["anyOf"], list):
repaired.pop("type", None)
non_null = [b for b in repaired["anyOf"] if isinstance(b, dict) and b.get("type") != "null"]
if not non_null or len(non_null) == len(repaired["anyOf"]):
return repaired
if len(non_null) > 1:
repaired["anyOf"] = non_null
return repaired
repaired = {**{k: v for k, v in repaired.items() if k != "anyOf"}, **non_null[0]}
# Moonshot also rejects the non-standard ``nullable`` keyword.
repaired.pop("nullable", None)
# Rule 1 ($ref nodes take their type from the referenced definition).
# Runs before rule 3 so enum cleanup can see the type.
if "$ref" not in repaired:
repaired = _fill_missing_type(repaired)
# Rule 3: drop null/"" enum values under scalar types; drop an emptied enum.
if isinstance(repaired.get("enum"), list) and repaired.get("type") in _SCALAR_TYPES:
cleaned = [v for v in repaired["enum"] if v is not None and v != ""]
if cleaned:
repaired["enum"] = cleaned
else:
repaired.pop("enum")
# Rule 4.
if repaired.get("type") == "object":
repaired = _ensure_required_array(repaired)
return repaired
def _ensure_required_array(node: Dict[str, Any]) -> Dict[str, Any]:
"""Guarantee an object schema carries a ``required`` list, pruning names not in
``properties`` (Moonshot also rejects dangling names). Mutates and returns ``node``."""
props = node.get("properties")
req = node.get("required")
if isinstance(req, list):
if isinstance(props, dict):
node["required"] = [r for r in req if r in props]
else:
node["required"] = []
return node
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
"""Infer a ``type`` if this schema node has none.
A type list collapses to its first concrete member; otherwise
``properties``/``required``/``additionalProperties`` → object,
``items``/``prefixItems`` → array, ``enum`` → type of its first value,
else ``string`` (safest scalar). A bare ``if``/``then``/``else`` node
constrains whatever instance it is attached to rather than describing
its own type, so it is left untyped instead of defaulting to ``string``.
"""
node_type = node.get("type")
if isinstance(node_type, list):
concrete = next((t for t in node_type if isinstance(t, str) and t not in {"", "null"}), "string")
return {**node, "type": concrete}
if "type" in node and node_type not in {None, ""}:
return node
if "properties" in node or "required" in node or "additionalProperties" in node:
inferred = "object"
elif "items" in node or "prefixItems" in node:
inferred = "array"
elif isinstance(node.get("enum"), list) and node["enum"]:
sample = node["enum"][0]
inferred = next((t for cls, t in _ENUM_SAMPLE_TYPES if isinstance(sample, cls)), "string")
elif "if" in node or "then" in node or "else" in node:
return node
else:
inferred = "string"
return {**node, "type": inferred}
def sanitize_moonshot_tool_parameters(parameters: Any) -> Dict[str, Any]:
"""Deep-copied, Moonshot-compatible object schema; input is not mutated."""
if not isinstance(parameters, dict):
return _empty_object_schema()
repaired = _repair_schema(copy.deepcopy(parameters))
if not isinstance(repaired, dict):
return _empty_object_schema()
# Top-level must be an object schema.
repaired["type"] = "object"
repaired.setdefault("properties", {})
return _ensure_required_array(repaired)
def sanitize_moonshot_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Apply ``sanitize_moonshot_tool_parameters`` to every tool's parameters.
Returns the input list object itself when nothing needed repairing.
"""
if not tools:
return tools
sanitized: List[Dict[str, Any]] = []
any_change = False
for tool in tools:
fn = tool.get("function") if isinstance(tool, dict) else None
if isinstance(fn, dict):
params = fn.get("parameters")
repaired = sanitize_moonshot_tool_parameters(params)
if repaired is not params:
any_change = True
tool = {**tool, "function": {**fn, "parameters": repaired}}
sanitized.append(tool)
return sanitized if any_change else tools
def is_moonshot_model(model: str | None) -> bool:
"""True for any Kimi / Moonshot model slug, regardless of aggregator prefix.
Matches bare names (``kimi-k2.6``, ``moonshotai/Kimi-K2.6``) and
aggregator-prefixed slugs (``nous/moonshotai/kimi-k2.6``), since aggregators
route to Moonshot inference under their own base URL.
"""
if not model:
return False
bare = model.strip().lower()
tail = bare.rsplit("/", 1)[-1]
if tail.startswith("kimi-") or tail == "kimi":
return True
# Kimi Coding Plan serves K3 under the bare slug ``k3`` (plus ``k3.1`` / ``k3-turbo``).
if tail == "k3" or tail.startswith(("k3.", "k3-")):
return True
return "moonshot" in bare or "/kimi" in bare or bare.startswith("kimi")