codekingpro/portable-devtools
115k
1"""Methods for creating function specs in the style of OpenAI Functions."""2 3from __future__ import annotations4 5import collections6import inspect7import logging8import types9import typing10import uuid11from typing import (12 TYPE_CHECKING,13 Annotated,14 Any,15 Literal,16 Union,17 cast,18 get_args,19 get_origin,20 get_type_hints,21)22 23import typing_extensions24from pydantic import BaseModel25from pydantic.errors import PydanticInvalidForJsonSchema26from pydantic.v1 import BaseModel as BaseModelV127from pydantic.v1 import Field as Field_v128from pydantic.v1 import create_model as create_model_v129from typing_extensions import TypedDict, is_typeddict30 31import langchain_core32from langchain_core._api import beta33from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage34from langchain_core.utils.json_schema import dereference_refs35from langchain_core.utils.pydantic import is_basemodel_subclass36 37if TYPE_CHECKING:38 from collections.abc import Callable, Mapping39 40 from langchain_core.tools import BaseTool41 42logger = logging.getLogger(__name__)43 44PYTHON_TO_JSON_TYPES = {45 "str": "string",46 "int": "integer",47 "float": "number",48 "bool": "boolean",49}50 51_ORIGIN_MAP: dict[type, Any] = {52 dict: dict,53 list: list,54 tuple: tuple,55 set: set,56 collections.abc.Iterable: typing.Iterable,57 collections.abc.Mapping: typing.Mapping,58 collections.abc.Sequence: typing.Sequence,59 collections.abc.MutableMapping: typing.MutableMapping,60}61# Add UnionType mapping for Python 3.10+62if hasattr(types, "UnionType"):63 _ORIGIN_MAP[types.UnionType] = Union64 65 66class FunctionDescription(TypedDict):67 """Representation of a callable function to send to an LLM."""68 69 name: str70 """The name of the function."""71 72 description: str73 """A description of the function."""74 75 parameters: dict76 """The parameters of the function."""77 78 79class ToolDescription(TypedDict):80 """Representation of a callable function to the OpenAI API."""81 82 type: Literal["function"]83 """The type of the tool."""84 85 function: FunctionDescription86 """The function description."""87 88 89def _rm_titles(kv: dict, prev_key: str = "") -> dict:90 """Recursively removes `'title'` fields from a JSON schema dictionary.91 92 Remove `'title'` fields from the input JSON schema dictionary,93 except when a `'title'` appears within a property definition under `'properties'`.94 95 Args:96 kv: The input JSON schema as a dictionary.97 prev_key: The key from the parent dictionary, used to identify context.98 99 Returns:100 A new dictionary with appropriate `'title'` fields removed.101 """102 new_kv = {}103 104 for k, v in kv.items():105 if k == "title":106 # If the value is a nested dict and part of a property under "properties",107 # preserve the title but continue recursion108 if isinstance(v, dict) and prev_key == "properties":109 new_kv[k] = _rm_titles(v, k)110 else:111 # Otherwise, remove this "title" key112 continue113 elif isinstance(v, dict):114 # Recurse into nested dictionaries115 new_kv[k] = _rm_titles(v, k)116 else:117 # Leave non-dict values untouched118 new_kv[k] = v119 120 return new_kv121 122 123def _convert_json_schema_to_openai_function(124 schema: dict,125 *,126 name: str | None = None,127 description: str | None = None,128 rm_titles: bool = True,129) -> FunctionDescription:130 """Converts a Pydantic model to a function description for the OpenAI API.131 132 Args:133 schema: The JSON schema to convert.134 name: The name of the function.135 136 If not provided, the title of the schema will be used.137 description: The description of the function.138 139 If not provided, the description of the schema will be used.140 rm_titles: Whether to remove titles from the schema.141 142 Returns:143 The function description.144 """145 schema = dereference_refs(schema)146 if "definitions" in schema: # pydantic 1147 schema.pop("definitions", None)148 if "$defs" in schema: # pydantic 2149 schema.pop("$defs", None)150 title = schema.pop("title", "")151 default_description = schema.pop("description", "")152 return {153 "name": name or title,154 "description": description or default_description,155 "parameters": _rm_titles(schema) if rm_titles else schema,156 }157 158 159def _convert_pydantic_to_openai_function(160 model: type,161 *,162 name: str | None = None,163 description: str | None = None,164 rm_titles: bool = True,165) -> FunctionDescription:166 """Converts a Pydantic model to a function description for the OpenAI API.167 168 Args:169 model: The Pydantic model to convert.170 name: The name of the function.171 172 If not provided, the title of the schema will be used.173 description: The description of the function.174 175 If not provided, the description of the schema will be used.176 rm_titles: Whether to remove titles from the schema.177 178 Raises:179 TypeError: If the model is not a Pydantic model.180 TypeError: If the model contains types that cannot be converted to JSON schema.181 182 Returns:183 The function description.184 """185 try:186 if hasattr(model, "model_json_schema"):187 schema = model.model_json_schema() # Pydantic 2188 elif hasattr(model, "schema"):189 schema = model.schema() # Pydantic 1190 else:191 msg = "Model must be a Pydantic model."192 raise TypeError(msg)193 except PydanticInvalidForJsonSchema as e:194 model_name = getattr(model, "__name__", str(model))195 msg = (196 f"Failed to generate JSON schema for '{model_name}': {e}\n\n"197 "Tool argument schemas must be JSON-serializable. If your schema includes "198 "custom Python classes, consider:\n"199 " 1. Converting them to Pydantic models with JSON-compatible fields\n"200 " 2. Using primitive types (str, int, float, bool, list, dict) instead\n"201 " 3. Passing the data as serialized JSON strings\n\n"202 )203 raise PydanticInvalidForJsonSchema(msg) from e204 return _convert_json_schema_to_openai_function(205 schema, name=name, description=description, rm_titles=rm_titles206 )207 208 209def _get_python_function_name(function: Callable) -> str:210 """Get the name of a Python function."""211 return function.__name__212 213 214def _convert_python_function_to_openai_function(215 function: Callable,216) -> FunctionDescription:217 """Convert a Python function to an OpenAI function-calling API compatible dict.218 219 Assumes the Python function has type hints and a docstring with a description. If220 the docstring has Google Python style argument descriptions, these will be included221 as well.222 223 Args:224 function: The Python function to convert.225 226 Returns:227 The OpenAI function description.228 """229 func_name = _get_python_function_name(function)230 model = langchain_core.tools.base.create_schema_from_function(231 func_name,232 function,233 filter_args=(),234 parse_docstring=True,235 error_on_invalid_docstring=False,236 include_injected=False,237 )238 return _convert_pydantic_to_openai_function(239 model,240 name=func_name,241 description=model.__doc__,242 )243 244 245def _convert_typed_dict_to_openai_function(typed_dict: type) -> FunctionDescription:246 visited: dict = {}247 248 model = cast(249 "type[BaseModel]",250 _convert_any_typed_dicts_to_pydantic(typed_dict, visited=visited),251 )252 return _convert_pydantic_to_openai_function(model)253 254 255_MAX_TYPED_DICT_RECURSION = 25256 257 258def _convert_any_typed_dicts_to_pydantic(259 type_: type,260 *,261 visited: dict[type, type],262 depth: int = 0,263) -> type:264 if type_ in visited:265 return visited[type_]266 if depth >= _MAX_TYPED_DICT_RECURSION:267 return type_268 if is_typeddict(type_):269 typed_dict = type_270 docstring = inspect.getdoc(typed_dict)271 # Use get_type_hints to properly resolve forward references and272 # string annotations in Python 3.14+ (PEP 649 deferred annotations).273 # include_extras=True preserves Annotated metadata.274 try:275 annotations_ = get_type_hints(typed_dict, include_extras=True)276 except Exception:277 # Fallback for edge cases where get_type_hints might fail278 annotations_ = typed_dict.__annotations__279 description, arg_descriptions = _parse_google_docstring(280 docstring, list(annotations_)281 )282 fields: dict = {}283 for arg, arg_type in annotations_.items():284 if get_origin(arg_type) in {Annotated, typing_extensions.Annotated}:285 annotated_args = get_args(arg_type)286 new_arg_type = _convert_any_typed_dicts_to_pydantic(287 annotated_args[0], depth=depth + 1, visited=visited288 )289 field_kwargs = dict(290 zip(("default", "description"), annotated_args[1:], strict=False)291 )292 if (field_desc := field_kwargs.get("description")) and not isinstance(293 field_desc, str294 ):295 msg = (296 f"Invalid annotation for field {arg}. Third argument to "297 f"Annotated must be a string description, received value of "298 f"type {type(field_desc)}."299 )300 raise ValueError(msg)301 if arg_desc := arg_descriptions.get(arg):302 field_kwargs["description"] = arg_desc303 fields[arg] = (new_arg_type, Field_v1(**field_kwargs))304 else:305 new_arg_type = _convert_any_typed_dicts_to_pydantic(306 arg_type, depth=depth + 1, visited=visited307 )308 field_kwargs = {"default": ...}309 if arg_desc := arg_descriptions.get(arg):310 field_kwargs["description"] = arg_desc311 fields[arg] = (new_arg_type, Field_v1(**field_kwargs))312 model = cast(313 "type[BaseModelV1]", create_model_v1(typed_dict.__name__, **fields)314 )315 model.__doc__ = description316 visited[typed_dict] = model317 return model318 if (origin := get_origin(type_)) and (type_args := get_args(type_)):319 subscriptable_origin = _py_38_safe_origin(origin)320 type_args = tuple(321 _convert_any_typed_dicts_to_pydantic(arg, depth=depth + 1, visited=visited)322 for arg in type_args323 )324 return cast("type", subscriptable_origin[type_args]) # type: ignore[index]325 return type_326 327 328def _format_tool_to_openai_function(tool: BaseTool) -> FunctionDescription:329 """Format tool into the OpenAI function API.330 331 Args:332 tool: The tool to format.333 334 Raises:335 ValueError: If the tool call schema is not supported.336 337 Returns:338 The function description.339 """340 is_simple_oai_tool = (341 isinstance(tool, langchain_core.tools.simple.Tool) and not tool.args_schema342 )343 if tool.tool_call_schema and not is_simple_oai_tool:344 if isinstance(tool.tool_call_schema, dict):345 return _convert_json_schema_to_openai_function(346 tool.tool_call_schema, name=tool.name, description=tool.description347 )348 if issubclass(tool.tool_call_schema, (BaseModel, BaseModelV1)):349 return _convert_pydantic_to_openai_function(350 tool.tool_call_schema, name=tool.name, description=tool.description351 )352 error_msg = (353 f"Unsupported tool call schema: {tool.tool_call_schema}. "354 "Tool call schema must be a JSON schema dict or a Pydantic model."355 )356 raise ValueError(error_msg)357 return {358 "name": tool.name,359 "description": tool.description,360 "parameters": {361 # This is a hack to get around the fact that some tools362 # do not expose an args_schema, and expect an argument363 # which is a string.364 # And Open AI does not support an array type for the365 # parameters.366 "properties": {367 "__arg1": {"title": "__arg1", "type": "string"},368 },369 "required": ["__arg1"],370 "type": "object",371 },372 }373 374 375def convert_to_openai_function(376 function: Mapping[str, Any] | type | Callable | BaseTool,377 *,378 strict: bool | None = None,379) -> dict[str, Any]:380 """Convert a raw function/class to an OpenAI function.381 382 Args:383 function: A dictionary, Pydantic `BaseModel` class, `TypedDict` class, a384 LangChain `Tool` object, or a Python function.385 386 If a dictionary is passed in, it is assumed to already be a valid OpenAI387 function, a JSON schema with top-level `title` key specified, an Anthropic388 format tool, or an Amazon Bedrock Converse format tool.389 strict: If `True`, model output is guaranteed to exactly match the JSON Schema390 provided in the function definition.391 392 If `None`, `strict` argument will not be included in function definition.393 394 Returns:395 A dict version of the passed in function which is compatible with the OpenAI396 function-calling API.397 398 Raises:399 ValueError: If function is not in a supported format.400 401 !!! warning "Behavior changed in `langchain-core` 0.3.16"402 403 `description` and `parameters` keys are now optional. Only `name` is404 required and guaranteed to be part of the output.405 """406 # an Anthropic format tool407 if isinstance(function, dict) and all(408 k in function for k in ("name", "input_schema")409 ):410 oai_function = {411 "name": function["name"],412 "parameters": function["input_schema"],413 }414 if "description" in function:415 oai_function["description"] = function["description"]416 # an Amazon Bedrock Converse format tool417 elif isinstance(function, dict) and "toolSpec" in function:418 oai_function = {419 "name": function["toolSpec"]["name"],420 "parameters": function["toolSpec"]["inputSchema"]["json"],421 }422 if "description" in function["toolSpec"]:423 oai_function["description"] = function["toolSpec"]["description"]424 # already in OpenAI function format425 elif isinstance(function, dict) and "name" in function:426 oai_function = {427 k: v428 for k, v in function.items()429 if k in {"name", "description", "parameters", "strict"}430 }431 # a JSON schema with title and description432 elif isinstance(function, dict) and "title" in function:433 function_copy = function.copy()434 oai_function = {"name": function_copy.pop("title")}435 if "description" in function_copy:436 oai_function["description"] = function_copy.pop("description")437 if function_copy and "properties" in function_copy:438 oai_function["parameters"] = function_copy439 elif isinstance(function, type) and is_basemodel_subclass(function):440 oai_function = cast("dict", _convert_pydantic_to_openai_function(function))441 elif is_typeddict(function):442 oai_function = cast(443 "dict", _convert_typed_dict_to_openai_function(cast("type", function))444 )445 elif isinstance(function, langchain_core.tools.base.BaseTool):446 oai_function = cast("dict", _format_tool_to_openai_function(function))447 elif callable(function):448 oai_function = cast(449 "dict", _convert_python_function_to_openai_function(function)450 )451 else:452 if isinstance(function, dict) and (453 "type" in function or "properties" in function454 ):455 msg = (456 f"Unsupported function\n\n{function}\n\nTo use a JSON schema as a "457 "function, it must have a top-level 'title' key to be used as the "458 "function name."459 )460 raise ValueError(msg)461 msg = (462 f"Unsupported function\n\n{function}\n\nFunctions must be passed in"463 " as Dict, pydantic.BaseModel, or Callable. If they're a dict they must"464 " either be in OpenAI function format or valid JSON schema with top-level"465 " 'title' key."466 )467 raise ValueError(msg)468 469 if strict is not None:470 if "strict" in oai_function and oai_function["strict"] != strict:471 msg = (472 f"Tool/function already has a 'strict' key with value "473 f"{oai_function['strict']} which is different from the explicit "474 f"`strict` arg received {strict=}."475 )476 raise ValueError(msg)477 oai_function["strict"] = strict478 if strict:479 # All fields must be `required`480 parameters = oai_function.get("parameters")481 if isinstance(parameters, dict):482 fields = parameters.get("properties")483 if isinstance(fields, dict) and fields:484 parameters = dict(parameters)485 parameters["required"] = list(fields.keys())486 oai_function["parameters"] = parameters487 488 # As of 08/06/24, OpenAI requires that additionalProperties be supplied and489 # set to False if strict is True.490 # All properties layer needs 'additionalProperties=False'491 oai_function["parameters"] = _recursive_set_additional_properties_false(492 oai_function["parameters"]493 )494 return oai_function495 496 497# List of well known tools supported by OpenAI's chat models or responses API.498# These tools are not expected to be supported by other chat model providers499# that conform to the OpenAI function-calling API.500_WellKnownOpenAITools = (501 "function",502 "file_search",503 "computer",504 "computer_use_preview",505 "code_interpreter",506 "mcp",507 "image_generation",508 "web_search_preview",509 "web_search",510 "tool_search",511 "namespace",512)513 514 515def convert_to_openai_tool(516 tool: Mapping[str, Any] | type[BaseModel] | Callable | BaseTool,517 *,518 strict: bool | None = None,519) -> dict[str, Any]:520 """Convert a tool-like object to an OpenAI tool schema.521 522 [OpenAI tool schema reference](https://platform.openai.com/docs/api-reference/chat/create#chat-create-tools)523 524 Args:525 tool: Either a dictionary, a `pydantic.BaseModel` class, Python function, or526 `BaseTool`.527 528 If a dictionary is passed in, it is assumed to already be a valid OpenAI529 function, a JSON schema with top-level `title` key specified, an Anthropic530 format tool, or an Amazon Bedrock Converse format tool.531 strict: If `True`, model output is guaranteed to exactly match the JSON Schema532 provided in the function definition.533 534 If `None`, `strict` argument will not be included in tool definition.535 536 Returns:537 A dict version of the passed in tool which is compatible with the OpenAI538 tool-calling API.539 540 !!! warning "Behavior changed in `langchain-core` 0.3.16"541 542 `description` and `parameters` keys are now optional. Only `name` is543 required and guaranteed to be part of the output.544 545 !!! warning "Behavior changed in `langchain-core` 0.3.44"546 547 Return OpenAI Responses API-style tools unchanged. This includes548 any dict with `"type"` in `"file_search"`, `"function"`,549 `"computer_use_preview"`, `"web_search_preview"`.550 551 !!! warning "Behavior changed in `langchain-core` 0.3.63"552 553 Added support for OpenAI's image generation built-in tool.554 """555 # Import locally to prevent circular import556 from langchain_core.tools import Tool # noqa: PLC0415557 558 if isinstance(tool, dict):559 if tool.get("type") in _WellKnownOpenAITools:560 return tool561 # As of 03.12.25 can be "web_search_preview" or "web_search_preview_2025_03_11"562 if (tool.get("type") or "").startswith("web_search_preview"):563 return tool564 if isinstance(tool, Tool) and (tool.metadata or {}).get("type") == "custom_tool":565 oai_tool = {566 "type": "custom",567 "name": tool.name,568 "description": tool.description,569 }570 if tool.metadata is not None and "format" in tool.metadata:571 oai_tool["format"] = tool.metadata["format"]572 return oai_tool573 oai_function = convert_to_openai_function(tool, strict=strict)574 return {"type": "function", "function": oai_function}575 576 577def convert_to_json_schema(578 schema: dict[str, Any] | type[BaseModel] | Callable | BaseTool,579 *,580 strict: bool | None = None,581) -> dict[str, Any]:582 """Convert a schema representation to a JSON schema.583 584 Args:585 schema: The schema to convert.586 strict: If `True`, model output is guaranteed to exactly match the JSON Schema587 provided in the function definition.588 589 If `None`, `strict` argument will not be included in function definition.590 591 Raises:592 ValueError: If the input is not a valid OpenAI-format tool.593 594 Returns:595 A JSON schema representation of the input schema.596 """597 openai_tool = convert_to_openai_tool(schema, strict=strict)598 if (599 not isinstance(openai_tool, dict)600 or "function" not in openai_tool601 or "name" not in openai_tool["function"]602 ):603 error_message = "Input must be a valid OpenAI-format tool."604 raise ValueError(error_message)605 606 openai_function = openai_tool["function"]607 json_schema = {}608 json_schema["title"] = openai_function["name"]609 610 if "description" in openai_function:611 json_schema["description"] = openai_function["description"]612 613 if "parameters" in openai_function:614 parameters = openai_function["parameters"].copy()615 json_schema.update(parameters)616 617 return json_schema618 619 620@beta()621def tool_example_to_messages(622 input: str,623 tool_calls: list[BaseModel],624 tool_outputs: list[str] | None = None,625 *,626 ai_response: str | None = None,627) -> list[BaseMessage]:628 """Convert an example into a list of messages that can be fed into an LLM.629 630 This code is an adapter that converts a single example to a list of messages631 that can be fed into a chat model.632 633 The list of messages per example by default corresponds to:634 635 1. `HumanMessage`: contains the content from which content should be extracted.636 2. `AIMessage`: contains the extracted information from the model637 3. `ToolMessage`: contains confirmation to the model that the model requested a638 tool correctly.639 640 If `ai_response` is specified, there will be a final `AIMessage` with that641 response.642 643 The `ToolMessage` is required because some chat models are hyper-optimized for644 agents rather than for an extraction use case.645 646 Args:647 input: The user input648 tool_calls: Tool calls represented as Pydantic BaseModels649 tool_outputs: Tool call outputs.650 651 Does not need to be provided.652 653 If not provided, a placeholder value will be inserted.654 ai_response: If provided, content for a final `AIMessage`.655 656 Returns:657 A list of messages658 659 Examples:660 ```python661 from typing import Optional662 from pydantic import BaseModel, Field663 from langchain_openai import ChatOpenAI664 665 666 class Person(BaseModel):667 '''Information about a person.'''668 669 name: str | None = Field(..., description="The name of the person")670 hair_color: str | None = Field(671 ..., description="The color of the person's hair if known"672 )673 height_in_meters: str | None = Field(..., description="Height in METERS")674 675 676 examples = [677 (678 "The ocean is vast and blue. It's more than 20,000 feet deep.",679 Person(name=None, height_in_meters=None, hair_color=None),680 ),681 (682 "Fiona traveled far from France to Spain.",683 Person(name="Fiona", height_in_meters=None, hair_color=None),684 ),685 ]686 687 688 messages = []689 690 for txt, tool_call in examples:691 messages.extend(tool_example_to_messages(txt, [tool_call]))692 ```693 """694 messages: list[BaseMessage] = [HumanMessage(content=input)]695 696 openai_tool_calls = [697 {698 "id": str(uuid.uuid4()),699 "type": "function",700 "function": {701 # The name of the function right now corresponds to the name702 # of the Pydantic model. This is implicit in the API right now,703 # and will be improved over time.704 "name": tool_call.__class__.__name__,705 "arguments": tool_call.model_dump_json(),706 },707 }708 for tool_call in tool_calls709 ]710 711 messages.append(712 AIMessage(content="", additional_kwargs={"tool_calls": openai_tool_calls})713 )714 tool_outputs = tool_outputs or ["You have correctly called this tool."] * len(715 openai_tool_calls716 )717 for output, tool_call_dict in zip(tool_outputs, openai_tool_calls, strict=False):718 messages.append(ToolMessage(content=output, tool_call_id=tool_call_dict["id"]))719 720 if ai_response:721 messages.append(AIMessage(content=ai_response))722 return messages723 724 725_MIN_DOCSTRING_BLOCKS = 2726 727 728def _parse_google_docstring(729 docstring: str | None,730 args: list[str],731 *,732 error_on_invalid_docstring: bool = False,733) -> tuple[str, dict]:734 """Parse the function and argument descriptions from the docstring of a function.735 736 Assumes the function docstring follows Google Python style guide.737 738 Args:739 docstring: The docstring to parse.740 args: The list of argument names to extract descriptions for.741 error_on_invalid_docstring: Whether to raise an error if the docstring is742 invalid.743 744 Returns:745 A tuple of the function description and a dictionary of argument descriptions.746 """747 if docstring:748 docstring_blocks = docstring.split("\n\n")749 if error_on_invalid_docstring:750 filtered_annotations = {751 arg752 for arg in args753 if arg not in {"run_manager", "callbacks", "runtime", "return"}754 }755 if filtered_annotations and (756 len(docstring_blocks) < _MIN_DOCSTRING_BLOCKS757 or not any(block.startswith("Args:") for block in docstring_blocks[1:])758 ):759 msg = "Found invalid Google-Style docstring."760 raise ValueError(msg)761 descriptors = []762 args_block = None763 past_descriptors = False764 for block in docstring_blocks:765 if block.startswith("Args:"):766 args_block = block767 break768 if block.startswith(("Returns:", "Example:")):769 # Don't break in case Args come after770 past_descriptors = True771 elif not past_descriptors:772 descriptors.append(block)773 else:774 continue775 description = " ".join(descriptors).strip()776 else:777 if error_on_invalid_docstring:778 msg = "Found invalid Google-Style docstring."779 raise ValueError(msg)780 description = ""781 args_block = None782 arg_descriptions = {}783 if args_block:784 arg = None785 for line in args_block.split("\n")[1:]:786 if ":" in line:787 arg, desc = line.split(":", maxsplit=1)788 arg = arg.strip()789 arg_name, _, annotations_ = arg.partition(" ")790 if annotations_.startswith("(") and annotations_.endswith(")"):791 arg = arg_name792 arg_descriptions[arg] = desc.strip()793 elif arg:794 arg_descriptions[arg] += " " + line.strip()795 return description, arg_descriptions796 797 798def _py_38_safe_origin(origin: type) -> type:799 return cast("type", _ORIGIN_MAP.get(origin, origin))800 801 802def _recursive_set_additional_properties_false(803 schema: dict[str, Any],804) -> dict[str, Any]:805 if isinstance(schema, dict):806 # Check if 'required' is a key at the current level or if the schema is empty,807 # in which case additionalProperties still needs to be specified.808 if (809 "required" in schema810 or ("properties" in schema and not schema["properties"])811 # Since Pydantic 2.11, it will always add `additionalProperties: True`812 # for arbitrary dictionary schemas813 # See: https://pydantic.dev/articles/pydantic-v2-11-release#changes814 # If it is already set to True, we need override it to False815 or "additionalProperties" in schema816 ):817 schema["additionalProperties"] = False818 819 # Recursively check 'properties' and 'items' if they exist820 if "anyOf" in schema:821 for sub_schema in schema["anyOf"]:822 _recursive_set_additional_properties_false(sub_schema)823 if "properties" in schema:824 for sub_schema in schema["properties"].values():825 _recursive_set_additional_properties_false(sub_schema)826 if "items" in schema:827 _recursive_set_additional_properties_false(schema["items"])828 829 return schema830 