pydantic-ai-slim 1.0.2__py3-none-any.whl → 1.0.4__py3-none-any.whl
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.
Potentially problematic release.
This version of pydantic-ai-slim might be problematic. Click here for more details.
- pydantic_ai/_output.py +19 -7
- pydantic_ai/_parts_manager.py +10 -12
- pydantic_ai/_tool_manager.py +18 -1
- pydantic_ai/ag_ui.py +32 -17
- pydantic_ai/agent/abstract.py +8 -0
- pydantic_ai/durable_exec/dbos/_agent.py +5 -2
- pydantic_ai/durable_exec/temporal/_agent.py +1 -1
- pydantic_ai/messages.py +30 -6
- pydantic_ai/models/__init__.py +5 -1
- pydantic_ai/models/anthropic.py +54 -25
- pydantic_ai/models/bedrock.py +81 -31
- pydantic_ai/models/cohere.py +39 -13
- pydantic_ai/models/function.py +8 -1
- pydantic_ai/models/google.py +61 -33
- pydantic_ai/models/groq.py +35 -7
- pydantic_ai/models/huggingface.py +27 -5
- pydantic_ai/models/mistral.py +55 -21
- pydantic_ai/models/openai.py +135 -63
- pydantic_ai/profiles/openai.py +11 -0
- pydantic_ai/providers/__init__.py +3 -0
- pydantic_ai/providers/anthropic.py +8 -4
- pydantic_ai/providers/bedrock.py +9 -1
- pydantic_ai/providers/cohere.py +2 -2
- pydantic_ai/providers/gateway.py +187 -0
- pydantic_ai/providers/google.py +2 -2
- pydantic_ai/providers/google_gla.py +1 -1
- pydantic_ai/providers/groq.py +12 -5
- pydantic_ai/providers/heroku.py +2 -2
- pydantic_ai/providers/huggingface.py +1 -1
- pydantic_ai/providers/mistral.py +1 -1
- pydantic_ai/providers/openai.py +13 -0
- pydantic_ai/settings.py +1 -0
- {pydantic_ai_slim-1.0.2.dist-info → pydantic_ai_slim-1.0.4.dist-info}/METADATA +5 -5
- {pydantic_ai_slim-1.0.2.dist-info → pydantic_ai_slim-1.0.4.dist-info}/RECORD +37 -36
- {pydantic_ai_slim-1.0.2.dist-info → pydantic_ai_slim-1.0.4.dist-info}/WHEEL +0 -0
- {pydantic_ai_slim-1.0.2.dist-info → pydantic_ai_slim-1.0.4.dist-info}/entry_points.txt +0 -0
- {pydantic_ai_slim-1.0.2.dist-info → pydantic_ai_slim-1.0.4.dist-info}/licenses/LICENSE +0 -0
pydantic_ai/models/openai.py
CHANGED
|
@@ -4,7 +4,7 @@ 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
|
|
9
9
|
from typing import Any, Literal, cast, overload
|
|
10
10
|
|
|
@@ -31,6 +31,7 @@ from ..messages import (
|
|
|
31
31
|
ModelResponse,
|
|
32
32
|
ModelResponsePart,
|
|
33
33
|
ModelResponseStreamEvent,
|
|
34
|
+
PartStartEvent,
|
|
34
35
|
RetryPromptPart,
|
|
35
36
|
SystemPromptPart,
|
|
36
37
|
TextPart,
|
|
@@ -73,6 +74,7 @@ try:
|
|
|
73
74
|
)
|
|
74
75
|
from openai.types.responses import ComputerToolParam, FileSearchToolParam, WebSearchToolParam
|
|
75
76
|
from openai.types.responses.response_input_param import FunctionCallOutput, Message
|
|
77
|
+
from openai.types.responses.response_reasoning_item_param import Summary
|
|
76
78
|
from openai.types.responses.response_status import ResponseStatus
|
|
77
79
|
from openai.types.shared import ReasoningEffort
|
|
78
80
|
from openai.types.shared_params import Reasoning
|
|
@@ -491,9 +493,17 @@ class OpenAIChatModel(Model):
|
|
|
491
493
|
|
|
492
494
|
choice = response.choices[0]
|
|
493
495
|
items: list[ModelResponsePart] = []
|
|
494
|
-
# The `reasoning_content` is only present in DeepSeek models.
|
|
496
|
+
# The `reasoning_content` field is only present in DeepSeek models.
|
|
497
|
+
# https://api-docs.deepseek.com/guides/reasoning_model
|
|
495
498
|
if reasoning_content := getattr(choice.message, 'reasoning_content', None):
|
|
496
|
-
items.append(ThinkingPart(content=reasoning_content))
|
|
499
|
+
items.append(ThinkingPart(id='reasoning_content', content=reasoning_content, provider_name=self.system))
|
|
500
|
+
|
|
501
|
+
# NOTE: We don't currently handle OpenRouter `reasoning_details`:
|
|
502
|
+
# - https://openrouter.ai/docs/use-cases/reasoning-tokens#preserving-reasoning-blocks
|
|
503
|
+
# NOTE: We don't currently handle OpenRouter/gpt-oss `reasoning`:
|
|
504
|
+
# - https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot#chat-completions-api
|
|
505
|
+
# - https://openrouter.ai/docs/use-cases/reasoning-tokens#basic-usage-with-reasoning-tokens
|
|
506
|
+
# If you need this, please file an issue.
|
|
497
507
|
|
|
498
508
|
vendor_details: dict[str, Any] = {}
|
|
499
509
|
|
|
@@ -513,7 +523,10 @@ class OpenAIChatModel(Model):
|
|
|
513
523
|
]
|
|
514
524
|
|
|
515
525
|
if choice.message.content is not None:
|
|
516
|
-
items.extend(
|
|
526
|
+
items.extend(
|
|
527
|
+
(replace(part, id='content', provider_name=self.system) if isinstance(part, ThinkingPart) else part)
|
|
528
|
+
for part in split_content_into_text_and_thinking(choice.message.content, self.profile.thinking_tags)
|
|
529
|
+
)
|
|
517
530
|
if choice.message.tool_calls is not None:
|
|
518
531
|
for c in choice.message.tool_calls:
|
|
519
532
|
if isinstance(c, ChatCompletionMessageFunctionToolCall):
|
|
@@ -527,10 +540,9 @@ class OpenAIChatModel(Model):
|
|
|
527
540
|
part.tool_call_id = _guard_tool_call_id(part)
|
|
528
541
|
items.append(part)
|
|
529
542
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
finish_reason = _CHAT_FINISH_REASON_MAP.get(raw_finish_reason)
|
|
543
|
+
raw_finish_reason = choice.finish_reason
|
|
544
|
+
vendor_details['finish_reason'] = raw_finish_reason
|
|
545
|
+
finish_reason = _CHAT_FINISH_REASON_MAP.get(raw_finish_reason)
|
|
534
546
|
|
|
535
547
|
return ModelResponse(
|
|
536
548
|
parts=items,
|
|
@@ -556,7 +568,7 @@ class OpenAIChatModel(Model):
|
|
|
556
568
|
|
|
557
569
|
return OpenAIStreamedResponse(
|
|
558
570
|
model_request_parameters=model_request_parameters,
|
|
559
|
-
_model_name=
|
|
571
|
+
_model_name=first_chunk.model,
|
|
560
572
|
_model_profile=self.profile,
|
|
561
573
|
_response=peekable_response,
|
|
562
574
|
_timestamp=number_to_datetime(first_chunk.created),
|
|
@@ -569,6 +581,12 @@ class OpenAIChatModel(Model):
|
|
|
569
581
|
def _get_web_search_options(self, model_request_parameters: ModelRequestParameters) -> WebSearchOptions | None:
|
|
570
582
|
for tool in model_request_parameters.builtin_tools:
|
|
571
583
|
if isinstance(tool, WebSearchTool): # pragma: no branch
|
|
584
|
+
if not OpenAIModelProfile.from_profile(self.profile).openai_chat_supports_web_search:
|
|
585
|
+
raise UserError(
|
|
586
|
+
f'WebSearchTool is not supported with `OpenAIChatModel` and model {self.model_name!r}. '
|
|
587
|
+
f'Please use `OpenAIResponsesModel` instead.'
|
|
588
|
+
)
|
|
589
|
+
|
|
572
590
|
if tool.user_location:
|
|
573
591
|
return WebSearchOptions(
|
|
574
592
|
search_context_size=tool.search_context_size,
|
|
@@ -580,7 +598,7 @@ class OpenAIChatModel(Model):
|
|
|
580
598
|
return WebSearchOptions(search_context_size=tool.search_context_size)
|
|
581
599
|
else:
|
|
582
600
|
raise UserError(
|
|
583
|
-
f'`{tool.__class__.__name__}` is not supported by `
|
|
601
|
+
f'`{tool.__class__.__name__}` is not supported by `OpenAIChatModel`. If it should be, please file an issue.'
|
|
584
602
|
)
|
|
585
603
|
|
|
586
604
|
async def _map_messages(self, messages: list[ModelMessage]) -> list[chat.ChatCompletionMessageParam]:
|
|
@@ -597,10 +615,11 @@ class OpenAIChatModel(Model):
|
|
|
597
615
|
if isinstance(item, TextPart):
|
|
598
616
|
texts.append(item.content)
|
|
599
617
|
elif isinstance(item, ThinkingPart):
|
|
600
|
-
# NOTE:
|
|
601
|
-
#
|
|
602
|
-
#
|
|
603
|
-
|
|
618
|
+
# NOTE: DeepSeek `reasoning_content` field should NOT be sent back per https://api-docs.deepseek.com/guides/reasoning_model,
|
|
619
|
+
# but we currently just send it in `<think>` tags anyway as we don't want DeepSeek-specific checks here.
|
|
620
|
+
# If you need this changed, please file an issue.
|
|
621
|
+
start_tag, end_tag = self.profile.thinking_tags
|
|
622
|
+
texts.append('\n'.join([start_tag, item.content, end_tag]))
|
|
604
623
|
elif isinstance(item, ToolCallPart):
|
|
605
624
|
tool_calls.append(self._map_tool_call(item))
|
|
606
625
|
# OpenAI doesn't return built-in tool calls
|
|
@@ -838,17 +857,30 @@ class OpenAIResponsesModel(Model):
|
|
|
838
857
|
timestamp = number_to_datetime(response.created_at)
|
|
839
858
|
items: list[ModelResponsePart] = []
|
|
840
859
|
for item in response.output:
|
|
841
|
-
if item.
|
|
860
|
+
if isinstance(item, responses.ResponseReasoningItem):
|
|
861
|
+
signature = item.encrypted_content
|
|
842
862
|
for summary in item.summary:
|
|
843
|
-
#
|
|
844
|
-
#
|
|
845
|
-
items.append(
|
|
846
|
-
|
|
863
|
+
# We use the same id for all summaries so that we can merge them on the round trip.
|
|
864
|
+
# We only need to store the signature once.
|
|
865
|
+
items.append(
|
|
866
|
+
ThinkingPart(
|
|
867
|
+
content=summary.text,
|
|
868
|
+
id=item.id,
|
|
869
|
+
signature=signature,
|
|
870
|
+
provider_name=self.system if signature else None,
|
|
871
|
+
)
|
|
872
|
+
)
|
|
873
|
+
signature = None
|
|
874
|
+
# NOTE: We don't currently handle the raw CoT from gpt-oss `reasoning_text`: https://cookbook.openai.com/articles/gpt-oss/handle-raw-cot
|
|
875
|
+
# If you need this, please file an issue.
|
|
876
|
+
elif isinstance(item, responses.ResponseOutputMessage):
|
|
847
877
|
for content in item.content:
|
|
848
|
-
if content.
|
|
878
|
+
if isinstance(content, responses.ResponseOutputText): # pragma: no branch
|
|
849
879
|
items.append(TextPart(content.text))
|
|
850
|
-
elif item.
|
|
851
|
-
items.append(
|
|
880
|
+
elif isinstance(item, responses.ResponseFunctionToolCall):
|
|
881
|
+
items.append(
|
|
882
|
+
ToolCallPart(item.name, item.arguments, tool_call_id=_combine_tool_call_ids(item.call_id, item.id))
|
|
883
|
+
)
|
|
852
884
|
|
|
853
885
|
finish_reason: FinishReason | None = None
|
|
854
886
|
provider_details: dict[str, Any] | None = None
|
|
@@ -882,7 +914,7 @@ class OpenAIResponsesModel(Model):
|
|
|
882
914
|
assert isinstance(first_chunk, responses.ResponseCreatedEvent)
|
|
883
915
|
return OpenAIResponsesStreamedResponse(
|
|
884
916
|
model_request_parameters=model_request_parameters,
|
|
885
|
-
_model_name=
|
|
917
|
+
_model_name=first_chunk.response.model,
|
|
886
918
|
_response=peekable_response,
|
|
887
919
|
_timestamp=number_to_datetime(first_chunk.response.created_at),
|
|
888
920
|
_provider_name=self._provider.name,
|
|
@@ -950,10 +982,15 @@ class OpenAIResponsesModel(Model):
|
|
|
950
982
|
text = text or {}
|
|
951
983
|
text['verbosity'] = verbosity
|
|
952
984
|
|
|
953
|
-
|
|
985
|
+
profile = OpenAIModelProfile.from_profile(self.profile)
|
|
986
|
+
unsupported_model_settings = profile.openai_unsupported_model_settings
|
|
954
987
|
for setting in unsupported_model_settings:
|
|
955
988
|
model_settings.pop(setting, None)
|
|
956
989
|
|
|
990
|
+
include: list[responses.ResponseIncludable] | None = None
|
|
991
|
+
if profile.openai_supports_encrypted_reasoning_content:
|
|
992
|
+
include = ['reasoning.encrypted_content']
|
|
993
|
+
|
|
957
994
|
try:
|
|
958
995
|
extra_headers = model_settings.get('extra_headers', {})
|
|
959
996
|
extra_headers.setdefault('User-Agent', get_user_agent())
|
|
@@ -974,6 +1011,7 @@ class OpenAIResponsesModel(Model):
|
|
|
974
1011
|
reasoning=reasoning,
|
|
975
1012
|
user=model_settings.get('openai_user', NOT_GIVEN),
|
|
976
1013
|
text=text or NOT_GIVEN,
|
|
1014
|
+
include=include or NOT_GIVEN,
|
|
977
1015
|
extra_headers=extra_headers,
|
|
978
1016
|
extra_body=model_settings.get('extra_body'),
|
|
979
1017
|
)
|
|
@@ -1035,7 +1073,7 @@ class OpenAIResponsesModel(Model):
|
|
|
1035
1073
|
),
|
|
1036
1074
|
}
|
|
1037
1075
|
|
|
1038
|
-
async def _map_messages(
|
|
1076
|
+
async def _map_messages( # noqa: C901
|
|
1039
1077
|
self, messages: list[ModelMessage]
|
|
1040
1078
|
) -> tuple[str | NotGiven, list[responses.ResponseInputItemParam]]:
|
|
1041
1079
|
"""Just maps a `pydantic_ai.Message` to a `openai.types.responses.ResponseInputParam`."""
|
|
@@ -1048,13 +1086,14 @@ class OpenAIResponsesModel(Model):
|
|
|
1048
1086
|
elif isinstance(part, UserPromptPart):
|
|
1049
1087
|
openai_messages.append(await self._map_user_prompt(part))
|
|
1050
1088
|
elif isinstance(part, ToolReturnPart):
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
)
|
|
1089
|
+
call_id = _guard_tool_call_id(t=part)
|
|
1090
|
+
call_id, _ = _split_combined_tool_call_id(call_id)
|
|
1091
|
+
item = FunctionCallOutput(
|
|
1092
|
+
type='function_call_output',
|
|
1093
|
+
call_id=call_id,
|
|
1094
|
+
output=part.model_response_str(),
|
|
1057
1095
|
)
|
|
1096
|
+
openai_messages.append(item)
|
|
1058
1097
|
elif isinstance(part, RetryPromptPart):
|
|
1059
1098
|
# TODO(Marcelo): How do we test this conditional branch?
|
|
1060
1099
|
if part.tool_name is None: # pragma: no cover
|
|
@@ -1062,43 +1101,41 @@ class OpenAIResponsesModel(Model):
|
|
|
1062
1101
|
Message(role='user', content=[{'type': 'input_text', 'text': part.model_response()}])
|
|
1063
1102
|
)
|
|
1064
1103
|
else:
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
)
|
|
1104
|
+
call_id = _guard_tool_call_id(t=part)
|
|
1105
|
+
call_id, _ = _split_combined_tool_call_id(call_id)
|
|
1106
|
+
item = FunctionCallOutput(
|
|
1107
|
+
type='function_call_output',
|
|
1108
|
+
call_id=call_id,
|
|
1109
|
+
output=part.model_response(),
|
|
1071
1110
|
)
|
|
1111
|
+
openai_messages.append(item)
|
|
1072
1112
|
else:
|
|
1073
1113
|
assert_never(part)
|
|
1074
1114
|
elif isinstance(message, ModelResponse):
|
|
1075
|
-
|
|
1115
|
+
reasoning_item: responses.ResponseReasoningItemParam | None = None
|
|
1076
1116
|
for item in message.parts:
|
|
1077
1117
|
if isinstance(item, TextPart):
|
|
1078
1118
|
openai_messages.append(responses.EasyInputMessageParam(role='assistant', content=item.content))
|
|
1079
1119
|
elif isinstance(item, ToolCallPart):
|
|
1080
1120
|
openai_messages.append(self._map_tool_call(item))
|
|
1081
|
-
# OpenAI doesn't return built-in tool calls
|
|
1082
1121
|
elif isinstance(item, BuiltinToolCallPart | BuiltinToolReturnPart):
|
|
1122
|
+
# We don't currently track built-in tool calls from OpenAI
|
|
1083
1123
|
pass
|
|
1084
1124
|
elif isinstance(item, ThinkingPart):
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
# )
|
|
1100
|
-
# )
|
|
1101
|
-
pass
|
|
1125
|
+
if reasoning_item is not None and item.id == reasoning_item['id']:
|
|
1126
|
+
reasoning_item['summary'] = [
|
|
1127
|
+
*reasoning_item['summary'],
|
|
1128
|
+
Summary(text=item.content, type='summary_text'),
|
|
1129
|
+
]
|
|
1130
|
+
continue
|
|
1131
|
+
|
|
1132
|
+
reasoning_item = responses.ResponseReasoningItemParam(
|
|
1133
|
+
id=item.id or _utils.generate_tool_call_id(),
|
|
1134
|
+
summary=[Summary(text=item.content, type='summary_text')],
|
|
1135
|
+
encrypted_content=item.signature if item.provider_name == self.system else None,
|
|
1136
|
+
type='reasoning',
|
|
1137
|
+
)
|
|
1138
|
+
openai_messages.append(reasoning_item)
|
|
1102
1139
|
else:
|
|
1103
1140
|
assert_never(item)
|
|
1104
1141
|
else:
|
|
@@ -1108,12 +1145,18 @@ class OpenAIResponsesModel(Model):
|
|
|
1108
1145
|
|
|
1109
1146
|
@staticmethod
|
|
1110
1147
|
def _map_tool_call(t: ToolCallPart) -> responses.ResponseFunctionToolCallParam:
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1148
|
+
call_id = _guard_tool_call_id(t=t)
|
|
1149
|
+
call_id, id = _split_combined_tool_call_id(call_id)
|
|
1150
|
+
|
|
1151
|
+
param = responses.ResponseFunctionToolCallParam(
|
|
1114
1152
|
name=t.tool_name,
|
|
1153
|
+
arguments=t.args_as_json_str(),
|
|
1154
|
+
call_id=call_id,
|
|
1115
1155
|
type='function_call',
|
|
1116
1156
|
)
|
|
1157
|
+
if id: # pragma: no branch
|
|
1158
|
+
param['id'] = id
|
|
1159
|
+
return param
|
|
1117
1160
|
|
|
1118
1161
|
def _map_json_schema(self, o: OutputObjectDefinition) -> responses.ResponseFormatTextJSONSchemaConfigParam:
|
|
1119
1162
|
response_format_param: responses.ResponseFormatTextJSONSchemaConfigParam = {
|
|
@@ -1231,12 +1274,19 @@ class OpenAIStreamedResponse(StreamedResponse):
|
|
|
1231
1274
|
ignore_leading_whitespace=self._model_profile.ignore_streamed_leading_whitespace,
|
|
1232
1275
|
)
|
|
1233
1276
|
if maybe_event is not None: # pragma: no branch
|
|
1277
|
+
if isinstance(maybe_event, PartStartEvent) and isinstance(maybe_event.part, ThinkingPart):
|
|
1278
|
+
maybe_event.part.id = 'content'
|
|
1279
|
+
maybe_event.part.provider_name = self.provider_name
|
|
1234
1280
|
yield maybe_event
|
|
1235
1281
|
|
|
1236
|
-
#
|
|
1282
|
+
# The `reasoning_content` field is only present in DeepSeek models.
|
|
1283
|
+
# https://api-docs.deepseek.com/guides/reasoning_model
|
|
1237
1284
|
if reasoning_content := getattr(choice.delta, 'reasoning_content', None):
|
|
1238
1285
|
yield self._parts_manager.handle_thinking_delta(
|
|
1239
|
-
vendor_part_id='reasoning_content',
|
|
1286
|
+
vendor_part_id='reasoning_content',
|
|
1287
|
+
id='reasoning_content',
|
|
1288
|
+
content=reasoning_content,
|
|
1289
|
+
provider_name=self.provider_name,
|
|
1240
1290
|
)
|
|
1241
1291
|
|
|
1242
1292
|
for dtc in choice.delta.tool_calls or []:
|
|
@@ -1325,7 +1375,7 @@ class OpenAIResponsesStreamedResponse(StreamedResponse):
|
|
|
1325
1375
|
vendor_part_id=chunk.item.id,
|
|
1326
1376
|
tool_name=chunk.item.name,
|
|
1327
1377
|
args=chunk.item.arguments,
|
|
1328
|
-
tool_call_id=chunk.item.call_id,
|
|
1378
|
+
tool_call_id=_combine_tool_call_ids(chunk.item.call_id, chunk.item.id),
|
|
1329
1379
|
)
|
|
1330
1380
|
elif isinstance(chunk.item, responses.ResponseReasoningItem):
|
|
1331
1381
|
pass
|
|
@@ -1340,7 +1390,15 @@ class OpenAIResponsesStreamedResponse(StreamedResponse):
|
|
|
1340
1390
|
)
|
|
1341
1391
|
|
|
1342
1392
|
elif isinstance(chunk, responses.ResponseOutputItemDoneEvent):
|
|
1343
|
-
|
|
1393
|
+
if isinstance(chunk.item, responses.ResponseReasoningItem):
|
|
1394
|
+
# Add the signature to the part corresponding to the first summary item
|
|
1395
|
+
signature = chunk.item.encrypted_content
|
|
1396
|
+
yield self._parts_manager.handle_thinking_delta(
|
|
1397
|
+
vendor_part_id=f'{chunk.item.id}-0',
|
|
1398
|
+
id=chunk.item.id,
|
|
1399
|
+
signature=signature,
|
|
1400
|
+
provider_name=self.provider_name if signature else None,
|
|
1401
|
+
)
|
|
1344
1402
|
pass
|
|
1345
1403
|
|
|
1346
1404
|
elif isinstance(chunk, responses.ResponseReasoningSummaryPartAddedEvent):
|
|
@@ -1458,3 +1516,17 @@ def _map_usage(response: chat.ChatCompletion | ChatCompletionChunk | responses.R
|
|
|
1458
1516
|
u.input_audio_tokens = response_usage.prompt_tokens_details.audio_tokens or 0
|
|
1459
1517
|
u.cache_read_tokens = response_usage.prompt_tokens_details.cached_tokens or 0
|
|
1460
1518
|
return u
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def _combine_tool_call_ids(call_id: str, id: str | None) -> str:
|
|
1522
|
+
# When reasoning, the Responses API requires the `ResponseFunctionToolCall` to be returned with both the `call_id` and `id` fields.
|
|
1523
|
+
# Our `ToolCallPart` has only the `call_id` field, so we combine the two fields into a single string.
|
|
1524
|
+
return f'{call_id}|{id}' if id else call_id
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
def _split_combined_tool_call_id(combined_id: str) -> tuple[str, str | None]:
|
|
1528
|
+
if '|' in combined_id:
|
|
1529
|
+
call_id, id = combined_id.split('|', 1)
|
|
1530
|
+
return call_id, id
|
|
1531
|
+
else:
|
|
1532
|
+
return combined_id, None # pragma: no cover
|
pydantic_ai/profiles/openai.py
CHANGED
|
@@ -38,6 +38,12 @@ class OpenAIModelProfile(ModelProfile):
|
|
|
38
38
|
openai_system_prompt_role: OpenAISystemPromptRole | None = None
|
|
39
39
|
"""The role to use for the system prompt message. If not provided, defaults to `'system'`."""
|
|
40
40
|
|
|
41
|
+
openai_chat_supports_web_search: bool = False
|
|
42
|
+
"""Whether the model supports web search in Chat Completions API."""
|
|
43
|
+
|
|
44
|
+
openai_supports_encrypted_reasoning_content: bool = False
|
|
45
|
+
"""Whether the model supports including encrypted reasoning content in the response."""
|
|
46
|
+
|
|
41
47
|
def __post_init__(self): # pragma: no cover
|
|
42
48
|
if not self.openai_supports_sampling_settings:
|
|
43
49
|
warnings.warn(
|
|
@@ -50,6 +56,9 @@ class OpenAIModelProfile(ModelProfile):
|
|
|
50
56
|
def openai_model_profile(model_name: str) -> ModelProfile:
|
|
51
57
|
"""Get the model profile for an OpenAI model."""
|
|
52
58
|
is_reasoning_model = model_name.startswith('o') or model_name.startswith('gpt-5')
|
|
59
|
+
# Check if the model supports web search (only specific search-preview models)
|
|
60
|
+
supports_web_search = '-search-preview' in model_name
|
|
61
|
+
|
|
53
62
|
# Structured Outputs (output mode 'native') is only supported with the gpt-4o-mini, gpt-4o-mini-2024-07-18, and gpt-4o-2024-08-06 model snapshots and later.
|
|
54
63
|
# We leave it in here for all models because the `default_structured_output_mode` is `'tool'`, so `native` is only used
|
|
55
64
|
# when the user specifically uses the `NativeOutput` marker, so an error from the API is acceptable.
|
|
@@ -77,6 +86,8 @@ def openai_model_profile(model_name: str) -> ModelProfile:
|
|
|
77
86
|
supports_json_object_output=True,
|
|
78
87
|
openai_unsupported_model_settings=openai_unsupported_model_settings,
|
|
79
88
|
openai_system_prompt_role=openai_system_prompt_role,
|
|
89
|
+
openai_chat_supports_web_search=supports_web_search,
|
|
90
|
+
openai_supports_encrypted_reasoning_content=is_reasoning_model,
|
|
80
91
|
)
|
|
81
92
|
|
|
82
93
|
|
|
@@ -47,6 +47,9 @@ class Provider(ABC, Generic[InterfaceClient]):
|
|
|
47
47
|
"""The model profile for the named model, if available."""
|
|
48
48
|
return None # pragma: no cover
|
|
49
49
|
|
|
50
|
+
def __repr__(self) -> str:
|
|
51
|
+
return f'{self.__class__.__name__}(name={self.name}, base_url={self.base_url})'
|
|
52
|
+
|
|
50
53
|
|
|
51
54
|
def infer_provider_class(provider: str) -> type[Provider[Any]]: # noqa: C901
|
|
52
55
|
"""Infers the provider class from the provider name."""
|
|
@@ -45,12 +45,15 @@ class AnthropicProvider(Provider[AsyncAnthropicClient]):
|
|
|
45
45
|
def __init__(self, *, anthropic_client: AsyncAnthropicClient | None = None) -> None: ...
|
|
46
46
|
|
|
47
47
|
@overload
|
|
48
|
-
def __init__(
|
|
48
|
+
def __init__(
|
|
49
|
+
self, *, api_key: str | None = None, base_url: str | None = None, http_client: httpx.AsyncClient | None = None
|
|
50
|
+
) -> None: ...
|
|
49
51
|
|
|
50
52
|
def __init__(
|
|
51
53
|
self,
|
|
52
54
|
*,
|
|
53
55
|
api_key: str | None = None,
|
|
56
|
+
base_url: str | None = None,
|
|
54
57
|
anthropic_client: AsyncAnthropicClient | None = None,
|
|
55
58
|
http_client: httpx.AsyncClient | None = None,
|
|
56
59
|
) -> None:
|
|
@@ -59,6 +62,7 @@ class AnthropicProvider(Provider[AsyncAnthropicClient]):
|
|
|
59
62
|
Args:
|
|
60
63
|
api_key: The API key to use for authentication, if not provided, the `ANTHROPIC_API_KEY` environment variable
|
|
61
64
|
will be used if available.
|
|
65
|
+
base_url: The base URL to use for the Anthropic API.
|
|
62
66
|
anthropic_client: An existing [`AsyncAnthropic`](https://github.com/anthropics/anthropic-sdk-python)
|
|
63
67
|
client to use. If provided, the `api_key` and `http_client` arguments will be ignored.
|
|
64
68
|
http_client: An existing `httpx.AsyncClient` to use for making HTTP requests.
|
|
@@ -68,14 +72,14 @@ class AnthropicProvider(Provider[AsyncAnthropicClient]):
|
|
|
68
72
|
assert api_key is None, 'Cannot provide both `anthropic_client` and `api_key`'
|
|
69
73
|
self._client = anthropic_client
|
|
70
74
|
else:
|
|
71
|
-
api_key = api_key or os.
|
|
75
|
+
api_key = api_key or os.getenv('ANTHROPIC_API_KEY')
|
|
72
76
|
if not api_key:
|
|
73
77
|
raise UserError(
|
|
74
78
|
'Set the `ANTHROPIC_API_KEY` environment variable or pass it via `AnthropicProvider(api_key=...)`'
|
|
75
79
|
'to use the Anthropic provider.'
|
|
76
80
|
)
|
|
77
81
|
if http_client is not None:
|
|
78
|
-
self._client = AsyncAnthropic(api_key=api_key, http_client=http_client)
|
|
82
|
+
self._client = AsyncAnthropic(api_key=api_key, base_url=base_url, http_client=http_client)
|
|
79
83
|
else:
|
|
80
84
|
http_client = cached_async_http_client(provider='anthropic')
|
|
81
|
-
self._client = AsyncAnthropic(api_key=api_key, http_client=http_client)
|
|
85
|
+
self._client = AsyncAnthropic(api_key=api_key, base_url=base_url, http_client=http_client)
|
pydantic_ai/providers/bedrock.py
CHANGED
|
@@ -48,6 +48,14 @@ def bedrock_amazon_model_profile(model_name: str) -> ModelProfile | None:
|
|
|
48
48
|
return profile
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def bedrock_deepseek_model_profile(model_name: str) -> ModelProfile | None:
|
|
52
|
+
"""Get the model profile for a DeepSeek model used via Bedrock."""
|
|
53
|
+
profile = deepseek_model_profile(model_name)
|
|
54
|
+
if 'r1' in model_name:
|
|
55
|
+
return BedrockModelProfile(bedrock_send_back_thinking_parts=True).update(profile)
|
|
56
|
+
return profile # pragma: no cover
|
|
57
|
+
|
|
58
|
+
|
|
51
59
|
class BedrockProvider(Provider[BaseClient]):
|
|
52
60
|
"""Provider for AWS Bedrock."""
|
|
53
61
|
|
|
@@ -74,7 +82,7 @@ class BedrockProvider(Provider[BaseClient]):
|
|
|
74
82
|
'cohere': cohere_model_profile,
|
|
75
83
|
'amazon': bedrock_amazon_model_profile,
|
|
76
84
|
'meta': meta_model_profile,
|
|
77
|
-
'deepseek':
|
|
85
|
+
'deepseek': bedrock_deepseek_model_profile,
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
# Split the model name into parts
|
pydantic_ai/providers/cohere.py
CHANGED
|
@@ -60,14 +60,14 @@ class CohereProvider(Provider[AsyncClientV2]):
|
|
|
60
60
|
assert api_key is None, 'Cannot provide both `cohere_client` and `api_key`'
|
|
61
61
|
self._client = cohere_client
|
|
62
62
|
else:
|
|
63
|
-
api_key = api_key or os.
|
|
63
|
+
api_key = api_key or os.getenv('CO_API_KEY')
|
|
64
64
|
if not api_key:
|
|
65
65
|
raise UserError(
|
|
66
66
|
'Set the `CO_API_KEY` environment variable or pass it via `CohereProvider(api_key=...)`'
|
|
67
67
|
'to use the Cohere provider.'
|
|
68
68
|
)
|
|
69
69
|
|
|
70
|
-
base_url = os.
|
|
70
|
+
base_url = os.getenv('CO_BASE_URL')
|
|
71
71
|
if http_client is not None:
|
|
72
72
|
self._client = AsyncClientV2(api_key=api_key, httpx_client=http_client, base_url=base_url)
|
|
73
73
|
else:
|