struct-sdk 0.2.15__tar.gz → 0.2.22__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.4
2
2
  Name: struct-sdk
3
- Version: 0.2.15
3
+ Version: 0.2.22
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
@@ -28,6 +28,7 @@ Provides-Extra: anthropic
28
28
  Requires-Dist: anthropic>=0.30.0; extra == 'anthropic'
29
29
  Provides-Extra: claude-agent-sdk
30
30
  Requires-Dist: claude-agent-sdk>=0.1.59; extra == 'claude-agent-sdk'
31
+ Requires-Dist: mcp>=1.28.1; extra == 'claude-agent-sdk'
31
32
  Provides-Extra: demo
32
33
  Requires-Dist: langchain-anthropic>=1.4.6; extra == 'demo'
33
34
  Requires-Dist: langchain-core>=1.3.3; extra == 'demo'
@@ -380,7 +381,7 @@ backlink still renders.
380
381
  Emits attributes per the OTel GenAI semantic conventions:
381
382
 
382
383
  - `gen_ai.operation.name` — `chat`, `execute_tool`, `invoke_agent`, `retrieval`
383
- - `gen_ai.provider.name` — `anthropic`, `openai`, `langchain`, `struct`,
384
+ - `gen_ai.provider.name` — the GenAI provider on inference spans (`anthropic`, `openai`, …); platform-routed calls report the platform value (`aws.bedrock`, `gcp.vertex_ai`, `azure.ai.openai`) when detectable from the client. `invoke_agent` spans inherit the provider from their first child inference call (omitted when no model is reached); `execute_tool`/`retrieval` spans carry no provider.
384
385
  - `gen_ai.request.{model, max_tokens, temperature, top_p, top_k, stop_sequences}`
385
386
  - `gen_ai.response.{model, id, finish_reasons}`
386
387
  - `gen_ai.usage.{input_tokens, output_tokens, cache_read.input_tokens, cache_creation.input_tokens}`
@@ -332,7 +332,7 @@ backlink still renders.
332
332
  Emits attributes per the OTel GenAI semantic conventions:
333
333
 
334
334
  - `gen_ai.operation.name` — `chat`, `execute_tool`, `invoke_agent`, `retrieval`
335
- - `gen_ai.provider.name` — `anthropic`, `openai`, `langchain`, `struct`,
335
+ - `gen_ai.provider.name` — the GenAI provider on inference spans (`anthropic`, `openai`, …); platform-routed calls report the platform value (`aws.bedrock`, `gcp.vertex_ai`, `azure.ai.openai`) when detectable from the client. `invoke_agent` spans inherit the provider from their first child inference call (omitted when no model is reached); `execute_tool`/`retrieval` spans carry no provider.
336
336
  - `gen_ai.request.{model, max_tokens, temperature, top_p, top_k, stop_sequences}`
337
337
  - `gen_ai.response.{model, id, finish_reasons}`
338
338
  - `gen_ai.usage.{input_tokens, output_tokens, cache_read.input_tokens, cache_creation.input_tokens}`
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "struct-sdk"
7
- version = "0.2.15"
7
+ version = "0.2.22"
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"
@@ -37,7 +37,11 @@ Issues = "https://struct.ai/support"
37
37
 
38
38
  [project.optional-dependencies]
39
39
  anthropic = ["anthropic>=0.30.0"]
40
- claude-agent-sdk = ["claude-agent-sdk>=0.1.59"]
40
+ # mcp>=1.28.1 pinned directly (not just in override-dependencies below) so it
41
+ # ships in this extra's published wheel metadata — CVE-2026-52870,
42
+ # CVE-2026-52869, CVE-2026-59950 (GHSA-hvrp-rf83-w775, GHSA-jpw9-pfvf-9f58,
43
+ # GHSA-vj7q-gjh5-988w).
44
+ claude-agent-sdk = ["claude-agent-sdk>=0.1.59", "mcp>=1.28.1"]
41
45
  langchain = ["langchain-core>=1.3.3"]
42
46
  # Responses API (openai.resources.responses) exists from 1.66.0 onward.
43
47
  openai = ["openai>=1.66.0"]
