Skip to content

Strict mode and nulls

OpenAI strict mode (the default for chat_tools and responses_tools) has two rules:

  1. Every property is listed in required.
  2. No object accepts extra properties.

So an optional argument cannot be left out. It becomes nullable instead, and the model sends null to mean "not given". invoke turns that null back into your default.

Example

@registry.tool
def search(query: str, top_k: int = 8, region: str | None = None) -> list[str]:
    """Search articles.

    Args:
        query: Search text.
        top_k: How many hits.
        region: Optional region filter.
    """
    return [f"{query} #{i} ({region})" for i in range(top_k)]

Rendered for OpenAI strict mode:

parameters = chat_tools(registry)[0]["function"]["parameters"]
print(json.dumps(parameters, indent=2))
{
  "additionalProperties": false,
  "properties": {
    "query": {"description": "Search text.", "type": "string"},
    "top_k": {
      "anyOf": [{"type": "integer"}, {"type": "null"}],
      "description": "How many hits. Default: 8."
    },
    "region": {
      "anyOf": [{"type": "string"}, {"type": "null"}],
      "description": "Optional region filter. Default: null."
    }
  },
  "required": ["query", "top_k", "region"],
  "type": "object"
}

The model sends every field. null stands for "use the default":

executor = Executor(registry)  # model tool calls always go through the executor
# Strict mode sends every field; null stands for "not given".
hits = await executor.run("search", {"query": "returns", "top_k": None, "region": None})
print(len(hits))  # 8: top_k's default applied
print(hits[0])  # returns #0 (None): region's default is None, so null is kept

The rule

flowchart TD
  N["argument arrives as null"] --> R{"required?"}
  R -- yes --> K1["kept → ToolArgsInvalid<br/>(unless the type allows None)"]
  R -- no --> D{"default is None?"}
  D -- yes --> K2["kept → function gets None"]
  D -- no --> DR["dropped → function gets the default"]
  • It applies at any depth: nested models, and models inside lists.
  • It follows aliases on nested models.
  • To let the model pass None on purpose, make None the default.

Defaults in descriptions

Strict mode removes default from the schema, so the model would not know it. Each argument's default is written into its description instead: Default: 8. Gemini gets the same note.

Every property gets the note, at any depth — including fields of models inside lists. A default placed directly on a list item type or a union branch (which pydantic ignores) is dropped without a note.

Turning strict mode off

tools = chat_tools(registry, strict=False)

The schema is then the plain one: optional fields stay optional and keep default. Each tool dict carries "strict": false.

This page's code is examples/strict_mode.py. The test suite runs it.