mirror of
https://github.com/hwchase17/langchain.git
synced 2026-04-24 04:36:46 +00:00
fix(core): Fix tool name check in name_dict for PydanticToolsParser (#33479)
- **Description:** The root cause of this issue is that when a user
defines `model_config` in a `BaseModel`, the `{"type": <tool_name>}`
value is derived from the title specified in `model_config` when the
results are parsed
[here](https://vscode.dev/github/keenborder786/langchain/blob/fix/tool_name_dict/libs/core/langchain_core/output_parsers/openai_tools.py#L199).
However,
[tool.__name__](https://vscode.dev/github/keenborder786/langchain/blob/fix/tool_name_dict/libs/core/langchain_core/output_parsers/openai_tools.py#L331)
uses the class name (in uppercase) of the `BaseModel`, resulting in a
`KeyError` when a custom title is provided in `model_config`.
The Best Solution will be to use the title provided in `model_config`
attribute if provided one since that is what `type` will be parsed to,
if not then use `tool.__name__`. But need to make sure that this works
only for Pydantic V2.
- **Issue:** #27260
---------
Co-authored-by: Mason Daugherty <mason@langchain.dev>
This commit is contained in:
committed by
GitHub
parent
ee630b4539
commit
8e31a5d7bd
@@ -15,7 +15,11 @@ from langchain_core.messages.tool import tool_call as create_tool_call
|
||||
from langchain_core.output_parsers.transform import BaseCumulativeTransformOutputParser
|
||||
from langchain_core.outputs import ChatGeneration, Generation
|
||||
from langchain_core.utils.json import parse_partial_json
|
||||
from langchain_core.utils.pydantic import TypeBaseModel
|
||||
from langchain_core.utils.pydantic import (
|
||||
TypeBaseModel,
|
||||
is_pydantic_v1_subclass,
|
||||
is_pydantic_v2_subclass,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -323,7 +327,15 @@ class PydanticToolsParser(JsonOutputToolsParser):
|
||||
return None if self.first_tool_only else []
|
||||
|
||||
json_results = [json_results] if self.first_tool_only else json_results
|
||||
name_dict = {tool.__name__: tool for tool in self.tools}
|
||||
name_dict_v2: dict[str, TypeBaseModel] = {
|
||||
tool.model_config.get("title") or tool.__name__: tool
|
||||
for tool in self.tools
|
||||
if is_pydantic_v2_subclass(tool)
|
||||
}
|
||||
name_dict_v1: dict[str, TypeBaseModel] = {
|
||||
tool.__name__: tool for tool in self.tools if is_pydantic_v1_subclass(tool)
|
||||
}
|
||||
name_dict: dict[str, TypeBaseModel] = {**name_dict_v2, **name_dict_v1}
|
||||
pydantic_objects = []
|
||||
for res in json_results:
|
||||
if not isinstance(res["args"], dict):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sys
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from typing import Any
|
||||
|
||||
@@ -886,3 +887,461 @@ def test_max_tokens_error(caplog: Any) -> None:
|
||||
"`max_tokens` stop reason" in msg and record.levelname == "ERROR"
|
||||
for record, msg in zip(caplog.records, caplog.messages, strict=False)
|
||||
)
|
||||
|
||||
|
||||
def test_pydantic_tools_parser_with_mixed_pydantic_versions() -> None:
|
||||
"""Test PydanticToolsParser with both Pydantic v1 and v2 models."""
|
||||
# For Python 3.14+ compatibility, use create_model for Pydantic v1
|
||||
if sys.version_info >= (3, 14):
|
||||
WeatherV1 = pydantic.v1.create_model( # noqa: N806
|
||||
"WeatherV1",
|
||||
__doc__="Weather information using Pydantic v1.",
|
||||
temperature=(int, ...),
|
||||
conditions=(str, ...),
|
||||
)
|
||||
else:
|
||||
|
||||
class WeatherV1(pydantic.v1.BaseModel):
|
||||
"""Weather information using Pydantic v1."""
|
||||
|
||||
temperature: int
|
||||
conditions: str
|
||||
|
||||
class LocationV2(BaseModel):
|
||||
"""Location information using Pydantic v2."""
|
||||
|
||||
city: str
|
||||
country: str
|
||||
|
||||
# Test with Pydantic v1 model
|
||||
parser_v1 = PydanticToolsParser(tools=[WeatherV1])
|
||||
message_v1 = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_weather",
|
||||
"name": "WeatherV1",
|
||||
"args": {"temperature": 25, "conditions": "sunny"},
|
||||
}
|
||||
],
|
||||
)
|
||||
generation_v1 = ChatGeneration(message=message_v1)
|
||||
result_v1 = parser_v1.parse_result([generation_v1])
|
||||
|
||||
assert len(result_v1) == 1
|
||||
assert isinstance(result_v1[0], WeatherV1)
|
||||
assert result_v1[0].temperature == 25 # type: ignore[attr-defined,unused-ignore]
|
||||
assert result_v1[0].conditions == "sunny" # type: ignore[attr-defined,unused-ignore]
|
||||
|
||||
# Test with Pydantic v2 model
|
||||
parser_v2 = PydanticToolsParser(tools=[LocationV2])
|
||||
message_v2 = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_location",
|
||||
"name": "LocationV2",
|
||||
"args": {"city": "Paris", "country": "France"},
|
||||
}
|
||||
],
|
||||
)
|
||||
generation_v2 = ChatGeneration(message=message_v2)
|
||||
result_v2 = parser_v2.parse_result([generation_v2])
|
||||
|
||||
assert len(result_v2) == 1
|
||||
assert isinstance(result_v2[0], LocationV2)
|
||||
assert result_v2[0].city == "Paris"
|
||||
assert result_v2[0].country == "France"
|
||||
|
||||
# Test with both v1 and v2 models
|
||||
parser_mixed = PydanticToolsParser(tools=[WeatherV1, LocationV2])
|
||||
message_mixed = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_weather",
|
||||
"name": "WeatherV1",
|
||||
"args": {"temperature": 20, "conditions": "cloudy"},
|
||||
},
|
||||
{
|
||||
"id": "call_location",
|
||||
"name": "LocationV2",
|
||||
"args": {"city": "London", "country": "UK"},
|
||||
},
|
||||
],
|
||||
)
|
||||
generation_mixed = ChatGeneration(message=message_mixed)
|
||||
result_mixed = parser_mixed.parse_result([generation_mixed])
|
||||
|
||||
assert len(result_mixed) == 2
|
||||
assert isinstance(result_mixed[0], WeatherV1)
|
||||
assert result_mixed[0].temperature == 20 # type: ignore[attr-defined,unused-ignore]
|
||||
assert isinstance(result_mixed[1], LocationV2)
|
||||
assert result_mixed[1].city == "London"
|
||||
|
||||
|
||||
def test_pydantic_tools_parser_with_custom_title() -> None:
|
||||
"""Test PydanticToolsParser with Pydantic v2 model using custom title."""
|
||||
|
||||
class CustomTitleTool(BaseModel):
|
||||
"""Tool with custom title in model config."""
|
||||
|
||||
model_config = {"title": "MyCustomToolName"}
|
||||
|
||||
value: int
|
||||
description: str
|
||||
|
||||
# Test with custom title - tool should be callable by custom name
|
||||
parser = PydanticToolsParser(tools=[CustomTitleTool])
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_custom",
|
||||
"name": "MyCustomToolName",
|
||||
"args": {"value": 42, "description": "test"},
|
||||
}
|
||||
],
|
||||
)
|
||||
generation = ChatGeneration(message=message)
|
||||
result = parser.parse_result([generation])
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], CustomTitleTool)
|
||||
assert result[0].value == 42
|
||||
assert result[0].description == "test"
|
||||
|
||||
|
||||
def test_pydantic_tools_parser_name_dict_fallback() -> None:
|
||||
"""Test that name_dict properly falls back to __name__ when title is None."""
|
||||
|
||||
class ToolWithoutTitle(BaseModel):
|
||||
"""Tool without explicit title."""
|
||||
|
||||
data: str
|
||||
|
||||
# Ensure model_config doesn't have a title or it's None
|
||||
# (This is the default behavior)
|
||||
parser = PydanticToolsParser(tools=[ToolWithoutTitle])
|
||||
message = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_no_title",
|
||||
"name": "ToolWithoutTitle",
|
||||
"args": {"data": "test_data"},
|
||||
}
|
||||
],
|
||||
)
|
||||
generation = ChatGeneration(message=message)
|
||||
result = parser.parse_result([generation])
|
||||
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], ToolWithoutTitle)
|
||||
assert result[0].data == "test_data"
|
||||
|
||||
|
||||
def test_pydantic_tools_parser_with_nested_models() -> None:
|
||||
"""Test PydanticToolsParser with nested Pydantic v1 and v2 models."""
|
||||
# Nested v1 models
|
||||
if sys.version_info >= (3, 14):
|
||||
AddressV1 = pydantic.v1.create_model( # noqa: N806
|
||||
"AddressV1",
|
||||
__doc__="Address using Pydantic v1.",
|
||||
street=(str, ...),
|
||||
city=(str, ...),
|
||||
zip_code=(str, ...),
|
||||
)
|
||||
PersonV1 = pydantic.v1.create_model( # noqa: N806
|
||||
"PersonV1",
|
||||
__doc__="Person with nested address using Pydantic v1.",
|
||||
name=(str, ...),
|
||||
age=(int, ...),
|
||||
address=(AddressV1, ...),
|
||||
)
|
||||
else:
|
||||
|
||||
class AddressV1(pydantic.v1.BaseModel):
|
||||
"""Address using Pydantic v1."""
|
||||
|
||||
street: str
|
||||
city: str
|
||||
zip_code: str
|
||||
|
||||
class PersonV1(pydantic.v1.BaseModel):
|
||||
"""Person with nested address using Pydantic v1."""
|
||||
|
||||
name: str
|
||||
age: int
|
||||
address: AddressV1
|
||||
|
||||
# Nested v2 models
|
||||
class CoordinatesV2(BaseModel):
|
||||
"""Coordinates using Pydantic v2."""
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
class LocationV2(BaseModel):
|
||||
"""Location with nested coordinates using Pydantic v2."""
|
||||
|
||||
name: str
|
||||
coordinates: CoordinatesV2
|
||||
|
||||
# Test with nested Pydantic v1 model
|
||||
parser_v1 = PydanticToolsParser(tools=[PersonV1])
|
||||
message_v1 = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_person",
|
||||
"name": "PersonV1",
|
||||
"args": {
|
||||
"name": "Alice",
|
||||
"age": 30,
|
||||
"address": {
|
||||
"street": "123 Main St",
|
||||
"city": "Springfield",
|
||||
"zip_code": "12345",
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
generation_v1 = ChatGeneration(message=message_v1)
|
||||
result_v1 = parser_v1.parse_result([generation_v1])
|
||||
|
||||
assert len(result_v1) == 1
|
||||
assert isinstance(result_v1[0], PersonV1)
|
||||
assert result_v1[0].name == "Alice" # type: ignore[attr-defined,unused-ignore]
|
||||
assert result_v1[0].age == 30 # type: ignore[attr-defined,unused-ignore]
|
||||
assert isinstance(result_v1[0].address, AddressV1) # type: ignore[attr-defined,unused-ignore]
|
||||
assert result_v1[0].address.street == "123 Main St" # type: ignore[attr-defined,unused-ignore]
|
||||
assert result_v1[0].address.city == "Springfield" # type: ignore[attr-defined,unused-ignore]
|
||||
|
||||
# Test with nested Pydantic v2 model
|
||||
parser_v2 = PydanticToolsParser(tools=[LocationV2])
|
||||
message_v2 = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_location",
|
||||
"name": "LocationV2",
|
||||
"args": {
|
||||
"name": "Eiffel Tower",
|
||||
"coordinates": {"latitude": 48.8584, "longitude": 2.2945},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
generation_v2 = ChatGeneration(message=message_v2)
|
||||
result_v2 = parser_v2.parse_result([generation_v2])
|
||||
|
||||
assert len(result_v2) == 1
|
||||
assert isinstance(result_v2[0], LocationV2)
|
||||
assert result_v2[0].name == "Eiffel Tower"
|
||||
assert isinstance(result_v2[0].coordinates, CoordinatesV2)
|
||||
assert result_v2[0].coordinates.latitude == 48.8584
|
||||
assert result_v2[0].coordinates.longitude == 2.2945
|
||||
|
||||
# Test with both nested models in one message
|
||||
parser_mixed = PydanticToolsParser(tools=[PersonV1, LocationV2])
|
||||
message_mixed = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_person",
|
||||
"name": "PersonV1",
|
||||
"args": {
|
||||
"name": "Bob",
|
||||
"age": 25,
|
||||
"address": {
|
||||
"street": "456 Oak Ave",
|
||||
"city": "Portland",
|
||||
"zip_code": "97201",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "call_location",
|
||||
"name": "LocationV2",
|
||||
"args": {
|
||||
"name": "Golden Gate Bridge",
|
||||
"coordinates": {"latitude": 37.8199, "longitude": -122.4783},
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
generation_mixed = ChatGeneration(message=message_mixed)
|
||||
result_mixed = parser_mixed.parse_result([generation_mixed])
|
||||
|
||||
assert len(result_mixed) == 2
|
||||
assert isinstance(result_mixed[0], PersonV1)
|
||||
assert result_mixed[0].name == "Bob" # type: ignore[attr-defined,unused-ignore]
|
||||
assert result_mixed[0].address.city == "Portland" # type: ignore[attr-defined,unused-ignore]
|
||||
assert isinstance(result_mixed[1], LocationV2)
|
||||
assert result_mixed[1].name == "Golden Gate Bridge"
|
||||
assert result_mixed[1].coordinates.latitude == 37.8199
|
||||
|
||||
|
||||
def test_pydantic_tools_parser_with_optional_fields() -> None:
|
||||
"""Test PydanticToolsParser with optional fields in v1 and v2 models."""
|
||||
if sys.version_info >= (3, 14):
|
||||
ProductV1 = pydantic.v1.create_model( # noqa: N806
|
||||
"ProductV1",
|
||||
__doc__="Product with optional fields using Pydantic v1.",
|
||||
name=(str, ...),
|
||||
price=(float, ...),
|
||||
description=(str | None, None),
|
||||
stock=(int, 0),
|
||||
)
|
||||
else:
|
||||
|
||||
class ProductV1(pydantic.v1.BaseModel):
|
||||
"""Product with optional fields using Pydantic v1."""
|
||||
|
||||
name: str
|
||||
price: float
|
||||
description: str | None = None
|
||||
stock: int = 0
|
||||
|
||||
# v2 model with optional fields
|
||||
class UserV2(BaseModel):
|
||||
"""User with optional fields using Pydantic v2."""
|
||||
|
||||
username: str
|
||||
email: str
|
||||
bio: str | None = None
|
||||
age: int | None = None
|
||||
|
||||
# Test v1 with all fields provided
|
||||
parser_v1_full = PydanticToolsParser(tools=[ProductV1])
|
||||
message_v1_full = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_product_full",
|
||||
"name": "ProductV1",
|
||||
"args": {
|
||||
"name": "Laptop",
|
||||
"price": 999.99,
|
||||
"description": "High-end laptop",
|
||||
"stock": 50,
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
generation_v1_full = ChatGeneration(message=message_v1_full)
|
||||
result_v1_full = parser_v1_full.parse_result([generation_v1_full])
|
||||
|
||||
assert len(result_v1_full) == 1
|
||||
assert isinstance(result_v1_full[0], ProductV1)
|
||||
assert result_v1_full[0].name == "Laptop" # type: ignore[attr-defined,unused-ignore]
|
||||
assert result_v1_full[0].price == 999.99 # type: ignore[attr-defined,unused-ignore]
|
||||
assert result_v1_full[0].description == "High-end laptop" # type: ignore[attr-defined,unused-ignore]
|
||||
assert result_v1_full[0].stock == 50 # type: ignore[attr-defined,unused-ignore]
|
||||
|
||||
# Test v1 with only required fields
|
||||
parser_v1_minimal = PydanticToolsParser(tools=[ProductV1])
|
||||
message_v1_minimal = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_product_minimal",
|
||||
"name": "ProductV1",
|
||||
"args": {"name": "Mouse", "price": 29.99},
|
||||
}
|
||||
],
|
||||
)
|
||||
generation_v1_minimal = ChatGeneration(message=message_v1_minimal)
|
||||
result_v1_minimal = parser_v1_minimal.parse_result([generation_v1_minimal])
|
||||
|
||||
assert len(result_v1_minimal) == 1
|
||||
assert isinstance(result_v1_minimal[0], ProductV1)
|
||||
assert result_v1_minimal[0].name == "Mouse" # type: ignore[attr-defined,unused-ignore]
|
||||
assert result_v1_minimal[0].price == 29.99 # type: ignore[attr-defined,unused-ignore]
|
||||
assert result_v1_minimal[0].description is None # type: ignore[attr-defined,unused-ignore]
|
||||
assert result_v1_minimal[0].stock == 0 # type: ignore[attr-defined,unused-ignore]
|
||||
|
||||
# Test v2 with all fields provided
|
||||
parser_v2_full = PydanticToolsParser(tools=[UserV2])
|
||||
message_v2_full = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_user_full",
|
||||
"name": "UserV2",
|
||||
"args": {
|
||||
"username": "john_doe",
|
||||
"email": "john@example.com",
|
||||
"bio": "Software developer",
|
||||
"age": 28,
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
generation_v2_full = ChatGeneration(message=message_v2_full)
|
||||
result_v2_full = parser_v2_full.parse_result([generation_v2_full])
|
||||
|
||||
assert len(result_v2_full) == 1
|
||||
assert isinstance(result_v2_full[0], UserV2)
|
||||
assert result_v2_full[0].username == "john_doe"
|
||||
assert result_v2_full[0].email == "john@example.com"
|
||||
assert result_v2_full[0].bio == "Software developer"
|
||||
assert result_v2_full[0].age == 28
|
||||
|
||||
# Test v2 with only required fields
|
||||
parser_v2_minimal = PydanticToolsParser(tools=[UserV2])
|
||||
message_v2_minimal = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_user_minimal",
|
||||
"name": "UserV2",
|
||||
"args": {"username": "jane_smith", "email": "jane@example.com"},
|
||||
}
|
||||
],
|
||||
)
|
||||
generation_v2_minimal = ChatGeneration(message=message_v2_minimal)
|
||||
result_v2_minimal = parser_v2_minimal.parse_result([generation_v2_minimal])
|
||||
|
||||
assert len(result_v2_minimal) == 1
|
||||
assert isinstance(result_v2_minimal[0], UserV2)
|
||||
assert result_v2_minimal[0].username == "jane_smith"
|
||||
assert result_v2_minimal[0].email == "jane@example.com"
|
||||
assert result_v2_minimal[0].bio is None
|
||||
assert result_v2_minimal[0].age is None
|
||||
|
||||
# Test mixed v1 and v2 with partial optional fields
|
||||
parser_mixed = PydanticToolsParser(tools=[ProductV1, UserV2])
|
||||
message_mixed = AIMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_product",
|
||||
"name": "ProductV1",
|
||||
"args": {"name": "Keyboard", "price": 79.99, "stock": 100},
|
||||
},
|
||||
{
|
||||
"id": "call_user",
|
||||
"name": "UserV2",
|
||||
"args": {
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"age": 35,
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
generation_mixed = ChatGeneration(message=message_mixed)
|
||||
result_mixed = parser_mixed.parse_result([generation_mixed])
|
||||
|
||||
assert len(result_mixed) == 2
|
||||
assert isinstance(result_mixed[0], ProductV1)
|
||||
assert result_mixed[0].name == "Keyboard" # type: ignore[attr-defined,unused-ignore]
|
||||
assert result_mixed[0].description is None # type: ignore[attr-defined,unused-ignore]
|
||||
assert result_mixed[0].stock == 100 # type: ignore[attr-defined,unused-ignore]
|
||||
assert isinstance(result_mixed[1], UserV2)
|
||||
assert result_mixed[1].username == "alice"
|
||||
assert result_mixed[1].bio is None
|
||||
assert result_mixed[1].age == 35
|
||||
|
||||
Reference in New Issue
Block a user