Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
c713a6e
infer custom tool format from schema
matthewfranglen Aug 13, 2025
de3bc18
Merge branch 'main' into freeform-and-cfg-tools
matthewfranglen Aug 15, 2025
e7ca5ca
update free_form to be a parameter, set parallel_tool_calls
matthewfranglen Aug 15, 2025
f0a5cbe
Map the response type
matthewfranglen Aug 15, 2025
68fc7cf
Fix assertion ordering, remove some intermediate variables
matthewfranglen Aug 15, 2025
5d9af16
add free_form output
matthewfranglen Aug 15, 2025
3687580
add context free grammar to free form function calling
matthewfranglen Aug 15, 2025
61f7291
Merge branch 'main' into freeform-and-cfg-tools
matthewfranglen Aug 15, 2025
7a869b9
get the grammar working on the output
matthewfranglen Aug 15, 2025
5e8cef9
use FunctionTextFormat object to hold fffc/cfg settings
matthewfranglen Aug 15, 2025
79b519a
add literal text as an option for text_format
matthewfranglen Aug 15, 2025
991c01d
remove parameter added in error
matthewfranglen Aug 15, 2025
c70bc1d
address some of the pyright errors
matthewfranglen Aug 15, 2025
9586e6c
remove default value
matthewfranglen Aug 15, 2025
0b47135
drop pedantic check
matthewfranglen Aug 15, 2025
92db07a
update snapshots
matthewfranglen Aug 15, 2025
3e605e0
update docstrings
matthewfranglen Aug 15, 2025
ab45262
update snapshots
matthewfranglen Aug 15, 2025
3982f32
Merge branch 'main' into freeform-and-cfg-tools
matthewfranglen Aug 29, 2025
f3595b3
work on tests
matthewfranglen Aug 29, 2025
bef5dec
reviewing some of the new tests
matthewfranglen Aug 29, 2025
21e1a0b
typing
matthewfranglen Aug 29, 2025
cfcf7cf
update snapshots
matthewfranglen Aug 29, 2025
1c5c500
more generated tests
matthewfranglen Aug 31, 2025
e0017b4
fix up tests for tools.py
matthewfranglen Aug 31, 2025
307b011
add lark
matthewfranglen Aug 31, 2025
131ab91
use find_spec to see if lark resolves
matthewfranglen Aug 31, 2025
b386eb6
add runtime validation of syntax
matthewfranglen Aug 31, 2025
01988a5
Can't throw the exception and maintain coverage
matthewfranglen Aug 31, 2025
88b8b28
Merge branch 'main' into freeform-and-cfg-tools
matthewfranglen Aug 31, 2025
3a46eea
revert the uv.lock
matthewfranglen Aug 31, 2025
c084523
use keyword arguments
matthewfranglen Sep 1, 2025
ef1a696
add missing property decorator
matthewfranglen Sep 1, 2025
e3f514d
remove deprecated setting
matthewfranglen Sep 2, 2025
0dbcdaf
update snapshot
matthewfranglen Sep 2, 2025
4533df1
get coverage on the tests up to 100%
matthewfranglen Sep 2, 2025
fc477bb
review the openai tests
matthewfranglen Sep 2, 2025
4cacf9b
Merge branch 'main' into freeform-and-cfg-tools
matthewfranglen Sep 3, 2025
185c929
fiddling with tests
matthewfranglen Sep 4, 2025
a81e7b9
Merge branch 'main' into freeform-and-cfg-tools
matthewfranglen Sep 4, 2025
4a6e540
add simple test for output tool
matthewfranglen Sep 4, 2025
8714253
remove utc import
matthewfranglen Sep 4, 2025
b55c9ab
use older utc
matthewfranglen Sep 4, 2025
b347edc
Merge branch 'main' into freeform-and-cfg-tools
matthewfranglen Sep 4, 2025
997d2ac
formatting
matthewfranglen Sep 4, 2025
afdd6ef
drop NOT_GIVEN import
matthewfranglen Sep 4, 2025
175eee9
formatting
matthewfranglen Sep 4, 2025
19eb167
address linter errors
matthewfranglen Sep 4, 2025
6e259c8
TypeError: Logfire.instrument_pydantic_ai() got an unexpected keyword…
matthewfranglen Sep 4, 2025
742fb91
Merge branch 'main' into freeform-and-cfg-tools
matthewfranglen Sep 5, 2025
d69daad
remove the condition over the output tools
matthewfranglen Sep 5, 2025
d78a5c2
remove redundant line pragma
matthewfranglen Sep 5, 2025
dc1c182
move the no cover line
matthewfranglen Sep 5, 2025
d1fb3a4
formatting
matthewfranglen Sep 5, 2025
97b4d82
revert version change
matthewfranglen Sep 5, 2025
c54f26e
Merge branch 'main' into freeform-and-cfg-tools
matthewfranglen Sep 6, 2025
e206e3e
start on documentation
matthewfranglen Sep 6, 2025
c483fda
test the examples manually, fix errors
matthewfranglen Sep 6, 2025
5265211
no fancy comma
matthewfranglen Sep 6, 2025
f337820
fix bad syntax in example
matthewfranglen Sep 6, 2025
2c7367f
Add section on output tool use
matthewfranglen Sep 6, 2025
f3a4afd
double to single quotes
matthewfranglen Sep 6, 2025
c4665a2
fix the output_tool
matthewfranglen Sep 6, 2025
b86d2b1
actually use a lark grammar
matthewfranglen Sep 6, 2025
d713c29
Update docs/models/openai.md
matthewfranglen Sep 9, 2025
d0c346c
Update pydantic_ai_slim/pydantic_ai/models/openai.py
matthewfranglen Sep 9, 2025
3037e6e
make the introduction to cfg stronger
matthewfranglen Sep 9, 2025
4e2264d
make FunctionTextFormat directly importable from pydantic_ai
matthewfranglen Sep 9, 2025
e28836b
use direct import
matthewfranglen Sep 9, 2025
b49cd81
add headings
matthewfranglen Sep 9, 2025
a7112f4
of course there was an easier way to do this
matthewfranglen Sep 9, 2025
7c96803
quote coding terms
matthewfranglen Sep 9, 2025
5d2b372
free-form -> freeform
matthewfranglen Sep 9, 2025
ec057c8
gpt -> GPT or quoted
matthewfranglen Sep 9, 2025
c949c83
free form -> freeform
matthewfranglen Sep 9, 2025
36a0759
Merge branch 'main' into freeform-and-cfg-tools
matthewfranglen Oct 12, 2025
ae64d6b
fix imports
matthewfranglen Oct 12, 2025
f5ca42e
add missing import
matthewfranglen Oct 12, 2025
e7bea60
snapshot updates
matthewfranglen Oct 12, 2025
637774f
update regex match over error message
matthewfranglen Oct 12, 2025
9cf0931
add more known model names
matthewfranglen Oct 12, 2025
8c6c976
fix uv.lock, undo some of the changes
matthewfranglen Oct 12, 2025
febe88d
another snapshot update related to uv.lock
matthewfranglen Oct 12, 2025
3106219
text_format text -> plain
matthewfranglen Oct 13, 2025
673ef1e
default to an argument name of input
matthewfranglen Oct 13, 2025
2581873
use !r formatting for tool name
matthewfranglen Oct 13, 2025
9ec5b69
link to best practices
matthewfranglen Oct 13, 2025
3927cf0
FunctionTextFormat -> TextFormat, change handling
matthewfranglen Oct 13, 2025
5990d8b
Update a test to check for unknown tool mapping
matthewfranglen Oct 13, 2025
32a6c08
Merge remote-tracking branch 'origin/main' into refactor-freeform-cfg…
cjohnhanson Nov 28, 2025
0283547
feat: annotation-based API for freeform function calling
cjohnhanson Nov 28, 2025
b5e7d5a
Merge branch 'main' into refactor-freeform-cfg-tools
cjohnhanson Dec 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions docs/models/openai.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,137 @@ print(result2.output)
#> This is an excellent joke invented by Samuel Colvin, it needs no explanation.
```

### Freeform Function Calling
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fact that this can be used for output is a bit buried now, I'd like that to be clearer. If we do what I wrote in the other comment about validating on the agent side, this would warrant sections on the Output and Tool docs.


GPT‑5 can now send raw text payloads - anything from Python scripts to SQL queries - to your custom tool without wrapping the data in JSON using freeform function calling. This differs from classic structured function calls, giving you greater flexibility when interacting with external runtimes such as:

* code execution with sandboxes (Python, C++, Java, …)
* SQL databases
* Shell environments
* Configuration generators

Note that freeform function calling does NOT support parallel tool calling.

You can enable freeform function calling for a tool by annotating the string parameter with [`FreeformText`][pydantic_ai.tools.FreeformText]. The tool must take a single string argument (other than the runtime context) and the model must be one of the GPT-5 responses models. For example:

```python
from typing import Annotated

