pydantic-ai-slim 0.0.52__tar.gz → 0.0.53__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/.gitignore +1 -0
  2. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/PKG-INFO +3 -3
  3. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/_agent_graph.py +1 -0
  4. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/_cli.py +3 -5
  5. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/_utils.py +5 -1
  6. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/agent.py +30 -2
  7. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/models/__init__.py +9 -0
  8. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/models/gemini.py +12 -2
  9. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/models/openai.py +151 -6
  10. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/settings.py +0 -5
  11. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/tools.py +27 -4
  12. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/README.md +0 -0
  13. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/__init__.py +0 -0
  14. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/__main__.py +0 -0
  15. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/_griffe.py +0 -0
  16. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/_parts_manager.py +0 -0
  17. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/_pydantic.py +0 -0
  18. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/_result.py +0 -0
  19. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/_system_prompt.py +0 -0
  20. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/common_tools/__init__.py +0 -0
  21. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/common_tools/duckduckgo.py +0 -0
  22. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/common_tools/tavily.py +0 -0
  23. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/exceptions.py +0 -0
  24. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/format_as_xml.py +0 -0
  25. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/mcp.py +0 -0
  26. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/messages.py +0 -0
  27. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/models/anthropic.py +0 -0
  28. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/models/bedrock.py +0 -0
  29. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/models/cohere.py +0 -0
  30. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/models/fallback.py +0 -0
  31. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/models/function.py +0 -0
  32. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/models/groq.py +0 -0
  33. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/models/instrumented.py +0 -0
  34. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/models/mistral.py +0 -0
  35. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/models/test.py +0 -0
  36. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/models/wrapper.py +0 -0
  37. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/providers/__init__.py +0 -0
  38. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/providers/anthropic.py +0 -0
  39. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/providers/azure.py +0 -0
  40. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/providers/bedrock.py +0 -0
  41. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/providers/cohere.py +0 -0
  42. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/providers/deepseek.py +0 -0
  43. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/providers/google_gla.py +0 -0
  44. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/providers/google_vertex.py +0 -0
  45. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/providers/groq.py +0 -0
  46. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/providers/mistral.py +0 -0
  47. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/providers/openai.py +0 -0
  48. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/py.typed +0 -0
  49. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/result.py +0 -0
  50. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pydantic_ai/usage.py +0 -0
  51. {pydantic_ai_slim-0.0.52 → pydantic_ai_slim-0.0.53}/pyproject.toml +0 -0
@@ -16,3 +16,4 @@ examples/pydantic_ai_examples/.chat_app_messages.sqlite
16
16
  /question_graph_history.json
17
17
  /docs-site/.wrangler/
18
18
  /CLAUDE.md
19
+ node_modules/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydantic-ai-slim
3
- Version: 0.0.52
3
+ Version: 0.0.53
4
4
  Summary: Agent Framework / shim to use Pydantic with LLMs, slim package
5
5
  Author-email: Samuel Colvin <samuel@pydantic.dev>
6
6
  License-Expression: MIT
@@ -29,7 +29,7 @@ Requires-Dist: exceptiongroup; python_version < '3.11'
29
29
  Requires-Dist: griffe>=1.3.2
30
30
  Requires-Dist: httpx>=0.27
31
31
  Requires-Dist: opentelemetry-api>=1.28.0
32
- Requires-Dist: pydantic-graph==0.0.52
32
+ Requires-Dist: pydantic-graph==0.0.53
33
33
  Requires-Dist: pydantic>=2.10
34
34
  Requires-Dist: typing-inspection>=0.4.0
35
35
  Provides-Extra: anthropic
@@ -45,7 +45,7 @@ Requires-Dist: cohere>=5.13.11; (platform_system != 'Emscripten') and extra == '
45
45
  Provides-Extra: duckduckgo
46
46
  Requires-Dist: duckduckgo-search>=7.0.0; extra == 'duckduckgo'
47
47
  Provides-Extra: evals
48
- Requires-Dist: pydantic-evals==0.0.52; extra == 'evals'
48
+ Requires-Dist: pydantic-evals==0.0.53; extra == 'evals'
49
49
  Provides-Extra: groq
50
50
  Requires-Dist: groq>=0.15.0; extra == 'groq'
51
51
  Provides-Extra: logfire
@@ -311,6 +311,7 @@ class ModelRequestNode(AgentNode[DepsT, NodeRunEndT]):
311
311
  return self._result
312
312
 
313
313
  model_settings, model_request_parameters = await self._prepare_request(ctx)
314
+ model_request_parameters = ctx.deps.model.customize_request_parameters(model_request_parameters)
314
315
  model_response, request_usage = await ctx.deps.model.request(
315
316
  ctx.state.message_history, model_settings, model_request_parameters
316
317
  )
@@ -15,7 +15,7 @@ from typing_inspection.introspection import get_literal_values
15
15
 
16
16
  from pydantic_ai.agent import Agent
17
17
  from pydantic_ai.exceptions import UserError
18
- from pydantic_ai.messages import ModelMessage, PartDeltaEvent, TextPartDelta
18
+ from pydantic_ai.messages import ModelMessage
19
19
  from pydantic_ai.models import KnownModelName, infer_model
20
20
 
21
21
  try:
@@ -222,10 +222,8 @@ async def ask_agent(
222
222
  status.stop() # stopping multiple times is idempotent
223
223
  stack.enter_context(live) # entering multiple times is idempotent
224
224
 
225
- async for event in handle_stream:
226
- if isinstance(event, PartDeltaEvent) and isinstance(event.delta, TextPartDelta):
227
- content += event.delta.content_delta
228
- live.update(Markdown(content, code_theme=code_theme))
225
+ async for content in handle_stream.stream_output():
226
+ live.update(Markdown(content, code_theme=code_theme))
229
227
 
230
228
  assert agent_run.result is not None
231
229
  return agent_run.result.all_messages()
@@ -50,7 +50,11 @@ def check_object_json_schema(schema: JsonSchemaValue) -> ObjectJsonSchema:
50
50
  if schema.get('type') == 'object':
51
51
  return schema
52
52
  elif schema.get('$ref') is not None:
53
- return schema.get('$defs', {}).get(schema['$ref'][8:]) # This removes the initial "#/$defs/".
53
+ maybe_result = schema.get('$defs', {}).get(schema['$ref'][8:]) # This removes the initial "#/$defs/".
54
+
55
+ if "'$ref': '#/$defs/" in str(maybe_result):
56
+ return schema # We can't remove the $defs because the schema contains other references
57
+ return maybe_result
54
58
  else:
55
59
  raise UserError('Schema must be an object')
56
60
 
@@ -940,6 +940,7 @@ class Agent(Generic[AgentDepsT, ResultDataT]):
940
940
  docstring_format: DocstringFormat = 'auto',
941
941
  require_parameter_descriptions: bool = False,
942
942
  schema_generator: type[GenerateJsonSchema] = GenerateToolJsonSchema,
943
+ strict: bool | None = None,
943
944
  ) -> Callable[[ToolFuncContext[AgentDepsT, ToolParams]], ToolFuncContext[AgentDepsT, ToolParams]]: ...
944
945
 
945
946
  def tool(
@@ -953,6 +954,7 @@ class Agent(Generic[AgentDepsT, ResultDataT]):
953
954
  docstring_format: DocstringFormat = 'auto',
954
955
  require_parameter_descriptions: bool = False,
955
956
  schema_generator: type[GenerateJsonSchema] = GenerateToolJsonSchema,
957
+ strict: bool | None = None,
956
958
  ) -> Any:
957
959
  """Decorator to register a tool function which takes [`RunContext`][pydantic_ai.tools.RunContext] as its first argument.
958
960
 
@@ -995,6 +997,8 @@ class Agent(Generic[AgentDepsT, ResultDataT]):
995
997
  Defaults to `'auto'`, such that the format is inferred from the structure of the docstring.
996
998
  require_parameter_descriptions: If True, raise an error if a parameter description is missing. Defaults to False.
997
999
  schema_generator: The JSON schema generator class to use for this tool. Defaults to `GenerateToolJsonSchema`.
1000
+ strict: Whether to enforce JSON schema compliance (only affects OpenAI).
1001
+ See [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] for more info.
998
1002
  """
999
1003
  if func is None:
1000
1004
 
@@ -1011,6 +1015,7 @@ class Agent(Generic[AgentDepsT, ResultDataT]):
1011
1015
  docstring_format,
1012
1016
  require_parameter_descriptions,
1013
1017
  schema_generator,
1018
+ strict,
1014
1019
  )
1015
1020
  return func_
1016
1021
 
@@ -1018,7 +1023,15 @@ class Agent(Generic[AgentDepsT, ResultDataT]):
1018
1023
  else:
1019
1024
  # noinspection PyTypeChecker
1020
1025
  self._register_function(
1021
- func, True, name, retries, prepare, docstring_format, require_parameter_descriptions, schema_generator
1026
+ func,
1027
+ True,
1028
+ name,
1029
+ retries,
1030
+ prepare,
1031
+ docstring_format,
1032
+ require_parameter_descriptions,
1033
+ schema_generator,
1034
+ strict,
1022
1035
  )
1023
1036
  return func
1024
1037
 
@@ -1036,6 +1049,7 @@ class Agent(Generic[AgentDepsT, ResultDataT]):
1036
1049
  docstring_format: DocstringFormat = 'auto',
1037
1050
  require_parameter_descriptions: bool = False,
1038
1051
  schema_generator: type[GenerateJsonSchema] = GenerateToolJsonSchema,
1052
+ strict: bool | None = None,
1039
1053
  ) -> Callable[[ToolFuncPlain[ToolParams]], ToolFuncPlain[ToolParams]]: ...
1040
1054
 
1041
1055
  def tool_plain(
@@ -1049,6 +1063,7 @@ class Agent(Generic[AgentDepsT, ResultDataT]):
1049
1063
  docstring_format: DocstringFormat = 'auto',
1050
1064
  require_parameter_descriptions: bool = False,
1051
1065
  schema_generator: type[GenerateJsonSchema] = GenerateToolJsonSchema,
1066
+ strict: bool | None = None,
1052
1067
  ) -> Any:
1053
1068
  """Decorator to register a tool function which DOES NOT take `RunContext` as an argument.
1054
1069
 
@@ -1091,6 +1106,8 @@ class Agent(Generic[AgentDepsT, ResultDataT]):
1091
1106
  Defaults to `'auto'`, such that the format is inferred from the structure of the docstring.
1092
1107
  require_parameter_descriptions: If True, raise an error if a parameter description is missing. Defaults to False.
1093
1108
  schema_generator: The JSON schema generator class to use for this tool. Defaults to `GenerateToolJsonSchema`.
1109
+ strict: Whether to enforce JSON schema compliance (only affects OpenAI).
1110
+ See [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] for more info.
1094
1111
  """
1095
1112
  if func is None:
1096
1113
 
@@ -1105,13 +1122,22 @@ class Agent(Generic[AgentDepsT, ResultDataT]):
1105
1122
  docstring_format,
1106
1123
  require_parameter_descriptions,
1107
1124
  schema_generator,
1125
+ strict,
1108
1126
  )
1109
1127
  return func_
1110
1128
 
1111
1129
  return tool_decorator
1112
1130
  else:
1113
1131
  self._register_function(
1114
- func, False, name, retries, prepare, docstring_format, require_parameter_descriptions, schema_generator
1132
+ func,
1133
+ False,
1134
+ name,
1135
+ retries,
1136
+ prepare,
1137
+ docstring_format,
1138
+ require_parameter_descriptions,
1139
+ schema_generator,
1140
+ strict,
1115
1141
  )
1116
1142
  return func
1117
1143
 
@@ -1125,6 +1151,7 @@ class Agent(Generic[AgentDepsT, ResultDataT]):
1125
1151
  docstring_format: DocstringFormat,
1126
1152
  require_parameter_descriptions: bool,
1127
1153
  schema_generator: type[GenerateJsonSchema],
1154
+ strict: bool | None,
1128
1155
  ) -> None:
1129
1156
  """Private utility to register a function as a tool."""
1130
1157
  retries_ = retries if retries is not None else self._default_retries
@@ -1137,6 +1164,7 @@ class Agent(Generic[AgentDepsT, ResultDataT]):
1137
1164
  docstring_format=docstring_format,
1138
1165
  require_parameter_descriptions=require_parameter_descriptions,
1139
1166
  schema_generator=schema_generator,
1167
+ strict=strict,
1140
1168
  )
1141
1169
  self._register_tool(tool)
1142
1170
 
@@ -274,6 +274,15 @@ class Model(ABC):
274
274
  # noinspection PyUnreachableCode
275
275
  yield # pragma: no cover
276
276
 
277
+ def customize_request_parameters(self, model_request_parameters: ModelRequestParameters) -> ModelRequestParameters:
278
+ """Customize the request parameters for the model.
279
+
280
+ This method can be overridden by subclasses to modify the request parameters before sending them to the model.
281
+ In particular, this method can be used to make modifications to the generated tool JSON schemas if necessary
282
+ for vendor/model-specific reasons.
283
+ """
284
+ return model_request_parameters
285
+
277
286
  @property
278
287
  @abstractmethod
279
288
  def model_name(self) -> str:
@@ -5,7 +5,7 @@ import re
5
5
  from collections.abc import AsyncIterator, Sequence
6
6
  from contextlib import asynccontextmanager
7
7
  from copy import deepcopy
8
- from dataclasses import dataclass, field
8
+ from dataclasses import dataclass, field, replace
9
9
  from datetime import datetime
10
10
  from typing import Annotated, Any, Literal, Protocol, Union, cast
11
11
  from uuid import uuid4
@@ -152,6 +152,16 @@ class GeminiModel(Model):
152
152
  ) as http_response:
153
153
  yield await self._process_streamed_response(http_response)
154
154
 
155
+ def customize_request_parameters(self, model_request_parameters: ModelRequestParameters) -> ModelRequestParameters:
156
+ def _customize_tool_def(t: ToolDefinition):
157
+ return replace(t, parameters_json_schema=_GeminiJsonSchema(t.parameters_json_schema).simplify())
158
+
159
+ return ModelRequestParameters(
160
+ function_tools=[_customize_tool_def(tool) for tool in model_request_parameters.function_tools],
161
+ allow_text_result=model_request_parameters.allow_text_result,
162
+ result_tools=[_customize_tool_def(tool) for tool in model_request_parameters.result_tools],
163
+ )
164
+
155
165
  @property
156
166
  def model_name(self) -> GeminiModelName:
157
167
  """The model name."""
@@ -640,7 +650,7 @@ class _GeminiFunction(TypedDict):
640
650
 
641
651
 
642
652
  def _function_from_abstract_tool(tool: ToolDefinition) -> _GeminiFunction:
643
- json_schema = _GeminiJsonSchema(tool.parameters_json_schema).simplify()
653
+ json_schema = tool.parameters_json_schema
644
654
  f = _GeminiFunction(name=tool.name, description=tool.description)
645
655
  if json_schema.get('properties'):
646
656
  f['parameters'] = json_schema
@@ -4,9 +4,9 @@ import base64
4
4
  import warnings
