Skip to content

Commit 735df29

Browse files
committed
Merge branch 'main' into toolsets
# Conflicts: # pydantic_ai_slim/pydantic_ai/_agent_graph.py # pydantic_ai_slim/pydantic_ai/tools.py
2 parents 0e0bf35 + a341e56 commit 735df29

File tree

11 files changed

+388
-31
lines changed

11 files changed

+388
-31
lines changed

docs/logfire.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,3 +323,22 @@ agent = Agent('gpt-4o', instrument=instrumentation_settings)
323323
# or to instrument all agents:
324324
Agent.instrument_all(instrumentation_settings)
325325
```
326+
327+
### Excluding prompts and completions
328+
329+
For privacy and security reasons, you may want to monitor your agent's behavior and performance without exposing sensitive user data or proprietary prompts in your observability platform. PydanticAI allows you to exclude the actual content from instrumentation events while preserving the structural information needed for debugging and monitoring.
330+
331+
When `include_content=False` is set, PydanticAI will exclude sensitive content from OpenTelemetry events, including user prompts and model completions, tool call arguments and responses, and any other message content.
332+
333+
```python {title="excluding_sensitive_content.py"}
334+
from pydantic_ai.agent import Agent
335+
from pydantic_ai.models.instrumented import InstrumentationSettings
336+
337+
instrumentation_settings = InstrumentationSettings(include_content=False)
338+
339+
agent = Agent('gpt-4o', instrument=instrumentation_settings)
340+
# or to instrument all agents:
341+
Agent.instrument_all(instrumentation_settings)
342+
```
343+
344+
This setting is particularly useful in production environments where compliance requirements or data sensitivity concerns make it necessary to limit what content is sent to your observability platform.

docs/models/openai.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,21 @@ agent = Agent(model)
6161

6262
`OpenAIProvider` also accepts a custom `AsyncOpenAI` client via the `openai_client` parameter, so you can customise the `organization`, `project`, `base_url` etc. as defined in the [OpenAI API docs](https://platform.openai.com/docs/api-reference).
6363

64-
You could also use the [`AsyncAzureOpenAI`](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/switching-endpoints) client to use the Azure OpenAI API.
64+
```python {title="custom_openai_client.py"}
65+
from openai import AsyncOpenAI
66+
67+
from pydantic_ai import Agent
68+
from pydantic_ai.models.openai import OpenAIModel
69+
from pydantic_ai.providers.openai import OpenAIProvider
70+
71+
client = AsyncOpenAI(max_retries=3)
72+
model = OpenAIModel('gpt-4o', provider=OpenAIProvider(openai_client=client))
73+
agent = Agent(model)
74+
...
75+
```
76+
77+
You could also use the [`AsyncAzureOpenAI`](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/switching-endpoints) client
78+
to use the Azure OpenAI API. Note that the `AsyncAzureOpenAI` is a subclass of `AsyncOpenAI`.
6579

6680
```python
6781
from openai import AsyncAzureOpenAI

pydantic_ai_slim/pydantic_ai/_agent_graph.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from .tools import RunContext, ToolDefinition, ToolKind
2828

2929
if TYPE_CHECKING:
30-
pass
30+
from .models.instrumented import InstrumentationSettings
3131

3232
__all__ = (
3333
'GraphAgentState',
@@ -115,6 +115,7 @@ class GraphAgentDeps(Generic[DepsT, OutputDataT]):
115115
toolset: RunToolset[DepsT]
116116

117117
tracer: Tracer
118+
instrumentation_settings: InstrumentationSettings | None = None
118119

119120

120121
class AgentNode(BaseNode[GraphAgentState, GraphAgentDeps[DepsT, Any], result.FinalResult[NodeRunEndT]]):
@@ -669,6 +670,10 @@ async def process_function_tools(
669670

670671
user_parts: list[_messages.UserPromptPart] = []
671672

673+
include_content = (
674+
ctx.deps.instrumentation_settings is not None and ctx.deps.instrumentation_settings.include_content
675+
)
676+
672677
# Run all tool tasks in parallel
673678
results_by_index: dict[int, _messages.ModelRequestPart] = {}
674679
with ctx.deps.tracer.start_as_current_span(
@@ -679,7 +684,9 @@ async def process_function_tools(
679684
},
680685
):
681686
tasks = [
682-
asyncio.create_task(_call_function_tool(toolset, call, run_context, ctx.deps.tracer), name=call.tool_name)
687+
asyncio.create_task(
688+
_call_function_tool(toolset, call, run_context, ctx.deps.tracer, include_content), name=call.tool_name
689+
)
683690
for call in calls_to_run
684691
]
685692

@@ -736,6 +743,7 @@ async def _call_function_tool(
736743
tool_call: _messages.ToolCallPart,
737744
run_context: RunContext[DepsT],
738745
tracer: Tracer,
746+
include_content: bool = False,
739747
) -> _messages.ToolReturnPart | _messages.RetryPromptPart:
740748
"""Run the tool function asynchronously.
741749
@@ -745,14 +753,14 @@ async def _call_function_tool(
745753
'gen_ai.tool.name': tool_call.tool_name,
746754
# NOTE: this means `gen_ai.tool.call.id` will be included even if it was generated by pydantic-ai
747755
'gen_ai.tool.call.id': tool_call.tool_call_id,
748-
'tool_arguments': tool_call.args_as_json_str(),
756+
**({'tool_arguments': tool_call.args_as_json_str()} if include_content else {}),
749757
'logfire.msg': f'running tool: {tool_call.tool_name}',
750758
# add the JSON schema so these attributes are formatted nicely in Logfire
751759
'logfire.json_schema': json.dumps(
752760
{
753761
'type': 'object',
754762
'properties': {
755-
'tool_arguments': {'type': 'object'},
763+
**({'tool_arguments': {'type': 'object'}} if include_content else {}),
756764
'gen_ai.tool.name': {},
757765
'gen_ai.tool.call.id': {},
758766
},

pydantic_ai_slim/pydantic_ai/agent.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,7 @@ async def get_instructions(run_context: RunContext[AgentDepsT]) -> str | None:
743743
toolset=run_toolset,
744744
tracer=tracer,
745745
get_instructions=get_instructions,
746+
instrumentation_settings=instrumentation_settings,
746747
)
747748
start_node = _agent_graph.UserPromptNode[AgentDepsT](
748749
user_prompt=user_prompt,

pydantic_ai_slim/pydantic_ai/messages.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,11 @@ class SystemPromptPart:
7676
part_kind: Literal['system-prompt'] = 'system-prompt'
7777
"""Part type identifier, this is available on all parts as a discriminator."""
7878

79-
def otel_event(self, _settings: InstrumentationSettings) -> Event:
80-
return Event('gen_ai.system.message', body={'content': self.content, 'role': 'system'})
79+
def otel_event(self, settings: InstrumentationSettings) -> Event:
80+
return Event(
81+
'gen_ai.system.message',
82+
body={'role': 'system', **({'content': self.content} if settings.include_content else {})},
83+
)
8184

8285
__repr__ = _utils.dataclasses_no_defaults_repr
8386

@@ -362,12 +365,12 @@ def otel_event(self, settings: InstrumentationSettings) -> Event:
362365
content = []
363366
for part in self.content:
364367
if isinstance(part, str):
365-
content.append(part)
368+
content.append(part if settings.include_content else {'kind': 'text'})
366369
elif isinstance(part, (ImageUrl, AudioUrl, DocumentUrl, VideoUrl)):
367-
content.append({'kind': part.kind, 'url': part.url})
370+
content.append({'kind': part.kind, **({'url': part.url} if settings.include_content else {})})
368371
elif isinstance(part, BinaryContent):
369372
converted_part = {'kind': part.kind, 'media_type': part.media_type}
370-
if settings.include_binary_content:
373+
if settings.include_content and settings.include_binary_content:
371374
converted_part['binary_content'] = base64.b64encode(part.data).decode()
372375
content.append(converted_part)
373376
else:
@@ -414,10 +417,15 @@ def model_response_object(self) -> dict[str, Any]:
414417
else:
415418
return {'return_value': tool_return_ta.dump_python(self.content, mode='json')}
416419

417-
def otel_event(self, _settings: InstrumentationSettings) -> Event:
420+
def otel_event(self, settings: InstrumentationSettings) -> Event:
418421
return Event(
419422
'gen_ai.tool.message',
420-
body={'content': self.content, 'role': 'tool', 'id': self.tool_call_id, 'name': self.tool_name},
423+
body={
424+
**({'content': self.content} if settings.include_content else {}),
425+
'role': 'tool',
426+
'id': self.tool_call_id,
427+
'name': self.tool_name,
428+
},
421429
)
422430

423431
__repr__ = _utils.dataclasses_no_defaults_repr
@@ -473,14 +481,14 @@ def model_response(self) -> str:
473481
description = f'{len(self.content)} validation errors: {json_errors.decode()}'
474482
return f'{description}\n\nFix the errors and try again.'
475483

476-
def otel_event(self, _settings: InstrumentationSettings) -> Event:
484+
def otel_event(self, settings: InstrumentationSettings) -> Event:
477485
if self.tool_name is None:
478486
return Event('gen_ai.user.message', body={'content': self.model_response(), 'role': 'user'})
479487
else:
480488
return Event(
481489
'gen_ai.tool.message',
482490
body={
483-
'content': self.model_response(),
491+
**({'content': self.model_response()} if settings.include_content else {}),
484492
'role': 'tool',
485493
'id': self.tool_call_id,
486494
'name': self.tool_name,
@@ -657,7 +665,7 @@ class ModelResponse:
657665
vendor_id: str | None = None
658666
"""Vendor ID as specified by the model provider. This can be used to track the specific request to the model."""
659667

660-
def otel_events(self) -> list[Event]:
668+
def otel_events(self, settings: InstrumentationSettings) -> list[Event]:
661669
"""Return OpenTelemetry events for the response."""
662670
result: list[Event] = []
663671

@@ -683,7 +691,8 @@ def new_event_body():
683691
elif isinstance(part, TextPart):
684692
if body.get('content'):
685693
body = new_event_body()
686-
body['content'] = part.content
694+
if settings.include_content:
695+
body['content'] = part.content
687696

688697
return result
689698

pydantic_ai_slim/pydantic_ai/models/anthropic.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -342,15 +342,13 @@ async def _map_message(self, messages: list[ModelMessage]) -> tuple[str, list[Be
342342
if response_part.content: # Only add non-empty text
343343
assistant_content_params.append(BetaTextBlockParam(text=response_part.content, type='text'))
344344
elif isinstance(response_part, ThinkingPart):
345-
# NOTE: We don't send ThinkingPart to the providers yet. If you are unsatisfied with this,
346-
# please open an issue. The below code is the code to send thinking to the provider.
347-
# assert response_part.signature is not None, 'Thinking part must have a signature'
348-
# assistant_content_params.append(
349-
# BetaThinkingBlockParam(
350-
# thinking=response_part.content, signature=response_part.signature, type='thinking'
351-
# )
352-
# )
353-
pass
345+
# NOTE: We only send thinking part back for Anthropic, otherwise they raise an error.
346+
if response_part.signature is not None: # pragma: no branch
347+
assistant_content_params.append(
348+
BetaThinkingBlockParam(
349+
thinking=response_part.content, signature=response_part.signature, type='thinking'
350+
)
351+
)
354352
else:
355353
tool_use_block_param = BetaToolUseBlockParam(
356354
id=_guard_tool_call_id(t=response_part),

pydantic_ai_slim/pydantic_ai/models/instrumented.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ def __init__(
9292
meter_provider: MeterProvider | None = None,
9393
event_logger_provider: EventLoggerProvider | None = None,
9494
include_binary_content: bool = True,
95+
include_content: bool = True,
9596
):
9697
"""Create instrumentation options.
9798
@@ -109,6 +110,8 @@ def __init__(
109110
Calling `logfire.configure()` sets the global event logger provider, so most users don't need this.
110111
This is only used if `event_mode='logs'`.
111112
include_binary_content: Whether to include binary content in the instrumentation events.
113+
include_content: Whether to include prompts, completions, and tool call arguments and responses
114+
in the instrumentation events.
112115
"""
113116
from pydantic_ai import __version__
114117

@@ -121,6 +124,7 @@ def __init__(
121124
self.event_logger = event_logger_provider.get_event_logger(scope_name, __version__)
122125
self.event_mode = event_mode
123126
self.include_binary_content = include_binary_content
127+
self.include_content = include_content
124128

125129
# As specified in the OpenTelemetry GenAI metrics spec:
126130
# https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/#metric-gen_aiclienttokenusage
@@ -161,7 +165,7 @@ def messages_to_otel_events(self, messages: list[ModelMessage]) -> list[Event]:
161165
if hasattr(part, 'otel_event'):
162166
message_events.append(part.otel_event(self))
163167
elif isinstance(message, ModelResponse): # pragma: no branch
164-
message_events = message.otel_events()
168+
message_events = message.otel_events(self)
165169
for event in message_events:
166170
event.attributes = {
167171
'gen_ai.message.index': message_index,

0 commit comments

Comments
 (0)