struct-sdk 0.2.10__tar.gz → 0.2.11__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.
@@ -87,12 +87,9 @@ apps/edgedive-functions/local.settings.json
87
87
  # Claude
88
88
  .claude/worktrees/
89
89
  .claude/scheduled_tasks.lock
90
- .superpowers/
91
- docs/superpowers/
92
90
 
93
- # Superpowers
91
+ # Superpowers working state (design specs and plans under docs/superpowers/ ARE committed)
94
92
  .superpowers/
95
- docs/superpowers/
96
93
 
97
94
  .playwright-mcp/
98
95
  struct-agent-key.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: struct-sdk
3
- Version: 0.2.10
3
+ Version: 0.2.11
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
@@ -8,7 +8,7 @@ Project-URL: Issues, https://struct.ai/support
8
8
  Author-email: Struct <support@struct.ai>
9
9
  License: Apache-2.0
10
10
  License-File: LICENSE
11
- Keywords: agent,ai,anthropic,langchain,observability,opentelemetry,struct,tracing
11
+ Keywords: agent,ai,anthropic,langchain,observability,openai,opentelemetry,struct,tracing
12
12
  Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: License :: OSI Approved :: Apache Software License
@@ -42,14 +42,16 @@ Requires-Dist: pytest>=9.0.3; extra == 'dev'
42
42
  Requires-Dist: ruff>=0.5; extra == 'dev'
43
43
  Provides-Extra: langchain
44
44
  Requires-Dist: langchain-core>=1.3.3; extra == 'langchain'
45
+ Provides-Extra: openai
46
+ Requires-Dist: openai>=1.66.0; extra == 'openai'
45
47
  Description-Content-Type: text/markdown
46
48
 
47
49
  # struct-sdk
48
50
 
49
51
  OpenTelemetry instrumentation for AI agents in Python. Captures spans,
50
- token usage, and message events from the Anthropic SDK, the Claude Agent
51
- SDK, and LangChain / LangGraph, and exports them to
52
- [struct.ai](https://struct.ai) for observability.
52
+ token usage, and message events from the Anthropic SDK, the OpenAI SDK
53
+ (Responses API), the Claude Agent SDK, and LangChain / LangGraph, and
54
+ exports them to [struct.ai](https://struct.ai) for observability.
53
55
 
54
56
  A TypeScript version is available as
55
57
  [`@struct-ai/sdk`](https://www.npmjs.com/package/@struct-ai/sdk). Both
@@ -62,6 +64,7 @@ mix languages without a fragmented view.
62
64
  pip install struct-sdk
63
65
  # optional — the SDK auto-instruments these if present
64
66
  pip install anthropic
67
+ pip install openai
65
68
  pip install claude-agent-sdk
66
69
  pip install langchain-core langgraph
67
70
  ```
@@ -106,6 +109,7 @@ async with struct.agent(name="checkout"):
106
109
  | Library | Span type | Notes |
107
110
  |---|---|---|
108
111
  | `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
112
+ | `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). |
109
113
  | `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. |
110
114
  | `langchain_core` `BaseChatModel` | `chat {model}` | Skipped when an underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic` → a single span). |
111
115
  | `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
@@ -1,9 +1,9 @@
1
1
  # struct-sdk
2
2
 
3
3
  OpenTelemetry instrumentation for AI agents in Python. Captures spans,
4
- token usage, and message events from the Anthropic SDK, the Claude Agent
5
- SDK, and LangChain / LangGraph, and exports them to
6
- [struct.ai](https://struct.ai) for observability.
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.
7
7
 
8
8
  A TypeScript version is available as
9
9
  [`@struct-ai/sdk`](https://www.npmjs.com/package/@struct-ai/sdk). Both
@@ -16,6 +16,7 @@ mix languages without a fragmented view.
16
16
  pip install struct-sdk
17
17
  # optional — the SDK auto-instruments these if present
18
18
  pip install anthropic
19
+ pip install openai
19
20
  pip install claude-agent-sdk
20
21
  pip install langchain-core langgraph
21
22
  ```
@@ -60,6 +61,7 @@ async with struct.agent(name="checkout"):
60
61
  | Library | Span type | Notes |
61
62
  |---|---|---|
62
63
  | `anthropic` | `chat {model}` | Cache-token accounting; streaming chats with tool-use reconstruction. Bedrock and Vertex variants supported if installed. |
64
+ | `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). |
63
65
  | `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. |
64
66
  | `langchain_core` `BaseChatModel` | `chat {model}` | Skipped when an underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic` → a single span). |
65
67
  | `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "struct-sdk"
7
- version = "0.2.10"
7
+ version = "0.2.11"
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"
11
11
  license = { text = "Apache-2.0" }
12
12
  authors = [{ name = "Struct", email = "support@struct.ai" }]
13
- keywords = ["struct", "observability", "opentelemetry", "anthropic", "langchain", "agent", "ai", "tracing"]
13
+ keywords = ["struct", "observability", "opentelemetry", "anthropic", "openai", "langchain", "agent", "ai", "tracing"]
14
14
  classifiers = [
15
15
  "Development Status :: 4 - Beta",
16
16
  "Intended Audience :: Developers",
@@ -39,6 +39,8 @@ Issues = "https://struct.ai/support"
39
39
  anthropic = ["anthropic>=0.30.0"]
40
40
  claude-agent-sdk = ["claude-agent-sdk>=0.1.59"]
41
41
  langchain = ["langchain-core>=1.3.3"]
42
+ # Responses API (openai.resources.responses) exists from 1.66.0 onward.
43
+ openai = ["openai>=1.66.0"]
42
44
  demo = [
43
45
  "langchain>=1.3.9",
44
46
  "langchain-core>=1.3.3",
@@ -60,6 +62,12 @@ dev = [
60
62
  "pytest-asyncio>=1.3.0",
61
63
  "mypy>=1.10",
62
64
  "ruff>=0.5",
65
+ # The OpenAI conformance suite (tests/test_openai_conformance.py) exercises
66
+ # the REAL openai client with HTTP mocked at the httpx transport boundary,
67
+ # so both must be present in the default test environment (CI runs
68
+ # `uv run pytest` with no extras).
69
+ "openai>=1.66.0",
70
+ "httpx>=0.27.0",
63
71
  ]
64
72
 
65
73
  [tool.uv]
@@ -81,7 +89,7 @@ markers = [
81
89
 
82
90
  [tool.mypy]
83
91
  [[tool.mypy.overrides]]
84
- module = ["anthropic", "anthropic.*", "claude_agent_sdk", "claude_agent_sdk.*", "langchain_core", "langchain_core.*", "langchain", "langchain.*", "langgraph", "langgraph.*"]
92
+ module = ["anthropic", "anthropic.*", "claude_agent_sdk", "claude_agent_sdk.*", "langchain_core", "langchain_core.*", "langchain", "langchain.*", "langgraph", "langgraph.*", "openai", "openai.*"]
85
93
  ignore_missing_imports = true
86
94
 
87
95
  [tool.hatch.build.targets.wheel]
@@ -0,0 +1,265 @@
1
+ """Provider-agnostic GenAI content primitives — shared by the anthropic and
2
+ openai integrations.
3
+
4
+ This module is the single home for the pieces that are genuinely the same
5
+ across providers once a message/response has been mapped to the spec's
6
+ ``{"role", "parts"}`` shape:
7
+
8
+ - Truncation constants + helpers (``truncate_field`` / ``truncate_parts`` /
9
+ ``truncate_and_serialize`` / ``safe_json``).
10
+ - The low-level per-message and choice LogRecord emitters
11
+ (``emit_message_event`` / ``emit_choice_event``) — they take already-built
12
+ ``parts`` and a ``provider`` string, so the OTel logs data-model wiring
13
+ (``body`` = event tag, ``attributes['body']`` = JSON payload, span-context
14
+ from the EXPLICIT span) lives in exactly one place.
15
+ - ``propagate_user_prompt_to_parent`` — write-once parent ``gen_ai.input.messages``
16
+ from the last user message's parts.
17
+
18
+ The per-provider MAPPER (Anthropic message blocks vs OpenAI Responses items ->
19
+ ``parts``) is the only content code that stays provider-specific; it lives in
20
+ ``anthropic.py`` / ``openai.py`` and calls into this module.
21
+
22
+ Both this bug (OpenAI emitting zero content) and the earlier reset bug came from
23
+ ``openai.py`` diverging from ``anthropic.py``; sharing these primitives is the
24
+ structural fix. Everything here is written to NEVER fault the customer call —
25
+ each public emitter is wrapped in try/except -> ``logger.debug``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import logging
32
+ import time
33
+ from typing import Any, Optional
34
+
35
+ from opentelemetry import trace
36
+
37
+ logger = logging.getLogger("struct_sdk._genai_content")
38
+
39
+ # Max size for content attributes (JSON strings on spans).
40
+ _MAX_CONTENT_SIZE = 128 * 1024 # 128KB — generous limit for full message capture
41
+ _TRUNCATION_MARKER = "… [truncated]"
42
+ # Per-field cap: individual text/content fields longer than this get truncated.
43
+ _MAX_FIELD_SIZE = 16384 # 16KB per field
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Truncation / serialization
48
+ # ---------------------------------------------------------------------------
49
+
50
+ def truncate_field(value: "str | Any", max_len: int = _MAX_FIELD_SIZE) -> "str | Any":
51
+ """Truncate a single string field if it exceeds ``max_len``."""
52
+ if isinstance(value, str) and len(value) > max_len:
53
+ return value[:max_len] + _TRUNCATION_MARKER
54
+ return value
55
+
56
+
57
+ def truncate_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
58
+ """Truncate content fields within message parts (content/arguments/response)."""
59
+ result = []
60
+ for part in parts:
61
+ part = dict(part) # shallow copy
62
+ if "content" in part and isinstance(part["content"], str):
63
+ part["content"] = truncate_field(part["content"])
64
+ if "arguments" in part:
65
+ arg = part["arguments"]
66
+ if isinstance(arg, str):
67
+ part["arguments"] = truncate_field(arg)
68
+ elif isinstance(arg, dict):
69
+ serialized = json.dumps(arg, default=str)
70
+ if len(serialized) > _MAX_FIELD_SIZE:
71
+ part["arguments"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
72
+ if "response" in part:
73
+ resp = part["response"]
74
+ if isinstance(resp, str):
75
+ part["response"] = truncate_field(resp)
76
+ elif isinstance(resp, (dict, list)):
77
+ serialized = json.dumps(resp, default=str)
78
+ if len(serialized) > _MAX_FIELD_SIZE:
79
+ part["response"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
80
+ result.append(part)
81
+ return result
82
+
83
+
84
+ def truncate_and_serialize(obj: Any, max_size: int = _MAX_CONTENT_SIZE) -> str:
85
+ """Truncate individual fields in message structures, then serialize.
86
+
87
+ Unlike a blind string slice, this preserves valid JSON by truncating
88
+ content/arguments/response fields within parts before serialization.
89
+ If the result still exceeds ``max_size`` after field truncation, applies
90
+ a final hard cap that tries to keep the JSON array parseable.
91
+ """
92
+ if isinstance(obj, list):
93
+ truncated = []
94
+ for item in obj:
95
+ if isinstance(item, dict):
96
+ item = dict(item) # shallow copy
97
+ if "parts" in item and isinstance(item["parts"], list):
98
+ item["parts"] = truncate_parts(item["parts"])
99
+ elif "content" in item and isinstance(item["content"], str):
100
+ # Top-level content (e.g. system_instructions format)
101
+ item["content"] = truncate_field(item["content"])
102
+ truncated.append(item)
103
+ result = json.dumps(truncated, default=str)
104
+ else:
105
+ result = json.dumps(obj, default=str)
106
+
107
+ # If still too large after field-level truncation, do a final hard cap
108
+ # but try to close the JSON array to keep it parseable.
109
+ if len(result) > max_size:
110
+ cut = result[:max_size - 50] # leave room for closing
111
+ last_brace = cut.rfind("}")
112
+ if last_brace > 0:
113
+ result = cut[:last_brace + 1] + "]"
114
+ else:
115
+ result = "[]"
116
+
117
+ return result
118
+
119
+
120
+ def safe_json(obj: Any) -> str:
121
+ """Safely serialize to JSON string with field-level truncation."""
122
+ try:
123
+ return truncate_and_serialize(obj)
124
+ except Exception:
125
+ return "[]"
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # LogRecord emission — OTel logs data model
130
+ # ---------------------------------------------------------------------------
131
+
132
+ def emit_message_event(
133
+ otel_logger: Any,
134
+ *,
135
+ role: str,
136
+ parts: list[dict[str, Any]],
137
+ event_name: str,
138
+ provider: str,
139
+ message_index: int,
140
+ span: Optional[trace.Span] = None,
141
+ ) -> None:
142
+ """Emit ONE per-message LogRecord linked to ``span``.
143
+
144
+ OTel logs data-model convention:
145
+ - ``body`` (log record body): the event tag (``gen_ai.user.message`` etc.).
146
+ - ``attributes['body']``: the JSON payload ``{"role": ..., "parts": [...]}``.
147
+ - Other attributes: ``event.name``, ``gen_ai.provider.name``,
148
+ ``gen_ai.message.index``, and ``gen_ai.conversation.id`` when a session is
149
+ active.
150
+
151
+ Span-context is taken from the EXPLICIT ``span`` when given —
152
+ ``trace.get_current_span()`` can drop the active span across the generator
153
+ wrapper's async awaits. All work is best-effort: a failure here NEVER
154
+ reaches the customer's call (it becomes a ``logger.debug``).
155
+ """
156
+ try:
157
+ from opentelemetry._logs import LogRecord, SeverityNumber
158
+
159
+ span_ctx = (span or trace.get_current_span()).get_span_context()
160
+ from struct_sdk.core import _current_session_id
161
+ session_id = _current_session_id.get(None)
162
+
163
+ payload = json.dumps({"role": role, "parts": truncate_parts(parts)}, default=str)
164
+ attrs: dict[str, Any] = {
165
+ "event.name": event_name,
166
+ "body": payload,
167
+ "gen_ai.provider.name": provider,
168
+ "gen_ai.message.index": message_index,
169
+ }
170
+ if session_id:
171
+ attrs["gen_ai.conversation.id"] = session_id
172
+
173
+ otel_logger.emit(LogRecord(
174
+ timestamp=int(time.time_ns()),
175
+ trace_id=span_ctx.trace_id,
176
+ span_id=span_ctx.span_id,
177
+ trace_flags=span_ctx.trace_flags,
178
+ severity_number=SeverityNumber.INFO,
179
+ body=event_name,
180
+ attributes=attrs,
181
+ ))
182
+ except Exception:
183
+ logger.debug("Failed to emit message event", exc_info=True)
184
+
185
+
186
+ def emit_choice_event(
187
+ otel_logger: Any,
188
+ *,
189
+ parts: list[dict[str, Any]],
190
+ finish_reason: str,
191
+ provider: str,
192
+ span: Optional[trace.Span] = None,
193
+ ) -> None:
194
+ """Emit a ``gen_ai.choice`` LogRecord for the assistant's response.
195
+
196
+ ``parts`` are already spec-shaped assistant parts and ``finish_reason`` is
197
+ already mapped to the spec name by the caller (mapping is provider-specific).
198
+ The ``choice`` event omits ``gen_ai.message.index``.
199
+ """
200
+ try:
201
+ from opentelemetry._logs import LogRecord, SeverityNumber
202
+
203
+ span_ctx = (span or trace.get_current_span()).get_span_context()
204
+ from struct_sdk.core import _current_session_id
205
+ session_id = _current_session_id.get(None)
206
+
207
+ choice_body = {
208
+ "index": 0,
209
+ "finish_reason": finish_reason or "stop",
210
+ "message": {"role": "assistant", "parts": truncate_parts(parts)},
211
+ }
212
+ payload = json.dumps(choice_body, default=str)
213
+ event_name = "gen_ai.choice"
214
+ attrs: dict[str, Any] = {
215
+ "event.name": event_name,
216
+ "body": payload,
217
+ "gen_ai.provider.name": provider,
218
+ }
219
+ if session_id:
220
+ attrs["gen_ai.conversation.id"] = session_id
221
+
222
+ otel_logger.emit(LogRecord(
223
+ timestamp=int(time.time_ns()),
224
+ trace_id=span_ctx.trace_id,
225
+ span_id=span_ctx.span_id,
226
+ trace_flags=span_ctx.trace_flags,
227
+ severity_number=SeverityNumber.INFO,
228
+ body=event_name,
229
+ attributes=attrs,
230
+ ))
231
+ except Exception:
232
+ logger.debug("Failed to emit choice event", exc_info=True)
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Parent-span propagation
237
+ # ---------------------------------------------------------------------------
238
+
239
+ def propagate_user_prompt_to_parent(last_user_parts: Optional[list[dict[str, Any]]]) -> None:
240
+ """Set the last user message on the parent invoke_agent span (write-once).
241
+
242
+ This lets the waterfall UI show what the user asked without drilling into
243
+ the child chat span. Provider-agnostic: the caller extracts the last user
244
+ message's spec parts (Anthropic message blocks vs Responses items differ);
245
+ this just stamps them once on the ambient ``_current_agent_span``.
246
+ """
247
+ try:
248
+ if not last_user_parts:
249
+ return
250
+ from struct_sdk.core import _current_agent_span
251
+ agent_span = _current_agent_span.get(None)
252
+ if agent_span is None:
253
+ return
254
+ # Only set once — the first chat call within an invoke_agent scope wins.
255
+ existing = None
256
+ if hasattr(agent_span, "attributes"):
257
+ existing = agent_span.attributes.get("gen_ai.input.messages") # type: ignore[union-attr]
258
+ if existing:
259
+ return
260
+ agent_span.set_attribute(
261
+ "gen_ai.input.messages",
262
+ truncate_and_serialize([{"role": "user", "parts": last_user_parts}]),
263
+ )
264
+ except Exception:
265
+ pass # Never break the application for telemetry