struct-sdk 0.3.1__tar.gz → 0.3.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: struct-sdk
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
5
5
  Project-URL: Homepage, https://struct.ai
6
6
  Project-URL: Documentation, https://struct.ai/docs
@@ -92,6 +92,8 @@ Provides-Extra: langchain
92
92
  Requires-Dist: langchain-core>=1.3.3; extra == 'langchain'
93
93
  Provides-Extra: openai
94
94
  Requires-Dist: openai>=1.66.0; extra == 'openai'
95
+ Provides-Extra: pydantic-ai
96
+ Requires-Dist: pydantic-ai-slim>=2.53.0; extra == 'pydantic-ai'
95
97
  Provides-Extra: structlog
96
98
  Requires-Dist: opentelemetry-instrumentation-structlog; extra == 'structlog'
97
99
  Provides-Extra: temporal
@@ -102,8 +104,8 @@ Description-Content-Type: text/markdown
102
104
 
103
105
  OpenTelemetry instrumentation for AI agents in Python. Captures spans,
104
106
  token usage, and message events from the Anthropic SDK, the OpenAI SDK
105
- (Responses API), the Claude Agent SDK, and LangChain / LangGraph, and
106
- exports them to [struct.ai](https://struct.ai) for observability.
107
+ (Responses API), pydantic-ai, the Claude Agent SDK, and LangChain /
108
+ LangGraph, and exports them to [struct.ai](https://struct.ai) for observability.
107
109
 
108
110
  A TypeScript version is available as
109
111
  [`@struct-ai/sdk`](https://www.npmjs.com/package/@struct-ai/sdk). Both
@@ -117,6 +119,7 @@ pip install struct-sdk
117
119
  # optional — the SDK auto-instruments these if present
118
120
  pip install anthropic
119
121
  pip install openai
122
+ pip install pydantic-ai-slim
120
123
  pip install claude-agent-sdk
121
124
  pip install langchain-core langgraph
122
125
  ```
@@ -162,6 +165,7 @@ async with struct.agent(name="checkout"):
162
165
  |---|---|---|
163
166
  | `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
164
167
  | `openai` (Responses API) | `chat {model}` | Non-streaming `responses.create` on sync and async clients (Azure clients included). Cached-token accounting from `usage.input_tokens_details`. Streaming and `chat.completions` calls pass through untraced. Message-content capture at parity with the Anthropic integration: per-message events (`gen_ai.system/user/assistant/tool.message`) and `gen_ai.choice`, plus `gen_ai.input.messages` / `gen_ai.output.messages` in span-content mode. Reasoning items are skipped (`encrypted_content` is never emitted). |
168
+ | `pydantic_ai` | `invoke_agent {agent}`, `chat {model}`, `execute_tool {tool}` | pydantic-ai's own instrumentation, turned on with Struct's tracer provider. See [pydantic-ai](#pydantic-ai). |
165
169
  | `claude_agent_sdk` | `agent`, `chat`, `execute_tool` | Telemetry comes from Claude Code itself in the subprocess; ingest credentials are propagated to it via `ClaudeAgentOptions`. Subagents inherit the configuration. |
166
170
  | `langchain_core` `BaseChatModel` | `chat {model}` | When the underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic`), the provider's span is suppressed, so there is a single span. |
167
171
  | `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
@@ -202,6 +206,40 @@ from claude_agent_sdk import ClaudeAgentOptions, query
202
206
  # credentials on ClaudeAgentOptions; subagents inherit them automatically.
203
207
  ```
204
208
 
209
+ #### pydantic-ai
210
+
211
+ ```python
212
+ from struct_sdk import struct
213
+ struct.init(ingest_key="pk-...", service_name="support-agent")
214
+
215
+ from pydantic_ai import Agent
216
+ agent = Agent("openai:gpt-5.5", name="support")
217
+ result = await agent.run("Refund order 42", conversation_id="thread-42")
218
+ ```
219
+
220
+ pydantic-ai already emits OpenTelemetry GenAI spans; `struct.init()` turns
221
+ that on for every agent (`Agent.instrument_all`) with Struct's tracer
222
+ provider, unless you already set your own default (for example with
223
+ logfire). You get pydantic-ai's `invoke_agent`, `chat` and `execute_tool`
224
+ spans, with one `chat` span per model call (Struct's OpenAI and Anthropic
225
+ patches stay out of the way for pydantic-ai's own provider calls; model
226
+ calls your code makes while reading a stream keep their spans). During agent
227
+ runs, Struct guards telemetry faults and caps content. Realtime sessions and
228
+ direct `InstrumentedModel` exception recording are outside this guarantee.
229
+
230
+ - **Content** goes on span attributes only (pydantic-ai has no message
231
+ events): `span_only` / `span_and_event` include prompts, answers and tool
232
+ arguments; `event_only` and `none` do not. Tool definitions are always
233
+ recorded.
234
+ - **Sessions:** on Struct's provider, a `struct.agent(session_id=...)`
235
+ around the run wins; otherwise pydantic-ai's `conversation_id` is used.
236
+ Instrumentation configured on your own provider keeps its conversation IDs.
237
+ - **Turn it off** with
238
+ `struct.init(..., instrumentation_options={"pydantic_ai": {"enabled": False}})`,
239
+ or for one agent with `agent.instrument = False`. An
240
+ `Agent.instrument_all(False)` before `struct.init()` is pydantic-ai's
241
+ initial value, so it does not keep pydantic-ai off.
242
+
205
243
  #### LangChain / LangGraph (with an agent or graph)
206
244
 
207
245
  ```python
@@ -2,8 +2,8 @@
2
2
 
3
3
  OpenTelemetry instrumentation for AI agents in Python. Captures spans,
4
4
  token usage, and message events from the Anthropic SDK, the OpenAI SDK
5
- (Responses API), the Claude Agent SDK, and LangChain / LangGraph, and
6
- exports them to [struct.ai](https://struct.ai) for observability.
5
+ (Responses API), pydantic-ai, the Claude Agent SDK, and LangChain /
6
+ LangGraph, and exports them to [struct.ai](https://struct.ai) for observability.
7
7
 
8
8
  A TypeScript version is available as
9
9
  [`@struct-ai/sdk`](https://www.npmjs.com/package/@struct-ai/sdk). Both
@@ -17,6 +17,7 @@ pip install struct-sdk
17
17
  # optional — the SDK auto-instruments these if present
18
18
  pip install anthropic
19
19
  pip install openai
20
+ pip install pydantic-ai-slim
20
21
  pip install claude-agent-sdk
21
22
  pip install langchain-core langgraph
22
23
  ```
@@ -62,6 +63,7 @@ async with struct.agent(name="checkout"):
62
63
  |---|---|---|
63
64
  | `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
64
65
  | `openai` (Responses API) | `chat {model}` | Non-streaming `responses.create` on sync and async clients (Azure clients included). Cached-token accounting from `usage.input_tokens_details`. Streaming and `chat.completions` calls pass through untraced. Message-content capture at parity with the Anthropic integration: per-message events (`gen_ai.system/user/assistant/tool.message`) and `gen_ai.choice`, plus `gen_ai.input.messages` / `gen_ai.output.messages` in span-content mode. Reasoning items are skipped (`encrypted_content` is never emitted). |
66
+ | `pydantic_ai` | `invoke_agent {agent}`, `chat {model}`, `execute_tool {tool}` | pydantic-ai's own instrumentation, turned on with Struct's tracer provider. See [pydantic-ai](#pydantic-ai). |
65
67
  | `claude_agent_sdk` | `agent`, `chat`, `execute_tool` | Telemetry comes from Claude Code itself in the subprocess; ingest credentials are propagated to it via `ClaudeAgentOptions`. Subagents inherit the configuration. |
66
68
  | `langchain_core` `BaseChatModel` | `chat {model}` | When the underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic`), the provider's span is suppressed, so there is a single span. |
67
69
  | `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
@@ -102,6 +104,40 @@ from claude_agent_sdk import ClaudeAgentOptions, query
102
104
  # credentials on ClaudeAgentOptions; subagents inherit them automatically.
103
105
  ```
104
106
 
107
+ #### pydantic-ai
108
+
109
+ ```python
110
+ from struct_sdk import struct
111
+ struct.init(ingest_key="pk-...", service_name="support-agent")
112
+
113
+ from pydantic_ai import Agent
114
+ agent = Agent("openai:gpt-5.5", name="support")
115
+ result = await agent.run("Refund order 42", conversation_id="thread-42")
116
+ ```
117
+
118
+ pydantic-ai already emits OpenTelemetry GenAI spans; `struct.init()` turns
119
+ that on for every agent (`Agent.instrument_all`) with Struct's tracer
120
+ provider, unless you already set your own default (for example with
121
+ logfire). You get pydantic-ai's `invoke_agent`, `chat` and `execute_tool`
122
+ spans, with one `chat` span per model call (Struct's OpenAI and Anthropic
123
+ patches stay out of the way for pydantic-ai's own provider calls; model
124
+ calls your code makes while reading a stream keep their spans). During agent
125
+ runs, Struct guards telemetry faults and caps content. Realtime sessions and
126
+ direct `InstrumentedModel` exception recording are outside this guarantee.
127
+
128
+ - **Content** goes on span attributes only (pydantic-ai has no message
129
+ events): `span_only` / `span_and_event` include prompts, answers and tool
130
+ arguments; `event_only` and `none` do not. Tool definitions are always
131
+ recorded.
132
+ - **Sessions:** on Struct's provider, a `struct.agent(session_id=...)`
133
+ around the run wins; otherwise pydantic-ai's `conversation_id` is used.
134
+ Instrumentation configured on your own provider keeps its conversation IDs.
135
+ - **Turn it off** with
136
+ `struct.init(..., instrumentation_options={"pydantic_ai": {"enabled": False}})`,
137
+ or for one agent with `agent.instrument = False`. An
138
+ `Agent.instrument_all(False)` before `struct.init()` is pydantic-ai's
139
+ initial value, so it does not keep pydantic-ai off.
140
+
105
141
  #### LangChain / LangGraph (with an agent or graph)
106
142
 
107
143
  ```python
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "struct-sdk"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -48,6 +48,9 @@ claude-agent-sdk = ["claude-agent-sdk>=0.2.121", "mcp>=1.30.0,<2"]
48
48
  langchain = ["langchain-core>=1.3.3"]
49
49
  # Responses API (openai.resources.responses) exists from 1.66.0 onward.
50
50
  openai = ["openai>=1.66.0"]
51
+ # pydantic-ai's own OpenTelemetry instrumentation, driven with Struct's
52
+ # provider. 2.53.0 is the oldest release the suite runs against.
53
+ pydantic-ai = ["pydantic-ai-slim>=2.53.0"]
51
54
  # Full-app mode (the default). Each instrumentor only activates when its
52
55
  # target library is installed. Otherwise unpinned so the resolver picks the
53
56
  # release matching the host's opentelemetry-sdk. The floor is contrib 0.51b0
@@ -154,6 +157,9 @@ dev = [
154
157
  # Zero-code activation is tested next to the standard distro's configurator.
155
158
  "opentelemetry-distro",
156
159
  "temporalio>=1.11.0",
160
+ # pydantic-ai conformance suite (tests/test_pydantic_ai.py): real Agent
161
+ # runs, and pydantic-ai's OpenAI model over the transport-mocked client.
162
+ "pydantic-ai-slim[openai]>=2.53.0",
157
163
  ]
158
164
 
159
165
  [tool.uv]
@@ -177,7 +183,7 @@ markers = [
177
183
 
178
184
  [tool.mypy]
179
185
  [[tool.mypy.overrides]]
180
- module = ["anthropic", "anthropic.*", "claude_agent_sdk", "claude_agent_sdk.*", "langchain_core", "langchain_core.*", "langchain", "langchain.*", "langgraph", "langgraph.*", "openai", "openai.*", "opentelemetry.instrumentation.*", "azure.core.*", "temporalio", "temporalio.*", "requests", "requests.*", "opentelemetry.resource.*", "ddtrace", "ddtrace.*"]
186
+ module = ["anthropic", "anthropic.*", "pydantic_ai", "pydantic_ai.*", "claude_agent_sdk", "claude_agent_sdk.*", "langchain_core", "langchain_core.*", "langchain", "langchain.*", "langgraph", "langgraph.*", "openai", "openai.*", "opentelemetry.instrumentation.*", "azure.core.*", "temporalio", "temporalio.*", "requests", "requests.*", "opentelemetry.resource.*", "ddtrace", "ddtrace.*"]
181
187
  ignore_missing_imports = true
182
188
 
183
189
  [tool.hatch.build.targets.wheel]
@@ -60,29 +60,26 @@ def truncate_field(value: "str | Any", max_len: int = _MAX_FIELD_SIZE) -> "str |
60
60
  return value
61
61
 
62
62
 
63
+ # Part fields that hold a structured payload (tool arguments and results), capped as
64
+ # serialized JSON. pydantic-ai puts a tool's output under ``result``, OTel under
65
+ # ``response``.
66
+ _STRUCTURED_PART_FIELDS = ("arguments", "response", "result")
67
+
68
+
63
69
  def truncate_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
64
- """Truncate content fields within message parts (content/arguments/response)."""
70
+ """Cap every field of message parts: any string field (text, reasoning, ids, URIs,
71
+ inline data, tool results) at 16 KiB, and a structured tool payload at 16 KiB of
72
+ its JSON. Keys are kept."""
65
73
  result = []
66
74
  for part in parts:
67
75
  part = dict(part) # shallow copy
68
- if "content" in part and isinstance(part["content"], str):
69
- part["content"] = truncate_field(part["content"])
70
- if "arguments" in part:
71
- arg = part["arguments"]
72
- if isinstance(arg, str):
73
- part["arguments"] = truncate_field(arg)
74
- elif isinstance(arg, dict):
75
- serialized = json.dumps(arg, default=str)
76
+ for key, value in part.items():
77
+ if isinstance(value, str):
78
+ part[key] = truncate_field(value)
79
+ elif key in _STRUCTURED_PART_FIELDS and isinstance(value, (dict, list)):
80
+ serialized = json.dumps(value, default=str)
76
81
  if len(serialized) > _MAX_FIELD_SIZE:
77
- part["arguments"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
78
- if "response" in part:
79
- resp = part["response"]
80
- if isinstance(resp, str):
81
- part["response"] = truncate_field(resp)
82
- elif isinstance(resp, (dict, list)):
83
- serialized = json.dumps(resp, default=str)
84
- if len(serialized) > _MAX_FIELD_SIZE:
85
- part["response"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
82
+ part[key] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
86
83
  result.append(part)
87
84
  return result
88
85
 
@@ -92,10 +89,10 @@ def truncate_and_serialize(obj: Any, max_size: int = _MAX_CONTENT_SIZE) -> str:
92
89
 
93
90
  Unlike a blind string slice, this preserves valid JSON by truncating
94
91
  content/arguments/response fields within parts before serialization.
95
- If the result still exceeds ``max_size`` after field truncation, applies
96
- a final hard cap that tries to keep the JSON array parseable.
92
+ If the result still exceeds ``max_size`` after field truncation, keeps the
93
+ whole leading array elements that fit, so the result stays valid JSON.
97
94
  """
98
- if isinstance(obj, list):
95
+ if isinstance(obj, (list, tuple)):
99
96
  truncated = []
100
97
  for item in obj:
101
98
  if isinstance(item, dict):
@@ -110,19 +107,72 @@ def truncate_and_serialize(obj: Any, max_size: int = _MAX_CONTENT_SIZE) -> str:
110
107
  else:
111
108
  result = json.dumps(obj, default=str)
112
109
 
113
- # If still too large after field-level truncation, do a final hard cap
114
- # but try to close the JSON array to keep it parseable.
110
+ # Still too large after field-level truncation: keep whole leading array
111
+ # elements that fit, so the result is always valid JSON. (Cutting at the last
112
+ # "}" could land inside a nested part and leave its message open.)
115
113
  if len(result) > max_size:
116
- cut = result[:max_size - 50] # leave room for closing
117
- last_brace = cut.rfind("}")
118
- if last_brace > 0:
119
- result = cut[:last_brace + 1] + "]"
120
- else:
121
- result = "[]"
114
+ if not isinstance(obj, (list, tuple)):
115
+ return "[]"
116
+ kept: list[str] = []
117
+ size = 2 # the enclosing brackets
118
+ for item in truncated:
119
+ encoded = json.dumps(item, default=str)
120
+ if not kept and size + len(encoded) > max_size:
121
+ # A first message too large on its own keeps its whole leading
122
+ # parts instead, so a single oversize message is never dropped.
123
+ encoded = _leading_parts_that_fit(item, max_size - size) or ""
124
+ if encoded:
125
+ kept.append(encoded)
126
+ break
127
+ extra = len(encoded) + (2 if kept else 0) # ", " between elements
128
+ if size + extra > max_size:
129
+ break
130
+ kept.append(encoded)
131
+ size += extra
132
+ result = "[" + ", ".join(kept) + "]"
122
133
 
123
134
  return result
124
135
 
125
136
 
137
+ def _leading_parts_that_fit(item: Any, budget: int) -> Optional[str]:
138
+ """``item`` (a message) serialized with only the leading ``parts`` that fit in
139
+ ``budget`` characters, or ``None`` when it has no parts or none fit."""
140
+ if not isinstance(item, dict) or not isinstance(item.get("parts"), list):
141
+ return None
142
+ # Linear: the envelope and each part are serialized once (re-serializing the
143
+ # growing prefix per part was quadratic on the host's request path).
144
+ marker = "\x00struct-parts\x00"
145
+ envelope = json.dumps({**item, "parts": marker}, default=str)
146
+ marker_json = json.dumps(marker)
147
+ if envelope.count(marker_json) != 1: # the message itself holds the marker text
148
+ return _leading_parts_that_fit_slow(item, budget)
149
+ size = len(envelope) - len(marker_json) + 2 # the parts list's brackets
150
+ kept: list[str] = []
151
+ for part in item["parts"]:
152
+ encoded = json.dumps(part, default=str)
153
+ extra = len(encoded) + (2 if kept else 0) # ", " between parts
154
+ if size + extra > budget:
155
+ break
156
+ kept.append(encoded)
157
+ size += extra
158
+ if not kept:
159
+ return None
160
+ return envelope.replace(marker_json, "[" + ", ".join(kept) + "]", 1)
161
+
162
+
163
+ def _leading_parts_that_fit_slow(item: Any, budget: int) -> Optional[str]:
164
+ """``_leading_parts_that_fit`` for a message containing its marker text."""
165
+ parts: list[Any] = []
166
+ best: Optional[str] = None
167
+ for part in item["parts"]:
168
+ candidate = json.dumps({**item, "parts": [*parts, part]}, default=str)
169
+ if len(candidate) > budget:
170
+ break
171
+ parts.append(part)
172
+ best = candidate
173
+ return best
174
+
175
+
126
176
  def safe_json(obj: Any) -> str:
127
177
  """Safely serialize to JSON string with field-level truncation."""
128
178
  try:
@@ -578,9 +628,15 @@ def stamp_provider_once(agent_span: Any, provider: Optional[str]) -> None:
578
628
 
579
629
 
580
630
  def propagate_provider_to_parent(provider: Optional[str]) -> None:
581
- """Stamp the ambient ``_current_agent_span`` with the child call's provider."""
631
+ """Stamp the ambient ``_current_agent_span`` with the child call's provider,
632
+ unless a framework span that stamps it itself owns this call (SDK-PY-9)."""
582
633
  try:
583
- from struct_sdk.core import _current_agent_span
634
+ from opentelemetry import context as otel_context
635
+
636
+ from struct_sdk.core import _PROVIDER_STAMP_OWNED_KEY, _current_agent_span
637
+
638
+ if otel_context.get_value(_PROVIDER_STAMP_OWNED_KEY):
639
+ return
584
640
  stamp_provider_once(_current_agent_span.get(None), provider)
585
641
  except Exception:
586
642
  pass # Never break the application for telemetry
@@ -190,6 +190,10 @@ _pending_tool_calls: contextvars.ContextVar[Optional[dict[str, list[str]]]] = co
190
190
  # and whose contextvar token could raise ``ValueError: Token created in a different
191
191
  # Context`` when detached from a different async context).
192
192
  _GENAI_SUPPRESS_KEY = create_key("struct.suppress_genai")
193
+ # Set with the suppression key when the owning framework span stamps the agent's
194
+ # provider itself (pydantic-ai, from the call that answered): a stood-down provider
195
+ # patch then leaves the agent span's provider to it (SDK-PY-9).
196
+ _PROVIDER_STAMP_OWNED_KEY = _otel_context.create_key("struct_provider_stamp_owned")
193
197
 
194
198
 
195
199
  def is_genai_suppressed() -> bool:
@@ -199,7 +203,7 @@ def is_genai_suppressed() -> bool:
199
203
  return bool(_otel_context.get_value(_GENAI_SUPPRESS_KEY))
200
204
 
201
205
 
202
- def suppress_genai_token() -> "tuple[Token[_OtelContext], Optional[asyncio.Task[Any]]]":
206
+ def suppress_genai_token(*, owns_provider_stamp: bool = False) -> "tuple[Token[_OtelContext], Optional[asyncio.Task[Any]]]":
203
207
  """Attach the suppression key to the current OTel context.
204
208
 
205
209
  Returns ``(token, enter_task)`` that MUST be passed to ``reset_genai()`` when
@@ -208,7 +212,10 @@ def suppress_genai_token() -> "tuple[Token[_OtelContext], Optional[asyncio.Task[
208
212
  schedule on DIFFERENT asyncio tasks, so the enter-task travels with the token
209
213
  for a task-affinity-safe detach.
210
214
  """
211
- token = _otel_context.attach(_otel_context.set_value(_GENAI_SUPPRESS_KEY, True))
215
+ context = _otel_context.set_value(_GENAI_SUPPRESS_KEY, True)
216
+ if owns_provider_stamp:
217
+ context = _otel_context.set_value(_PROVIDER_STAMP_OWNED_KEY, True, context)
218
+ token = _otel_context.attach(context)
212
219
  return token, _current_task_or_none()
213
220
 
214
221
 
@@ -812,6 +819,9 @@ class StructSDK:
812
819
  Other keys are passed to that instrumentor's ``instrument()``.
813
820
  Names listed in ``OTEL_PYTHON_DISABLED_INSTRUMENTATIONS`` (or
814
821
  ``*``) default to disabled; an explicit entry here wins.
822
+ ``{"enabled": False}`` also turns off a GenAI integration in
823
+ any mode: ``anthropic``, ``claude_agent_sdk``,
824
+ ``langchain_core``, ``openai``, ``pydantic_ai``.
815
825
  redact_attributes: Glob patterns (case-insensitive) for span,
816
826
  span-event and log attributes whose values Struct sends as
817
827
  "[REDACTED]", e.g. ``["http.request.header.authorization",
@@ -878,7 +888,7 @@ class StructSDK:
878
888
  global _init_pid
879
889
  _init_pid = os.getpid()
880
890
 
881
- self._auto_instrument()
891
+ self._auto_instrument(instrumentation_options or {})
882
892
  except Exception:
883
893
  logger.warning(
884
894
  "Struct SDK init failed; SDK disabled: service=%s endpoint=%s",
@@ -905,18 +915,22 @@ class StructSDK:
905
915
  self._service_name, endpoint, self._content_capture.value,
906
916
  )
907
917
 
908
- def _auto_instrument(self):
909
- """Detect and patch installed libraries."""
918
+ def _auto_instrument(self, options: Optional[Mapping[str, Mapping[str, Any]]] = None):
919
+ """Detect and patch installed libraries, except those turned off with
920
+ ``instrumentation_options={name: {"enabled": False}}``."""
910
921
  integrations = [
911
922
  ("anthropic", "struct_sdk.anthropic"),
912
923
  ("claude_agent_sdk", "struct_sdk.claude_agent"),
913
924
  ("langchain_core", "struct_sdk.langchain"),
914
925
  ("openai", "struct_sdk.openai"),
926
+ ("pydantic_ai", "struct_sdk.pydantic_ai"),
915
927
  ]
916
928
 
917
929
  for lib_name, module_path in integrations:
918
930
  if lib_name in _patched_integrations:
919
931
  continue
932
+ if (options or {}).get(lib_name, {}).get("enabled", True) is False:
933
+ continue
920
934
  try:
921
935
  import importlib
922
936
  mod = importlib.import_module(module_path)