5
5
  from collections.abc import AsyncIterable, AsyncIterator, Sequence
6
6
  from contextlib import asynccontextmanager
7
- from dataclasses import dataclass, field
7
+ from dataclasses import dataclass, field, replace
8
8
  from datetime import datetime, timezone
9
- from typing import Literal, Union, cast, overload
9
+ from typing import Any, Literal, Union, cast, overload
10
10
 
11
11
  from typing_extensions import assert_never
12
12
 
@@ -150,7 +150,7 @@ class OpenAIModel(Model):
150
150
  """
151
151
 
152
152
  client: AsyncOpenAI = field(repr=False)
153
- system_prompt_role: OpenAISystemPromptRole | None = field(default=None)
153
+ system_prompt_role: OpenAISystemPromptRole | None = field(default=None, repr=False)
154
154
 
155
155
  _model_name: OpenAIModelName = field(repr=False)
156
156
  _system: str = field(default='openai', repr=False)
@@ -208,6 +208,9 @@ class OpenAIModel(Model):
208
208
  async with response:
209
209
  yield await self._process_streamed_response(response)
210
210
 
211
+ def customize_request_parameters(self, model_request_parameters: ModelRequestParameters) -> ModelRequestParameters:
212
+ return _customize_request_parameters(model_request_parameters)
213
+
211
214
  @property
212
215
  def model_name(self) -> OpenAIModelName:
213
216
  """The model name."""
@@ -351,7 +354,7 @@ class OpenAIModel(Model):
351
354
 
352
355
  @staticmethod
353
356
  def _map_tool_definition(f: ToolDefinition) -> chat.ChatCompletionToolParam:
354
- return {
357
+ tool_param: chat.ChatCompletionToolParam = {
355
358
  'type': 'function',
356
359
  'function': {
357
360
  'name': f.name,
@@ -359,6 +362,9 @@ class OpenAIModel(Model):
359
362
  'parameters': f.parameters_json_schema,
360
363
  },
361
364
  }
365
+ if f.strict:
366
+ tool_param['function']['strict'] = f.strict
367
+ return tool_param
362
368
 
363
369
  async def _map_user_message(self, message: ModelRequest) -> AsyncIterable[chat.ChatCompletionMessageParam]:
364
370
  for part in message.parts:
@@ -522,6 +528,9 @@ class OpenAIResponsesModel(Model):
522
528
  async with response:
523
529
  yield await self._process_streamed_response(response)
524
530
 
531
+ def customize_request_parameters(self, model_request_parameters: ModelRequestParameters) -> ModelRequestParameters:
532
+ return _customize_request_parameters(model_request_parameters)
533
+
525
534
  def _process_response(self, response: responses.Response) -> ModelResponse:
526
535
  """Process a non-streamed response, and prepare a message to return."""
527
536
  timestamp = datetime.fromtimestamp(response.created_at, tz=timezone.utc)
@@ -630,8 +639,8 @@ class OpenAIResponsesModel(Model):
630
639
  'parameters': f.parameters_json_schema,
631
640
  'type': 'function',
632
641
  'description': f.description,
633
- # TODO(Marcelo): We should make this configurable, and if True, set `additionalProperties` to False.
634
- 'strict': False,
642
+ # NOTE: f.strict should already be a boolean thanks to customize_request_parameters
643
+ 'strict': f.strict or False,
635
644
  }
636
645
 
637
646
  async def _map_message(self, messages: list[ModelMessage]) -> tuple[str, list[responses.ResponseInputItemParam]]:
@@ -907,3 +916,139 @@ def _map_usage(response: chat.ChatCompletion | ChatCompletionChunk | responses.R
907
916
  total_tokens=response_usage.total_tokens,
908
917
  details=details,
909
918
  )
919
+
920
+
921
+ class _StrictSchemaHelper:
922
+ def make_schema_strict(self, schema: dict[str, Any]) -> dict[str, Any]:
923
+ """Recursively handle the schema to make it compatible with OpenAI strict mode.
924
+
925
+ See https://platform.openai.com/docs/guides/function-calling?api-mode=responses#strict-mode for more details,
926
+ but this basically just requires:
927
+ * `additionalProperties` must be set to false for each object in the parameters
928
+ * all fields in properties must be marked as required
929
+ """
930
+ assert isinstance(schema, dict), 'Schema must be a dictionary, this is probably a bug'
931
+
932
+ # Create a copy to avoid modifying the original schema
933
+ schema = schema.copy()
934
+
935
+ # Handle $defs
936
+ if defs := schema.get('$defs'):
937
+ schema['$defs'] = {k: self.make_schema_strict(v) for k, v in defs.items()}
938
+
939
+ # Process schema based on its type
940
+ schema_type = schema.get('type')
941
+ if schema_type == 'object':
942
+ # Handle object type by setting additionalProperties to false
943
+ # and adding all properties to required list
944
+ self._make_object_schema_strict(schema)
945
+ elif schema_type == 'array':
946
+ # Handle array types by processing their items
947
+ if 'items' in schema:
948
+ items: Any = schema['items']
949
+ schema['items'] = self.make_schema_strict(items)
950
+ if 'prefixItems' in schema:
951
+ prefix_items: list[Any] = schema['prefixItems']
952
+ schema['prefixItems'] = [self.make_schema_strict(item) for item in prefix_items]
953
+
954
+ elif schema_type in {'string', 'number', 'integer', 'boolean', 'null'}:
955
+ pass # Primitive types need no special handling
956
+ elif 'oneOf' in schema:
957
+ schema['oneOf'] = [self.make_schema_strict(item) for item in schema['oneOf']]
958
+ elif 'anyOf' in schema:
959
+ schema['anyOf'] = [self.make_schema_strict(item) for item in schema['anyOf']]
960
+
961
+ return schema
962
+
963
+ def _make_object_schema_strict(self, schema: dict[str, Any]) -> None:
964
+ schema['additionalProperties'] = False
965
+
966
+ # Handle patternProperties; note this may not be compatible with strict mode but is included for completeness
967
+ if 'patternProperties' in schema and isinstance(schema['patternProperties'], dict):
968
+ pattern_props: dict[str, Any] = schema['patternProperties']
969
+ schema['patternProperties'] = {str(k): self.make_schema_strict(v) for k, v in pattern_props.items()}
970
+
971
+ # Handle properties — update their schemas recursively, and make all properties required
972
+ if 'properties' in schema and isinstance(schema['properties'], dict):
973
+ properties: dict[str, Any] = schema['properties']
974
+ schema['properties'] = {k: self.make_schema_strict(v) for k, v in properties.items()}
975
+ schema['required'] = list(properties.keys())
976
+
977
+ def is_schema_strict(self, schema: dict[str, Any]) -> bool:
978
+ """Check if the schema is strict-mode-compatible.
979
+
980
+ A schema is compatible if:
981
+ * `additionalProperties` is set to false for each object in the parameters
982
+ * all fields in properties are marked as required
983
+
984
+ See https://platform.openai.com/docs/guides/function-calling?api-mode=responses#strict-mode for more details.
985
+ """
986
+ assert isinstance(schema, dict), 'Schema must be a dictionary, this is probably a bug'
987
+
988
+ # Note that checking the defs first is usually the fastest way to proceed, but
989
+ # it makes it hard/impossible to hit coverage below, hence all the pragma no covers.
990
+ # I still included the handling below because I'm not _confident_ those code paths can't be hit.
991
+ if defs := schema.get('$defs'):
992
+ if not all(self.is_schema_strict(v) for v in defs.values()): # pragma: no branch
993
+ return False
994
+
995
+ schema_type = schema.get('type')
996
+ if schema_type == 'object':
997
+ if not self._is_object_schema_strict(schema):
998
+ return False
999
+ elif schema_type == 'array':
1000
+ if 'items' in schema:
1001
+ items: Any = schema['items']
1002
+ if not self.is_schema_strict(items): # pragma: no cover
1003
+ return False
1004
+ if 'prefixItems' in schema:
1005
+ prefix_items: list[Any] = schema['prefixItems']
1006
+ if not all(self.is_schema_strict(item) for item in prefix_items): # pragma: no cover
1007
+ return False
1008
+ elif schema_type in {'string', 'number', 'integer', 'boolean', 'null'}:
1009
+ pass
1010
+ elif 'oneOf' in schema: # pragma: no cover
1011
+ if not all(self.is_schema_strict(item) for item in schema['oneOf']):
1012
+ return False
1013
+
1014
+ elif 'anyOf' in schema: # pragma: no cover
1015
+ if not all(self.is_schema_strict(item) for item in schema['anyOf']):
1016
+ return False
1017
+
1018
+ return True
1019
+
1020
+ def _is_object_schema_strict(self, schema: dict[str, Any]) -> bool:
1021
+ """Check if the schema is an object and has additionalProperties set to false."""
1022
+ if schema.get('additionalProperties') is not False:
1023
+ return False
1024
+ if 'properties' not in schema: # pragma: no cover
1025
+ return False
1026
+ if 'required' not in schema: # pragma: no cover
1027
+ return False
1028
+
1029
+ for k, v in schema['properties'].items():
1030
+ if k not in schema['required']:
1031
+ return False
1032
+ if not self.is_schema_strict(v): # pragma: no cover
1033
+ return False
1034
+
1035
+ return True
1036
+
1037
+
1038
+ def _customize_request_parameters(model_request_parameters: ModelRequestParameters) -> ModelRequestParameters:
1039
+ """Customize the request parameters for OpenAI models."""
1040
+
1041
+ def _customize_tool_def(t: ToolDefinition):
1042
+ if t.strict is True:
1043
+ parameters_json_schema = _StrictSchemaHelper().make_schema_strict(t.parameters_json_schema)
1044
+ return replace(t, parameters_json_schema=parameters_json_schema)
1045
+ elif t.strict is None:
1046
+ strict = _StrictSchemaHelper().is_schema_strict(t.parameters_json_schema)
1047
+ return replace(t, strict=strict)
1048
+ return t
1049
+
1050
+ return ModelRequestParameters(
1051
+ function_tools=[_customize_tool_def(tool) for tool in model_request_parameters.function_tools],
1052
+ allow_text_result=model_request_parameters.allow_text_result,
1053
+ result_tools=[_customize_tool_def(tool) for tool in model_request_parameters.result_tools],
1054
+ )
@@ -1,13 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING
4
-
5
3
  from httpx import Timeout
6
4
  from typing_extensions import TypedDict
7
5
 
8
- if TYPE_CHECKING:
9
- pass
10
-
11
6
 
12
7
  class ModelSettings(TypedDict, total=False):
13
8
  """Settings to configure an LLM.
