Skip to content

Python: Map NoneType to JSON Schema "null" instead of "object" in schema builder - #14155

Open
Shaurya Singh (LeSingh1) wants to merge 2 commits into
microsoft:mainfrom
LeSingh1:fix/json-schema-null-type
Open

Shaurya Singh (LeSingh1) wants to merge 2 commits into
microsoft:mainfrom
LeSingh1:fix/json-schema-null-type

Conversation

@LeSingh1

Copy link
Copy Markdown

Motivation

KernelJsonSchemaBuilder omitted NoneType from TYPE_MAPPING, so get_json_schema(type(None)) — and any union of 3+ members that includes None (e.g. int | str | None) — emitted {"type": "object"} for the None member instead of the correct {"type": "null"}. These schemas are sent to models during function calling / structured output, where an object type for a None member is misleading and can distort tool arguments.

The two-member Optional[T] path builds nullability separately and was already correct; this only affected NoneType reached directly or as one member of a 3+-member union.

Fix

Add NoneType (and the "None" / "NoneType" string names used by build_from_type_name) to TYPE_MAPPING, so None maps to JSON Schema {"type": "null"}.

Files

  • python/semantic_kernel/schema/kernel_json_schema_builder.py
  • python/tests/unit/schema/test_schema_builder.py — regression tests

Verification

Added regression tests covering type(None) directly and a 3-member union containing None; they fail before the change ({"type":"object"}) and pass after ({"type":"null"}). Existing Optional[T] behavior is unchanged.

…ema builder

KernelJsonSchemaBuilder omitted NoneType from TYPE_MAPPING, so
get_json_schema(type(None)) and any union of 3+ members containing None
(e.g. int | str | None) emitted {"type": "object"} for the None member
instead of the correct {"type": "null"}. These schemas mislead LLMs
during function calling / structured output. The two-member Optional[T]
path builds nullability separately and was already correct.

Add NoneType (and the "None"/"NoneType" string names used by
build_from_type_name) to TYPE_MAPPING, with regression tests.
Copilot AI review requested due to automatic review settings July 15, 2026 00:49
@LeSingh1
Shaurya Singh (LeSingh1) requested a review from a team as a code owner July 15, 2026 00:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Python JSON Schema generation for NoneType in KernelJsonSchemaBuilder, ensuring None is represented as JSON Schema {"type": "null"} (instead of defaulting to {"type": "object"}) when encountered directly or as a member of larger unions used for function calling / structured output.

Changes:

  • Add type(None) to TYPE_MAPPING so get_json_schema(type(None)) and union-member schema generation emit "null".
  • Add "None" and "NoneType" string mappings so build_from_type_name(...) correctly emits "null".
  • Add regression tests covering type(None), build(type(None)), Union[int, str, None], and "None"/"NoneType" type-name paths.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
python/semantic_kernel/schema/kernel_json_schema_builder.py Maps type(None) / "None" / "NoneType" to JSON Schema "null" via TYPE_MAPPING.
python/tests/unit/schema/test_schema_builder.py Adds regression tests for direct NoneType and 3+ member unions containing None.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@rogerbarreto Roger Barreto (rogerbarreto) added the python Pull requests for the Python Semantic Kernel label Jul 22, 2026
@percymcn

percymcn commented Aug 9, 2026

Copy link
Copy Markdown

This fix does more than the description claims, and it's worth recording because it strengthens the case for merging — but it does not finish the job for the structured-output path. Everything below is measured on semantic-kernel==1.44.1 / py3.12, with the diff applied verbatim (TYPE_MAPPING[type(None)] = "null"), and checked against openai@7.4.0's toStrictJsonSchema() — the transformer the OpenAI JS client uses to build the wire payload. It runs offline, so all of this is CI-testable with no API key.

1. The PR removes a hard vendor rejection, not just a misleading type.

For val: Union[int, str, None] under KernelJsonSchemaBuilder.build(..., structured_output=True):

properties/val vendor verdict
before anyOf:[integer, string, **object**] THROWS
after anyOf:[integer, string, **null**] accepted (after repair, see §2)

The pre-fix error is:

Schema field at properties/val uses .optional() without .nullable() which is not supported by the API.

Note it is phrased in Zod vocabulary. A Python user who hits this has no .optional() anywhere in their code and will spend the search budget in the wrong place. That alone is a decent argument for the fix.

2. But the field is still dropped from required, so the schema is still not send-safe.

_is_optional (kernel_json_schema_builder.py:113) returns True for any union containing None, so build_model_schema:98-99 omits val from required — before and after this PR:

after PR: {"type":"object",
           "properties":{"title":{...},"val":{"anyOf":[...,{"type":"null"}]}},
           "required":["title"],            # <- val still missing
           "additionalProperties":false}

The vendor transformer now accepts this, but only because it repairs it — it rewrites required to ["title","val"]. A client that silently repairs your schema is telling you the raw form is not what the API wants; OpenAI's structured-output rule is that every key in properties must appear in required.

That repair does not happen on this path. generate_structured_output_response_format_schema (connectors/utils/structured_output_schema.py:6) applies no transform at all — it wraps the dict and sets strict: True — and _handle_structured_output case 2 (open_ai_handler.py:205-207) hands it the builder's output directly. So the un-repaired required goes on the wire with strict: True.

Worth contrasting with case 1 of that same function: a BaseModel goes through type_to_response_format_param, whose _ensure_strict_json_schema does widen required. Measured on the same logical model, the two branches disagree — the BaseModel branch is accepted verbatim, the plain-class branch is not.

3. The regression test will pass either way on this point.

All four added tests assert on a member/union fragment (get_json_schema(type(None)), build(type(None)), build(Union[int, str, None]), build_from_type_name), and none of them passes structured_output=True. That is exactly right for what the PR claims, but it means nothing in the new suite exercises the path that stamps strict: True, and nothing can fail because of the required omission. If you want it covered, the cheap version is one assertion on the full build_model_schema(..., structured_output=True) output for a model with an optional field.

4. Two more structured_output=True outputs from the same function that the vendor won't take (mentioning them because they're in the file this PR touches, not to widen its scope):

  • Dict[str, str] → {"type":"object","additionalProperties":false}. handle_complex_type builds the value schema and then overwrites it with False a few lines later, so the map isn't closed, it's emptied: the only legal instance is {}. The vendor accepts this verbatim, so there is no error — the request succeeds and the field can never be populated. This is the worst of the three, because nothing anywhere reports it.
  • Tuple[int, int, int, int] → {"type":"array","items":[...]} (draft-07 tuple form). Vendor: THROWS — uses tuple-form `items` . For a homogeneous tuple, items: {"type":"integer"} plus minItems/maxItems of 4 is an equivalent that strict mode does accept; a heterogeneous one has no strict-mode equivalent and is better raised at build time than sent.
  • Minor: structured_output=True also staples additionalProperties: false onto non-object nodes (a str member, an array). Harmless — the vendor ignores it — but it's noise in every emitted schema.

This branch had an error being deployed

1 failed deployment
github-app-auth — 4e2de3cb Deployed Sep 28, 2026 by eavanvalkenburg via add_label #29106
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Pull requests for the Python Semantic Kernel

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants