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.

Files changed (37) hide show
  1. pydantic_ai/_output.py +19 -7
  2. pydantic_ai/_parts_manager.py +10 -12
  3. pydantic_ai/_tool_manager.py +18 -1
  4. pydantic_ai/ag_ui.py +32 -17
  5. pydantic_ai/agent/abstract.py +8 -0
  6. pydantic_ai/durable_exec/dbos/_agent.py +5 -2
  7. pydantic_ai/durable_exec/temporal/_agent.py +1 -1
  8. pydantic_ai/messages.py +30 -6
  9. pydantic_ai/models/__init__.py +5 -1
  10. pydantic_ai/models/anthropic.py +54 -25
  11. pydantic_ai/models/bedrock.py +81 -31
  12. pydantic_ai/models/cohere.py +39 -13
  13. pydantic_ai/models/function.py +8 -1
  14. pydantic_ai/models/google.py +61 -33
  15. pydantic_ai/models/groq.py +35 -7
  16. pydantic_ai/models/huggingface.py +27 -5
  17. pydantic_ai/models/mistral.py +55 -21
  18. pydantic_ai/models/openai.py +135 -63
  19. pydantic_ai/profiles/openai.py +11 -0
  20. pydantic_ai/providers/__init__.py +3 -0
  21. pydantic_ai/providers/anthropic.py +8 -4
  22. pydantic_ai/providers/bedrock.py +9 -1
  23. pydantic_ai/providers/cohere.py +2 -2
  24. pydantic_ai/providers/gateway.py +187 -0
  25. pydantic_ai/providers/google.py +2 -2
  26. pydantic_ai/providers/google_gla.py +1 -1
  27. pydantic_ai/providers/groq.py +12 -5
  28. pydantic_ai/providers/heroku.py +2 -2
  29. pydantic_ai/providers/huggingface.py +1 -1
  30. pydantic_ai/providers/mistral.py +1 -1
  31. pydantic_ai/providers/openai.py +13 -0
  32. pydantic_ai/settings.py +1 -0
  33. {pydantic_ai_slim-1.0.2.dist-info → pydantic_ai_slim-1.0.4.dist-info}/METADATA +5 -5
  34. {pydantic_ai_slim-1.0.2.dist-info → pydantic_ai_slim-1.0.4.dist-info}/RECORD +37 -36
  35. {pydantic_ai_slim-1.0.2.dist-info → pydantic_ai_slim-1.0.4.dist-info}/WHEEL +0 -0
  36. {pydantic_ai_slim-1.0.2.dist-info → pydantic_ai_slim-1.0.4.dist-info}/entry_points.txt +0 -0
  37. {pydantic_ai_slim-1.0.2.dist-info → pydantic_ai_slim-1.0.4.dist-info}/licenses/LICENSE +0 -0
@@ -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(split_content_into_text_and_thinking(choice.message.content, self.profile.thinking_tags))
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
- finish_reason: FinishReason | None = None
531
- if raw_finish_reason := choice.finish_reason: # pragma: no branch
532
- vendor_details['finish_reason'] = raw_finish_reason
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=self._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 `OpenAIModel`. If it should be, please file an issue.'
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: We don't send ThinkingPart to the providers yet. If you are unsatisfied with this,
601
- # please open an issue. The below code is the code to send thinking to the provider.
602
- # texts.append(f'<think>\n{item.content}\n</think>')
603
- pass
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.type == 'reasoning':
860
+ if isinstance(item, responses.ResponseReasoningItem):
861
+ signature = item.encrypted_content
842
862
  for summary in item.summary:
843
- # NOTE: We use the same id for all summaries because we can merge them on the round trip.
844
- # The providers don't force the signature to be unique.
845
- items.append(ThinkingPart(content=summary.text, id=item.id))
846
- elif item.type == 'message':
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.type == 'output_text': # pragma: no branch
878
+ if isinstance(content, responses.ResponseOutputText): # pragma: no branch
849
879
  items.append(TextPart(content.text))
850
- elif item.type == 'function_call':
851
- items.append(ToolCallPart(item.name, item.arguments, tool_call_id=item.call_id))
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=self._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
- unsupported_model_settings = OpenAIModelProfile.from_profile(self.profile).openai_unsupported_model_settings
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
- openai_messages.append(
1052
- FunctionCallOutput(
1053
- type='function_call_output',
1054
- call_id=_guard_tool_call_id(t=part),
1055
- output=part.model_response_str(),
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
- openai_messages.append(
1066
- FunctionCallOutput(
1067
- type='function_call_output',
1068
- call_id=_guard_tool_call_id(t=part),
1069
- output=part.model_response(),
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
- # last_thinking_part_idx: int | None = None
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
- # NOTE: We don't send ThinkingPart to the providers yet. If you are unsatisfied with this,
1086
- # please open an issue. The below code is the code to send thinking to the provider.
1087
- # if last_thinking_part_idx is not None:
1088
- # reasoning_item = cast(responses.ResponseReasoningItemParam, openai_messages[last_thinking_part_idx]) # fmt: skip
1089
- # if item.id == reasoning_item['id']:
1090
- # assert isinstance(reasoning_item['summary'], list)
1091
- # reasoning_item['summary'].append(Summary(text=item.content, type='summary_text'))
1092
- # continue
1093
- # last_thinking_part_idx = len(openai_messages)
1094
- # openai_messages.append(
1095
- # responses.ResponseReasoningItemParam(
1096
- # id=item.id or generate_tool_call_id(),
1097
- # summary=[Summary(text=item.content, type='summary_text')],
1098
- # type='reasoning',
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
- return responses.ResponseFunctionToolCallParam(
1112
- arguments=t.args_as_json_str(),
1113
- call_id=_guard_tool_call_id(t=t),
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
- # Handle reasoning part of the response, present in DeepSeek models
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', content=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
- # NOTE: We only need this if the tool call deltas don't include the final info.
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
@@ -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__(self, *, api_key: str | None = None, http_client: httpx.AsyncClient | None = None) -> None: ...
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.environ.get('ANTHROPIC_API_KEY')
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)
@@ -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': deepseek_model_profile,
85
+ 'deepseek': bedrock_deepseek_model_profile,
78
86
  }
79
87
 
80
88
  # Split the model name into parts
@@ -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.environ.get('CO_API_KEY')
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.environ.get('CO_BASE_URL')
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: