mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
191 lines
7.4 KiB
Python
191 lines
7.4 KiB
Python
|
|
"""Helpers for translating OpenAI-style tool schemas to Moonshot's schema subset.
|
||
|
|
|
||
|
|
Moonshot (Kimi) accepts a stricter subset of JSON Schema than standard OpenAI
|
||
|
|
tool calling. Requests that violate it fail with HTTP 400:
|
||
|
|
|
||
|
|
tools.function.parameters is not a valid moonshot flavored json schema,
|
||
|
|
details: <...>
|
||
|
|
|
||
|
|
Known rejection modes documented at
|
||
|
|
https://forum.moonshot.ai/t/tool-calling-specification-violation-on-moonshot-api/102
|
||
|
|
and MoonshotAI/kimi-cli#1595:
|
||
|
|
|
||
|
|
1. Every property schema must carry a ``type``. Standard JSON Schema allows
|
||
|
|
type to be omitted (the value is then unconstrained); Moonshot refuses.
|
||
|
|
2. When ``anyOf`` is used, ``type`` must be on the ``anyOf`` children, not
|
||
|
|
the parent. Presence of both causes "type should be defined in anyOf
|
||
|
|
items instead of the parent schema".
|
||
|
|
|
||
|
|
The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is
|
||
|
|
handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it
|
||
|
|
applies at MCP registration time for all providers.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import copy
|
||
|
|
from typing import Any, Dict, List
|
||
|
|
|
||
|
|
# Keys whose values are maps of name → schema (not schemas themselves).
|
||
|
|
# When we recurse, we walk the values of these maps as schemas, but we do
|
||
|
|
# NOT apply the missing-type repair to the map itself.
|
||
|
|
_SCHEMA_MAP_KEYS = frozenset({"properties", "patternProperties", "$defs", "definitions"})
|
||
|
|
|
||
|
|
# Keys whose values are lists of schemas.
|
||
|
|
_SCHEMA_LIST_KEYS = frozenset({"anyOf", "oneOf", "allOf", "prefixItems"})
|
||
|
|
|
||
|
|
# Keys whose values are a single nested schema.
|
||
|
|
_SCHEMA_NODE_KEYS = frozenset({"items", "contains", "not", "additionalProperties", "propertyNames"})
|
||
|
|
|
||
|
|
|
||
|
|
def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||
|
|
"""Recursively apply Moonshot repairs to a schema node.
|
||
|
|
|
||
|
|
``is_schema=True`` means this dict is a JSON Schema node and gets the
|
||
|
|
missing-type + anyOf-parent repairs applied. ``is_schema=False`` means
|
||
|
|
it's a container map (e.g. the value of ``properties``) and we only
|
||
|
|
recurse into its values.
|
||
|
|
"""
|
||
|
|
if isinstance(node, list):
|
||
|
|
# Lists only show up under schema-list keys (anyOf/oneOf/allOf), so
|
||
|
|
# every element is itself a schema.
|
||
|
|
return [_repair_schema(item, is_schema=True) for item in node]
|
||
|
|
if not isinstance(node, dict):
|
||
|
|
return node
|
||
|
|
|
||
|
|
# Walk the dict, deciding per-key whether recursion is into a schema
|
||
|
|
# node, a container map, or a scalar.
|
||
|
|
repaired: Dict[str, Any] = {}
|
||
|
|
for key, value in node.items():
|
||
|
|
if key in _SCHEMA_MAP_KEYS and isinstance(value, dict):
|
||
|
|
# Map of name → schema. Don't treat the map itself as a schema
|
||
|
|
# (it has no type / properties of its own), but each value is.
|
||
|
|
repaired[key] = {
|
||
|
|
sub_key: _repair_schema(sub_val, is_schema=True)
|
||
|
|
for sub_key, sub_val in value.items()
|
||
|
|
}
|
||
|
|
elif key in _SCHEMA_LIST_KEYS and isinstance(value, list):
|
||
|
|
repaired[key] = [_repair_schema(v, is_schema=True) for v in value]
|
||
|
|
elif key in _SCHEMA_NODE_KEYS:
|
||
|
|
# items / not / additionalProperties: single nested schema.
|
||
|
|
# additionalProperties can also be a bool — leave those alone.
|
||
|
|
if isinstance(value, dict):
|
||
|
|
repaired[key] = _repair_schema(value, is_schema=True)
|
||
|
|
else:
|
||
|
|
repaired[key] = value
|
||
|
|
else:
|
||
|
|
# Scalars (description, title, format, enum values, etc.) pass through.
|
||
|
|
repaired[key] = value
|
||
|
|
|
||
|
|
if not is_schema:
|
||
|
|
return repaired
|
||
|
|
|
||
|
|
# Rule 2: when anyOf is present, type belongs only on the children.
|
||
|
|
if "anyOf" in repaired and isinstance(repaired["anyOf"], list):
|
||
|
|
repaired.pop("type", None)
|
||
|
|
return repaired
|
||
|
|
|
||
|
|
# Rule 1: property schemas without type need one. $ref nodes are exempt
|
||
|
|
# — their type comes from the referenced definition.
|
||
|
|
if "$ref" in repaired:
|
||
|
|
return repaired
|
||
|
|
return _fill_missing_type(repaired)
|
||
|
|
|
||
|
|
|
||
|
|
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
|
||
|
|
"""Infer a reasonable ``type`` if this schema node has none."""
|
||
|
|
if "type" in node and node["type"] not in (None, ""):
|
||
|
|
return node
|
||
|
|
|
||
|
|
# Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum``
|
||
|
|
# → type of first enum value, else fall back to ``string`` (safest scalar).
|
||
|
|
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 "enum" in node and isinstance(node["enum"], list) and node["enum"]:
|
||
|
|
sample = node["enum"][0]
|
||
|
|
if isinstance(sample, bool):
|
||
|
|
inferred = "boolean"
|
||
|
|
elif isinstance(sample, int):
|
||
|
|
inferred = "integer"
|
||
|
|
elif isinstance(sample, float):
|
||
|
|
inferred = "number"
|
||
|
|
else:
|
||
|
|
inferred = "string"
|
||
|
|
else:
|
||
|
|
inferred = "string"
|
||
|
|
|
||
|
|
return {**node, "type": inferred}
|
||
|
|
|
||
|
|
|
||
|
|
def sanitize_moonshot_tool_parameters(parameters: Any) -> Dict[str, Any]:
|
||
|
|
"""Normalize tool parameters to a Moonshot-compatible object schema.
|
||
|
|
|
||
|
|
Returns a deep-copied schema with the two flavored-JSON-Schema repairs
|
||
|
|
applied. Input is not mutated.
|
||
|
|
"""
|
||
|
|
if not isinstance(parameters, dict):
|
||
|
|
return {"type": "object", "properties": {}}
|
||
|
|
|
||
|
|
repaired = _repair_schema(copy.deepcopy(parameters), is_schema=True)
|
||
|
|
if not isinstance(repaired, dict):
|
||
|
|
return {"type": "object", "properties": {}}
|
||
|
|
|
||
|
|
# Top-level must be an object schema
|
||
|
|
if repaired.get("type") != "object":
|
||
|
|
repaired["type"] = "object"
|
||
|
|
if "properties" not in repaired:
|
||
|
|
repaired["properties"] = {}
|
||
|
|
|
||
|
|
return repaired
|
||
|
|
|
||
|
|
|
||
|
|
def sanitize_moonshot_tools(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
|
|
"""Apply ``sanitize_moonshot_tool_parameters`` to every tool's parameters."""
|
||
|
|
if not tools:
|
||
|
|
return tools
|
||
|
|
|
||
|
|
sanitized: List[Dict[str, Any]] = []
|
||
|
|
any_change = False
|
||
|
|
for tool in tools:
|
||
|
|
if not isinstance(tool, dict):
|
||
|
|
sanitized.append(tool)
|
||
|
|
continue
|
||
|
|
fn = tool.get("function")
|
||
|
|
if not isinstance(fn, dict):
|
||
|
|
sanitized.append(tool)
|
||
|
|
continue
|
||
|
|
params = fn.get("parameters")
|
||
|
|
repaired = sanitize_moonshot_tool_parameters(params)
|
||
|
|
if repaired is not params:
|
||
|
|
any_change = True
|
||
|
|
new_fn = {**fn, "parameters": repaired}
|
||
|
|
sanitized.append({**tool, "function": new_fn})
|
||
|
|
else:
|
||
|
|
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``, ``openrouter/moonshotai/...``).
|
||
|
|
Detection by model name covers Nous / OpenRouter / other aggregators that
|
||
|
|
route to Moonshot's inference, where the base URL is the aggregator's, not
|
||
|
|
``api.moonshot.ai``.
|
||
|
|
"""
|
||
|
|
if not model:
|
||
|
|
return False
|
||
|
|
bare = model.strip().lower()
|
||
|
|
# Last path segment (covers aggregator-prefixed slugs)
|
||
|
|
tail = bare.rsplit("/", 1)[-1]
|
||
|
|
if tail.startswith("kimi-") or tail == "kimi":
|
||
|
|
return True
|
||
|
|
# Vendor-prefixed forms commonly used on aggregators
|
||
|
|
if "moonshot" in bare or "/kimi" in bare or bare.startswith("kimi"):
|
||
|
|
return True
|
||
|
|
return False
|