Skip to content

json_schema_to_pydantic_model applies extra="forbid" only to the outermost model, emitting nested $defs without additionalProperties and free-form objects with additionalProperties: true #120

Description

@vz-ibm

Summary

json_schema_to_pydantic_model() applies extra="forbid" only to the outermost generated model, and returns a bare dict for free-form type: object properties. The rendered JSON Schema therefore comes out with nested $defs carrying no additionalProperties key at all, and free-form object properties carrying additionalProperties: true. Strict structured-output providers reject both.

Still reproducible on 0.11.0, so #116 does not cover this.

Environment

agent-lifecycle-toolkit 0.11.0 (tag v0.11.0, PyPI 2026-08-10)
litellm 1.96.0
pydantic 2.13.4
Python 3.12.13
Package root /usr/local/lib/python3.12/site-packages/altk/

Reproduction

Using ALTK's own SPARC metric schema, no network needed:

import json
from altk.core.llm.output_parser import json_schema_to_pydantic_model

P = "<site-packages>/altk/pre_tool/sparc/function_calling/metrics"
d = json.load(open(f"{P}/function_call/general_metrics_runtime.json"))
m = [x for x in d if x["name"] == "general_hallucination_check"][0]
out = json_schema_to_pydantic_model(m["jsonschema"]).model_json_schema()

def walk(o, path="$"):
    if isinstance(o, dict):
        if o.get("additionalProperties") is True:
            print("additionalProperties: true   ", path)
        if o.get("type") == "object" and "additionalProperties" not in o:
            print("additionalProperties MISSING  ", path)
        for k, v in o.items():
            walk(v, f"{path}.{k}")
    elif isinstance(o, list):
        for i, v in enumerate(o):
            walk(v, f"{path}[{i}]")

walk(out)

Output on 0.11.0:

additionalProperties MISSING   $.$defs.AutoModel_1
additionalProperties MISSING   $.$defs.AutoModel_1_1
additionalProperties: true     $.$defs.AutoModel_1_1.properties.corrected_value
additionalProperties MISSING   $.$defs.AutoModel_1_2
additionalProperties: true     $.$defs.AutoModel_1_2.properties.arguments

Three nested $defs with no additionalProperties key, two properties with additionalProperties: true. The top-level object does get additionalProperties: false.

The same sweep across all seven runtime metrics shows every metric affected — for example parameter_hallucination_check yields additionalProperties: true at $.$defs.AutoModel_1.properties.parameter, and function_selection_appropriateness yields one $defs with the key missing.

Note that the source schema (m["jsonschema"]) does declare "additionalProperties": false at its top level, so this is the conversion losing information the input carried, not the input being underspecified.

Expected vs actual

Expected: the rendered schema declares additionalProperties: false on every object schema, including nested $defs, unless the source schema explicitly asks for true.

Actual: only the outermost object gets it; nested $defs get nothing, and free-form objects get true.

Cause

Two places, both in altk/core/llm/output_parser.py:

1. output_parser.py:156-162extra="forbid" is set on the model that create_model just returned, so it applies to that model only. Nested models are built by a recursive json_schema_to_pydantic_model call at :71-76 on the sub-schema, and those sub-schemas typically do not repeat additionalProperties: false, so the if at :160 is false for them:

model = create_model(model_name, **fields)
# Mirror ``additionalProperties: false`` — providers with strict structured
# output need it, and without it the model may invent extra keys that the
# original schema then rejects.
if schema.get("additionalProperties") is False:
    model.model_config["extra"] = "forbid"
return model

2. output_parser.py:62-79_map_object_for_prop returns a bare dict for a free-form object (one with no properties sub-schema) when free_form_object_as_str is false, and Pydantic renders dict as {"type": "object", "additionalProperties": true}:

if "properties" in prop_schema:
    return json_schema_to_pydantic_model(...)
if free_form_object_as_str:
    return str
return dict

free_form_object_as_str=True does drop the additionalProperties: true count to zero, but it is off by default (output_parser.py:32, :242) and changes the emitted type from object to string, which is a different trade-off rather than a fix for the strict case.

The free-form objects in the SPARC schemas come from CorrectionField (altk/pre_tool/sparc/metrics/field.py:220-236), which matches a correction property of type: object with no sub-properties.

Provider errors this produces

Recorded against ALTK 0.10.x, same schema shape:

BedrockException: output_config.format.schema: For 'object' type, 'additionalProperties: true' is not supported. Please set 'additionalProperties' to false
Azure: Invalid schema for response_format 'AutoModel': In context=('properties', 'correction'), 'additionalProperties' is required to be supplied and to be false.

In fairness, on our current setup (openai/aws/claude-haiku-4-5 through an OpenAI-compatible LiteLLM proxy) the additionalProperties rejection is not what we hit today — we hit #118 instead, and litellm's own strict-schema post-processing appears to inject additionalProperties: false into generated $defs before the request leaves, masking cause 1 on that path. So the schema defect above is reproducible with certainty; how visible it is depends on whether a litellm layer sits in front of the provider. It would still bite a strict client that talks to the provider directly.

Possible direction

Offered as a suggestion:

  1. Default nested generated models to extra="forbid", opting out only where the source sub-schema explicitly declares additionalProperties: true. Threading the outer decision down through the recursive call at :71-76 would be one way.
  2. For free-form objects under a strict-mode target, emit an object schema with additionalProperties: false rather than a bare dict.

A regression test asserting zero additionalProperties: true and no missing additionalProperties in $defs for a nested-object schema would pin this down. Happy to open a PR along those lines if the direction looks right.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions