struct-sdk 0.1.0__py3-none-any.whl
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.
- struct_sdk/__init__.py +14 -0
- struct_sdk/anthropic.py +938 -0
- struct_sdk/claude_agent.py +85 -0
- struct_sdk/core.py +755 -0
- struct_sdk/langchain.py +1450 -0
- struct_sdk-0.1.0.dist-info/METADATA +333 -0
- struct_sdk-0.1.0.dist-info/RECORD +9 -0
- struct_sdk-0.1.0.dist-info/WHEEL +4 -0
- struct_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
struct_sdk/anthropic.py
ADDED
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
"""Anthropic SDK auto-instrumentation — OTel GenAI Semantic Conventions v1.37+.
|
|
2
|
+
|
|
3
|
+
Patches anthropic.Messages.create() and messages.stream() at the CLASS level.
|
|
4
|
+
Supports multiple content capture modes:
|
|
5
|
+
- EVENT_ONLY (default): per-message log events linked to the chat span
|
|
6
|
+
- SPAN_ONLY: content as JSON string span attributes (legacy)
|
|
7
|
+
- SPAN_AND_EVENT: both log events and span attributes
|
|
8
|
+
|
|
9
|
+
Follows the OTel GenAI spec:
|
|
10
|
+
- Span name: "chat {model}"
|
|
11
|
+
- gen_ai.operation.name = "chat"
|
|
12
|
+
- gen_ai.provider.name = "anthropic"
|
|
13
|
+
- Structured message format with parts (text, tool_call, tool_call_response)
|
|
14
|
+
- Per-message log events: gen_ai.user.message, gen_ai.assistant.message, etc.
|
|
15
|
+
- Choice log event: gen_ai.choice
|
|
16
|
+
|
|
17
|
+
Auto-applied by struct.init() when the anthropic package is installed.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import functools
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import time
|
|
26
|
+
from typing import TYPE_CHECKING, Any, Generator, Optional
|
|
27
|
+
|
|
28
|
+
from opentelemetry import trace
|
|
29
|
+
from opentelemetry.trace import StatusCode
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from struct_sdk.core import StructSDK
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger("struct_sdk.anthropic")
|
|
35
|
+
|
|
36
|
+
# Max size for content attributes (JSON strings on spans)
|
|
37
|
+
_MAX_CONTENT_SIZE = 128 * 1024 # 128KB — generous limit for full message capture
|
|
38
|
+
_TRUNCATION_MARKER = "… [truncated]"
|
|
39
|
+
# Per-field cap: individual text/content fields longer than this get truncated
|
|
40
|
+
_MAX_FIELD_SIZE = 16384 # 16KB per field
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def patch(sdk: StructSDK) -> None:
|
|
44
|
+
"""Patch anthropic SDK classes. Raises ImportError if not installed."""
|
|
45
|
+
import anthropic
|
|
46
|
+
from anthropic.resources import Messages, AsyncMessages
|
|
47
|
+
|
|
48
|
+
if getattr(anthropic, "__struct_patched", False):
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
tracer = sdk.get_tracer("struct-sdk-anthropic")
|
|
52
|
+
otel_logger = sdk.get_logger("struct-sdk-anthropic") if sdk.emit_events else None
|
|
53
|
+
|
|
54
|
+
# Collect all Messages classes to patch: regular, beta, and Bedrock beta.
|
|
55
|
+
sync_classes = [Messages]
|
|
56
|
+
async_classes = [AsyncMessages]
|
|
57
|
+
|
|
58
|
+
for import_path in [
|
|
59
|
+
("anthropic.resources.beta.messages.messages", "Messages", "AsyncMessages"),
|
|
60
|
+
("anthropic.lib.bedrock._beta_messages", "Messages", "AsyncMessages"),
|
|
61
|
+
("anthropic.lib.vertex._beta_messages", "Messages", "AsyncMessages"),
|
|
62
|
+
]:
|
|
63
|
+
try:
|
|
64
|
+
mod = __import__(import_path[0], fromlist=[import_path[1], import_path[2]])
|
|
65
|
+
sync_classes.append(getattr(mod, import_path[1]))
|
|
66
|
+
async_classes.append(getattr(mod, import_path[2]))
|
|
67
|
+
except (ImportError, AttributeError):
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
for sync_cls in sync_classes:
|
|
71
|
+
if not getattr(sync_cls.create, "__struct_wrapped__", False):
|
|
72
|
+
sync_cls.create = _wrap_create(sync_cls.create, tracer, sdk, otel_logger, is_async=False) # type: ignore[method-assign]
|
|
73
|
+
if hasattr(sync_cls, "stream") and not getattr(sync_cls.stream, "__struct_wrapped__", False):
|
|
74
|
+
sync_cls.stream = _wrap_stream(sync_cls.stream, tracer, sdk, otel_logger, is_async=False) # type: ignore[method-assign]
|
|
75
|
+
|
|
76
|
+
for async_cls in async_classes:
|
|
77
|
+
if not getattr(async_cls.create, "__struct_wrapped__", False):
|
|
78
|
+
async_cls.create = _wrap_create(async_cls.create, tracer, sdk, otel_logger, is_async=True) # type: ignore[method-assign]
|
|
79
|
+
if hasattr(async_cls, "stream") and not getattr(async_cls.stream, "__struct_wrapped__", False):
|
|
80
|
+
async_cls.stream = _wrap_stream(async_cls.stream, tracer, sdk, otel_logger, is_async=True) # type: ignore[method-assign]
|
|
81
|
+
|
|
82
|
+
anthropic.__struct_patched = True # type: ignore[attr-defined]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def unpatch() -> None:
|
|
86
|
+
"""Remove patches. Restores original methods."""
|
|
87
|
+
try:
|
|
88
|
+
import anthropic
|
|
89
|
+
except ImportError:
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if not getattr(anthropic, "__struct_patched", False):
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
from anthropic.resources import Messages, AsyncMessages
|
|
96
|
+
for cls in (Messages, AsyncMessages):
|
|
97
|
+
for method_name in ("create", "stream"):
|
|
98
|
+
method = getattr(cls, method_name, None)
|
|
99
|
+
if method and getattr(method, "__struct_wrapped__", False):
|
|
100
|
+
setattr(cls, method_name, method.__struct_original__)
|
|
101
|
+
|
|
102
|
+
anthropic.__struct_patched = False # type: ignore[attr-defined]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# messages.create — sync/async via generator
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _create_common(
|
|
110
|
+
f: Any, args: tuple, kwargs: dict, tracer: trace.Tracer, sdk: StructSDK,
|
|
111
|
+
otel_logger: Any = None,
|
|
112
|
+
) -> Generator:
|
|
113
|
+
"""Shared pre/post logic for messages.create. Generator pattern.
|
|
114
|
+
|
|
115
|
+
Telemetry side-effects (pre-call attribute setup, post-call attribute
|
|
116
|
+
extraction, error-path attribute writes) are wrapped in ``_safe`` so a
|
|
117
|
+
failure inside instrumentation can never replace the user's response or
|
|
118
|
+
mask the user's API exception.
|
|
119
|
+
"""
|
|
120
|
+
from struct_sdk.core import _safe
|
|
121
|
+
|
|
122
|
+
model = kwargs.get("model", "unknown")
|
|
123
|
+
|
|
124
|
+
with tracer.start_as_current_span(
|
|
125
|
+
f"chat {model}", kind=trace.SpanKind.CLIENT
|
|
126
|
+
) as span:
|
|
127
|
+
def set_pre_call_attrs() -> None:
|
|
128
|
+
# Required attributes
|
|
129
|
+
span.set_attribute("gen_ai.operation.name", "chat")
|
|
130
|
+
span.set_attribute("gen_ai.provider.name", "anthropic")
|
|
131
|
+
|
|
132
|
+
# Conditionally required
|
|
133
|
+
span.set_attribute("gen_ai.request.model", model)
|
|
134
|
+
from struct_sdk.core import _current_session_id
|
|
135
|
+
session_id = _current_session_id.get(None)
|
|
136
|
+
if session_id:
|
|
137
|
+
span.set_attribute("gen_ai.conversation.id", session_id)
|
|
138
|
+
|
|
139
|
+
# Recommended request attributes
|
|
140
|
+
if kwargs.get("max_tokens"):
|
|
141
|
+
span.set_attribute("gen_ai.request.max_tokens", kwargs["max_tokens"])
|
|
142
|
+
if kwargs.get("temperature") is not None:
|
|
143
|
+
span.set_attribute("gen_ai.request.temperature", kwargs["temperature"])
|
|
144
|
+
if kwargs.get("top_p") is not None:
|
|
145
|
+
span.set_attribute("gen_ai.request.top_p", kwargs["top_p"])
|
|
146
|
+
if kwargs.get("top_k") is not None:
|
|
147
|
+
span.set_attribute("gen_ai.request.top_k", kwargs["top_k"])
|
|
148
|
+
if kwargs.get("stop_sequences"):
|
|
149
|
+
span.set_attribute("gen_ai.request.stop_sequences", kwargs["stop_sequences"])
|
|
150
|
+
|
|
151
|
+
# Always: message count and user prompt propagation
|
|
152
|
+
if "messages" in kwargs:
|
|
153
|
+
span.set_attribute("struct.input.message_count", len(kwargs["messages"]))
|
|
154
|
+
_propagate_user_prompt_to_parent(kwargs["messages"])
|
|
155
|
+
|
|
156
|
+
# Log events: per-message log records linked to this span
|
|
157
|
+
if sdk.emit_events and otel_logger and "messages" in kwargs:
|
|
158
|
+
_emit_message_events(otel_logger, kwargs["messages"], kwargs.get("system"), span)
|
|
159
|
+
|
|
160
|
+
# Span attributes: content on the span (legacy / SPAN_AND_EVENT)
|
|
161
|
+
if sdk.emit_span_content:
|
|
162
|
+
if "messages" in kwargs:
|
|
163
|
+
span.set_attribute("gen_ai.input.messages", _to_input_messages(kwargs["messages"]))
|
|
164
|
+
if kwargs.get("system"):
|
|
165
|
+
span.set_attribute("gen_ai.system_instructions", _to_system_instructions(kwargs["system"]))
|
|
166
|
+
if kwargs.get("tools"):
|
|
167
|
+
span.set_attribute("gen_ai.tool.definitions", _safe_json(kwargs["tools"]))
|
|
168
|
+
|
|
169
|
+
_safe(set_pre_call_attrs, site="anthropic.create.pre_call_attrs")
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
result = yield f, args, kwargs
|
|
173
|
+
except Exception as e:
|
|
174
|
+
_safe(lambda: span.set_attribute("error.type", type(e).__name__),
|
|
175
|
+
site="anthropic.create.error_type")
|
|
176
|
+
_safe(lambda: span.set_status(StatusCode.ERROR, str(e)),
|
|
177
|
+
site="anthropic.create.error_status")
|
|
178
|
+
_safe(lambda: span.record_exception(e),
|
|
179
|
+
site="anthropic.create.record_exception")
|
|
180
|
+
raise
|
|
181
|
+
_safe(lambda: _set_response_attrs(span, sdk, model, result, otel_logger),
|
|
182
|
+
site="anthropic.create.set_response_attrs")
|
|
183
|
+
_safe(lambda: span.set_status(StatusCode.OK),
|
|
184
|
+
site="anthropic.create.set_ok_status")
|
|
185
|
+
return result # noqa: B901
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _wrap_create(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logger: Any, is_async: bool) -> Any:
|
|
189
|
+
if is_async:
|
|
190
|
+
@functools.wraps(original)
|
|
191
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
192
|
+
from struct_sdk.core import _safe
|
|
193
|
+
|
|
194
|
+
gen: Optional[Generator] = None
|
|
195
|
+
yielded: Any = None
|
|
196
|
+
|
|
197
|
+
def _enter() -> None:
|
|
198
|
+
nonlocal gen, yielded
|
|
199
|
+
gen = _create_common(original, args, kwargs, tracer, sdk, otel_logger)
|
|
200
|
+
yielded = next(gen)
|
|
201
|
+
|
|
202
|
+
_safe(_enter, site="anthropic.create.start_span_async")
|
|
203
|
+
if gen is None or yielded is None:
|
|
204
|
+
# Telemetry setup raised before the user's call. Bypass
|
|
205
|
+
# instrumentation entirely so the user's call always runs.
|
|
206
|
+
return await original(*args, **kwargs)
|
|
207
|
+
|
|
208
|
+
f, call_args, call_kwargs = yielded
|
|
209
|
+
try:
|
|
210
|
+
result = await f(*call_args, **call_kwargs)
|
|
211
|
+
return gen.send(result)
|
|
212
|
+
except StopIteration as e:
|
|
213
|
+
return e.value
|
|
214
|
+
except Exception:
|
|
215
|
+
try:
|
|
216
|
+
gen.throw(*__import__("sys").exc_info())
|
|
217
|
+
except StopIteration as e:
|
|
218
|
+
return e.value
|
|
219
|
+
raise
|
|
220
|
+
else:
|
|
221
|
+
@functools.wraps(original)
|
|
222
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
223
|
+
from struct_sdk.core import _safe
|
|
224
|
+
|
|
225
|
+
gen: Optional[Generator] = None
|
|
226
|
+
yielded: Any = None
|
|
227
|
+
|
|
228
|
+
def _enter() -> None:
|
|
229
|
+
nonlocal gen, yielded
|
|
230
|
+
gen = _create_common(original, args, kwargs, tracer, sdk, otel_logger)
|
|
231
|
+
yielded = next(gen)
|
|
232
|
+
|
|
233
|
+
_safe(_enter, site="anthropic.create.start_span")
|
|
234
|
+
if gen is None or yielded is None:
|
|
235
|
+
# Telemetry setup raised before the user's call. Bypass
|
|
236
|
+
# instrumentation entirely so the user's call always runs.
|
|
237
|
+
return original(*args, **kwargs)
|
|
238
|
+
|
|
239
|
+
f, call_args, call_kwargs = yielded
|
|
240
|
+
try:
|
|
241
|
+
result = f(*call_args, **call_kwargs)
|
|
242
|
+
return gen.send(result)
|
|
243
|
+
except StopIteration as e:
|
|
244
|
+
return e.value
|
|
245
|
+
except Exception:
|
|
246
|
+
try:
|
|
247
|
+
gen.throw(*__import__("sys").exc_info())
|
|
248
|
+
except StopIteration as e:
|
|
249
|
+
return e.value
|
|
250
|
+
raise
|
|
251
|
+
|
|
252
|
+
wrapper.__struct_wrapped__ = True # type: ignore[attr-defined]
|
|
253
|
+
wrapper.__struct_original__ = original # type: ignore[attr-defined]
|
|
254
|
+
return wrapper
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# messages.stream — context manager wrapping
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logger: Any, is_async: bool) -> Any:
|
|
262
|
+
"""Wrap messages.stream() to trace the streaming context manager.
|
|
263
|
+
|
|
264
|
+
KNOWN GAP: this wrapper creates the chat span and stashes it on the
|
|
265
|
+
stream_manager for use by downstream code, but does NOT currently hook
|
|
266
|
+
the stream's finalization to call _set_response_attrs on the
|
|
267
|
+
accumulated final message. As a result, streaming chat calls do not
|
|
268
|
+
populate the _pending_tool_calls contextvar, and ``@struct.tool()``
|
|
269
|
+
callers downstream of a ``messages.stream(...)`` call must pass
|
|
270
|
+
``tool_call_id=`` explicitly until this is fixed. Non-streaming
|
|
271
|
+
``messages.create()`` is fully covered.
|
|
272
|
+
"""
|
|
273
|
+
if is_async:
|
|
274
|
+
@functools.wraps(original)
|
|
275
|
+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
276
|
+
from struct_sdk.core import _safe, _current_session_id
|
|
277
|
+
model = kwargs.get("model", "unknown")
|
|
278
|
+
|
|
279
|
+
span: Optional[trace.Span] = None
|
|
280
|
+
|
|
281
|
+
def start() -> None:
|
|
282
|
+
nonlocal span
|
|
283
|
+
span = tracer.start_span(f"chat {model}", kind=trace.SpanKind.CLIENT)
|
|
284
|
+
|
|
285
|
+
_safe(start, site="anthropic.stream.start_span_async")
|
|
286
|
+
|
|
287
|
+
if span is not None:
|
|
288
|
+
def set_pre_call_attrs() -> None:
|
|
289
|
+
assert span is not None
|
|
290
|
+
span.set_attribute("gen_ai.operation.name", "chat")
|
|
291
|
+
span.set_attribute("gen_ai.provider.name", "anthropic")
|
|
292
|
+
span.set_attribute("gen_ai.request.model", model)
|
|
293
|
+
session_id = _current_session_id.get(None)
|
|
294
|
+
if session_id:
|
|
295
|
+
span.set_attribute("gen_ai.conversation.id", session_id)
|
|
296
|
+
if "messages" in kwargs:
|
|
297
|
+
span.set_attribute("struct.input.message_count", len(kwargs["messages"]))
|
|
298
|
+
_propagate_user_prompt_to_parent(kwargs["messages"])
|
|
299
|
+
if sdk.emit_events and otel_logger and "messages" in kwargs:
|
|
300
|
+
_emit_message_events(otel_logger, kwargs["messages"], kwargs.get("system"), span)
|
|
301
|
+
if sdk.emit_span_content and "messages" in kwargs:
|
|
302
|
+
span.set_attribute("gen_ai.input.messages", _to_input_messages(kwargs["messages"]))
|
|
303
|
+
|
|
304
|
+
_safe(set_pre_call_attrs, site="anthropic.stream.pre_call_attrs")
|
|
305
|
+
|
|
306
|
+
stream_manager = await original(*args, **kwargs) if _is_coroutine(original) else original(*args, **kwargs)
|
|
307
|
+
|
|
308
|
+
if span is not None:
|
|
309
|
+
def stash_attrs() -> None:
|
|
310
|
+
stream_manager._struct_span = span # type: ignore[attr-defined]
|
|
311
|
+
stream_manager._struct_sdk = sdk # type: ignore[attr-defined]
|
|
312
|
+
stream_manager._struct_model = model # type: ignore[attr-defined]
|
|
313
|
+
stream_manager._struct_logger = otel_logger # type: ignore[attr-defined]
|
|
314
|
+
|
|
315
|
+
_safe(stash_attrs, site="anthropic.stream.stash_attrs")
|
|
316
|
+
|
|
317
|
+
return stream_manager
|
|
318
|
+
else:
|
|
319
|
+
@functools.wraps(original)
|
|
320
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
321
|
+
from struct_sdk.core import _safe, _current_session_id
|
|
322
|
+
model = kwargs.get("model", "unknown")
|
|
323
|
+
|
|
324
|
+
span: Optional[trace.Span] = None
|
|
325
|
+
|
|
326
|
+
def start() -> None:
|
|
327
|
+
nonlocal span
|
|
328
|
+
span = tracer.start_span(f"chat {model}", kind=trace.SpanKind.CLIENT)
|
|
329
|
+
|
|
330
|
+
_safe(start, site="anthropic.stream.start_span")
|
|
331
|
+
|
|
332
|
+
if span is not None:
|
|
333
|
+
def set_pre_call_attrs() -> None:
|
|
334
|
+
assert span is not None
|
|
335
|
+
span.set_attribute("gen_ai.operation.name", "chat")
|
|
336
|
+
span.set_attribute("gen_ai.provider.name", "anthropic")
|
|
337
|
+
span.set_attribute("gen_ai.request.model", model)
|
|
338
|
+
session_id = _current_session_id.get(None)
|
|
339
|
+
if session_id:
|
|
340
|
+
span.set_attribute("gen_ai.conversation.id", session_id)
|
|
341
|
+
if "messages" in kwargs:
|
|
342
|
+
span.set_attribute("struct.input.message_count", len(kwargs["messages"]))
|
|
343
|
+
_propagate_user_prompt_to_parent(kwargs["messages"])
|
|
344
|
+
if sdk.emit_events and otel_logger and "messages" in kwargs:
|
|
345
|
+
_emit_message_events(otel_logger, kwargs["messages"], kwargs.get("system"), span)
|
|
346
|
+
if sdk.emit_span_content and "messages" in kwargs:
|
|
347
|
+
span.set_attribute("gen_ai.input.messages", _to_input_messages(kwargs["messages"]))
|
|
348
|
+
|
|
349
|
+
_safe(set_pre_call_attrs, site="anthropic.stream.pre_call_attrs")
|
|
350
|
+
|
|
351
|
+
stream_manager = original(*args, **kwargs)
|
|
352
|
+
|
|
353
|
+
if span is not None:
|
|
354
|
+
def stash_attrs() -> None:
|
|
355
|
+
stream_manager._struct_span = span # type: ignore[attr-defined]
|
|
356
|
+
stream_manager._struct_sdk = sdk # type: ignore[attr-defined]
|
|
357
|
+
stream_manager._struct_model = model # type: ignore[attr-defined]
|
|
358
|
+
stream_manager._struct_logger = otel_logger # type: ignore[attr-defined]
|
|
359
|
+
|
|
360
|
+
_safe(stash_attrs, site="anthropic.stream.stash_attrs")
|
|
361
|
+
|
|
362
|
+
return stream_manager
|
|
363
|
+
|
|
364
|
+
wrapper.__struct_wrapped__ = True # type: ignore[attr-defined]
|
|
365
|
+
wrapper.__struct_original__ = original # type: ignore[attr-defined]
|
|
366
|
+
return wrapper
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
# Response attribute extraction
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
def _iter_tool_uses(content_blocks: Any) -> list[tuple[str, str]]:
|
|
374
|
+
"""Walk response content blocks and yield (tool_name, tool_use_id) pairs.
|
|
375
|
+
|
|
376
|
+
Pure helper — no side effects. Used to (a) emit events and output messages,
|
|
377
|
+
and (b) populate the SDK-internal pending-tool-calls contextvar so that
|
|
378
|
+
``@struct.tool()`` spans get auto-linked to the originating tool_use.
|
|
379
|
+
"""
|
|
380
|
+
result: list[tuple[str, str]] = []
|
|
381
|
+
if not content_blocks:
|
|
382
|
+
return result
|
|
383
|
+
for block in content_blocks:
|
|
384
|
+
block_type = None
|
|
385
|
+
if hasattr(block, "type"):
|
|
386
|
+
block_type = getattr(block, "type", None)
|
|
387
|
+
elif isinstance(block, dict):
|
|
388
|
+
block_type = block.get("type")
|
|
389
|
+
if block_type != "tool_use":
|
|
390
|
+
continue
|
|
391
|
+
if hasattr(block, "name"):
|
|
392
|
+
name = getattr(block, "name", "") or ""
|
|
393
|
+
tool_id = getattr(block, "id", "") or ""
|
|
394
|
+
else:
|
|
395
|
+
name = (block.get("name") or "") if isinstance(block, dict) else ""
|
|
396
|
+
tool_id = (block.get("id") or "") if isinstance(block, dict) else ""
|
|
397
|
+
if name and tool_id:
|
|
398
|
+
result.append((name, tool_id))
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _record_pending_tool_calls(content_blocks: Any) -> None:
|
|
403
|
+
"""Push every tool_use (name, id) from the response into the SDK contextvar.
|
|
404
|
+
|
|
405
|
+
Always runs (not gated on content-capture mode) — the linkage between a
|
|
406
|
+
chat span and its child execute_tool spans is structural, not content.
|
|
407
|
+
"""
|
|
408
|
+
from struct_sdk.core import _pending_tool_calls
|
|
409
|
+
pairs = _iter_tool_uses(content_blocks)
|
|
410
|
+
if not pairs:
|
|
411
|
+
return
|
|
412
|
+
pending = _pending_tool_calls.get()
|
|
413
|
+
if pending is None:
|
|
414
|
+
# No active agent scope — initialise a transient dict in the current
|
|
415
|
+
# context so a @struct.tool() call outside of @struct.agent() still
|
|
416
|
+
# gets the linkage. (Will not be reset automatically; that's fine.)
|
|
417
|
+
pending = {}
|
|
418
|
+
_pending_tool_calls.set(pending)
|
|
419
|
+
for name, tool_id in pairs:
|
|
420
|
+
pending.setdefault(name, []).append(tool_id)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _set_response_attrs(span: trace.Span, sdk: StructSDK, model: str, response: Any, otel_logger: Any = None) -> None:
|
|
424
|
+
if not hasattr(response, "usage"):
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
usage = response.usage
|
|
428
|
+
input_tokens = getattr(usage, "input_tokens", 0) or 0
|
|
429
|
+
output_tokens = getattr(usage, "output_tokens", 0) or 0
|
|
430
|
+
cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
|
|
431
|
+
cache_creation = getattr(usage, "cache_creation_input_tokens", 0) or 0
|
|
432
|
+
|
|
433
|
+
# Anthropic's input_tokens excludes cached tokens — add them back for true total
|
|
434
|
+
total_input = input_tokens + cache_read + cache_creation
|
|
435
|
+
|
|
436
|
+
span.set_attribute("gen_ai.usage.input_tokens", total_input)
|
|
437
|
+
span.set_attribute("gen_ai.usage.output_tokens", output_tokens)
|
|
438
|
+
if cache_read:
|
|
439
|
+
span.set_attribute("gen_ai.usage.cache_read.input_tokens", cache_read)
|
|
440
|
+
if cache_creation:
|
|
441
|
+
span.set_attribute("gen_ai.usage.cache_creation.input_tokens", cache_creation)
|
|
442
|
+
|
|
443
|
+
if hasattr(response, "model"):
|
|
444
|
+
span.set_attribute("gen_ai.response.model", response.model)
|
|
445
|
+
if hasattr(response, "stop_reason") and response.stop_reason:
|
|
446
|
+
span.set_attribute("gen_ai.response.finish_reasons", [response.stop_reason])
|
|
447
|
+
if hasattr(response, "id"):
|
|
448
|
+
span.set_attribute("gen_ai.response.id", response.id)
|
|
449
|
+
|
|
450
|
+
if hasattr(response, "content"):
|
|
451
|
+
# Populate pending tool_use ids for @struct.tool() auto-linkage.
|
|
452
|
+
# Runs unconditionally — independent of content-capture mode.
|
|
453
|
+
_record_pending_tool_calls(response.content)
|
|
454
|
+
|
|
455
|
+
finish_reason = getattr(response, "stop_reason", None)
|
|
456
|
+
|
|
457
|
+
# Log event: gen_ai.choice
|
|
458
|
+
if sdk.emit_events and otel_logger:
|
|
459
|
+
_emit_choice_event(otel_logger, response.content, finish_reason, span)
|
|
460
|
+
|
|
461
|
+
# Span attribute (legacy / SPAN_AND_EVENT)
|
|
462
|
+
if sdk.emit_span_content:
|
|
463
|
+
span.set_attribute("gen_ai.output.messages", _to_output_messages(response.content, finish_reason))
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# ---------------------------------------------------------------------------
|
|
467
|
+
# Log event emission — per-message LogRecords (OTel GenAI spec)
|
|
468
|
+
# ---------------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
_EVENT_NAME_MAP = {
|
|
471
|
+
"user": "gen_ai.user.message",
|
|
472
|
+
"assistant": "gen_ai.assistant.message",
|
|
473
|
+
"system": "gen_ai.system.message",
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _emit_message_events(
|
|
478
|
+
otel_logger: Any,
|
|
479
|
+
messages: list,
|
|
480
|
+
system: Any = None,
|
|
481
|
+
span: Optional[trace.Span] = None,
|
|
482
|
+
) -> None:
|
|
483
|
+
"""Emit one LogRecord per message, linked to the current span via context.
|
|
484
|
+
|
|
485
|
+
Follows the OTel logs data model convention:
|
|
486
|
+
- ``body`` (log record body): the event tag string (``gen_ai.user.message``
|
|
487
|
+
etc.) — human-readable signal.
|
|
488
|
+
- ``attributes['body']`` (log record attribute): the JSON-serialised
|
|
489
|
+
structured payload ``{"role": ..., "parts": [...]}``.
|
|
490
|
+
- Other attributes: ``event.name``, ``gen_ai.system``,
|
|
491
|
+
``gen_ai.message.index``, ``gen_ai.conversation.id``.
|
|
492
|
+
|
|
493
|
+
``span`` — if provided, its span context is used for the LogRecord's
|
|
494
|
+
TraceId/SpanId fields. Fall back to ``trace.get_current_span()`` only for
|
|
495
|
+
backward compatibility. Always prefer passing the span explicitly — the
|
|
496
|
+
generator-based ``messages.create`` wrapper spans multiple async awaits
|
|
497
|
+
and contextvars can drop the active span on some Python + asyncio
|
|
498
|
+
interactions.
|
|
499
|
+
"""
|
|
500
|
+
try:
|
|
501
|
+
from opentelemetry._logs import LogRecord, SeverityNumber
|
|
502
|
+
|
|
503
|
+
span_ctx = (span or trace.get_current_span()).get_span_context()
|
|
504
|
+
from struct_sdk.core import _current_session_id
|
|
505
|
+
session_id = _current_session_id.get(None)
|
|
506
|
+
|
|
507
|
+
msg_index = 0
|
|
508
|
+
|
|
509
|
+
# System prompt first (if present)
|
|
510
|
+
if system:
|
|
511
|
+
if isinstance(system, str):
|
|
512
|
+
parts = [{"type": "text", "content": system}]
|
|
513
|
+
elif isinstance(system, list):
|
|
514
|
+
parts = _content_to_parts(system)
|
|
515
|
+
else:
|
|
516
|
+
parts = [{"type": "text", "content": str(system)}]
|
|
517
|
+
|
|
518
|
+
payload = json.dumps({"role": "system", "parts": _truncate_parts(parts)}, default=str)
|
|
519
|
+
event_name = "gen_ai.system.message"
|
|
520
|
+
attrs: dict[str, Any] = {
|
|
521
|
+
"event.name": event_name,
|
|
522
|
+
"body": payload,
|
|
523
|
+
"gen_ai.system": "anthropic",
|
|
524
|
+
"gen_ai.message.index": msg_index,
|
|
525
|
+
}
|
|
526
|
+
if session_id:
|
|
527
|
+
attrs["gen_ai.conversation.id"] = session_id
|
|
528
|
+
|
|
529
|
+
otel_logger.emit(LogRecord(
|
|
530
|
+
timestamp=int(time.time_ns()),
|
|
531
|
+
trace_id=span_ctx.trace_id,
|
|
532
|
+
span_id=span_ctx.span_id,
|
|
533
|
+
trace_flags=span_ctx.trace_flags,
|
|
534
|
+
severity_number=SeverityNumber.INFO,
|
|
535
|
+
body=event_name,
|
|
536
|
+
attributes=attrs,
|
|
537
|
+
))
|
|
538
|
+
msg_index += 1
|
|
539
|
+
|
|
540
|
+
# Input messages
|
|
541
|
+
for msg in messages:
|
|
542
|
+
if isinstance(msg, dict):
|
|
543
|
+
role = msg.get("role", "user")
|
|
544
|
+
content = msg.get("content")
|
|
545
|
+
else:
|
|
546
|
+
role = getattr(msg, "role", "user")
|
|
547
|
+
content = getattr(msg, "content", None)
|
|
548
|
+
|
|
549
|
+
parts = _content_to_parts(content)
|
|
550
|
+
event_name = _EVENT_NAME_MAP.get(role, f"gen_ai.{role}.message")
|
|
551
|
+
payload = json.dumps({"role": role, "parts": _truncate_parts(parts)}, default=str)
|
|
552
|
+
|
|
553
|
+
attrs = {
|
|
554
|
+
"event.name": event_name,
|
|
555
|
+
"body": payload,
|
|
556
|
+
"gen_ai.system": "anthropic",
|
|
557
|
+
"gen_ai.message.index": msg_index,
|
|
558
|
+
}
|
|
559
|
+
if session_id:
|
|
560
|
+
attrs["gen_ai.conversation.id"] = session_id
|
|
561
|
+
|
|
562
|
+
otel_logger.emit(LogRecord(
|
|
563
|
+
timestamp=int(time.time_ns()),
|
|
564
|
+
trace_id=span_ctx.trace_id,
|
|
565
|
+
span_id=span_ctx.span_id,
|
|
566
|
+
trace_flags=span_ctx.trace_flags,
|
|
567
|
+
severity_number=SeverityNumber.INFO,
|
|
568
|
+
body=event_name,
|
|
569
|
+
attributes=attrs,
|
|
570
|
+
))
|
|
571
|
+
msg_index += 1
|
|
572
|
+
|
|
573
|
+
except Exception:
|
|
574
|
+
logger.debug("Failed to emit message events", exc_info=True)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _emit_choice_event(
|
|
578
|
+
otel_logger: Any,
|
|
579
|
+
content_blocks: list,
|
|
580
|
+
finish_reason: str | None,
|
|
581
|
+
span: Optional[trace.Span] = None,
|
|
582
|
+
) -> None:
|
|
583
|
+
"""Emit a gen_ai.choice LogRecord for the assistant's response.
|
|
584
|
+
|
|
585
|
+
``span`` — same rationale as ``_emit_message_events``: prefer the
|
|
586
|
+
explicit span over the ambient ``trace.get_current_span()`` lookup.
|
|
587
|
+
"""
|
|
588
|
+
try:
|
|
589
|
+
from opentelemetry._logs import LogRecord, SeverityNumber
|
|
590
|
+
|
|
591
|
+
span_ctx = (span or trace.get_current_span()).get_span_context()
|
|
592
|
+
from struct_sdk.core import _current_session_id
|
|
593
|
+
session_id = _current_session_id.get(None)
|
|
594
|
+
|
|
595
|
+
parts = []
|
|
596
|
+
for block in content_blocks:
|
|
597
|
+
block_type = getattr(block, "type", None)
|
|
598
|
+
if block_type == "text":
|
|
599
|
+
parts.append({"type": "text", "content": getattr(block, "text", "")})
|
|
600
|
+
elif block_type == "tool_use":
|
|
601
|
+
part: dict[str, Any] = {"type": "tool_call", "name": getattr(block, "name", "")}
|
|
602
|
+
tool_id = getattr(block, "id", None)
|
|
603
|
+
if tool_id:
|
|
604
|
+
part["id"] = tool_id
|
|
605
|
+
tool_input = getattr(block, "input", None)
|
|
606
|
+
if tool_input is not None:
|
|
607
|
+
part["arguments"] = tool_input
|
|
608
|
+
parts.append(part)
|
|
609
|
+
elif block_type == "thinking":
|
|
610
|
+
parts.append({"type": "reasoning", "content": getattr(block, "thinking", "")})
|
|
611
|
+
else:
|
|
612
|
+
parts.append({"type": block_type or "unknown"})
|
|
613
|
+
|
|
614
|
+
# Map Anthropic stop reasons to spec finish reasons
|
|
615
|
+
reason_map = {
|
|
616
|
+
"end_turn": "stop",
|
|
617
|
+
"stop_sequence": "stop",
|
|
618
|
+
"max_tokens": "length",
|
|
619
|
+
"tool_use": "tool_call",
|
|
620
|
+
}
|
|
621
|
+
mapped_reason = reason_map.get(finish_reason, finish_reason) if finish_reason else None
|
|
622
|
+
|
|
623
|
+
choice_body = {
|
|
624
|
+
"index": 0,
|
|
625
|
+
"finish_reason": mapped_reason or "stop",
|
|
626
|
+
"message": {"role": "assistant", "parts": _truncate_parts(parts)},
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
payload = json.dumps(choice_body, default=str)
|
|
630
|
+
event_name = "gen_ai.choice"
|
|
631
|
+
attrs: dict[str, Any] = {
|
|
632
|
+
"event.name": event_name,
|
|
633
|
+
"body": payload,
|
|
634
|
+
"gen_ai.system": "anthropic",
|
|
635
|
+
}
|
|
636
|
+
if session_id:
|
|
637
|
+
attrs["gen_ai.conversation.id"] = session_id
|
|
638
|
+
|
|
639
|
+
otel_logger.emit(LogRecord(
|
|
640
|
+
timestamp=int(time.time_ns()),
|
|
641
|
+
trace_id=span_ctx.trace_id,
|
|
642
|
+
span_id=span_ctx.span_id,
|
|
643
|
+
trace_flags=span_ctx.trace_flags,
|
|
644
|
+
severity_number=SeverityNumber.INFO,
|
|
645
|
+
body=event_name,
|
|
646
|
+
attributes=attrs,
|
|
647
|
+
))
|
|
648
|
+
|
|
649
|
+
except Exception:
|
|
650
|
+
logger.debug("Failed to emit choice event", exc_info=True)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
# ---------------------------------------------------------------------------
|
|
654
|
+
# Message serialization — Anthropic format → OTel GenAI spec format
|
|
655
|
+
# ---------------------------------------------------------------------------
|
|
656
|
+
|
|
657
|
+
def _truncate_field(value: str | Any, max_len: int = _MAX_FIELD_SIZE) -> str | Any:
|
|
658
|
+
"""Truncate a single string field if it exceeds max_len."""
|
|
659
|
+
if isinstance(value, str) and len(value) > max_len:
|
|
660
|
+
return value[:max_len] + _TRUNCATION_MARKER
|
|
661
|
+
return value
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _truncate_parts(parts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
665
|
+
"""Truncate content fields within message parts."""
|
|
666
|
+
result = []
|
|
667
|
+
for part in parts:
|
|
668
|
+
part = dict(part) # shallow copy
|
|
669
|
+
if "content" in part and isinstance(part["content"], str):
|
|
670
|
+
part["content"] = _truncate_field(part["content"])
|
|
671
|
+
if "arguments" in part:
|
|
672
|
+
arg = part["arguments"]
|
|
673
|
+
if isinstance(arg, str):
|
|
674
|
+
part["arguments"] = _truncate_field(arg)
|
|
675
|
+
elif isinstance(arg, dict):
|
|
676
|
+
serialized = json.dumps(arg, default=str)
|
|
677
|
+
if len(serialized) > _MAX_FIELD_SIZE:
|
|
678
|
+
part["arguments"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
|
|
679
|
+
if "response" in part:
|
|
680
|
+
resp = part["response"]
|
|
681
|
+
if isinstance(resp, str):
|
|
682
|
+
part["response"] = _truncate_field(resp)
|
|
683
|
+
elif isinstance(resp, (dict, list)):
|
|
684
|
+
serialized = json.dumps(resp, default=str)
|
|
685
|
+
if len(serialized) > _MAX_FIELD_SIZE:
|
|
686
|
+
part["response"] = serialized[:_MAX_FIELD_SIZE] + _TRUNCATION_MARKER
|
|
687
|
+
result.append(part)
|
|
688
|
+
return result
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _truncate_and_serialize(obj: Any, max_size: int = _MAX_CONTENT_SIZE) -> str:
|
|
692
|
+
"""Truncate individual fields in message structures, then serialize.
|
|
693
|
+
|
|
694
|
+
Unlike a blind string slice, this preserves valid JSON by truncating
|
|
695
|
+
content/arguments/response fields within parts before serialization.
|
|
696
|
+
If the result still exceeds max_size after field truncation, applies
|
|
697
|
+
progressively more aggressive truncation.
|
|
698
|
+
"""
|
|
699
|
+
if isinstance(obj, list):
|
|
700
|
+
truncated = []
|
|
701
|
+
for item in obj:
|
|
702
|
+
if isinstance(item, dict):
|
|
703
|
+
item = dict(item) # shallow copy
|
|
704
|
+
if "parts" in item and isinstance(item["parts"], list):
|
|
705
|
+
item["parts"] = _truncate_parts(item["parts"])
|
|
706
|
+
elif "content" in item and isinstance(item["content"], str):
|
|
707
|
+
# Top-level content (e.g. system_instructions format)
|
|
708
|
+
item["content"] = _truncate_field(item["content"])
|
|
709
|
+
truncated.append(item)
|
|
710
|
+
result = json.dumps(truncated, default=str)
|
|
711
|
+
else:
|
|
712
|
+
result = json.dumps(obj, default=str)
|
|
713
|
+
|
|
714
|
+
# If still too large after field-level truncation, do a final hard cap
|
|
715
|
+
# but try to close the JSON array to keep it parseable
|
|
716
|
+
if len(result) > max_size:
|
|
717
|
+
# Find the last complete message boundary
|
|
718
|
+
cut = result[:max_size - 50] # leave room for closing
|
|
719
|
+
last_brace = cut.rfind("}")
|
|
720
|
+
if last_brace > 0:
|
|
721
|
+
# Try to close at a message boundary
|
|
722
|
+
result = cut[:last_brace + 1] + "]"
|
|
723
|
+
else:
|
|
724
|
+
result = "[]"
|
|
725
|
+
|
|
726
|
+
return result
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _to_input_messages(messages: list) -> str:
|
|
730
|
+
"""Convert Anthropic messages list to GenAI spec input messages format.
|
|
731
|
+
|
|
732
|
+
Spec format: [{"role": "user", "parts": [{"type": "text", "content": "..."}]}, ...]
|
|
733
|
+
"""
|
|
734
|
+
try:
|
|
735
|
+
result = []
|
|
736
|
+
for msg in messages:
|
|
737
|
+
if isinstance(msg, dict):
|
|
738
|
+
role = msg.get("role", "user")
|
|
739
|
+
content = msg.get("content")
|
|
740
|
+
else:
|
|
741
|
+
role = getattr(msg, "role", "user")
|
|
742
|
+
content = getattr(msg, "content", None)
|
|
743
|
+
|
|
744
|
+
parts = _content_to_parts(content)
|
|
745
|
+
result.append({"role": role, "parts": parts})
|
|
746
|
+
|
|
747
|
+
return _truncate_and_serialize(result)
|
|
748
|
+
except Exception:
|
|
749
|
+
return json.dumps([], default=str)
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _to_output_messages(content_blocks: list, finish_reason: str | None) -> str:
|
|
753
|
+
"""Convert Anthropic response content blocks to GenAI spec output messages format.
|
|
754
|
+
|
|
755
|
+
Spec format: [{"role": "assistant", "parts": [...], "finish_reason": "stop"}]
|
|
756
|
+
"""
|
|
757
|
+
try:
|
|
758
|
+
parts = []
|
|
759
|
+
for block in content_blocks:
|
|
760
|
+
block_type = getattr(block, "type", None)
|
|
761
|
+
if block_type == "text":
|
|
762
|
+
parts.append({"type": "text", "content": getattr(block, "text", "")})
|
|
763
|
+
elif block_type == "tool_use":
|
|
764
|
+
part: dict[str, Any] = {
|
|
765
|
+
"type": "tool_call",
|
|
766
|
+
"name": getattr(block, "name", ""),
|
|
767
|
+
}
|
|
768
|
+
tool_id = getattr(block, "id", None)
|
|
769
|
+
if tool_id:
|
|
770
|
+
part["id"] = tool_id
|
|
771
|
+
tool_input = getattr(block, "input", None)
|
|
772
|
+
if tool_input is not None:
|
|
773
|
+
part["arguments"] = tool_input
|
|
774
|
+
parts.append(part)
|
|
775
|
+
elif block_type == "thinking":
|
|
776
|
+
parts.append({"type": "reasoning", "content": getattr(block, "thinking", "")})
|
|
777
|
+
else:
|
|
778
|
+
parts.append({"type": block_type or "unknown"})
|
|
779
|
+
|
|
780
|
+
msg: dict[str, Any] = {"role": "assistant", "parts": parts}
|
|
781
|
+
if finish_reason:
|
|
782
|
+
# Map Anthropic stop reasons to spec finish reasons
|
|
783
|
+
reason_map = {
|
|
784
|
+
"end_turn": "stop",
|
|
785
|
+
"stop_sequence": "stop",
|
|
786
|
+
"max_tokens": "length",
|
|
787
|
+
"tool_use": "tool_call",
|
|
788
|
+
}
|
|
789
|
+
msg["finish_reason"] = reason_map.get(finish_reason, finish_reason)
|
|
790
|
+
|
|
791
|
+
return _truncate_and_serialize([msg])
|
|
792
|
+
except Exception:
|
|
793
|
+
return json.dumps([], default=str)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _to_system_instructions(system: Any) -> str:
|
|
797
|
+
"""Convert Anthropic system prompt to GenAI spec system_instructions format.
|
|
798
|
+
|
|
799
|
+
Spec format: [{"type": "text", "content": "..."}]
|
|
800
|
+
"""
|
|
801
|
+
try:
|
|
802
|
+
if isinstance(system, str):
|
|
803
|
+
return _truncate_and_serialize([{"type": "text", "content": system}])
|
|
804
|
+
elif isinstance(system, list):
|
|
805
|
+
parts = _content_to_parts(system)
|
|
806
|
+
return _truncate_and_serialize(parts)
|
|
807
|
+
return json.dumps([], default=str)
|
|
808
|
+
except Exception:
|
|
809
|
+
return json.dumps([], default=str)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _content_to_parts(content: Any) -> list[dict[str, Any]]:
|
|
813
|
+
"""Convert Anthropic content (string, list of blocks, or tool_result) to spec parts."""
|
|
814
|
+
if content is None:
|
|
815
|
+
return []
|
|
816
|
+
if isinstance(content, str):
|
|
817
|
+
return [{"type": "text", "content": content}]
|
|
818
|
+
if isinstance(content, list):
|
|
819
|
+
parts = []
|
|
820
|
+
for item in content:
|
|
821
|
+
if isinstance(item, dict):
|
|
822
|
+
item_type = item.get("type")
|
|
823
|
+
if item_type == "text":
|
|
824
|
+
parts.append({"type": "text", "content": item.get("text", "")})
|
|
825
|
+
elif item_type == "tool_use":
|
|
826
|
+
part: dict[str, Any] = {"type": "tool_call", "name": item.get("name", "")}
|
|
827
|
+
if item.get("id"):
|
|
828
|
+
part["id"] = item["id"]
|
|
829
|
+
if item.get("input") is not None:
|
|
830
|
+
part["arguments"] = item["input"]
|
|
831
|
+
parts.append(part)
|
|
832
|
+
elif item_type == "tool_result":
|
|
833
|
+
part2: dict[str, Any] = {"type": "tool_call_response"}
|
|
834
|
+
if item.get("tool_use_id"):
|
|
835
|
+
part2["id"] = item["tool_use_id"]
|
|
836
|
+
part2["response"] = item.get("content", "")
|
|
837
|
+
parts.append(part2)
|
|
838
|
+
elif item_type == "image":
|
|
839
|
+
source = item.get("source", {})
|
|
840
|
+
if source.get("type") == "base64":
|
|
841
|
+
parts.append({
|
|
842
|
+
"type": "blob",
|
|
843
|
+
"modality": "image",
|
|
844
|
+
"content": source.get("data", "")[:256] + "...",
|
|
845
|
+
"mime_type": source.get("media_type"),
|
|
846
|
+
})
|
|
847
|
+
elif source.get("type") == "url":
|
|
848
|
+
parts.append({
|
|
849
|
+
"type": "uri",
|
|
850
|
+
"modality": "image",
|
|
851
|
+
"uri": source.get("url", ""),
|
|
852
|
+
})
|
|
853
|
+
else:
|
|
854
|
+
parts.append({"type": item_type or "unknown"})
|
|
855
|
+
elif hasattr(item, "type"):
|
|
856
|
+
# Anthropic SDK objects
|
|
857
|
+
item_type = item.type
|
|
858
|
+
if item_type == "text":
|
|
859
|
+
parts.append({"type": "text", "content": getattr(item, "text", "")})
|
|
860
|
+
elif item_type == "tool_use":
|
|
861
|
+
part3: dict[str, Any] = {"type": "tool_call", "name": getattr(item, "name", "")}
|
|
862
|
+
if getattr(item, "id", None):
|
|
863
|
+
part3["id"] = item.id
|
|
864
|
+
if getattr(item, "input", None) is not None:
|
|
865
|
+
part3["arguments"] = item.input
|
|
866
|
+
parts.append(part3)
|
|
867
|
+
elif item_type == "tool_result":
|
|
868
|
+
part4: dict[str, Any] = {"type": "tool_call_response"}
|
|
869
|
+
if getattr(item, "tool_use_id", None):
|
|
870
|
+
part4["id"] = item.tool_use_id
|
|
871
|
+
part4["response"] = getattr(item, "content", "")
|
|
872
|
+
parts.append(part4)
|
|
873
|
+
else:
|
|
874
|
+
parts.append({"type": item_type})
|
|
875
|
+
elif isinstance(item, str):
|
|
876
|
+
parts.append({"type": "text", "content": item})
|
|
877
|
+
return parts
|
|
878
|
+
return [{"type": "text", "content": str(content)}]
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _propagate_user_prompt_to_parent(messages: list) -> None:
|
|
882
|
+
"""Set the last user message on the parent invoke_agent span if present.
|
|
883
|
+
|
|
884
|
+
This allows the waterfall UI to show what the user asked without
|
|
885
|
+
needing to drill into the child chat span. Only sets the attribute
|
|
886
|
+
once — the first chat call within an invoke_agent scope wins.
|
|
887
|
+
"""
|
|
888
|
+
try:
|
|
889
|
+
# Walk up — the current span is the chat span we just created;
|
|
890
|
+
# its parent context holds the invoke_agent span. However OTel
|
|
891
|
+
# Python flattens context, so get_current_span() returns the
|
|
892
|
+
# innermost (our chat span). We need the parent, which is the
|
|
893
|
+
# span that was current *before* start_as_current_span.
|
|
894
|
+
# Since we're inside start_as_current_span, the parent is not
|
|
895
|
+
# directly accessible. Instead, we stash the attribute on any
|
|
896
|
+
# ancestor invoke_agent span via the context var approach.
|
|
897
|
+
from struct_sdk.core import _current_agent_span
|
|
898
|
+
agent_span = _current_agent_span.get(None)
|
|
899
|
+
if agent_span is None:
|
|
900
|
+
return
|
|
901
|
+
# Only set once — don't overwrite if already captured
|
|
902
|
+
# (ReadableSpan check — attributes is a dict on SDK spans)
|
|
903
|
+
existing = None
|
|
904
|
+
if hasattr(agent_span, "attributes"):
|
|
905
|
+
existing = agent_span.attributes.get("gen_ai.input.messages") # type: ignore[union-attr]
|
|
906
|
+
if existing:
|
|
907
|
+
return
|
|
908
|
+
|
|
909
|
+
# Extract just the last user message for a clean prompt preview
|
|
910
|
+
last_user_msg = None
|
|
911
|
+
for msg in reversed(messages):
|
|
912
|
+
role = msg.get("role") if isinstance(msg, dict) else getattr(msg, "role", None)
|
|
913
|
+
if role == "user":
|
|
914
|
+
content = msg.get("content") if isinstance(msg, dict) else getattr(msg, "content", None)
|
|
915
|
+
parts = _content_to_parts(content)
|
|
916
|
+
last_user_msg = [{"role": "user", "parts": parts}]
|
|
917
|
+
break
|
|
918
|
+
|
|
919
|
+
if last_user_msg:
|
|
920
|
+
agent_span.set_attribute(
|
|
921
|
+
"gen_ai.input.messages",
|
|
922
|
+
_truncate_and_serialize(last_user_msg),
|
|
923
|
+
)
|
|
924
|
+
except Exception:
|
|
925
|
+
pass # Never break the application for telemetry
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _safe_json(obj: Any) -> str:
|
|
929
|
+
"""Safely serialize to JSON string with field-level truncation."""
|
|
930
|
+
try:
|
|
931
|
+
return _truncate_and_serialize(obj)
|
|
932
|
+
except Exception:
|
|
933
|
+
return "[]"
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def _is_coroutine(fn: Any) -> bool:
|
|
937
|
+
import asyncio
|
|
938
|
+
return asyncio.iscoroutinefunction(fn)
|