@@ -173,12 +173,18 @@ class Tool(Generic[AgentDepsT]):
173
173
  prepare: ToolPrepareFunc[AgentDepsT] | None
174
174
  docstring_format: DocstringFormat
175
175
  require_parameter_descriptions: bool
176
+ strict: bool | None
176
177
  _is_async: bool = field(init=False)
177
178
  _single_arg_name: str | None = field(init=False)
178
179
  _positional_fields: list[str] = field(init=False)
179
180
  _var_positional_field: str | None = field(init=False)
180
181
  _validator: SchemaValidator = field(init=False, repr=False)
181
- _parameters_json_schema: ObjectJsonSchema = field(init=False)
182
+ _base_parameters_json_schema: ObjectJsonSchema = field(init=False)
183
+ """
184
+ The base JSON schema for the tool's parameters.
185
+
186
+ This schema may be modified by the `prepare` function or by the Model class prior to including it in an API request.
187
+ """
182
188
 
183
189
  # TODO: Move this state off the Tool class, which is otherwise stateless.
184
190
  # This should be tracked inside a specific agent run, not the tool.
@@ -196,6 +202,7 @@ class Tool(Generic[AgentDepsT]):
196
202
  docstring_format: DocstringFormat = 'auto',
197
203
  require_parameter_descriptions: bool = False,