@@ -80,6 +84,8 @@ override-dependencies = [
80
84
  "starlette>=1.3.1",
81
85
  "langsmith>=0.8.18",
82
86
  "pydantic-settings>=2.14.2",
87
+ # CVE-2026-52870, CVE-2026-52869, CVE-2026-59950 (GHSA-hvrp-rf83-w775, GHSA-jpw9-pfvf-9f58, GHSA-vj7q-gjh5-988w)
88
+ "mcp>=1.28.1",
83
89
  ]
84
90
 
85
91
  [tool.pytest.ini_options]
@@ -0,0 +1,580 @@
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 contextlib
31
+ import json
32
+ import logging
33
+ import sys
34
+ import time
35
+ from typing import Any, Callable, Iterator, Optional
36
+ from urllib.parse import urlparse
37
+
38
+ from opentelemetry import trace
39
+ from opentelemetry.trace import StatusCode
40
+
41
+ logger = logging.getLogger("struct_sdk._genai_content")
42
+
43
+ # Max size for content attributes (JSON strings on spans).
44
+ _MAX_CONTENT_SIZE = 128 * 1024 # 128KB — generous limit for full message capture
45
+ _TRUNCATION_MARKER = "… [truncated]"
46
+ # Per-field cap: individual text/content fields longer than this get truncated.
47
+ _MAX_FIELD_SIZE = 16384 # 16KB per field
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Truncation / serialization
52
+ # ---------------------------------------------------------------------------
53
+
54
+ def truncate_field(value: "str | Any", max_len: int = _MAX_FIELD_SIZE) -> "str | Any":
55
+ """Truncate a single string field if it exceeds ``max_len``."""
56
+ if isinstance(value, str) and len(value) > max_len:
57
+ return value[:max_len] + _TRUNCATION_MARKER
58
+ return value
59
+
60
+
61
+ def truncate_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
62
+ """Truncate content fields within message parts (content/arguments/response)."""
63
+ result = []
64
+ for part in parts:
65
+ part = dict(part) # shallow copy
66
+ if "content" in part and isinstance(part["content"], str):
67
+ part["content"] = truncate_field(part["content"])
68
+ if "arguments" in part:
69
+ arg = part["arguments"]
70
+ if isinstance(arg, str):
71
+ part["arguments"] = truncate_field(arg)
72
+ elif isinstance(arg, dict):
73
+ serialized = json.dumps(arg, default=str)
74
+ if len(serialized) > _MAX_FIELD_SIZE:
75
+ part["arguments"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
76
+ if "response" in part:
77
+ resp = part["response"]
78
+ if isinstance(resp, str):
79
+ part["response"] = truncate_field(resp)
80
+ elif isinstance(resp, (dict, list)):
81
+ serialized = json.dumps(resp, default=str)
82
+ if len(serialized) > _MAX_FIELD_SIZE:
83
+ part["response"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
84
+ result.append(part)
85
+ return result
86
+
87
+
88
+ def truncate_and_serialize(obj: Any, max_size: int = _MAX_CONTENT_SIZE) -> str:
89
+ """Truncate individual fields in message structures, then serialize.
90
+
91
+ Unlike a blind string slice, this preserves valid JSON by truncating
92
+ content/arguments/response fields within parts before serialization.
93
+ If the result still exceeds ``max_size`` after field truncation, applies
94
+ a final hard cap that tries to keep the JSON array parseable.
95
+ """
96
+ if isinstance(obj, list):
97
+ truncated = []
98
+ for item in obj:
99
+ if isinstance(item, dict):
100
+ item = dict(item) # shallow copy
101
+ if "parts" in item and isinstance(item["parts"], list):
102
+ item["parts"] = truncate_parts(item["parts"])
103
+ elif "content" in item and isinstance(item["content"], str):
104
+ # Top-level content (e.g. system_instructions format)
105
+ item["content"] = truncate_field(item["content"])
106
+ truncated.append(item)
107
+ result = json.dumps(truncated, default=str)
108
+ else:
109
+ result = json.dumps(obj, default=str)
110
+
111
+ # If still too large after field-level truncation, do a final hard cap
112
+ # but try to close the JSON array to keep it parseable.
113
+ if len(result) > max_size:
114
+ cut = result[:max_size - 50] # leave room for closing
115
+ last_brace = cut.rfind("}")
116
+ if last_brace > 0:
117
+ result = cut[:last_brace + 1] + "]"
118
+ else:
119
+ result = "[]"
120
+
121
+ return result
122
+
123
+
124
+ def safe_json(obj: Any) -> str:
125
+ """Safely serialize to JSON string with field-level truncation."""
126
+ try:
127
+ return truncate_and_serialize(obj)
128
+ except Exception:
129
+ return "[]"
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # LogRecord emission — OTel logs data model
134
+ # ---------------------------------------------------------------------------
135
+
136
+ def emit_message_event(
137
+ otel_logger: Any,
138
+ *,
139
+ role: str,
140
+ parts: list[dict[str, Any]],
141
+ event_name: str,
142
+ provider: str,
143
+ message_index: int,
144
+ span: Optional[trace.Span] = None,
145
+ ) -> None:
146
+ """Emit ONE per-message LogRecord linked to ``span``.
147
+
148
+ OTel logs data-model convention:
149
+ - ``body`` (log record body): the event tag (``gen_ai.user.message`` etc.).
150
+ - ``attributes['body']``: the JSON payload ``{"role": ..., "parts": [...]}``.
151
+ - Other attributes: ``event.name``, ``gen_ai.provider.name``,
152
+ ``gen_ai.message.index``, and ``gen_ai.conversation.id`` when a session is
153
+ active.
154
+
155
+ Span-context is taken from the EXPLICIT ``span`` when given —
156
+ ``trace.get_current_span()`` can drop the active span across the generator
157
+ wrapper's async awaits. All work is best-effort: a failure here NEVER
158
+ reaches the customer's call (it becomes a ``logger.debug``).
159
+ """
160
+ try:
161
+ from opentelemetry._logs import LogRecord, SeverityNumber
162
+
163
+ span_ctx = (span or trace.get_current_span()).get_span_context()
164
+ from struct_sdk.core import _current_session_id
165
+ session_id = _current_session_id.get(None)
166
+
167
+ payload = json.dumps({"role": role, "parts": truncate_parts(parts)}, default=str)
168
+ attrs: dict[str, Any] = {
169
+ "event.name": event_name,
170
+ "body": payload,
171
+ "gen_ai.provider.name": provider,
172
+ "gen_ai.message.index": message_index,
173
+ }
174
+ if session_id:
175
+ attrs["gen_ai.conversation.id"] = session_id
176
+
177
+ otel_logger.emit(LogRecord(
178
+ timestamp=int(time.time_ns()),
179
+ trace_id=span_ctx.trace_id,
180
+ span_id=span_ctx.span_id,
181
+ trace_flags=span_ctx.trace_flags,
182
+ severity_number=SeverityNumber.INFO,
183
+ body=event_name,
184
+ attributes=attrs,
185
+ ))
186
+ except Exception:
187
+ logger.debug("Failed to emit message event", exc_info=True)
188
+
189
+
190
+ def emit_choice_event(
191
+ otel_logger: Any,
192
+ *,
193
+ parts: list[dict[str, Any]],
194
+ finish_reason: str,
195
+ provider: str,
196
+ span: Optional[trace.Span] = None,
197
+ ) -> None:
198
+ """Emit a ``gen_ai.choice`` LogRecord for the assistant's response.
199
+
200
+ ``parts`` are already spec-shaped assistant parts and ``finish_reason`` is
201
+ already mapped to the spec name by the caller (mapping is provider-specific).
202
+ The ``choice`` event omits ``gen_ai.message.index``.
203
+ """
204
+ try:
205
+ from opentelemetry._logs import LogRecord, SeverityNumber
206
+
207
+ span_ctx = (span or trace.get_current_span()).get_span_context()
208
+ from struct_sdk.core import _current_session_id
209
+ session_id = _current_session_id.get(None)
210
+
211
+ choice_body = {
212
+ "index": 0,
213
+ "finish_reason": finish_reason or "stop",
214
+ "message": {"role": "assistant", "parts": truncate_parts(parts)},
215
+ }
216
+ payload = json.dumps(choice_body, default=str)
217
+ event_name = "gen_ai.choice"
218
+ attrs: dict[str, Any] = {
219
+ "event.name": event_name,
220
+ "body": payload,
221
+ "gen_ai.provider.name": provider,
222
+ }
223
+ if session_id:
224
+ attrs["gen_ai.conversation.id"] = session_id
225
+
226
+ otel_logger.emit(LogRecord(
227
+ timestamp=int(time.time_ns()),
228
+ trace_id=span_ctx.trace_id,
229
+ span_id=span_ctx.span_id,
230
+ trace_flags=span_ctx.trace_flags,
231
+ severity_number=SeverityNumber.INFO,
232
+ body=event_name,
233
+ attributes=attrs,
234
+ ))
235
+ except Exception:
236
+ logger.debug("Failed to emit choice event", exc_info=True)
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # Host-boundary span lifecycle
241
+ # ---------------------------------------------------------------------------
242
+
243
+ def _contain(fn: Any, site: str) -> None:
244
+ """``_safe`` with BASE-exception containment, for satellites that run
245
+ directly ON THE HOST CALL PATH only: guarded_client_span's start/attach/
246
+ finalize/end, and the create-path error satellites that stringify the
247
+ host's exception inside ``gen.throw``. There, a host-installed
248
+ SpanProcessor (or a host exception's ``__str__``) raising
249
+ KeyboardInterrupt/SystemExit would otherwise replace the provider's
250
+ successful result or in-flight exception — the one thing the harness
251
+ guarantees cannot happen. Deliberately NOT the global ``_safe``
252
+ semantics: everywhere else, delaying a genuine Ctrl-C matters more than
253
+ a hostile input.
254
+ """
255
+ try:
256
+ fn()
257
+ except BaseException: # noqa: BLE001 — containment is the contract here
258
+ try:
259
+ logger.debug("telemetry self-fault contained at %s", site, exc_info=True)
260
+ except BaseException: # noqa: BLE001
261
+ pass
262
+
263
+
264
+ @contextlib.contextmanager
265
+ def guarded_client_span(tracer: Any, span_name: str) -> Iterator[Any]:
266
+ """Explicit CLIENT span with ``_safe``-guarded start and end.
267
+
268
+ Deliberately NOT ``start_as_current_span``: ``use_span``'s exit runs
269
+ ``span.end()`` — and with it every customer-added ``SpanProcessor.on_end``
270
+ — plus its own ``record_exception``/``set_status`` (which stringify the
271
+ host's exception) UNGUARDED, and in the create-path generator sandwich
272
+ that exit executes on the ``gen.send``/``gen.throw`` HOST path. A broken
273
+ processor or a host exception with a raising ``__str__`` would replace
274
+ the customer's result/exception (host-crash-on-success).
275
+
276
+ This CM starts the span under ``_safe`` (yields ``None`` on failure — the
277
+ caller's ``_safe`` satellites degrade to no-telemetry) and ends it under
278
+ ``_safe`` on every exit. Ambient propagation is GUARDED, not removed: the
279
+ span is attached to the current context via a ``_safe`` satellite so a
280
+ customer's own downstream instrumentation (their HTTP-layer spans fired
281
+ during the provider call) still nests under ``chat`` — removing
282
+ propagation outright was struct-sdk-typescript's round-2 regression
283
+ (#3280, 343de609d). A hostile runtime context degrades to no-ambient
284
+ (span still emitted; event linkage rides the explicit span); OTel's
285
+ ``context.detach`` swallows internally and is ``_safe``-wrapped besides.
286
+ Nothing host-controllable wraps the caller's block unguarded. Mirrors
287
+ struct-sdk-typescript's ``instrumentCall``.
288
+ """
289
+ from opentelemetry import trace as _trace
290
+
291
+ from struct_sdk.core import (
292
+ _attach_current_span,
293
+ _detach_current_span,
294
+ )
295
+
296
+ span: Any = None
297
+ token: Any = None
298
+ enter_task: Any = None
299
+
300
+ def start() -> None:
301
+ nonlocal span
302
+ span = tracer.start_span(span_name, kind=_trace.SpanKind.CLIENT)
303
+
304
+ # DOCUMENTED DEGRADE: if a host-installed SpanProcessor raises from
305
+ # on_start, the OTel SDK lets it propagate out of start_span WITHOUT
306
+ # guarding (SynchronousMultiSpanProcessor.on_start is a bare loop) and
307
+ # the just-created span's reference never escapes the SDK frame — it is
308
+ # unrecoverable at this layer, and earlier processors see an on_start
309
+ # with no on_end. Host survival takes precedence (the OTel spec requires
310
+ # processors not to throw); recovering would need a global span registry,
311
+ # which AGENTS.md §7 forbids for exactly this kind of unreachable-in-
312
+ # practice input. The same degrade existed for plain Exception under
313
+ # ``_safe`` since this CM's inception.
314
+ _contain(start, "genai.client_span.start")
315
+
316
+ if span is not None:
317
+ def attach_ambient() -> None:
318
+ # Reuse the SDK's ONE ambient-context lifecycle (task-aware pair
319
+ # from core): a cross-task exit must SKIP the detach — OTel logs
320
+ # "Failed to detach context" at ERROR internally, before _safe
321
+ # could suppress it. No second lifecycle to drift.
322
+ nonlocal token, enter_task
323
+ token, enter_task = _attach_current_span(span)
324
+
325
+ _contain(attach_ambient, "genai.client_span.attach")
326
+ try:
327
+ yield span
328
+ finally:
329
+ if span is not None:
330
+ def finalize_base_exception() -> None:
331
+ # The caller's except-blocks record ordinary Exceptions; a
332
+ # BaseException (CancelledError, KeyboardInterrupt) bypasses
333
+ # them, so record it here — status + error.type only, message
334
+ # is the type name (never str(exc): hostile __str__).
335
+ # GeneratorExit is normal generator teardown, not a failure.
336
+ exc = sys.exc_info()[1]
337
+ if (
338
+ exc is not None
339
+ and not isinstance(exc, Exception)
340
+ and not isinstance(exc, GeneratorExit)
341
+ ):
342
+ span.set_attribute("error.type", type(exc).__name__)
343
+ span.set_status(StatusCode.ERROR, type(exc).__name__)
344
+
345
+ _contain(finalize_base_exception, "genai.client_span.finalize_base")
346
+ _contain(lambda: _detach_current_span(token, enter_task), "genai.client_span.detach")
347
+ if span is not None:
348
+ _contain(span.end, "genai.client_span.end")
349
+
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # Generator-sandwich drivers — the host-boundary CHOKE POINT
353
+ # ---------------------------------------------------------------------------
354
+ #
355
+ # Provider modules express telemetry as a generator that yields exactly once:
356
+ # ``f, call_args, call_kwargs = yield`` marks the host call; code before the
357
+ # yield is setup, the except-block is error telemetry, code after is success
358
+ # telemetry. These two drivers own EVERY interaction with such a generator
359
+ # (prime / error-forward / complete) under BaseException containment, so the
360
+ # host-crash-on-success invariant holds structurally: a telemetry fault at
361
+ # ANY point degrades telemetry, never the host call. Per-site ``_safe`` /
362
+ # ``_contain`` calls inside the generators improve degradation quality (a
363
+ # broken satellite loses one attribute, not the span); they are no longer
364
+ # what stands between a hostile input and the customer's result. Mirrors
365
+ # struct-sdk-typescript's single ``instrumentCall`` combinator (and OTel
366
+ # contrib's ``safeExecuteInTheMiddle``).
367
+
368
+
369
+ def _log_contained(site: str) -> None:
370
+ try:
371
+ logger.debug("telemetry self-fault contained at %s", site, exc_info=True)
372
+ except BaseException: # noqa: BLE001 — containment is the contract here
373
+ pass
374
+
375
+
376
+ def _prime_sandwich(make_gen: Any, site: str) -> Any:
377
+ """Create + advance the telemetry generator to its yield, contained.
378
+
379
+ Returns ``(gen, f, call_args, call_kwargs)`` or ``None`` on any fault —
380
+ including a hostile yielded shape — so the caller can degrade to an
381
+ uninstrumented host call.
382
+ """
383
+ try:
384
+ gen = make_gen()
385
+ f, call_args, call_kwargs = next(gen)
386
+ return gen, f, call_args, call_kwargs
387
+ except BaseException: # noqa: BLE001
388
+ _log_contained(site + ".prime")
389
+ return None
390
+
391
+
392
+ def _forward_error(gen: Any, exc: BaseException, site: str) -> Any:
393
+ """Drive the generator's error path, contained. The host's original
394
+ exception must keep propagating AS ITSELF even if the generator's error
395
+ telemetry raises something else. Returns a ``StopIteration`` value only
396
+ if the generator swallowed the error and returned one."""
397
+ try:
398
+ gen.throw(exc)
399
+ except StopIteration as e:
400
+ return e.value
401
+ except BaseException as out:
402
+ if out is exc:
403
+ raise
404
+ _log_contained(site + ".error_telemetry")
405
+ raise exc from None
406
+ # Generator yielded again instead of re-raising — out of contract;
407
+ # surface the host's error regardless.
408
+ raise exc
409
+
410
+
411
+ def _complete_sandwich(gen: Any, result: Any, site: str) -> Any:
412
+ """Drive the generator's success path, contained. After the host call
413
+ SUCCEEDS, nothing raised during telemetry completion may replace the
414
+ result (a lazy response property can raise even ``KeyboardInterrupt``
415
+ during extraction)."""
416
+ try:
417
+ return gen.send(result)
418
+ except StopIteration as e:
419
+ return e.value
420
+ except BaseException: # noqa: BLE001 — telemetry-only from here
421
+ _log_contained(site + ".completion")
422
+ return result
423
+
424
+
425
+ def drive_instrumented_call(
426
+ make_gen: Any, original: Any, args: Any, kwargs: Any, site: str
427
+ ) -> Any:
428
+ """SYNC choke point: telemetry generator sandwich around ``original``."""
429
+ primed = _prime_sandwich(make_gen, site)
430
+ if primed is None:
431
+ return original(*args, **kwargs)
432
+ gen, f, call_args, call_kwargs = primed
433
+ try:
434
+ result = f(*call_args, **call_kwargs)
435
+ except BaseException as exc: # host error — forward into the generator
436
+ return _forward_error(gen, exc, site)
437
+ return _complete_sandwich(gen, result, site)
438
+
439
+
440
+ async def drive_instrumented_call_async(
441
+ make_gen: Any, original: Any, args: Any, kwargs: Any, site: str
442
+ ) -> Any:
443
+ """ASYNC choke point — identical semantics; only the host call awaits."""
444
+ primed = _prime_sandwich(make_gen, site)
445
+ if primed is None:
446
+ return await original(*args, **kwargs)
447
+ gen, f, call_args, call_kwargs = primed
448
+ try:
449
+ result = await f(*call_args, **call_kwargs)
450
+ except BaseException as exc: # host error — forward into the generator
451
+ return _forward_error(gen, exc, site)
452
+ return _complete_sandwich(gen, result, site)
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # Platform detection — shared by the anthropic/openai emitters
457
+ # ---------------------------------------------------------------------------
458
+
459
+ def detect_provider_from_resource(
460
+ resource: Any,
461
+ class_name_rules: tuple[tuple[frozenset[str], str], ...],
462
+ host_rules: tuple[tuple[Callable[[str], bool], str], ...],
463
+ default: str,
464
+ ) -> str:
465
+ """Best-knowledge ``gen_ai.provider.name`` from a bound resource.
466
+
467
+ The platform client flavors (Bedrock/Vertex/Azure) reuse the exact same
468
+ resource classes as the first-party clients, so the platform can only be
469
+ read at call time from the resource's owning client: EXACT class names in
470
+ the MRO first (exact, not substring — a customer class named
471
+ ``NotAzureOpenAI`` must not match; real subclasses still match via their
472
+ inherited base class in the MRO), then per-platform ``base_url`` host
473
+ predicates matching only official endpoint shapes.
474
+
475
+ Takes the RESOURCE, not the client: the ``_client`` attribute access is a
476
+ host-boundary read (a proxied resource can raise from its descriptor —
477
+ ``getattr(..., default)`` only swallows AttributeError), so it must happen
478
+ inside this function's guard. Falls back to ``default`` whenever routing
479
+ is not positively detectable; never raises.
480
+ """
481
+ try:
482
+ client = getattr(resource, "_client", None)
483
+ if client is None:
484
+ return default
485
+ mro_names = {cls.__name__ for cls in type(client).__mro__}
486
+ for names, provider in class_name_rules:
487
+ if mro_names & names:
488
+ return provider
489
+ host = urlparse(str(getattr(client, "base_url", "") or "")).hostname or ""
490
+ for matches_host, provider in host_rules:
491
+ if matches_host(host):
492
+ return provider
493
+ except Exception: # noqa: BLE001 — detection must never fault the user's call
494
+ pass
495
+ return default
496
+
497
+
498
+ # ---------------------------------------------------------------------------
499
+ # Parent-span propagation
500
+ # ---------------------------------------------------------------------------
501
+
502
+ def propagate_user_prompt_to_parent(last_user_parts: Optional[list[dict[str, Any]]]) -> None:
503
+ """Set the last user message on the parent invoke_agent span (write-once).
504
+
505
+ This lets the waterfall UI show what the user asked without drilling into
506
+ the child chat span. Provider-agnostic: the caller extracts the last user
507
+ message's spec parts (Anthropic message blocks vs Responses items differ);
508
+ this just stamps them once on the ambient ``_current_agent_span``.
509
+ """
510
+ try:
511
+ if not last_user_parts:
512
+ return
513
+ from struct_sdk.core import _current_agent_span
514
+ agent_span = _current_agent_span.get(None)
515
+ if agent_span is None:
516
+ return
517
+ # Only set once — the first chat call within an invoke_agent scope wins.
518
+ existing = None
519
+ if hasattr(agent_span, "attributes"):
520
+ existing = agent_span.attributes.get("gen_ai.input.messages") # type: ignore[union-attr]
521
+ if existing:
522
+ return
523
+ agent_span.set_attribute(
524
+ "gen_ai.input.messages",
525
+ truncate_and_serialize([{"role": "user", "parts": last_user_parts}]),
526
+ )
527
+ except Exception:
528
+ pass # Never break the application for telemetry
529
+
530
+
531
+ def stamp_provider_once(agent_span: Any, provider: Optional[str]) -> None:
532
+ """Write-once ``gen_ai.provider.name`` on an invoke_agent span.
533
+
534
+ The GenAI spec puts ``gen_ai.provider.name`` on invoke_agent spans, but
535
+ the layer that CREATES those spans (``struct.agent()``, the LangChain
536
+ handler) is provider-agnostic — only a child inference call knows the
537
+ value. The first child call stamps it; an agent that never reaches a
538
+ model omits the attribute rather than carrying a framework name.
539
+
540
+ CONTRACT: ``agent_span`` is always an SDK-OWNED span — created by our own
541
+ tracer in ``struct.agent()`` or the LangChain handler, and delivered here
542
+ via ``_current_agent_span`` / the handler's run map, which nothing else
543
+ writes. It is never a host-supplied object. That is why the industry-
544
+ standard owned-object pattern applies (state lives ON the object, like
545
+ Sentry/dd-trace private span fields): a private sentinel attribute set
546
+ after a successful write. No global registries, locks, or weakref
547
+ lifecycles — those are for FOREIGN objects (see AGENTS.md: prefer
548
+ industry-standard telemetry patterns; don't defend interfaces no call
549
+ path can violate).
550
+
551
+ Semantics: "a real child provider" — if two child calls with DIFFERENT
552
+ providers race in the same instant, either may win; both are real
553
+ providers, and the semconv does not privilege one for a multi-provider
554
+ agent. The sentinel is set only after a successful write, so a transient
555
+ ``set_attribute`` failure can be retried by the next child call.
556
+ """
557
+ try:
558
+ if not provider or agent_span is None:
559
+ return
560
+ if getattr(agent_span, "_struct_provider_stamped", False):
561
+ return
562
+ agent_span.set_attribute("gen_ai.provider.name", provider)
563
+ try:
564
+ agent_span._struct_provider_stamped = True
565
+ except Exception:
566
+ # Frozen/slotted span: sentinel unavailable. Worst case a later
567
+ # child re-stamps another REAL provider — acceptable per the
568
+ # semantics above.
569
+ pass
570
+ except Exception:
571
+ pass # Never break the application for telemetry
572
+
573
+
574
+ def propagate_provider_to_parent(provider: Optional[str]) -> None:
575
+ """Stamp the ambient ``_current_agent_span`` with the child call's provider."""
576
+ try:
577
+ from struct_sdk.core import _current_agent_span
578
+ stamp_provider_once(_current_agent_span.get(None), provider)
579
+ except Exception:
580
+ pass # Never break the application for telemetry