Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
89 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
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
125 changes: 125 additions & 0 deletions docs/models/openai.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,131 @@ print(result2.output)
#> This is an excellent joke invented by Samuel Colvin, it needs no explanation.
```

### Freeform Function Calling

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 using the `text_format` parameter when creating your tool. To use this 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 pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIResponsesModel

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

@agent.tool_plain(text_format='text') # (2)!
def freeform_tool(sql: str): ...
```

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:
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 have headings for Regular Expressions and LARK, so they're shown in the ToC on the right

Copy link
Contributor Author

Choose a reason for hiding this comment

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

addressed in b49cd81



```python
from pydantic_ai import Agent, FunctionTextFormat
from pydantic_ai.models.openai import OpenAIResponsesModel

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

timestamp_grammar_definition = 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(text_format=FunctionTextFormat(syntax='regex', grammar=timestamp_grammar_definition)) # (2)!
def timestamp_accepting_tool(timestamp: str): ...
```

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.

##### LARK

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

```python
from pydantic_ai import Agent, FunctionTextFormat
from pydantic_ai.models.openai import OpenAIResponsesModel

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

timestamp_grammar_definition = 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(text_format=FunctionTextFormat(syntax='lark', grammar=timestamp_grammar_definition)) # (2)!
def i_like_iso_dates(date: str): ...
```

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 tool for the agent:

```python
from pydantic_ai import Agent, FunctionTextFormat
from pydantic_ai.models.openai import OpenAIResponsesModel
from pydantic_ai.output import ToolOutput

sql_grammar_definition = 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)!

output_tool = ToolOutput(str, text_format=FunctionTextFormat(syntax='lark', grammar=sql_grammar_definition))
model = OpenAIResponsesModel('gpt-5')
agent = Agent(model, output_type=output_tool)
```

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
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd prefer to link to OpenAI's docs instead of having to keep this up to date


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
16 changes: 15 additions & 1 deletion pydantic_ai_slim/pydantic_ai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,18 @@
)
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,
LarkTextFormat,
RegexTextFormat,
RunContext,
TextFormat,
Tool,
ToolApproved,
ToolDefinition,
ToolDenied,
)
from .toolsets import (
AbstractToolset,
ApprovalRequiredToolset,
Expand Down Expand Up @@ -191,6 +202,9 @@
'DeferredToolResults',
'ToolApproved',
'ToolDenied',
'TextFormat',
'RegexTextFormat',
'LarkTextFormat',
# toolsets
'AbstractToolset',
'ApprovalRequiredToolset',
Expand Down
11 changes: 9 additions & 2 deletions 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 @@ -656,6 +656,7 @@ def __init__(
name: str | None = None,
description: str | None = None,
strict: bool | None = None,
text_format: Literal['plain'] | TextFormat | None = None,
):
if inspect.isfunction(output) or inspect.ismethod(output):
self._function_schema = _function_schema.function_schema(output, GenerateToolJsonSchema)
Expand Down Expand Up @@ -711,6 +712,7 @@ def __init__(
description=description,
json_schema=json_schema,
strict=strict,
text_format=text_format,
)
)

