mirror of
https://github.com/ComposioHQ/composio.git
synced 2026-09-22 11:46:35 +08:00
456 lines
18 KiB
Python
456 lines
18 KiB
Python
"""Normalize JSON Schemas for OpenAI structured outputs (``strict`` mode).
|
|
|
|
Python counterpart of the TypeScript SDK's ``toStrictJsonSchema``
|
|
(``ts/packages/core/src/utils/jsonSchema.ts``). OpenAI's structured-output
|
|
contract requires *every* object node — not just the root — to list all of its
|
|
properties in ``required``, set ``additionalProperties: False`` and avoid
|
|
annotation-only keywords. Optional fields are emulated the way OpenAI
|
|
documents: the property stays, becomes required, and is widened to accept
|
|
``null`` (``"type": ["string", "null"]`` or an extra ``anyOf`` branch). Nothing
|
|
is dropped, so the model keeps every parameter it could pass before; the strict
|
|
provider drops a ``null`` the tool's own schema does not accept before
|
|
executing the tool (see :func:`omit_null_tool_arguments`).
|
|
|
|
Local ``$ref`` pointers into ``$defs``/``definitions`` are kept (the API
|
|
supports them, including recursion) and the definitions themselves are
|
|
normalized; an optional ``$ref`` property is widened with an ``anyOf`` null
|
|
branch. Constructs strict mode cannot express — objects that accept arbitrary
|
|
keys (schema-valued or ``True`` ``additionalProperties``, ``patternProperties``,
|
|
property-less free-form objects), ``allOf``, ``prefixItems``, external or
|
|
dangling ``$ref`` pointers and a non-object root — are reported in
|
|
``unsupported`` instead of being rewritten into something narrower; the
|
|
provider sends such a tool without strict mode.
|
|
|
|
The input is never mutated, every rewrite is recorded (capped at 50 entries;
|
|
``total_changes`` carries the real count) and ``required`` arrays are
|
|
de-duplicated at the end.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import typing as t
|
|
|
|
from composio.utils.json_schema import MAX_REF_CHAIN_DEPTH, _try_resolve_pointer
|
|
|
|
MAX_NODE_DEPTH = 512
|
|
MAX_CHANGES = 50
|
|
|
|
# Annotation-only keywords OpenAI structured outputs rejects; safe to strip.
|
|
STRICT_STRIP_KEYWORDS = frozenset({"examples", "default"})
|
|
|
|
# Keywords whose values are literal data, not schemas - never recursed as schemas.
|
|
INSTANCE_VALUE_KEYWORDS = frozenset({"const", "default", "enum", "examples"})
|
|
|
|
SCHEMA_KEYWORDS = frozenset(
|
|
{
|
|
"additionalItems",
|
|
"additionalProperties",
|
|
"contains",
|
|
"contentSchema",
|
|
"else",
|
|
"if",
|
|
"not",
|
|
"propertyNames",
|
|
"then",
|
|
"unevaluatedItems",
|
|
"unevaluatedProperties",
|
|
}
|
|
)
|
|
SCHEMA_ARRAY_KEYWORDS = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"})
|
|
SCHEMA_MAP_KEYWORDS = frozenset(
|
|
{"$defs", "definitions", "dependentSchemas", "patternProperties", "properties"}
|
|
)
|
|
|
|
# Keywords OpenAI structured outputs reject and that have no lossless rewrite.
|
|
STRICT_UNSUPPORTED_KEYWORDS = (
|
|
"allOf",
|
|
"prefixItems",
|
|
"additionalItems",
|
|
"contains",
|
|
"dependencies",
|
|
"dependentSchemas",
|
|
"else",
|
|
"if",
|
|
"not",
|
|
"propertyNames",
|
|
"then",
|
|
"unevaluatedItems",
|
|
"unevaluatedProperties",
|
|
)
|
|
|
|
|
|
def _is_object_type(node_type: t.Any) -> bool:
|
|
"""Whether a ``type`` value declares exactly the object type."""
|
|
return node_type == "object" or (
|
|
isinstance(node_type, list) and node_type == ["object"]
|
|
)
|
|
|
|
|
|
StrictSchemaChangeReason = t.Literal[
|
|
"optional-property-nullable",
|
|
"unsupported-keyword-stripped",
|
|
"one-of-converted",
|
|
]
|
|
|
|
|
|
class StrictSchemaChange(t.NamedTuple):
|
|
"""A single lossless rewrite applied by :func:`to_strict_json_schema`."""
|
|
|
|
path: str
|
|
reason: StrictSchemaChangeReason
|
|
detail: str = ""
|
|
|
|
|
|
class StrictSchemaIncompatibility(t.NamedTuple):
|
|
"""A construct strict structured outputs cannot express."""
|
|
|
|
path: str
|
|
keyword: str
|
|
detail: str
|
|
|
|
|
|
class StrictJsonSchemaResult(t.NamedTuple):
|
|
"""Result of :func:`to_strict_json_schema`."""
|
|
|
|
schema: dict[str, t.Any]
|
|
"""The strict schema; only usable when ``unsupported`` is empty."""
|
|
source: dict[str, t.Any]
|
|
"""The input schema optionality was decided against."""
|
|
changes: list[StrictSchemaChange]
|
|
total_changes: int
|
|
unsupported: list[StrictSchemaIncompatibility]
|
|
|
|
|
|
def _join_path(parent: str, key: str) -> str:
|
|
return f"{parent}.{key}" if parent else key
|
|
|
|
|
|
def _widen_to_nullable(node: dict[str, t.Any]) -> dict[str, t.Any]:
|
|
"""Accept ``null`` without placing ``type`` beside ``anyOf``."""
|
|
any_of = node.get("anyOf")
|
|
if isinstance(any_of, list):
|
|
if any(isinstance(b, dict) and b.get("type") == "null" for b in any_of):
|
|
return node
|
|
return {**node, "anyOf": [*any_of, {"type": "null"}]}
|
|
node_type = node.get("type")
|
|
if isinstance(node_type, str):
|
|
return node if node_type == "null" else {**node, "type": [node_type, "null"]}
|
|
if isinstance(node_type, list):
|
|
return node if "null" in node_type else {**node, "type": [*node_type, "null"]}
|
|
if (isinstance(node.get("enum"), list) and None in node["enum"]) or (
|
|
"const" in node and node["const"] is None
|
|
):
|
|
return node
|
|
annotations: dict[str, t.Any] = {
|
|
k: node[k] for k in ("description", "title") if k in node
|
|
}
|
|
rest = {k: v for k, v in node.items() if k not in annotations}
|
|
if not rest:
|
|
return node
|
|
return {**annotations, "anyOf": [rest, {"type": "null"}]}
|
|
|
|
|
|
def _resolve_local_refs(node: t.Any, root: dict[str, t.Any]) -> t.Any:
|
|
"""Follow local ``$ref`` pointers until a concrete node is reached."""
|
|
current = node
|
|
for _ in range(MAX_REF_CHAIN_DEPTH):
|
|
if not isinstance(current, dict) or not isinstance(current.get("$ref"), str):
|
|
return current
|
|
resolution = _try_resolve_pointer(root, current["$ref"])
|
|
if not resolution.ok:
|
|
return current
|
|
current = resolution.value
|
|
return current
|
|
|
|
|
|
def _schema_accepts_null(schema: t.Any, root: dict[str, t.Any]) -> bool:
|
|
"""Whether a schema node accepts ``null`` as an instance."""
|
|
node = _resolve_local_refs(schema, root)
|
|
if not isinstance(node, dict):
|
|
return True
|
|
node_type = node.get("type")
|
|
if isinstance(node_type, str):
|
|
return node_type == "null"
|
|
if isinstance(node_type, list):
|
|
return "null" in node_type
|
|
if isinstance(node.get("enum"), list):
|
|
return None in node["enum"]
|
|
if "const" in node:
|
|
return node["const"] is None
|
|
for keyword in ("anyOf", "oneOf"):
|
|
branches = node.get(keyword)
|
|
if isinstance(branches, list):
|
|
return any(_schema_accepts_null(b, root) for b in branches)
|
|
return True
|
|
|
|
|
|
def _dedupe_required(value: t.Any, is_schema: bool = True, depth: int = 0) -> t.Any:
|
|
if depth > MAX_NODE_DEPTH:
|
|
raise ValueError(
|
|
f"JSON Schema exceeds maximum nesting depth of {MAX_NODE_DEPTH}"
|
|
)
|
|
if isinstance(value, list):
|
|
return [_dedupe_required(item, is_schema, depth + 1) for item in value]
|
|
if not isinstance(value, dict):
|
|
return value
|
|
clone: dict[str, t.Any] = {}
|
|
for key, child in value.items():
|
|
if is_schema and key == "required" and isinstance(child, list):
|
|
clone[key] = list(dict.fromkeys(child))
|
|
else:
|
|
clone[key] = _dedupe_required(
|
|
child, is_schema and key not in INSTANCE_VALUE_KEYWORDS, depth + 1
|
|
)
|
|
return clone
|
|
|
|
|
|
class _Walker:
|
|
def __init__(self, root: dict[str, t.Any]) -> None:
|
|
self.root = root
|
|
self.changes: list[StrictSchemaChange] = []
|
|
self.total_changes = 0
|
|
self.unsupported: list[StrictSchemaIncompatibility] = []
|
|
|
|
def record(self, path: str, reason: StrictSchemaChangeReason, detail: str) -> None:
|
|
self.total_changes += 1
|
|
if len(self.changes) < MAX_CHANGES:
|
|
self.changes.append(StrictSchemaChange(path, reason, detail))
|
|
|
|
def reject(self, path: str, keyword: str, detail: str) -> None:
|
|
self.unsupported.append(StrictSchemaIncompatibility(path, keyword, detail))
|
|
|
|
def walk_children(
|
|
self, node: dict[str, t.Any], mode: str, depth: int, path: str
|
|
) -> dict[str, t.Any]:
|
|
clone: dict[str, t.Any] = {}
|
|
for key, child in node.items():
|
|
child_mode = "value"
|
|
if mode == "schema-map":
|
|
child_mode = "schema"
|
|
elif mode == "schema" and key not in INSTANCE_VALUE_KEYWORDS:
|
|
if key in SCHEMA_MAP_KEYWORDS:
|
|
child_mode = "schema-map"
|
|
elif key in SCHEMA_ARRAY_KEYWORDS or (
|
|
key == "items" and isinstance(child, list)
|
|
):
|
|
child_mode = "schema-array"
|
|
elif key in SCHEMA_KEYWORDS or key == "items":
|
|
child_mode = "schema"
|
|
|
|
if child_mode == "schema-map" and isinstance(child, dict):
|
|
clone[key] = {
|
|
name: self.walk(
|
|
sub_schema,
|
|
"schema",
|
|
depth + 1,
|
|
_join_path(_join_path(path, key), name),
|
|
)
|
|
for name, sub_schema in child.items()
|
|
}
|
|
continue
|
|
clone[key] = self.walk(child, child_mode, depth + 1, _join_path(path, key))
|
|
return clone
|
|
|
|
def walk(self, value: t.Any, mode: str, depth: int, path: str) -> t.Any:
|
|
if depth > MAX_NODE_DEPTH:
|
|
raise ValueError(
|
|
f"JSON Schema exceeds maximum nesting depth of {MAX_NODE_DEPTH}"
|
|
)
|
|
if isinstance(value, list):
|
|
item_mode = "schema" if mode == "schema-array" else "value"
|
|
return [
|
|
self.walk(item, item_mode, depth + 1, f"{path}[{index}]")
|
|
for index, item in enumerate(value)
|
|
]
|
|
if not isinstance(value, dict) or mode == "value":
|
|
return value
|
|
|
|
node: dict[str, t.Any] = {}
|
|
for key, child in value.items():
|
|
if key in STRICT_STRIP_KEYWORDS:
|
|
self.record(
|
|
path, "unsupported-keyword-stripped", f'keyword "{key}" removed'
|
|
)
|
|
continue
|
|
node[key] = child
|
|
if isinstance(node.get("oneOf"), list) and "anyOf" not in node:
|
|
node["anyOf"] = node.pop("oneOf")
|
|
self.record(path, "one-of-converted", "oneOf became anyOf")
|
|
for keyword in STRICT_UNSUPPORTED_KEYWORDS:
|
|
if keyword in node:
|
|
self.reject(path, keyword, f'"{keyword}" has no strict-mode equivalent')
|
|
if "oneOf" in node:
|
|
self.reject(path, "oneOf", "oneOf beside anyOf cannot be merged")
|
|
if isinstance(node.get("items"), list):
|
|
self.reject(path, "items", "tuple-form items has no strict-mode equivalent")
|
|
elif isinstance(node.get("items"), bool):
|
|
self.reject(path, "items", "boolean subschema")
|
|
if "properties" in node and not isinstance(node["properties"], dict):
|
|
self.reject(path, "properties", "properties is not an object")
|
|
|
|
out = self.walk_children(node, mode, depth, path)
|
|
|
|
ref = node.get("$ref")
|
|
if isinstance(ref, str):
|
|
resolved = (ref == "#" or ref.startswith("#/")) and _try_resolve_pointer(
|
|
self.root, ref
|
|
).ok
|
|
if not resolved:
|
|
self.reject(path, "$ref", f'unresolved $ref "{ref}"')
|
|
# The referenced definition is normalized where it is declared.
|
|
return out
|
|
|
|
node_type = out.get("type")
|
|
declares_object = node_type == "object" or (
|
|
isinstance(node_type, list) and "object" in node_type
|
|
)
|
|
if not declares_object and not isinstance(out.get("properties"), dict):
|
|
return out
|
|
if "properties" in out and not isinstance(out["properties"], dict):
|
|
return out
|
|
|
|
properties: dict[str, t.Any] = dict(out.get("properties") or {})
|
|
required = out.get("required")
|
|
declared_required = {
|
|
entry
|
|
for entry in (required if isinstance(required, list) else [])
|
|
if isinstance(entry, str)
|
|
}
|
|
for name, property_schema in properties.items():
|
|
if isinstance(property_schema, bool):
|
|
self.reject(
|
|
_join_path(path, f"properties.{name}"),
|
|
"properties",
|
|
"boolean subschema",
|
|
)
|
|
continue
|
|
if name in declared_required or not isinstance(property_schema, dict):
|
|
continue
|
|
properties[name] = _widen_to_nullable(property_schema)
|
|
self.record(
|
|
_join_path(path, f"properties.{name}"),
|
|
"optional-property-nullable",
|
|
f'property "{name}" is now required and accepts null',
|
|
)
|
|
|
|
additional = out.get("additionalProperties")
|
|
accepts_dynamic_keys = additional is True or isinstance(additional, dict)
|
|
if accepts_dynamic_keys:
|
|
self.reject(path, "additionalProperties", "object accepts arbitrary keys")
|
|
elif "patternProperties" in out:
|
|
self.reject(
|
|
path, "patternProperties", "object accepts pattern-matched keys"
|
|
)
|
|
elif not properties and "additionalProperties" not in out:
|
|
self.reject(path, "properties", "free-form object accepts arbitrary keys")
|
|
|
|
result = {**out}
|
|
if "type" not in result:
|
|
result["type"] = "object"
|
|
result["properties"] = properties
|
|
result["required"] = list(properties.keys())
|
|
if not accepts_dynamic_keys:
|
|
result["additionalProperties"] = False
|
|
return result
|
|
|
|
|
|
def to_strict_json_schema(schema: t.Any) -> StrictJsonSchemaResult:
|
|
"""Normalize a tool parameter schema for OpenAI structured outputs.
|
|
|
|
Applies the strict contract at every depth: every object lists all of its
|
|
properties in ``required`` and is closed; optional properties are widened
|
|
to accept ``null`` instead of being dropped; ``oneOf`` becomes ``anyOf``;
|
|
``default`` and ``examples`` are stripped; local ``$ref`` pointers into
|
|
``$defs`` or ``definitions`` are kept and the definitions are normalized
|
|
where they are declared; ``required`` arrays are de-duplicated last.
|
|
Constructs strict mode cannot express are listed in ``unsupported``; when
|
|
that list is non-empty the returned ``schema`` must not be sent as strict.
|
|
|
|
:param schema: The JSON schema to normalize; never mutated.
|
|
:return: A :class:`StrictJsonSchemaResult`.
|
|
"""
|
|
root: dict[str, t.Any] = schema if isinstance(schema, dict) else {}
|
|
walker = _Walker(root)
|
|
normalized = _dedupe_required(walker.walk(root, "schema", 0, ""))
|
|
if not isinstance(normalized, dict) or not _is_object_type(normalized.get("type")):
|
|
walker.reject("", "type", "root must be a non-nullable object")
|
|
return StrictJsonSchemaResult(
|
|
schema=normalized,
|
|
source=root,
|
|
changes=walker.changes,
|
|
total_changes=walker.total_changes,
|
|
unsupported=walker.unsupported,
|
|
)
|
|
|
|
|
|
def omit_null_tool_arguments(
|
|
arguments: dict[str, t.Any], schema: t.Any
|
|
) -> dict[str, t.Any]:
|
|
"""Drop ``None``-valued arguments the tool's own schema does not accept.
|
|
|
|
Strict structured outputs cannot express optional parameters, so
|
|
:func:`to_strict_json_schema` makes every parameter required and nullable.
|
|
The model then sends ``null`` for a parameter it would otherwise have
|
|
omitted; forwarding it to the tool would fail validation against the
|
|
tool's real schema, so it is treated as "omitted". A ``null`` the original
|
|
schema accepts is kept. ``schema`` is the tool schema the strict rewrite
|
|
was computed from (``StrictJsonSchemaResult.source``). The input is not
|
|
mutated.
|
|
"""
|
|
root: dict[str, t.Any] = schema if isinstance(schema, dict) else {}
|
|
return t.cast(dict[str, t.Any], _omit_nulls(arguments, root, root, 0))
|
|
|
|
|
|
def _select_branch_for(
|
|
schema: t.Any, value: t.Any, root: dict[str, t.Any]
|
|
) -> dict[str, t.Any] | None:
|
|
"""Pick the composition branch that describes a value's shape."""
|
|
resolved = _resolve_local_refs(schema, root)
|
|
if not isinstance(resolved, dict):
|
|
return None
|
|
wanted = "items" if isinstance(value, list) else "properties"
|
|
|
|
def find(node: t.Any, depth: int) -> dict[str, t.Any] | None:
|
|
candidate = _resolve_local_refs(node, root)
|
|
if not isinstance(candidate, dict) or depth > MAX_NODE_DEPTH:
|
|
return None
|
|
if wanted in candidate:
|
|
return candidate
|
|
for keyword in ("anyOf", "oneOf"):
|
|
branches = candidate.get(keyword)
|
|
if not isinstance(branches, list):
|
|
continue
|
|
for branch in branches:
|
|
found = find(branch, depth + 1)
|
|
if found is not None:
|
|
return found
|
|
return None
|
|
|
|
return find(resolved, 0) or resolved
|
|
|
|
|
|
def _omit_nulls(
|
|
value: t.Any, schema: t.Any, root: dict[str, t.Any], depth: int
|
|
) -> t.Any:
|
|
if depth > MAX_NODE_DEPTH:
|
|
raise ValueError(
|
|
f"Tool arguments exceed maximum nesting depth of {MAX_NODE_DEPTH}"
|
|
)
|
|
node = _select_branch_for(schema, value, root) or {}
|
|
if isinstance(value, list):
|
|
items = node.get("items") if isinstance(node.get("items"), dict) else None
|
|
return [_omit_nulls(item, items, root, depth + 1) for item in value]
|
|
if not isinstance(value, dict):
|
|
return value
|
|
declared = node.get("properties")
|
|
properties: dict[str, t.Any] = declared if isinstance(declared, dict) else {}
|
|
clone: dict[str, t.Any] = {}
|
|
for key, child in value.items():
|
|
property_schema = properties.get(key)
|
|
if child is None:
|
|
if property_schema is None or _schema_accepts_null(property_schema, root):
|
|
clone[key] = child
|
|
continue
|
|
clone[key] = _omit_nulls(child, property_schema, root, depth + 1)
|
|
return clone
|