from pydantic_ai import Agent, FreeformText
from pydantic_ai.models.openai import OpenAIResponsesModel

model = OpenAIResponsesModel('gpt-5') # (1)!
agent = Agent(model)

@agent.tool_plain
def freeform_tool(sql: Annotated[str, FreeformText()]): ... # (2)!
```

1. The GPT-5 family (`gpt-5`, `gpt-5-mini`, `gpt-5-nano`) all support freeform function calling.
2. If the tool or model cannot be used with freeform function calling then it will be invoked in the normal way.

You can read more about this function calling style in the [OpenAI documentation](https://cookbook.openai.com/examples/gpt-5/gpt-5_new_params_and_tools#2-freeform-function-calling).

#### Context Free Grammar

A tool that queries an SQL database can only accept valid SQL. The freeform function calling of GPT-5 supports generation of valid SQL for this situation by constraining the generated text using a context free grammar.

A context‑free grammar is a collection of production rules that define which strings belong to a language. Each rule rewrites a non‑terminal symbol into a sequence of terminals (literal tokens) and/or other non‑terminals, independent of surrounding context—hence context‑free. CFGs can capture the syntax of most programming languages and, in OpenAI custom tools, serve as contracts that force the model to emit only strings that the grammar accepts.

##### Regular Expression

The grammar can be written as either a regular expression using [`RegexGrammar`][pydantic_ai.tools.RegexGrammar]:


```python
from typing import Annotated

