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 →