198
204
  schema_generator: type[GenerateJsonSchema] = GenerateToolJsonSchema,
205
+ strict: bool | None = None,
199
206
  ):
200
207
  """Create a new tool instance.
201
208
 
@@ -246,6 +253,8 @@ class Tool(Generic[AgentDepsT]):
246
253
  Defaults to `'auto'`, such that the format is inferred from the structure of the docstring.
247
254
  require_parameter_descriptions: If True, raise an error if a parameter description is missing. Defaults to False.
248
255
  schema_generator: The JSON schema generator class to use. Defaults to `GenerateToolJsonSchema`.
256
+ strict: Whether to enforce JSON schema compliance (only affects OpenAI).
257
+ See [`ToolDefinition`][pydantic_ai.tools.ToolDefinition] for more info.
249
258
  """
250
259
  if takes_ctx is None:
251
260
  takes_ctx = _pydantic.takes_ctx(function)
@@ -261,12 +270,13 @@ class Tool(Generic[AgentDepsT]):
261
270
  self.prepare = prepare
262
271
  self.docstring_format = docstring_format
263
272
  self.require_parameter_descriptions = require_parameter_descriptions
273
+ self.strict = strict
264
274
  self._is_async = inspect.iscoroutinefunction(self.function)
265
275
  self._single_arg_name = f['single_arg_name']