from pydantic_ai import Agent, RegexGrammar
from pydantic_ai.models.openai import OpenAIResponsesModel

model = OpenAIResponsesModel('gpt-5') # (1)!
agent = Agent(model)

timestamp_pattern = r'^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]) (?:[01]\d|2[0-3]):[0-5]\d$'

@agent.tool_plain
def timestamp_accepting_tool(timestamp: Annotated[str, RegexGrammar(timestamp_pattern)]): ... # (2)!
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about supporting the Pydantic pattern Annotated[str, Field(pattern=...)] as well?

```

1. The GPT-5 family (`gpt-5`, `gpt-5-mini`, `gpt-5-nano`) all support freeform function calling with context free grammar constraints. Unfortunately `gpt-5-nano` often struggles with these calls.
2. If the tool or model cannot be used with freeform function calling then it will be invoked in the normal way, which may lead to invalid input.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be super cool if we could perform our own agent-side validation of the input by defining __get_pydantic_core_schema__ on the grammar classes. That way this could work even with models other than gpt-5, provided we share the grammar in the tool description and models understand what it means, by relying on the same retry behavior for JSON args: https://ai.pydantic.dev/agents/#reflection-and-self-correction. That'd be similar to Prompted Output mode and non-strict JSON tool args, where we're just relying on the model's understanding instead of strict token constraints.

If we do that, then most of this can be documented outside of OpenAI context.


##### LARK

Or as a [LARK](https://lark-parser.readthedocs.io/en/latest/how_to_use.html) grammar using [`LarkGrammar`][pydantic_ai.tools.LarkGrammar]:

```python
from typing import Annotated

from pydantic_ai import Agent, LarkGrammar
from pydantic_ai.models.openai import OpenAIResponsesModel

model = OpenAIResponsesModel('gpt-5') # (1)!
agent = Agent(model)

timestamp_grammar = r'''
start: timestamp

timestamp: YEAR "-" MONTH "-" DAY " " HOUR ":" MINUTE

%import common.DIGIT

YEAR: DIGIT DIGIT DIGIT DIGIT
MONTH: /(0[1-9]|1[0-2])/
DAY: /(0[1-9]|[12]\d|3[01])/
HOUR: /([01]\d|2[0-3])/
MINUTE: /[0-5]\d/
'''

