libs/core/langchain_core/output_parsers/openai_tools.py PYTHON 385 lines View on github.com → Search inside
1"""Parse tools for OpenAI tools output."""23import copy4import json5import logging6from json import JSONDecodeError7from typing import Annotated, Any89from pydantic import SkipValidation, ValidationError1011from langchain_core.exceptions import OutputParserException12from langchain_core.messages import AIMessage, InvalidToolCall13from langchain_core.messages.tool import invalid_tool_call14from langchain_core.messages.tool import tool_call as create_tool_call15from langchain_core.output_parsers.transform import BaseCumulativeTransformOutputParser16from langchain_core.outputs import ChatGeneration, Generation17from langchain_core.utils.json import parse_partial_json18from langchain_core.utils.pydantic import (19    TypeBaseModel,20    is_pydantic_v1_subclass,21    is_pydantic_v2_subclass,22)2324logger = logging.getLogger(__name__)252627def parse_tool_call(28    raw_tool_call: dict[str, Any],29    *,30    partial: bool = False,31    strict: bool = False,32    return_id: bool = True,33) -> dict[str, Any] | None:34    """Parse a single tool call.3536    Args:37        raw_tool_call: The raw tool call to parse.38        partial: Whether to parse partial JSON.39        strict: Whether to allow non-JSON-compliant strings.40        return_id: Whether to return the tool call id.4142    Returns:43        The parsed tool call.4445    Raises:46        OutputParserException: If the tool call is not valid JSON.47    """48    if "function" not in raw_tool_call:49        return None5051    arguments = raw_tool_call["function"]["arguments"]5253    if partial:54        try:55            function_args = parse_partial_json(arguments, strict=strict)56        except (JSONDecodeError, TypeError):  # None args raise TypeError57            return None58    # Handle None or empty string arguments for parameter-less tools59    elif not arguments:60        function_args = {}61    else:62        try:63            function_args = json.loads(arguments, strict=strict)64        except JSONDecodeError as e:65            msg = (66                f"Function {raw_tool_call['function']['name']} arguments:\n\n"67                f"{arguments}\n\nare not valid JSON. "68                f"Received JSONDecodeError {e}"69            )70            raise OutputParserException(msg) from e71    parsed = {72        "name": raw_tool_call["function"]["name"] or "",73        "args": function_args or {},74    }75    if return_id:76        parsed["id"] = raw_tool_call.get("id")77        parsed = create_tool_call(**parsed)  # type: ignore[assignment,arg-type]78    return parsed798081def make_invalid_tool_call(82    raw_tool_call: dict[str, Any],83    error_msg: str | None,84) -> InvalidToolCall:85    """Create an `InvalidToolCall` from a raw tool call.8687    Args:88        raw_tool_call: The raw tool call.89        error_msg: The error message.9091    Returns:92        An `InvalidToolCall` instance with the error message.93    """94    return invalid_tool_call(95        name=raw_tool_call["function"]["name"],96        args=raw_tool_call["function"]["arguments"],97        id=raw_tool_call.get("id"),98        error=error_msg,99    )100101102def parse_tool_calls(103    raw_tool_calls: list[dict],104    *,105    partial: bool = False,106    strict: bool = False,107    return_id: bool = True,108) -> list[dict[str, Any]]:109    """Parse a list of tool calls.110111    Args:112        raw_tool_calls: The raw tool calls to parse.113        partial: Whether to parse partial JSON.114        strict: Whether to allow non-JSON-compliant strings.115        return_id: Whether to return the tool call id.116117    Returns:118        The parsed tool calls.119120    Raises:121        OutputParserException: If any of the tool calls are not valid JSON.122    """123    final_tools: list[dict[str, Any]] = []124    exceptions = []125    for tool_call in raw_tool_calls:126        try:127            parsed = parse_tool_call(128                tool_call, partial=partial, strict=strict, return_id=return_id129            )130            if parsed:131                final_tools.append(parsed)132        except OutputParserException as e:133            exceptions.append(str(e))134            continue135    if exceptions:136        raise OutputParserException("\n\n".join(exceptions))137    return final_tools138139140class JsonOutputToolsParser(BaseCumulativeTransformOutputParser[Any]):141    """Parse tools from OpenAI response."""142143    strict: bool = False144    """Whether to allow non-JSON-compliant strings.145146    See: https://docs.python.org/3/library/json.html#encoders-and-decoders147148    Useful when the parsed output may include unicode characters or new lines.149    """150151    return_id: bool = False152    """Whether to return the tool call id."""153154    first_tool_only: bool = False155    """Whether to return only the first tool call.156157    If `False`, the result will be a list of tool calls, or an empty list if no tool158    calls are found.159160    If `True`, and multiple tool calls are found, only the first one will be returned,161    and the other tool calls will be ignored.162163    If no tool calls are found, `None` will be returned.164    """165166    def parse_result(self, result: list[Generation], *, partial: bool = False) -> Any:167        """Parse the result of an LLM call to a list of tool calls.168169        Args:170            result: The result of the LLM call.171            partial: Whether to parse partial JSON.172173                If `True`, the output will be a JSON object containing174                all the keys that have been returned so far.175176                If `False`, the output will be the full JSON object.177178        Returns:179            The parsed tool calls.180181        Raises:182            OutputParserException: If the output is not valid JSON.183        """184        generation = result[0]185        if not isinstance(generation, ChatGeneration):186            msg = "This output parser can only be used with a chat generation."187            raise OutputParserException(msg)188        message = generation.message189        if isinstance(message, AIMessage) and message.tool_calls:190            tool_calls = [dict(tc) for tc in message.tool_calls]191            for tool_call in tool_calls:192                if not self.return_id:193                    _ = tool_call.pop("id")194        else:195            try:196                raw_tool_calls = copy.deepcopy(message.additional_kwargs["tool_calls"])197            except KeyError:198                return []199            tool_calls = parse_tool_calls(200                raw_tool_calls,201                partial=partial,202                strict=self.strict,203                return_id=self.return_id,204            )205        # for backwards compatibility206        for tc in tool_calls:207            tc["type"] = tc.pop("name")208209        if self.first_tool_only:210            return tool_calls[0] if tool_calls else None211        return tool_calls212213    def parse(self, text: str) -> Any:214        """Parse the output of an LLM call to a list of tool calls.215216        Args:217            text: The output of the LLM call.218219        Returns:220            The parsed tool calls.221        """222        raise NotImplementedError223224225class JsonOutputKeyToolsParser(JsonOutputToolsParser):226    """Parse tools from OpenAI response."""227228    key_name: str229    """The type of tools to return."""230231    def parse_result(self, result: list[Generation], *, partial: bool = False) -> Any:232        """Parse the result of an LLM call to a list of tool calls.233234        Args:235            result: The result of the LLM call.236            partial: Whether to parse partial JSON.237                If `True`, the output will be a JSON object containing238                    all the keys that have been returned so far.239                If `False`, the output will be the full JSON object.240241        Raises:242            OutputParserException: If the generation is not a chat generation.243244        Returns:245            The parsed tool calls.246        """247        generation = result[0]248        if not isinstance(generation, ChatGeneration):249            msg = "This output parser can only be used with a chat generation."250            raise OutputParserException(msg)251        message = generation.message252        if isinstance(message, AIMessage) and message.tool_calls:253            parsed_tool_calls = [dict(tc) for tc in message.tool_calls]254            for tool_call in parsed_tool_calls:255                if not self.return_id:256                    _ = tool_call.pop("id")257        else:258            try:259                # This exists purely for backward compatibility / cached messages260                # All new messages should use `message.tool_calls`261                raw_tool_calls = copy.deepcopy(message.additional_kwargs["tool_calls"])262            except KeyError:263                if self.first_tool_only:264                    return None265                return []266            parsed_tool_calls = parse_tool_calls(267                raw_tool_calls,268                partial=partial,269                strict=self.strict,270                return_id=self.return_id,271            )272        # For backwards compatibility273        for tc in parsed_tool_calls:274            tc["type"] = tc.pop("name")275        if self.first_tool_only:276            parsed_result = list(277                filter(lambda x: x["type"] == self.key_name, parsed_tool_calls)278            )279            single_result = (280                parsed_result[0]281                if parsed_result and parsed_result[0]["type"] == self.key_name282                else None283            )284            if self.return_id:285                return single_result286            if single_result:287                return single_result["args"]288            return None289        return (290            [res for res in parsed_tool_calls if res["type"] == self.key_name]291            if self.return_id292            else [293                res["args"] for res in parsed_tool_calls if res["type"] == self.key_name294            ]295        )296297298# Common cause of ValidationError is truncated output due to max_tokens.299_MAX_TOKENS_ERROR = (300    "Output parser received a `max_tokens` stop reason. "301    "The output is likely incomplete—please increase `max_tokens` "302    "or shorten your prompt."303)304305306class PydanticToolsParser(JsonOutputToolsParser):307    """Parse tools from OpenAI response."""308309    tools: Annotated[list[TypeBaseModel], SkipValidation()]310    """The tools to parse."""311312    # TODO: Support more granular streaming of objects.313    # Currently only streams once all Pydantic object fields are present.314    def parse_result(self, result: list[Generation], *, partial: bool = False) -> Any:315        """Parse the result of an LLM call to a list of Pydantic objects.316317        Args:318            result: The result of the LLM call.319            partial: Whether to parse partial JSON.320321                If `True`, the output will be a JSON object containing all the keys that322                have been returned so far.323324                If `False`, the output will be the full JSON object.325326        Returns:327            The parsed Pydantic objects.328329        Raises:330            ValueError: If the tool call arguments are not a dict.331            ValidationError: If the tool call arguments do not conform to the Pydantic332                model.333        """334        json_results = super().parse_result(result, partial=partial)335        if not json_results:336            return None if self.first_tool_only else []337338        json_results = [json_results] if self.first_tool_only else json_results339        name_dict_v2: dict[str, TypeBaseModel] = {340            tool.model_config.get("title") or tool.__name__: tool341            for tool in self.tools342            if is_pydantic_v2_subclass(tool)343        }344        name_dict_v1: dict[str, TypeBaseModel] = {345            tool.__name__: tool for tool in self.tools if is_pydantic_v1_subclass(tool)346        }347        name_dict: dict[str, TypeBaseModel] = {**name_dict_v2, **name_dict_v1}348        pydantic_objects = []349        for res in json_results:350            if not isinstance(res["args"], dict):351                if partial:352                    continue353                msg = (354                    f"Tool arguments must be specified as a dict, received: "355                    f"{res['args']}"356                )357                raise ValueError(msg)358359            try:360                tool = name_dict[res["type"]]361            except KeyError as e:362                available = ", ".join(name_dict.keys()) or "<no_tools>"363                msg = (364                    f"Unknown tool type: {res['type']!r}. Available tools: {available}"365                )366                raise OutputParserException(msg) from e367368            try:369                pydantic_objects.append(tool(**res["args"]))370            except (ValidationError, ValueError):371                if partial:372                    continue373                has_max_tokens_stop_reason = any(374                    generation.message.response_metadata.get("stop_reason")375                    == "max_tokens"376                    for generation in result377                    if isinstance(generation, ChatGeneration)378                )379                if has_max_tokens_stop_reason:380                    logger.exception(_MAX_TOKENS_ERROR)381                raise382        if self.first_tool_only:383            return pydantic_objects[0] if pydantic_objects else None384        return pydantic_objects

Code quality findings 10

Ensure functions have docstrings for documentation
missing-docstring
def parse_tool_call(
Ensure functions have docstrings for documentation
missing-docstring
def make_invalid_tool_call(
Ensure functions have docstrings for documentation
missing-docstring
def parse_tool_calls(
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(generation, ChatGeneration):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(message, AIMessage) and message.tool_calls:
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(generation, ChatGeneration):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(message, AIMessage) and message.tool_calls:
Avoid unnecessary list conversions; use generators where possible
unnecessary-list
parsed_result = list(
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if not isinstance(res["args"], dict):
Overuse may indicate design issues; consider polymorphism
isinstance-overuse
if isinstance(generation, ChatGeneration)

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.