266
276
  self._positional_fields = f['positional_fields']
267
277
  self._var_positional_field = f['var_positional_field']
268
278
  self._validator = f['validator']
269
- self._parameters_json_schema = f['json_schema']
279
+ self._base_parameters_json_schema = f['json_schema']
270
280
 
271
281
  async def prepare_tool_def(self, ctx: RunContext[AgentDepsT]) -> ToolDefinition | None:
272
282
  """Get the tool definition.
@@ -280,7 +290,8 @@ class Tool(Generic[AgentDepsT]):
280
290
  tool_def = ToolDefinition(
281
291
  name=self.name,
282
292
  description=self.description,
283
- parameters_json_schema=self._parameters_json_schema,
293
+ parameters_json_schema=self._base_parameters_json_schema,
294
+ strict=self.strict,
284
295
  )
285
296
  if self.prepare is not None:
286
297
  return await self.prepare(ctx, tool_def)
@@ -400,7 +411,7 @@ With PEP-728 this should be a TypedDict with `type: Literal['object']`, and `ext
400
411
  class ToolDefinition:
401
412
  """Definition of a tool passed to a model.
402
413
 
403
- This is used for both function tools result tools.
414
+ This is used for both function tools and result tools.
404
415
  """
405
416
 
406
417
  name: str
@@ -417,3 +428,15 @@ class ToolDefinition:
417
428
 
418
429
  This will only be set for result tools which don't have an `object` JSON schema.
419
430
  """
431
+
432
+ strict: bool | None = None
433
+ """Whether to enforce (vendor-specific) strict JSON schema validation for tool calls.
434
+
435
+ Setting this to `True` while using a supported model generally imposes some restrictions on the tool's JSON schema
436
+ in exchange for guaranteeing the API responses strictly match that schema.
437
+
438
+ When `False`, the model may be free to generate other properties or types (depending on the vendor).
439
+ When `None` (the default), the value will be inferred based on the compatibility of the parameters_json_schema.
440
+
441
+ Note: this is currently only supported by OpenAI models.
442
+ """