Skip to content

Strict Output Validator — Python source

Check a JSON Schema against OpenAI structured-outputs strict mode rules — open objects, missing required keys, unsupported keywords — before the API rejects it. 100% client-side.

This is the Python implementation — the same logic the interactive tool runs, in a shareable, citable form.

"""Strict Output Validator — check a JSON Schema against OpenAI structured-
outputs strict-mode rules, so it fails here instead of at the API.

Language: Python (3.10+, standard library only)
Port of src/lib/strictOutputValidator.ts (the canonical TypeScript
implementation). javascript.js in this set carries the same port; this file
mirrors it for Python.
Tool page: https://dev.cosmolabs.org/tools/strict-output-validator

Rules (2026 OpenAI strict mode):
  R1 root must be type "object"            (validate_strict_root)
  R2 every object node needs additionalProperties: false
  R3 every key in properties must be listed in required (no optional keys)
  R4 required must not name keys absent from properties
  R5 only the supported type values / keywords may appear
The keyword allowlist is conservative: keywords OpenAI documents as
unsupported are flagged so the verdict is actionable, not just binary.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from typing import Any, List, Union

#: Types strict mode supports.
SUPPORTED_TYPES = frozenset(
    {"object", "array", "string", "number", "integer", "boolean"}
)

#: Keywords strict mode understands per-node. Everything else is flagged.
#: ("allOf" is accepted only as single-element; checked in the walker.)
SUPPORTED_KEYWORDS = frozenset(
    {
        "type",
        "description",
        "title",
        "properties",
        "required",
        "additionalProperties",
        "items",
        "enum",
        "const",
        "anyOf",
        "allOf",
        "$ref",
        "$defs",
        "definitions",
        "format",
        "nullable",
        "default",
    }
)

# Rule identifiers — the Python spelling of the TypeScript StrictRule union.
ROOT_NOT_OBJECT = "root-not-object"
MISSING_ADDITIONAL_PROPERTIES = "missing-additional-properties"
PROPERTY_NOT_REQUIRED = "property-not-required"
REQUIRED_NOT_PROPERTY = "required-not-property"
UNSUPPORTED_TYPE = "unsupported-type"
UNSUPPORTED_KEYWORD = "unsupported-keyword"
INVALID_SCHEMA = "invalid-schema"


@dataclass(frozen=True)
class StrictIssue:
    """A single rule violation."""

    path: str
    rule: str
    message: str


@dataclass
class Counts:
    """Schema shape tally accumulated during the walk."""

    objects: int = 0
    properties: int = 0
    enums: int = 0


@dataclass
class StrictReport:
    """The whole verdict: ok plus every issue and the shape counts."""

    ok: bool
    issues: List[StrictIssue] = field(default_factory=list)
    counts: Counts = field(default_factory=Counts)


def _is_obj(value: Any) -> bool:
    """True for JSON objects (dicts) — not lists, not scalars, not None."""
    return isinstance(value, dict)


def validate_strict_schema(
    input: Union[str, dict, Any]
) -> StrictReport:
    """Validate a schema against the strict-mode structural rules.

    Accepts either a pre-parsed schema (a JSON object/dict) or a JSON string
    (parsed here; a parse failure is reported as a single ``invalid-schema``
    issue). R1 (root must be an object type) is NOT checked here — use
    :func:`validate_strict_root` for that.
    """
    issues: List[StrictIssue] = []
    counts = Counts()

    schema: Any = input
    if isinstance(input, str):
        try:
            schema = json.loads(input)
        except ValueError as e:
            return StrictReport(
                ok=False,
                issues=[
                    StrictIssue(
                        path="$", rule=INVALID_SCHEMA, message=f"Not valid JSON: {e}"
                    )
                ],
                counts=counts,
            )
    if not _is_obj(schema):
        return StrictReport(
            ok=False,
            issues=[
                StrictIssue(
                    path="$", rule=INVALID_SCHEMA, message="Schema must be a JSON object."
                )
            ],
            counts=counts,
        )

    _walk(schema, "$", issues, counts)
    return StrictReport(ok=not issues, issues=issues, counts=counts)


def _unsupported_keywords(node: dict, path: str, issues: List[StrictIssue]) -> None:
    """Flag every keyword strict mode does not understand."""
    for key in node:
        if key not in SUPPORTED_KEYWORDS:
            issues.append(
                StrictIssue(
                    path=path,
                    rule=UNSUPPORTED_KEYWORD,
                    message=f'"{key}" is not supported in strict mode — remove it or express the constraint another way.',
                )
            )


def _walk(node: dict, path: str, issues: List[StrictIssue], counts: Counts) -> None:
    """Depth-first walk emitting issues and accumulating counts."""
    _unsupported_keywords(node, path, issues)

    type_ = node.get("type")
    # 'null' is only expressible inside a type array (the nullable form).
    is_nullable_form = isinstance(type_, list)
    if is_nullable_form:
        type_list: List[Any] = type_
    elif isinstance(type_, str):
        type_list = [type_]
    else:
        type_list = []
    for t in type_list:
        supported = isinstance(t, str) and (
            t in SUPPORTED_TYPES or (is_nullable_form and t == "null")
        )
        if not supported:
            issues.append(
                StrictIssue(
                    path=path,
                    rule=UNSUPPORTED_TYPE,
                    message=(
                        "type "
                        + json.dumps(t)
                        + " is not supported — strict mode allows object, array, string, "
                        "number, integer, boolean (null only inside a type array)."
                    ),
                )
            )

    # allOf is accepted only as a single-element wrapper.
    all_of = node.get("allOf")
    if isinstance(all_of, list) and len(all_of) != 1:
        issues.append(
            StrictIssue(
                path=path,
                rule=UNSUPPORTED_KEYWORD,
                message="allOf is supported only with exactly one subschema (use anyOf for unions).",
            )
        )

    if node.get("type") == "object" or "properties" in node or "required" in node:
        counts.objects += 1
        if node.get("additionalProperties") is not False:
            issues.append(
                StrictIssue(
                    path=path,
                    rule=MISSING_ADDITIONAL_PROPERTIES,
                    message='Object needs "additionalProperties": false — strict mode rejects open objects.',
                )
            )
        props = node.get("properties") if _is_obj(node.get("properties")) else {}
        required = node.get("required") if isinstance(node.get("required"), list) else []
        counts.properties += len(props)
        for key in props:
            if key not in required:
                issues.append(
                    StrictIssue(
                        path=f"{path}.required",
                        rule=PROPERTY_NOT_REQUIRED,
                        message=(
                            f'"{key}" is defined in properties but missing from required — '
                            "strict mode requires every property."
                        ),
                    )
                )
        for key in required:
            if isinstance(key, str) and key not in props:
                issues.append(
                    StrictIssue(
                        path=f"{path}.required",
                        rule=REQUIRED_NOT_PROPERTY,
                        message=f'"{key}" is required but has no definition in properties.',
                    )
                )
        for key, sub in props.items():
            if _is_obj(sub):
                _walk(sub, f"{path}.properties.{key}", issues, counts)

    items = node.get("items")
    if _is_obj(items):
        _walk(items, f"{path}.items", issues, counts)
    if isinstance(node.get("enum"), list):
        counts.enums += 1
    for list_key in ("anyOf", "oneOf", "allOf"):
        lst = node.get(list_key)
        if isinstance(lst, list):
            if list_key == "oneOf":
                issues.append(
                    StrictIssue(
                        path=f"{path}.{list_key}",
                        rule=UNSUPPORTED_KEYWORD,
                        message="oneOf is not supported — strict mode unions are expressed with anyOf.",
                    )
                )
            for i, sub in enumerate(lst):
                if _is_obj(sub):
                    _walk(sub, f"{path}.{list_key}[{i}]", issues, counts)
    for defs_key in ("$defs", "definitions"):
        defs = node.get(defs_key)
        if _is_obj(defs):
            for name, sub in defs.items():
                if _is_obj(sub):
                    _walk(sub, f"{path}.{defs_key}.{name}", issues, counts)


def validate_strict_root(input: Union[str, dict, Any]) -> StrictReport:
    """Whole-report entry point: everything :func:`validate_strict_schema`
    checks, plus R1 — the root schema must be type ``"object"`` (strict mode
    cannot return a bare scalar or array). The root issue is inserted at the
    front of the list."""
    report = validate_strict_schema(input)
    schema: Any = input
    if isinstance(input, str):
        try:
            schema = json.loads(input)
        except ValueError:
            return report  # invalid-schema already reported
    if _is_obj(schema) and schema.get("type") != "object":
        report.issues.insert(
            0,
            StrictIssue(
                path="$",
                rule=ROOT_NOT_OBJECT,
                message=(
                    'The root schema must be type "object" — strict mode cannot return '
                    "a bare scalar or array."
                ),
            ),
        )
        report.ok = False
    return report

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →