pydantic-ai-slim 0.2.4__tar.gz → 0.2.5__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 (59) hide show
  1. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/.gitignore +2 -0
  2. pydantic_ai_slim-0.2.5/LICENSE +21 -0
  3. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/PKG-INFO +7 -4
  4. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/_agent_graph.py +8 -6
  5. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/_cli.py +3 -3
  6. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/_output.py +7 -7
  7. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/_parts_manager.py +1 -1
  8. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/agent.py +19 -13
  9. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/direct.py +2 -0
  10. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/exceptions.py +2 -2
  11. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/messages.py +29 -11
  12. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/__init__.py +42 -5
  13. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/anthropic.py +17 -12
  14. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/bedrock.py +10 -9
  15. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/cohere.py +4 -4
  16. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/fallback.py +2 -2
  17. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/function.py +1 -1
  18. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/gemini.py +26 -22
  19. pydantic_ai_slim-0.2.5/pydantic_ai/models/google.py +570 -0
  20. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/groq.py +12 -6
  21. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/instrumented.py +43 -33
  22. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/mistral.py +15 -9
  23. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/openai.py +45 -7
  24. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/test.py +1 -1
  25. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/wrapper.py +1 -1
  26. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/providers/__init__.py +4 -0
  27. pydantic_ai_slim-0.2.5/pydantic_ai/providers/google.py +143 -0
  28. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/providers/google_vertex.py +3 -3
  29. pydantic_ai_slim-0.2.5/pydantic_ai/providers/openrouter.py +69 -0
  30. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/result.py +13 -21
  31. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/tools.py +2 -2
  32. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/usage.py +1 -1
  33. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pyproject.toml +2 -0
  34. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/README.md +0 -0
  35. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/__init__.py +0 -0
  36. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/__main__.py +0 -0
  37. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/_a2a.py +0 -0
  38. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/_griffe.py +0 -0
  39. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/_pydantic.py +0 -0
  40. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/_system_prompt.py +0 -0
  41. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/_utils.py +0 -0
  42. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/common_tools/__init__.py +0 -0
  43. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/common_tools/duckduckgo.py +0 -0
  44. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/common_tools/tavily.py +0 -0
  45. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/format_as_xml.py +0 -0
  46. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/format_prompt.py +0 -0
  47. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/mcp.py +0 -0
  48. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/models/_json_schema.py +0 -0
  49. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/providers/anthropic.py +0 -0
  50. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/providers/azure.py +0 -0
  51. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/providers/bedrock.py +0 -0
  52. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/providers/cohere.py +0 -0
  53. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/providers/deepseek.py +0 -0
  54. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/providers/google_gla.py +0 -0
  55. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/providers/groq.py +0 -0
  56. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/providers/mistral.py +0 -0
  57. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/providers/openai.py +0 -0
  58. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/py.typed +0 -0
  59. {pydantic_ai_slim-0.2.4 → pydantic_ai_slim-0.2.5}/pydantic_ai/settings.py +0 -0
@@ -17,3 +17,5 @@ examples/pydantic_ai_examples/.chat_app_messages.sqlite
17
17
  /docs-site/.wrangler/
18
18
  /CLAUDE.md
19
19
  node_modules/
20
+ **.idea/
21
+ .coverage*
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) Pydantic Services Inc. 2024 to present
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydantic-ai-slim
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Agent Framework / shim to use Pydantic with LLMs, slim package
5
5
  Author-email: Samuel Colvin <samuel@pydantic.dev>, Marcelo Trylesinski <marcelotryle@gmail.com>, David Montague <david@pydantic.dev>, Alex Hall <alex@pydantic.dev>
6
6
  License-Expression: MIT
7
+ License-File: LICENSE
7
8
  Classifier: Development Status :: 4 - Beta
8
9
  Classifier: Environment :: Console
9
10
  Classifier: Environment :: MacOS X
@@ -29,11 +30,11 @@ Requires-Dist: exceptiongroup; python_version < '3.11'
29
30
  Requires-Dist: griffe>=1.3.2
30
31
  Requires-Dist: httpx>=0.27
31
32
  Requires-Dist: opentelemetry-api>=1.28.0
32
- Requires-Dist: pydantic-graph==0.2.4
33
+ Requires-Dist: pydantic-graph==0.2.5
33
34
  Requires-Dist: pydantic>=2.10
34
35
  Requires-Dist: typing-inspection>=0.4.0
35
36
  Provides-Extra: a2a
36
- Requires-Dist: fasta2a==0.2.4; extra == 'a2a'
37
+ Requires-Dist: fasta2a==0.2.5; extra == 'a2a'
37
38
  Provides-Extra: anthropic
38
39
  Requires-Dist: anthropic>=0.49.0; extra == 'anthropic'
39
40
  Provides-Extra: bedrock
@@ -47,7 +48,9 @@ Requires-Dist: cohere>=5.13.11; (platform_system != 'Emscripten') and extra == '
47
48
  Provides-Extra: duckduckgo
48
49
  Requires-Dist: duckduckgo-search>=7.0.0; extra == 'duckduckgo'
49
50
  Provides-Extra: evals
50
- Requires-Dist: pydantic-evals==0.2.4; extra == 'evals'
51
+ Requires-Dist: pydantic-evals==0.2.5; extra == 'evals'
52
+ Provides-Extra: google
53
+ Requires-Dist: google-genai>=1.15.0; extra == 'google'
51
54
  Provides-Extra: groq
52
55
  Requires-Dist: groq>=0.15.0; extra == 'groq'
53
56
  Provides-Extra: logfire
@@ -196,7 +196,9 @@ class UserPromptNode(AgentNode[DepsT, NodeRunEndT]):
196
196
  for i, part in enumerate(msg.parts):
197
197
  if isinstance(part, _messages.SystemPromptPart) and part.dynamic_ref:
198
198
  # Look up the runner by its ref
199
- if runner := self.system_prompt_dynamic_functions.get(part.dynamic_ref):
199
+ if runner := self.system_prompt_dynamic_functions.get( # pragma: lax no cover
200
+ part.dynamic_ref
201
+ ):
200
202
  updated_part_content = await runner.run(run_context)
201
203
  msg.parts[i] = _messages.SystemPromptPart(
202
204
  updated_part_content, dynamic_ref=part.dynamic_ref
@@ -265,7 +267,7 @@ class ModelRequestNode(AgentNode[DepsT, NodeRunEndT]):
265
267
  if self._did_stream:
266
268
  # `self._result` gets set when exiting the `stream` contextmanager, so hitting this
267
269
  # means that the stream was started but not finished before `run()` was called
268
- raise exceptions.AgentRunError('You must finish streaming before calling run()')
270
+ raise exceptions.AgentRunError('You must finish streaming before calling run()') # pragma: no cover
269
271
 
270
272
  return await self._make_request(ctx)
271
273
 
@@ -316,7 +318,7 @@ class ModelRequestNode(AgentNode[DepsT, NodeRunEndT]):
316
318
  self, ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]]
317
319
  ) -> CallToolsNode[DepsT, NodeRunEndT]:
318
320
  if self._result is not None:
319
- return self._result
321
+ return self._result # pragma: no cover
320
322
 
321
323
  model_settings, model_request_parameters = await self._prepare_request(ctx)
322
324
  model_request_parameters = ctx.deps.model.customize_request_parameters(model_request_parameters)
@@ -333,7 +335,7 @@ class ModelRequestNode(AgentNode[DepsT, NodeRunEndT]):
333
335
  ctx.state.message_history.append(self.request)
334
336
 
335
337
  # Check usage
336
- if ctx.deps.usage_limits:
338
+ if ctx.deps.usage_limits: # pragma: no branch
337
339
  ctx.deps.usage_limits.check_before_request(ctx.state.usage)
338
340
 
339
341
  # Increment run_step
@@ -350,7 +352,7 @@ class ModelRequestNode(AgentNode[DepsT, NodeRunEndT]):
350
352
  ) -> CallToolsNode[DepsT, NodeRunEndT]:
351
353
  # Update usage
352
354
  ctx.state.usage.incr(response.usage)
353
- if ctx.deps.usage_limits:
355
+ if ctx.deps.usage_limits: # pragma: no branch
354
356
  ctx.deps.usage_limits.check_tokens(ctx.state.usage)
355
357
 
356
358
  # Append the model response to state.message_history
@@ -735,7 +737,7 @@ async def _tool_from_mcp_server(
735
737
 
736
738
  for server in ctx.deps.mcp_servers:
737
739
  tools = await server.list_tools()
738
- if tool_name in {tool.name for tool in tools}:
740
+ if tool_name in {tool.name for tool in tools}: # pragma: no branch
739
741
  return Tool(name=tool_name, function=run_tool, takes_ctx=True, max_retries=ctx.deps.default_retries)
740
742
  return None
741
743
 
@@ -57,7 +57,7 @@ PROMPT_HISTORY_PATH = PYDANTIC_AI_HOME / 'prompt-history.txt'
57
57
 
58
58
 
59
59
  class SimpleCodeBlock(CodeBlock):
60
- """Customised code blocks in markdown.
60
+ """Customized code blocks in markdown.
61
61
 
62
62
  This avoids a background color which messes up copy-pasting and sets the language name as dim prefix and suffix.
63
63
  """
@@ -70,7 +70,7 @@ class SimpleCodeBlock(CodeBlock):
70
70
 
71
71
 
72
72
  class LeftHeading(Heading):
73
- """Customised headings in markdown to stop centering and prepend markdown style hashes."""
73
+ """Customized headings in markdown to stop centering and prepend markdown style hashes."""
74
74
 
75
75
  def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
76
76
  # note we use `Style(bold=True)` not `self.style_name` here to disable underlining which is ugly IMHO
@@ -202,7 +202,7 @@ Special prompts:
202
202
  elif args.code_theme == 'dark':
203
203
  code_theme = 'monokai'
204
204
  else:
205
- code_theme = args.code_theme
205
+ code_theme = args.code_theme # pragma: no cover
206
206
 
207
207
  if prompt := cast(str, args.prompt):
208
208
  try:
@@ -140,8 +140,8 @@ class OutputSchema(Generic[OutputDataT]):
140
140
  self, parts: Iterable[_messages.ModelResponsePart], tool_name: str
141
141
  ) -> tuple[_messages.ToolCallPart, OutputSchemaTool[OutputDataT]] | None:
142
142
  """Find a tool that matches one of the calls, with a specific name."""
143
- for part in parts:
144
- if isinstance(part, _messages.ToolCallPart):
143
+ for part in parts: # pragma: no branch
144
+ if isinstance(part, _messages.ToolCallPart): # pragma: no branch
145
145
  if part.tool_name == tool_name:
146
146
  return part, self.tools[tool_name]
147
147
 
@@ -151,7 +151,7 @@ class OutputSchema(Generic[OutputDataT]):
151
151
  ) -> Iterator[tuple[_messages.ToolCallPart, OutputSchemaTool[OutputDataT]]]:
152
152
  """Find a tool that matches one of the calls."""
153
153
  for part in parts:
154
- if isinstance(part, _messages.ToolCallPart):
154
+ if isinstance(part, _messages.ToolCallPart): # pragma: no branch
155
155
  if result := self.tools.get(part.tool_name):
156
156
  yield part, result
157
157
 
@@ -201,7 +201,7 @@ class OutputSchemaTool(Generic[OutputDataT]):
201
201
  if description is None:
202
202
  tool_description = json_schema_description
203
203
  else:
204
- tool_description = f'{description}. {json_schema_description}'
204
+ tool_description = f'{description}. {json_schema_description}' # pragma: no cover
205
205
  else:
206
206
  tool_description = description or DEFAULT_DESCRIPTION
207
207
  if multiple:
@@ -243,7 +243,7 @@ class OutputSchemaTool(Generic[OutputDataT]):
243
243
  )
244
244
  raise ToolRetryError(m) from e
245
245
  else:
246
- raise
246
+ raise # pragma: lax no cover
247
247
  else:
248
248
  if k := self.tool_def.outer_typed_dict_key:
249
249
  output = output[k]
@@ -269,11 +269,11 @@ def extract_str_from_union(output_type: Any) -> _utils.Option[Any]:
269
269
  includes_str = True
270
270
  else:
271
271
  remain_args.append(arg)
272
- if includes_str:
272
+ if includes_str: # pragma: no branch
273
273
  if len(remain_args) == 1:
274
274
  return _utils.Some(remain_args[0])
275
275
  else:
276
- return _utils.Some(Union[tuple(remain_args)])
276
+ return _utils.Some(Union[tuple(remain_args)]) # pragma: no cover
277
277
 
278
278
 
279
279
  def get_union_args(tp: Any) -> tuple[Any, ...]:
@@ -164,7 +164,7 @@ class ModelResponsePartsManager:
164
164
  if tool_name is None and self._parts:
165
165
  part_index = len(self._parts) - 1
166
166
  latest_part = self._parts[part_index]
167
- if isinstance(latest_part, (ToolCallPart, ToolCallPartDelta)):
167
+ if isinstance(latest_part, (ToolCallPart, ToolCallPartDelta)): # pragma: no branch
168
168
  existing_matching_part_and_index = latest_part, part_index
169
169
  else:
170
170
  # vendor_part_id is provided, so look up the corresponding part or delta
@@ -585,6 +585,7 @@ class Agent(Generic[AgentDepsT, OutputDataT]):
585
585
  model_name='gpt-4o',
586
586
  timestamp=datetime.datetime(...),
587
587
  kind='response',
588
+ vendor_id=None,
588
589
  )
589
590
  ),
590
591
  End(data=FinalResult(output='Paris', tool_name=None, tool_call_id=None)),
@@ -654,8 +655,10 @@ class Agent(Generic[AgentDepsT, OutputDataT]):
654
655
  usage_limits = usage_limits or _usage.UsageLimits()
655
656
 
656
657
  if isinstance(model_used, InstrumentedModel):
658
+ instrumentation_settings = model_used.settings
657
659
  tracer = model_used.settings.tracer
658
660
  else:
661
+ instrumentation_settings = None
659
662
  tracer = NoOpTracer()
660
663
  agent_name = self.name or 'agent'
661
664
  run_span = tracer.start_span(
@@ -723,19 +726,18 @@ class Agent(Generic[AgentDepsT, OutputDataT]):
723
726
  )
724
727
  finally:
725
728
  try:
726
- if run_span.is_recording():
727
- run_span.set_attributes(self._run_span_end_attributes(state, usage))
729
+ if instrumentation_settings and run_span.is_recording():
730
+ run_span.set_attributes(self._run_span_end_attributes(state, usage, instrumentation_settings))
728
731
  finally:
729
732
  run_span.end()
730
733
 
731
- def _run_span_end_attributes(self, state: _agent_graph.GraphAgentState, usage: _usage.Usage):
734
+ def _run_span_end_attributes(
735
+ self, state: _agent_graph.GraphAgentState, usage: _usage.Usage, settings: InstrumentationSettings
736
+ ):
732
737
  return {
733
738
  **usage.opentelemetry_attributes(),
734
739
  'all_messages_events': json.dumps(
735
- [
736
- InstrumentedModel.event_to_dict(e)
737
- for e in InstrumentedModel.messages_to_otel_events(state.message_history)
738
- ]
740
+ [InstrumentedModel.event_to_dict(e) for e in settings.messages_to_otel_events(state.message_history)]
739
741
  ),
740
742
  'logfire.json_schema': json.dumps(
741
743
  {
@@ -1001,7 +1003,7 @@ class Agent(Generic[AgentDepsT, OutputDataT]):
1001
1003
  final_result_details = await stream_to_final(streamed_response)
1002
1004
  if final_result_details is not None:
1003
1005
  if yielded:
1004
- raise exceptions.AgentRunError('Agent run produced final results')
1006
+ raise exceptions.AgentRunError('Agent run produced final results') # pragma: no cover
1005
1007
  yielded = True
1006
1008
 
1007
1009
  messages = graph_ctx.state.message_history.copy()
@@ -1048,11 +1050,13 @@ class Agent(Generic[AgentDepsT, OutputDataT]):
1048
1050
  break
1049
1051
  next_node = await agent_run.next(node)
1050
1052
  if not isinstance(next_node, _agent_graph.AgentNode):
1051
- raise exceptions.AgentRunError('Should have produced a StreamedRunResult before getting here')
1053
+ raise exceptions.AgentRunError( # pragma: no cover
1054
+ 'Should have produced a StreamedRunResult before getting here'
1055
+ )
1052
1056
  node = cast(_agent_graph.AgentNode[Any, Any], next_node)
1053
1057
 
1054
1058
  if not yielded:
1055
- raise exceptions.AgentRunError('Agent run finished without producing a final result')
1059
+ raise exceptions.AgentRunError('Agent run finished without producing a final result') # pragma: no cover
1056
1060
 
1057
1061
  @contextmanager
1058
1062
  def override(
@@ -1226,7 +1230,7 @@ class Agent(Generic[AgentDepsT, OutputDataT]):
1226
1230
  ) -> _system_prompt.SystemPromptFunc[AgentDepsT]:
1227
1231
  runner = _system_prompt.SystemPromptRunner[AgentDepsT](func_, dynamic=dynamic)
1228
1232
  self._system_prompt_functions.append(runner)
1229
- if dynamic:
1233
+ if dynamic: # pragma: lax no cover
1230
1234
  self._system_prompt_dynamic_functions[func_.__qualname__] = runner
1231
1235
  return func_
1232
1236
 
@@ -1608,7 +1612,7 @@ class Agent(Generic[AgentDepsT, OutputDataT]):
1608
1612
  if item is self:
1609
1613
  self.name = name
1610
1614
  return
1611
- if parent_frame.f_locals != parent_frame.f_globals:
1615
+ if parent_frame.f_locals != parent_frame.f_globals: # pragma: no branch
1612
1616
  # if we couldn't find the agent in locals and globals are a different dict, try globals
1613
1617
  for name, item in parent_frame.f_globals.items():
1614
1618
  if item is self:
@@ -1851,6 +1855,7 @@ class AgentRun(Generic[AgentDepsT, OutputDataT]):
1851
1855
  model_name='gpt-4o',
1852
1856
  timestamp=datetime.datetime(...),
1853
1857
  kind='response',
1858
+ vendor_id=None,
1854
1859
  )
1855
1860
  ),
1856
1861
  End(data=FinalResult(output='Paris', tool_name=None, tool_call_id=None)),
@@ -1996,6 +2001,7 @@ class AgentRun(Generic[AgentDepsT, OutputDataT]):
1996
2001
  model_name='gpt-4o',
1997
2002
  timestamp=datetime.datetime(...),
1998
2003
  kind='response',
2004
+ vendor_id=None,
1999
2005
  )
2000
2006
  ),
2001
2007
  End(data=FinalResult(output='Paris', tool_name=None, tool_call_id=None)),
@@ -2024,7 +2030,7 @@ class AgentRun(Generic[AgentDepsT, OutputDataT]):
2024
2030
  """Get usage statistics for the run so far, including token usage, model requests, and so on."""
2025
2031
  return self._graph_run.state.usage
2026
2032
 
2027
- def __repr__(self) -> str:
2033
+ def __repr__(self) -> str: # pragma: no cover
2028
2034
  result = self._graph_run.result
2029
2035
  result_repr = '<run not finished>' if result is None else repr(result.output)
2030
2036
  return f'<{type(self).__name__} result={result_repr} usage={self.usage()}>'
@@ -52,6 +52,7 @@ async def model_request(
52
52
  model_name='claude-3-5-haiku-latest',
53
53
  timestamp=datetime.datetime(...),
54
54
  kind='response',
55
+ vendor_id=None,
55
56
  )
56
57
  '''
57
58
  ```
@@ -108,6 +109,7 @@ def model_request_sync(
108
109
  model_name='claude-3-5-haiku-latest',
109
110
  timestamp=datetime.datetime(...),
110
111
  kind='response',
112
+ vendor_id=None,
111
113
  )
112
114
  '''
113
115
  ```
@@ -4,9 +4,9 @@ import json
4
4
  import sys
5
5
 
6
6
  if sys.version_info < (3, 11):
7
- from exceptiongroup import ExceptionGroup
7
+ from exceptiongroup import ExceptionGroup # pragma: lax no cover
8
8
  else:
9
- ExceptionGroup = ExceptionGroup
9
+ ExceptionGroup = ExceptionGroup # pragma: lax no cover
10
10
 
11
11
  __all__ = (
12
12
  'ModelRetry',
@@ -6,7 +6,7 @@ from collections.abc import Sequence
6
6
  from dataclasses import dataclass, field, replace
7
7
  from datetime import datetime
8
8
  from mimetypes import guess_type
9
- from typing import Annotated, Any, Literal, Union, cast, overload
9
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, Union, cast, overload
10
10
 
11
11
  import pydantic
12
12
  import pydantic_core
@@ -17,6 +17,10 @@ from ._utils import generate_tool_call_id as _generate_tool_call_id, now_utc as
17
17
  from .exceptions import UnexpectedModelBehavior
18
18
  from .usage import Usage
19
19
 
20
+ if TYPE_CHECKING:
21
+ from .models.instrumented import InstrumentationSettings
22
+
23
+
20
24
  AudioMediaType: TypeAlias = Literal['audio/wav', 'audio/mpeg']
21
25
  ImageMediaType: TypeAlias = Literal['image/jpeg', 'image/png', 'image/gif', 'image/webp']
22
26
  DocumentMediaType: TypeAlias = Literal[
@@ -68,7 +72,7 @@ class SystemPromptPart:
68
72
  part_kind: Literal['system-prompt'] = 'system-prompt'
69
73
  """Part type identifier, this is available on all parts as a discriminator."""
70
74
 
71
- def otel_event(self) -> Event:
75
+ def otel_event(self, _settings: InstrumentationSettings) -> Event:
72
76
  return Event('gen_ai.system.message', body={'content': self.content, 'role': 'system'})
73
77
 
74
78
 
@@ -305,7 +309,7 @@ class UserPromptPart:
305
309
  part_kind: Literal['user-prompt'] = 'user-prompt'
306
310
  """Part type identifier, this is available on all parts as a discriminator."""
307
311
 
308
- def otel_event(self) -> Event:
312
+ def otel_event(self, settings: InstrumentationSettings) -> Event:
309
313
  content: str | list[dict[str, Any] | str]
310
314
  if isinstance(self.content, str):
311
315
  content = self.content
@@ -317,10 +321,12 @@ class UserPromptPart:
317
321
  elif isinstance(part, (ImageUrl, AudioUrl, DocumentUrl, VideoUrl)):
318
322
  content.append({'kind': part.kind, 'url': part.url})
319
323
  elif isinstance(part, BinaryContent):
320
- base64_data = base64.b64encode(part.data).decode()
321
- content.append({'kind': part.kind, 'content': base64_data, 'media_type': part.media_type})
324
+ converted_part = {'kind': part.kind, 'media_type': part.media_type}
325
+ if settings.include_binary_content:
326
+ converted_part['binary_content'] = base64.b64encode(part.data).decode()
327
+ content.append(converted_part)
322
328
  else:
323
- content.append({'kind': part.kind})
329
+ content.append({'kind': part.kind}) # pragma: no cover
324
330
  return Event('gen_ai.user.message', body={'content': content, 'role': 'user'})
325
331
 
326
332
 
@@ -357,11 +363,11 @@ class ToolReturnPart:
357
363
  """Return a dictionary representation of the content, wrapping non-dict types appropriately."""
358
364
  # gemini supports JSON dict return values, but no other JSON types, hence we wrap anything else in a dict
359
365
  if isinstance(self.content, dict):
360
- return tool_return_ta.dump_python(self.content, mode='json') # pyright: ignore[reportUnknownMemberType]
366
+ return tool_return_ta.dump_python(self.content, mode='json') # pyright: ignore[reportUnknownMemberType] # pragma: no cover
361
367
  else:
362
368
  return {'return_value': tool_return_ta.dump_python(self.content, mode='json')}
363
369
 
364
- def otel_event(self) -> Event:
370
+ def otel_event(self, _settings: InstrumentationSettings) -> Event:
365
371
  return Event(
366
372
  'gen_ai.tool.message',
367
373
  body={'content': self.content, 'role': 'tool', 'id': self.tool_call_id, 'name': self.tool_name},
@@ -418,7 +424,7 @@ class RetryPromptPart:
418
424
  description = f'{len(self.content)} validation errors: {json_errors.decode()}'
419
425
  return f'{description}\n\nFix the errors and try again.'
420
426
 
421
- def otel_event(self) -> Event:
427
+ def otel_event(self, _settings: InstrumentationSettings) -> Event:
422
428
  if self.tool_name is None:
423
429
  return Event('gen_ai.user.message', body={'content': self.model_response(), 'role': 'user'})
424
430
  else:
@@ -556,6 +562,16 @@ class ModelResponse:
556
562
  kind: Literal['response'] = 'response'
557
563
  """Message type identifier, this is available on all parts as a discriminator."""
558
564
 
565
+ vendor_details: dict[str, Any] | None = field(default=None, repr=False)
566
+ """Additional vendor-specific details in a serializable format.
567
+
568
+ This allows storing selected vendor-specific data that isn't mapped to standard ModelResponse fields.
569
+ For OpenAI models, this may include 'logprobs', 'finish_reason', etc.
570
+ """
571
+
572
+ vendor_id: str | None = None
573
+ """Vendor ID as specified by the model provider. This can be used to track the specific request to the model."""
574
+
559
575
  def otel_events(self) -> list[Event]:
560
576
  """Return OpenTelemetry events for the response."""
561
577
  result: list[Event] = []
@@ -619,7 +635,7 @@ class TextPartDelta:
619
635
  ValueError: If `part` is not a `TextPart`.
620
636
  """
621
637
  if not isinstance(part, TextPart):
622
- raise ValueError('Cannot apply TextPartDeltas to non-TextParts')
638
+ raise ValueError('Cannot apply TextPartDeltas to non-TextParts') # pragma: no cover
623
639
  return replace(part, content=part.content + self.content_delta)
624
640
 
625
641
 
@@ -682,7 +698,9 @@ class ToolCallPartDelta:
682
698
  if isinstance(part, ToolCallPartDelta):
683
699
  return self._apply_to_delta(part)
684
700
 
685
- raise ValueError(f'Can only apply ToolCallPartDeltas to ToolCallParts or ToolCallPartDeltas, not {part}')
701
+ raise ValueError( # pragma: no cover
702
+ f'Can only apply ToolCallPartDeltas to ToolCallParts or ToolCallPartDeltas, not {part}'
703
+ )
686
704
 
687
705
  def _apply_to_delta(self, delta: ToolCallPartDelta) -> ToolCallPart | ToolCallPartDelta:
688
706
  """Internal helper to apply this delta to another delta."""
@@ -324,11 +324,48 @@ class Model(ABC):
324
324
  """The base URL for the provider API, if available."""
325
325
  return None
326
326
 
327
- def _get_instructions(self, messages: list[ModelMessage]) -> str | None:
328
- """Get instructions from the first ModelRequest found when iterating messages in reverse."""
327
+ @staticmethod
328
+ def _get_instructions(messages: list[ModelMessage]) -> str | None:
329
+ """Get instructions from the first ModelRequest found when iterating messages in reverse.
330
+
331
+ In the case that a "mock" request was generated to include a tool-return part for a result tool,
332
+ we want to use the instructions from the second-to-most-recent request (which should correspond to the
333
+ original request that generated the response that resulted in the tool-return part).
334
+ """
335
+ last_two_requests: list[ModelRequest] = []
329
336
  for message in reversed(messages):
330
337
  if isinstance(message, ModelRequest):
331
- return message.instructions
338
+ last_two_requests.append(message)
339
+ if len(last_two_requests) == 2:
340
+ break
341
+ if message.instructions is not None:
342
+ return message.instructions
343
+
344
+ # If we don't have two requests, and we didn't already return instructions, there are definitely not any:
345
+ if len(last_two_requests) != 2:
346
+ return None
347
+
348
+ most_recent_request = last_two_requests[0]
349
+ second_most_recent_request = last_two_requests[1]
350
+
351
+ # If we've gotten this far and the most recent request consists of only tool-return parts or retry-prompt parts,
352
+ # we use the instructions from the second-to-most-recent request. This is necessary because when handling
353
+ # result tools, we generate a "mock" ModelRequest with a tool-return part for it, and that ModelRequest will not
354
+ # have the relevant instructions from the agent.
355
+
356
+ # While it's possible that you could have a message history where the most recent request has only tool returns,
357
+ # I believe there is no way to achieve that would _change_ the instructions without manually crafting the most
358
+ # recent message. That might make sense in principle for some usage pattern, but it's enough of an edge case
359
+ # that I think it's not worth worrying about, since you can work around this by inserting another ModelRequest
360
+ # with no parts at all immediately before the request that has the tool calls (that works because we only look
361
+ # at the two most recent ModelRequests here).
362
+
363
+ # If you have a use case where this causes pain, please open a GitHub issue and we can discuss alternatives.
364
+
365
+ if all(p.part_kind == 'tool-return' or p.part_kind == 'retry-prompt' for p in most_recent_request.parts):
366
+ return second_most_recent_request.instructions
367
+
368
+ return None
332
369
 
333
370
 
334
371
  @dataclass
@@ -448,7 +485,7 @@ def infer_model(model: Model | KnownModelName | str) -> Model:
448
485
  raise UserError(f'Unknown model: {model}')
449
486
 
450
487
  if provider == 'vertexai':
451
- provider = 'google-vertex'
488
+ provider = 'google-vertex' # pragma: no cover
452
489
 
453
490
  if provider == 'cohere':
454
491
  from .cohere import CohereModel
@@ -479,7 +516,7 @@ def infer_model(model: Model | KnownModelName | str) -> Model:
479
516
 
480
517
  return BedrockConverseModel(model_name, provider=provider)
481
518
  else:
482
- raise UserError(f'Unknown model: {model}')
519
+ raise UserError(f'Unknown model: {model}') # pragma: no cover
483
520
 
484
521
 
485
522
  def cached_async_http_client(*, provider: str | None = None, timeout: int = 600, connect: int = 5) -> httpx.AsyncClient:
@@ -244,7 +244,7 @@ class AnthropicModel(Model):
244
244
  except APIStatusError as e:
245
245
  if (status_code := e.status_code) >= 400:
246
246
  raise ModelHTTPError(status_code=status_code, model_name=self.model_name, body=e.body) from e
247
- raise
247
+ raise # pragma: lax no cover
248
248
 
249
249
  def _process_response(self, response: AnthropicMessage) -> ModelResponse:
250
250
  """Process a non-streamed response, and prepare a message to return."""
@@ -262,13 +262,13 @@ class AnthropicModel(Model):
262
262
  )
263
263
  )
264
264
 
265
- return ModelResponse(items, usage=_map_usage(response), model_name=response.model)
265
+ return ModelResponse(items, usage=_map_usage(response), model_name=response.model, vendor_id=response.id)
266
266
 
267
267
  async def _process_streamed_response(self, response: AsyncStream[RawMessageStreamEvent]) -> StreamedResponse:
268
268
  peekable_response = _utils.PeekableAsyncStream(response)
269
269
  first_chunk = await peekable_response.peek()
270
270
  if isinstance(first_chunk, _utils.Unset):
271
- raise UnexpectedModelBehavior('Streamed response ended without content or tool calls')
271
+ raise UnexpectedModelBehavior('Streamed response ended without content or tool calls') # pragma: no cover
272
272
 
273
273
  # Since Anthropic doesn't provide a timestamp in the message, we'll use the current time
274
274
  timestamp = datetime.now(tz=timezone.utc)
@@ -305,9 +305,10 @@ class AnthropicModel(Model):
305
305
  is_error=False,
306
306
  )
307
307
  user_content_params.append(tool_result_block_param)
308
- elif isinstance(request_part, RetryPromptPart):
308
+ elif isinstance(request_part, RetryPromptPart): # pragma: no branch
309
309
  if request_part.tool_name is None:
310
- retry_param = TextBlockParam(type='text', text=request_part.model_response())
310
+ text = request_part.model_response() # pragma: no cover
311
+ retry_param = TextBlockParam(type='text', text=text) # pragma: no cover
311
312
  else:
312
313
  retry_param = ToolResultBlockParam(
313
314
  tool_use_id=_guard_tool_call_id(t=request_part),
@@ -380,7 +381,7 @@ class AnthropicModel(Model):
380
381
  else: # pragma: no cover
381
382
  raise RuntimeError(f'Unsupported media type: {item.media_type}')
382
383
  else:
383
- raise RuntimeError(f'Unsupported content type: {type(item)}')
384
+ raise RuntimeError(f'Unsupported content type: {type(item)}') # pragma: no cover
384
385
 
385
386
  @staticmethod
386
387
  def _map_tool_definition(f: ToolDefinition) -> ToolParam:
@@ -447,21 +448,25 @@ class AnthropicStreamedResponse(StreamedResponse):
447
448
  if isinstance(event, RawContentBlockStartEvent):
448
449
  current_block = event.content_block
449
450
  if isinstance(current_block, TextBlock) and current_block.text:
450
- yield self._parts_manager.handle_text_delta(vendor_part_id='content', content=current_block.text)
451
- elif isinstance(current_block, ToolUseBlock):
451
+ yield self._parts_manager.handle_text_delta( # pragma: lax no cover
452
+ vendor_part_id='content', content=current_block.text
453
+ )
454
+ elif isinstance(current_block, ToolUseBlock): # pragma: no branch
452
455
  maybe_event = self._parts_manager.handle_tool_call_delta(
453
456
  vendor_part_id=current_block.id,
454
457
  tool_name=current_block.name,
455
458
  args=cast(dict[str, Any], current_block.input),
456
459
  tool_call_id=current_block.id,
457
460
  )
458
- if maybe_event is not None:
461
+ if maybe_event is not None: # pragma: no branch
459
462
  yield maybe_event
460
463
 
461
464
  elif isinstance(event, RawContentBlockDeltaEvent):
462
465
  if isinstance(event.delta, TextDelta):
463
- yield self._parts_manager.handle_text_delta(vendor_part_id='content', content=event.delta.text)
464
- elif (
466
+ yield self._parts_manager.handle_text_delta( # pragma: no cover
467
+ vendor_part_id='content', content=event.delta.text
468
+ )
469
+ elif ( # pragma: no branch
465
470
  current_block and event.delta.type == 'input_json_delta' and isinstance(current_block, ToolUseBlock)
466
471
  ):
467
472
  # Try to parse the JSON immediately, otherwise cache the value for later. This handles
@@ -480,7 +485,7 @@ class AnthropicStreamedResponse(StreamedResponse):
480
485
  args=parsed_args,
481
486
  tool_call_id=current_block.id,
482
487
  )
483
- if maybe_event is not None:
488
+ if maybe_event is not None: # pragma: no branch
484
489
  yield maybe_event
485
490
 
486
491
  elif isinstance(event, (RawContentBlockStopEvent, RawMessageStopEvent)):