@agent.tool_plain
def i_like_iso_dates(date: Annotated[str, LarkGrammar(timestamp_grammar)]): ... # (2)!
```

1. The GPT-5 family (`gpt-5`, `gpt-5-mini`, `gpt-5-nano`) all support freeform function calling with context free grammar constraints. Unfortunately `gpt-5-nano` often struggles with these calls.
2. If the tool or model cannot be used with freeform function calling then it will be invoked in the normal way, which may lead to invalid input.

There is a limit to the grammar complexity that GPT-5 supports, as such it is important to test your grammar.

Freeform function calling, with or without a context free grammar, can be used with the output type for the agent:

```python
from typing import Annotated

from pydantic_ai import Agent, LarkGrammar
from pydantic_ai.models.openai import OpenAIResponsesModel

sql_grammar = r'''
start: select_stmt
select_stmt: "SELECT" select_list "FROM" table ("WHERE" condition ("AND" condition)*)?
select_list: "*" | column ("," column)*
table: "users" | "orders"
column: "id" | "user_id" | "name" | "age"
condition: column ("=" | ">" | "<") (NUMBER | STRING)
%import common.NUMBER
%import common.ESCAPED_STRING -> STRING
%import common.WS
%ignore WS
''' # (1)!

model = OpenAIResponsesModel('gpt-5')
agent = Agent(model, output_type=Annotated[str, LarkGrammar(sql_grammar)])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's try to implement support for this feature in OutlinesModel as well, as I believe outlines supports similar grammar based constraints. That'd be a good way of verifying that the implementation is generic enough to work with providers other than OpenAI.

```

1. An inline SQL grammar definition would be quite extensive and so this simplified version has been written, you can find an example SQL grammar [in the openai example](https://cookbook.openai.com/examples/gpt-5/gpt-5_new_params_and_tools#33-example---sql-dialect--ms-sql-vs-postgresql). There are also example grammars in the [lark repo](https://github.com/lark-parser/lark/blob/master/examples/composition/json.lark). Remember that a simpler grammar that matches your DDL will be easier for GPT-5 to work with and will result in fewer semantically invalid results.

##### Best Practices

You can find recommended best practices in the [OpenAI Cookbook](https://cookbook.openai.com/examples/gpt-5/gpt-5_new_params_and_tools#35-best-practices).

* [Lark Docs](https://lark-parser.readthedocs.io/en/stable/)
* [Lark IDE](https://www.lark-parser.org/ide/)
* [OpenAI Cookbook on CFG](https://cookbook.openai.com/examples/gpt-5/gpt-5_new_params_and_tools#3-contextfree-grammar-cfg)

## OpenAI-compatible Models

Many providers and models are compatible with the OpenAI API, and can be used with `OpenAIChatModel` in Pydantic AI.
Expand Down
18 changes: 17 additions & 1 deletion pydantic_ai_slim/pydantic_ai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,19 @@
)
from .run import AgentRun, AgentRunResult, AgentRunResultEvent
from .settings import ModelSettings
from .tools import DeferredToolRequests, DeferredToolResults, RunContext, Tool, ToolApproved, ToolDefinition, ToolDenied
from .tools import (
DeferredToolRequests,
DeferredToolResults,
FreeformText,
LarkGrammar,
RegexGrammar,
RunContext,
TextFormat,
Tool,
ToolApproved,
ToolDefinition,
ToolDenied,
)
from .toolsets import (
AbstractToolset,
ApprovalRequiredToolset,
Expand Down Expand Up @@ -201,6 +213,10 @@
'DeferredToolResults',
'ToolApproved',
'ToolDenied',
'TextFormat',
'FreeformText',
'RegexGrammar',
'LarkGrammar',
# toolsets
'AbstractToolset',
'ApprovalRequiredToolset',
Expand Down
49 changes: 48 additions & 1 deletion pydantic_ai_slim/pydantic_ai/_function_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from ._utils import check_object_json_schema, is_async_callable, is_model_like, run_in_executor

if TYPE_CHECKING:
from .tools import DocstringFormat, ObjectJsonSchema
from .tools import DocstringFormat, ObjectJsonSchema, TextFormat


__all__ = ('function_schema',)
Expand All @@ -44,6 +44,8 @@ class FunctionSchema:
single_arg_name: str | None = None
positional_fields: list[str] = field(default_factory=list)
var_positional_field: str | None = None
text_format: TextFormat | None = None
"""Text format annotation extracted from a string parameter, if present."""

async def call(self, args_dict: dict[str, Any], ctx: RunContext[Any]) -> Any:
args, kwargs = self._call_args(args_dict, ctx)
Expand Down Expand Up @@ -111,6 +113,7 @@ def function_schema( # noqa: C901
positional_fields: list[str] = []
var_positional_field: str | None = None
decorators = _decorators.DecoratorInfos()
text_format: TextFormat | None = None

description, field_descriptions = doc_descriptions(function, sig, docstring_format=docstring_format)

Expand Down Expand Up @@ -147,6 +150,13 @@ def function_schema( # noqa: C901
errors.append('RunContext annotations can only be used as the first argument')
continue

# Extract text format annotation if present
if extracted_format := _extract_text_format(annotation):
if text_format is not None:
errors.append('Only one parameter may have a TextFormat annotation')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may be able to weaken this requirement and support multiple grammar-constrained str args, if we can do the validation on our side. Then we'd use OpenAI's custom tools functionality only if there is a single arg with a format annotation.

else:
text_format = extracted_format

field_name = p.name
if p.kind == Parameter.VAR_KEYWORD:
var_kwargs_schema = gen_schema.generate_schema(annotation)
Expand Down Expand Up @@ -222,6 +232,7 @@ def function_schema( # noqa: C901
takes_ctx=takes_ctx,
is_async=is_async_callable(function),
function=function,
text_format=text_format,
)


Expand Down Expand Up @@ -301,3 +312,39 @@ def _build_schema(
def _is_call_ctx(annotation: Any) -> bool:
"""Return whether the annotation is the `RunContext` class, parameterized or not."""
return annotation is RunContext or get_origin(annotation) is RunContext


def _extract_text_format(annotation: Any) -> TextFormat | None:
"""Extract a TextFormat annotation from an Annotated type hint.

Args:
annotation: The type annotation to check.

Returns:
The TextFormat instance if found, None otherwise.
"""
from typing import Annotated, get_args, get_origin
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move imports to the top of the file


from .tools import FreeformText, LarkGrammar, RegexGrammar
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should define the types in this file, and import them there


if get_origin(annotation) is not Annotated:
return None

args = get_args(annotation)
if len(args) < 2:
return None

# First arg is the base type, rest are metadata
base_type = args[0]
metadata = args[1:]

# Check if base type is str
if base_type is not str:
return None

# Look for TextFormat in metadata
for item in metadata:
if isinstance(item, (FreeformText, RegexGrammar, LarkGrammar)):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or if we make these all subclasses of one type that's defined here, the more interesting subtypes can be defined in tools

return item

return None
9 changes: 8 additions & 1 deletion pydantic_ai_slim/pydantic_ai/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
ToolOutput,
_OutputSpecItem, # type: ignore[reportPrivateUsage]
)
from .tools import GenerateToolJsonSchema, ObjectJsonSchema, ToolDefinition
from .tools import GenerateToolJsonSchema, ObjectJsonSchema, TextFormat, ToolDefinition
from .toolsets.abstract import AbstractToolset, ToolsetTool

if TYPE_CHECKING:
Expand Down Expand Up @@ -550,12 +550,17 @@ def __init__(
description: str | None = None,
strict: bool | None = None,
):
text_format: TextFormat | None = None

if inspect.isfunction(output) or inspect.ismethod(output):
self._function_schema = _function_schema.function_schema(output, GenerateToolJsonSchema)
self.validator = self._function_schema.validator
json_schema = self._function_schema.json_schema
json_schema['description'] = self._function_schema.description
text_format = self._function_schema.text_format
else:
# Extract text_format from Annotated type if present
text_format = _function_schema._extract_text_format(output)
json_schema_type_adapter: TypeAdapter[Any]
validation_type_adapter: TypeAdapter[Any]
if _utils.is_model_like(output):
Expand Down Expand Up @@ -604,6 +609,7 @@ def __init__(
description=description,
json_schema=json_schema,
strict=strict,
text_format=text_format,
)
)

Expand Down Expand Up @@ -938,6 +944,7 @@ def build(
description=description,
parameters_json_schema=object_def.json_schema,
strict=object_def.strict,
text_format=object_def.text_format,
outer_typed_dict_key=processor.outer_typed_dict_key,
kind='output',
)
Expand Down
Loading
Loading