Expand Down Expand Up @@ -979,19 +981,23 @@ def build(
name = None
description = None
strict = None
text_format = None
if isinstance(output, ToolOutput):
# do we need to error on conflicts here? (DavidM): If this is internal maybe doesn't matter, if public, use overloads
name = output.name
description = output.description
strict = output.strict
text_format = output.text_format

output = output.output

description = description or default_description
if strict is None:
strict = default_strict

processor = ObjectOutputProcessor(output=output, description=description, strict=strict)
processor = ObjectOutputProcessor(
output=output, description=description, strict=strict, text_format=text_format
)
object_def = processor.object_def

if name is None:
Expand All @@ -1016,6 +1022,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
13 changes: 12 additions & 1 deletion pydantic_ai_slim/pydantic_ai/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Sequence
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager, contextmanager
from contextvars import ContextVar
from typing import TYPE_CHECKING, Any, ClassVar, cast, overload
from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast, overload

from opentelemetry.trace import NoOpTracer, use_span
from pydantic.json_schema import GenerateJsonSchema
Expand Down Expand Up @@ -50,6 +50,7 @@
DocstringFormat,
GenerateToolJsonSchema,
RunContext,
TextFormat,
Tool,
ToolFuncContext,
ToolFuncEither,
Expand Down Expand Up @@ -1012,6 +1013,7 @@ def tool(
require_parameter_descriptions: bool = False,
schema_generator: type[GenerateJsonSchema] = GenerateToolJsonSchema,
strict: bool | None = None,
text_format: Literal['plain'] | TextFormat | None = None,
sequential: bool = False,
requires_approval: bool = False,
metadata: dict[str, Any] | None = None,
Expand All @@ -1029,6 +1031,7 @@ def tool(
require_parameter_descriptions: bool = False,
schema_generator: type[GenerateJsonSchema] = GenerateToolJsonSchema,
strict: bool | None = None,
text_format: Literal['plain'] | TextFormat | None = None,
sequential: bool = False,
requires_approval: bool = False,
metadata: dict[str, Any] | None = None,
Expand Down Expand Up @@ -1076,6 +1079,8 @@ async def spam(ctx: RunContext[str], y: float) -> float:
schema_generator: The JSON schema generator class to use for this tool. Defaults to `GenerateToolJsonSchema`.
strict: Whether to enforce JSON schema compliance (only affects OpenAI).
See [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] for more info.
text_format: Used to invoke the function using freeform function calling (only affects OpenAI).
See [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] for more info.
sequential: Whether the function requires a sequential/serial execution environment. Defaults to False.
requires_approval: Whether this tool requires human-in-the-loop approval. Defaults to False.
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
Expand All @@ -1096,6 +1101,7 @@ def tool_decorator(
require_parameter_descriptions=require_parameter_descriptions,
schema_generator=schema_generator,
strict=strict,
text_format=text_format,
sequential=sequential,
requires_approval=requires_approval,
metadata=metadata,
Expand All @@ -1119,6 +1125,7 @@ def tool_plain(
require_parameter_descriptions: bool = False,
schema_generator: type[GenerateJsonSchema] = GenerateToolJsonSchema,
strict: bool | None = None,
text_format: Literal['plain'] | TextFormat | None = None,
sequential: bool = False,
requires_approval: bool = False,
metadata: dict[str, Any] | None = None,
Expand All @@ -1136,6 +1143,7 @@ def tool_plain(
require_parameter_descriptions: bool = False,
schema_generator: type[GenerateJsonSchema] = GenerateToolJsonSchema,
strict: bool | None = None,
text_format: Literal['plain'] | TextFormat | None = None,
sequential: bool = False,
requires_approval: bool = False,
metadata: dict[str, Any] | None = None,
Expand Down Expand Up @@ -1183,6 +1191,8 @@ async def spam(ctx: RunContext[str]) -> float:
schema_generator: The JSON schema generator class to use for this tool. Defaults to `GenerateToolJsonSchema`.
strict: Whether to enforce JSON schema compliance (only affects OpenAI).
See [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] for more info.
text_format: Used to invoke the function using freeform function calling (only affects OpenAI).
See [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] for more info.
sequential: Whether the function requires a sequential/serial execution environment. Defaults to False.
requires_approval: Whether this tool requires human-in-the-loop approval. Defaults to False.
See the [tools documentation](../deferred-tools.md#human-in-the-loop-tool-approval) for more info.
Expand All @@ -1201,6 +1211,7 @@ def tool_decorator(func_: ToolFuncPlain[ToolParams]) -> ToolFuncPlain[ToolParams
require_parameter_descriptions=require_parameter_descriptions,
schema_generator=schema_generator,
strict=strict,
text_format=text_format,
sequential=sequential,
requires_approval=requires_approval,
metadata=metadata,
Expand Down
Loading
Loading