Skip to content

Commit f5ebf48

Browse files
cpsievertclaude
andauthored
fix: support Annotated types with pydantic.Field for tool parameters (#251)
* fix: support Annotated types with pydantic.Field for tool parameters Extract Field metadata (description, default, alias) from typing.Annotated type hints when creating tool schemas. This allows users to specify parameter descriptions using the standard FastMCP annotation pattern: def add_numbers( x: Annotated[int, Field(description="The first number")], y: Annotated[int, Field(description="The second number")], ) -> int: ... Closes #236 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * Simplify * test: add test for Annotated fields in BaseModel Verify that Annotated[type, Field(...)] works in models passed via the model parameter. Pydantic handles this natively. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * docs: add changelog entry for Annotated tool parameter support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * Top-level import * Cleanup changelog --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 55fd7fe commit f5ebf48

File tree

3 files changed

+213
-1
lines changed

3 files changed

+213
-1
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### New features
1313

14+
1415
* `ChatOpenAI()`, `ChatAnthropic()`, and `ChatGoogle()` gain a new `reasoning` parameter to easily opt-into, and fully customize, reasoning capabilities. (#202)
1516
* A new `ContentThinking` content type was added and captures the "thinking" portion of a reasoning model. (#192)
1617
* Added support for built-in provider tools via a new `ToolBuiltIn` class. This enables provider-specific functionality like OpenAI's image generation to be registered and used as tools. Built-in tools pass raw provider definitions directly to the API rather than wrapping Python functions. (#214)
@@ -22,15 +23,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2223

2324
* `repr()` now generally gives the same result as `str()` for many classes (`Chat`, `Turn`, `Content`, etc). This leads to a more human-readable result (and is closer to the result that gets `echo`ed by `.chat()`). (#245)
2425
* The `Chat.get_cost()` method's `options` parameter was renamed to `include`. (#244)
26+
* When supplying a `model` to `.register_tool(tool_func, model=ToolModel)`, the defaults for the `model` must match the `tool_func` defaults. Previously, if `tool_func` had defaults, but `ToolModel` didn't, those defaults would get silently ignored. (#253)
2527

2628
### Improvements
2729

2830
* `ChatSnowflake()` now sets the `application` config parameter for partner identification. Defaults to `"py_chatlas"` but can be overridden via the `SF_PARTNER` environment variable. (#209)
2931

3032
### Bug fixes
3133

32-
* `Tool.from_func()` now validates that function parameter defaults match model field defaults when a custom `BaseModel` is provided. Previously, defaults in the function signature were silently ignored if the model didn't include them, leading to schemas that didn't reflect the function's actual defaults. (#253)
3334
* Fixed structured data extraction with `ChatAnthropic()` failing for Pydantic models containing nested types (e.g., `list[NestedModel]`). The issue was that `$defs` (containing nested type definitions) was incorrectly placed inside the schema, breaking JSON `$ref` pointer references. (#100)
35+
* Tool functions parameters that are `typing.Annotated` with a `pydantic.Field` (e.g., `def add(x: Annotated[int, Field(description="First number")])`) are now handled correctly. (#251)
3436

3537
## [0.14.0] - 2025-12-09
3638

chatlas/_tools.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@
44
import warnings
55
from typing import (
66
TYPE_CHECKING,
7+
Annotated,
78
Any,
89
AsyncGenerator,
910
Awaitable,
1011
Callable,
1112
Optional,
1213
cast,
14+
get_args,
15+
get_origin,
1316
)
1417

1518
import openai
1619
from pydantic import BaseModel, Field, create_model
20+
from pydantic.fields import FieldInfo
1721
from pydantic_core import PydanticUndefined
1822

1923
from . import _utils
@@ -336,13 +340,23 @@ def func_to_basemodel(func: Callable) -> type[BaseModel]:
336340

337341
for name, param in params.items():
338342
annotation = param.annotation
343+
annotated_field: Optional[FieldInfo] = None
339344

340345
if annotation == inspect.Parameter.empty:
341346
warnings.warn(
342347
f"Parameter `{name}` of function `{name}` has no type hint. "
343348
"Using `Any` as a fallback."
344349
)
345350
annotation = Any
351+
# Check if annotation is Annotated[...] and extract Field metadata
352+
elif get_origin(annotation) is Annotated:
353+
args = get_args(annotation)
354+
# First arg is the actual type, rest are metadata
355+
annotation = args[0]
356+
for metadata in args[1:]:
357+
if isinstance(metadata, FieldInfo):
358+
annotated_field = metadata
359+
break
346360

347361
# create_model() will error if the field name starts with `_` (since Pydantic
348362
# uses this to indicate private fields). We can work around this by using an alias.
@@ -352,11 +366,16 @@ def func_to_basemodel(func: Callable) -> type[BaseModel]:
352366
else:
353367
field_name, alias = (name, None)
354368

369+
# Create the pydantic Field from a "normal" parameter
355370
if param.default != inspect.Parameter.empty:
356371
field = Field(default=param.default, alias=alias)
357372
else:
358373
field = Field(alias=alias)
359374

375+
# If we have an Annotated FieldInfo, merge it with alias/default overrides
376+
if annotated_field is not None:
377+
field = FieldInfo.merge_field_infos(annotated_field, field)
378+
360379
# Add the field to our fields dict
361380
fields[field_name] = (annotation, field)
362381

tests/test_tools_enhanced.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Annotated
2+
13
import pytest
24
from chatlas import ChatOpenAI
35
from chatlas._content import ToolInfo
@@ -117,6 +119,31 @@ def add(x: int, y: int) -> int:
117119
assert props["x"]["description"] == "First number" # type: ignore
118120
assert props["y"]["description"] == "Second number" # type: ignore
119121

122+
def test_from_func_with_annotated_model(self):
123+
"""Test creating a Tool with a model using Annotated fields."""
124+
125+
class AddParams(BaseModel):
126+
"""Parameters for adding numbers."""
127+
128+
x: Annotated[int, Field(description="First number", ge=0)]
129+
y: Annotated[int, Field(description="Second number", le=100)]
130+
131+
def add(x: int, y: int) -> int:
132+
return x + y
133+
134+
tool = Tool.from_func(add, model=AddParams)
135+
136+
assert tool.name == "AddParams"
137+
func = tool.schema["function"]
138+
139+
# Check that Annotated Field descriptions and constraints are preserved
140+
params = func.get("parameters", {})
141+
props = params["properties"]
142+
assert props["x"]["description"] == "First number"
143+
assert props["x"]["minimum"] == 0
144+
assert props["y"]["description"] == "Second number"
145+
assert props["y"]["maximum"] == 100
146+
120147
def test_from_func_with_model_missing_default_error(self):
121148
"""Test that error is raised when function has default but model doesn't.
122149
@@ -214,6 +241,170 @@ async def async_add(x: int, y: int) -> int:
214241
assert func.get("description") == "Add two numbers asynchronously."
215242

216243

244+
class TestAnnotatedParameters:
245+
"""Test support for typing.Annotated with pydantic.Field for parameter descriptions."""
246+
247+
def test_annotated_field_descriptions(self):
248+
"""Test that Field descriptions in Annotated types are extracted."""
249+
250+
def add_numbers(
251+
x: Annotated[int, Field(description="The first number to be added")],
252+
y: Annotated[int, Field(description="The second number to be added")],
253+
) -> int:
254+
"""Add two numbers"""
255+
return x + y
256+
257+
tool = Tool.from_func(add_numbers)
258+
259+
assert tool.name == "add_numbers"
260+
func = tool.schema["function"]
261+
assert func.get("description") == "Add two numbers"
262+
263+
params = func.get("parameters", {})
264+
props = params["properties"]
265+
assert props["x"]["description"] == "The first number to be added"
266+
assert props["y"]["description"] == "The second number to be added"
267+
assert props["x"]["type"] == "integer"
268+
assert props["y"]["type"] == "integer"
269+
270+
def test_annotated_with_default_value(self):
271+
"""Test Annotated parameters with default values in function signature."""
272+
273+
def greet(
274+
name: Annotated[str, Field(description="Name to greet")],
275+
greeting: Annotated[str, Field(description="Greeting phrase")] = "Hello",
276+
) -> str:
277+
"""Generate a greeting"""
278+
return f"{greeting}, {name}!"
279+
280+
tool = Tool.from_func(greet)
281+
func = tool.schema["function"]
282+
params = func.get("parameters", {})
283+
284+
# Check descriptions are preserved
285+
props = params["properties"]
286+
assert props["name"]["description"] == "Name to greet"
287+
assert props["greeting"]["description"] == "Greeting phrase"
288+
# Default value is preserved in schema
289+
assert props["greeting"]["default"] == "Hello"
290+
291+
def test_annotated_with_field_default(self):
292+
"""Test Annotated parameters with default in Field (not function signature)."""
293+
294+
def process(
295+
value: Annotated[int, Field(description="Value to process", default=42)],
296+
) -> int:
297+
"""Process a value"""
298+
return value * 2
299+
300+
tool = Tool.from_func(process)
301+
func = tool.schema["function"]
302+
params = func.get("parameters", {})
303+
304+
props = params["properties"]
305+
assert props["value"]["description"] == "Value to process"
306+
assert props["value"]["default"] == 42
307+
308+
def test_annotated_function_default_overrides_field_default(self):
309+
"""Test that function signature default takes precedence over Field default."""
310+
311+
def example(
312+
x: Annotated[int, Field(description="A number", default=10)] = 20,
313+
) -> int:
314+
"""Example function"""
315+
return x
316+
317+
tool = Tool.from_func(example)
318+
func = tool.schema["function"]
319+
params = func.get("parameters", {})
320+
321+
props = params["properties"]
322+
# Function signature default (20) should override Field default (10)
323+
assert props["x"]["default"] == 20
324+
325+
def test_mixed_annotated_and_regular_parameters(self):
326+
"""Test functions with both Annotated and regular parameters."""
327+
328+
def mixed_func(
329+
described: Annotated[str, Field(description="A described parameter")],
330+
plain: int,
331+
) -> str:
332+
"""Function with mixed parameter styles"""
333+
return f"{described}: {plain}"
334+
335+
tool = Tool.from_func(mixed_func)
336+
func = tool.schema["function"]
337+
params = func.get("parameters", {})
338+
props = params["properties"]
339+
340+
# Annotated param should have description
341+
assert props["described"]["description"] == "A described parameter"
342+
343+
# Plain param should not have description
344+
assert "description" not in props["plain"]
345+
346+
def test_annotated_with_underscore_prefix(self):
347+
"""Test Annotated parameters with underscore prefix (private-style names)."""
348+
349+
def func_with_private(
350+
_private: Annotated[int, Field(description="A private-style param")],
351+
) -> int:
352+
"""Function with underscore-prefixed param"""
353+
return _private
354+
355+
tool = Tool.from_func(func_with_private)
356+
func = tool.schema["function"]
357+
params = func.get("parameters", {})
358+
props = params["properties"]
359+
360+
# Schema uses the alias (_private) as the property key
361+
assert "_private" in props
362+
assert props["_private"]["description"] == "A private-style param"
363+
364+
def test_annotated_registration_via_chat(self):
365+
"""Test that Annotated tools work when registered via Chat.register_tool()."""
366+
chat = ChatOpenAI()
367+
368+
def add_numbers(
369+
x: Annotated[int, Field(description="The first number")],
370+
y: Annotated[int, Field(description="The second number")],
371+
) -> int:
372+
"""Add two numbers"""
373+
return x + y
374+
375+
chat.register_tool(add_numbers)
376+
377+
tools = chat.get_tools()
378+
assert len(tools) == 1
379+
380+
tool = tools[0]
381+
func = tool.schema["function"]
382+
params = func.get("parameters", {})
383+
props = params["properties"]
384+
385+
assert props["x"]["description"] == "The first number"
386+
assert props["y"]["description"] == "The second number"
387+
388+
def test_annotated_with_complex_types(self):
389+
"""Test Annotated with more complex types."""
390+
from typing import Optional
391+
392+
def search(
393+
query: Annotated[str, Field(description="Search query string")],
394+
limit: Annotated[Optional[int], Field(description="Maximum results")] = None,
395+
) -> str:
396+
"""Search for items"""
397+
return f"Searching: {query}"
398+
399+
tool = Tool.from_func(search)
400+
func = tool.schema["function"]
401+
params = func.get("parameters", {})
402+
props = params["properties"]
403+
404+
assert props["query"]["description"] == "Search query string"
405+
assert props["limit"]["description"] == "Maximum results"
406+
407+
217408
class TestChatGetSetTools:
218409
"""Test Chat.get_tools() and Chat.set_tools() methods."""
219410

0 commit comments

Comments
 (0)