otelzero 0.2.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.
@@ -0,0 +1,865 @@
1
+ """Prompt/response capture shared by all framework hooks.
2
+
3
+ The GenAI conventions record a run's prompt and response as
4
+ ``gen_ai.input.messages`` / ``gen_ai.output.messages``: arrays of
5
+ ``{"role": ..., "parts": [...]}`` messages (see :mod:`otelzero.semconv` for the
6
+ part shapes). Every framework spells its messages differently -- Anthropic
7
+ content blocks, Bedrock ``{"text": ...}`` blocks, OpenAI Responses items,
8
+ LangChain ``BaseMessage`` objects, ADK ``Content``/``Part`` objects -- and
9
+ :func:`messages_from` folds any of them into that one shape, the way
10
+ :mod:`otelzero.hooks._usage` does for token counts.
11
+
12
+ Two rules hold throughout:
13
+
14
+ * **Everything is Opt-In.** Each public function returns ``None`` immediately
15
+ unless ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`` is set, so a
16
+ hook can call them unconditionally and pay nothing by default.
17
+ * **Nothing raises.** All reads are dict-or-attr based and any failure degrades
18
+ to "no content" -- never a crash in the wrapper around a user's agent.
19
+
20
+ Size is bounded on the client: each text / arguments / response part is cut at
21
+ ``OTELZERO_CONTENT_MAX_CHARS`` characters (default 4000; ``0`` disables) with a
22
+ trailing marker, and only the last ``OTELZERO_CONTENT_MAX_MESSAGES`` messages
23
+ of a prompt or a streamed response are kept (default 20; ``0`` disables) so a
24
+ chat history replayed on every turn doesn't grow every event.
25
+ """
26
+
27
+ import json
28
+ import os
29
+ from typing import Any, List, Optional, Tuple
30
+
31
+ from otelzero import semconv
32
+ from otelzero.hooks._usage import _field
33
+
34
+ MAX_CHARS_ENV = "OTELZERO_CONTENT_MAX_CHARS"
35
+ MAX_MESSAGES_ENV = "OTELZERO_CONTENT_MAX_MESSAGES"
36
+ DEFAULT_MAX_CHARS = 4000
37
+ DEFAULT_MAX_MESSAGES = 20
38
+
39
+
40
+ def enabled() -> bool:
41
+ """True when the user opted in to recording message content."""
42
+ return semconv.capture_content_enabled()
43
+
44
+
45
+ def _env_int(name: str, default: int) -> int:
46
+ raw = os.environ.get(name, "").strip()
47
+ if not raw:
48
+ return default
49
+ try:
50
+ return max(0, int(raw))
51
+ except ValueError:
52
+ return default
53
+
54
+
55
+ def max_chars() -> int:
56
+ return _env_int(MAX_CHARS_ENV, DEFAULT_MAX_CHARS)
57
+
58
+
59
+ def max_messages() -> int:
60
+ return _env_int(MAX_MESSAGES_ENV, DEFAULT_MAX_MESSAGES)
61
+
62
+
63
+ def truncate(text: str) -> str:
64
+ """Cut ``text`` at the configured cap, marking how much was dropped."""
65
+ limit = max_chars()
66
+ if limit and len(text) > limit:
67
+ return text[:limit] + f"…[truncated, {len(text) - limit} more chars]"
68
+ return text
69
+
70
+
71
+ # --- Coercions ---------------------------------------------------------------
72
+
73
+ _TEXT_KEYS = ("text", "content", "output", "output_text", "result", "raw", "input", "answer")
74
+ _TEXT_ATTRS = ("raw", "text", "content", "output", "result")
75
+
76
+
77
+ def _first_field(obj: Any, *keys: str) -> Any:
78
+ for key in keys:
79
+ value = _field(obj, key)
80
+ if value is not None:
81
+ return value
82
+ return None
83
+
84
+
85
+ def _plain(value: Any) -> Any:
86
+ """Unwrap enum-like values (``Role.USER`` -> ``"user"``)."""
87
+ return getattr(value, "value", value)
88
+
89
+
90
+ def _text(obj: Any) -> Optional[str]:
91
+ """Best-effort plain text of a framework value (untruncated)."""
92
+ if obj is None:
93
+ return None
94
+ if isinstance(obj, str):
95
+ return obj
96
+ if isinstance(obj, dict):
97
+ for key in _TEXT_KEYS:
98
+ value = obj.get(key)
99
+ if isinstance(value, str):
100
+ return value
101
+ return json.dumps(obj, default=str)
102
+ if isinstance(obj, (list, tuple)):
103
+ texts = [_text(item) for item in obj]
104
+ return "\n".join(t for t in texts if t)
105
+ for attr in _TEXT_ATTRS:
106
+ value = getattr(obj, attr, None)
107
+ if isinstance(value, str):
108
+ return value
109
+ return str(obj)
110
+
111
+
112
+ def text_of(obj: Any) -> Optional[str]:
113
+ """Plain text of a framework result, for a one-message response.
114
+
115
+ ``CrewOutput.raw``, an ``AIMessage.content``, a ``{"output": ...}`` dict, a
116
+ bare string -- whichever the framework returns. ``None`` when not opted in
117
+ or when there is no text.
118
+ """
119
+ if not enabled():
120
+ return None
121
+ try:
122
+ text = _text(obj)
123
+ except Exception:
124
+ return None
125
+ return text or None
126
+
127
+
128
+ def _bounded(value: Any) -> Any:
129
+ """Structured tool arguments/results, kept structured while small."""
130
+ if value is None:
131
+ return None
132
+ if isinstance(value, str):
133
+ return truncate(value)
134
+ if isinstance(value, (dict, list, tuple)):
135
+ dumped = json.dumps(value, default=str)
136
+ limit = max_chars()
137
+ if limit and len(dumped) > limit:
138
+ return truncate(dumped)
139
+ return json.loads(dumped)
140
+ return truncate(str(value))
141
+
142
+
143
+ # --- Parts and messages ------------------------------------------------------
144
+
145
+
146
+ def _strip(part: dict) -> dict:
147
+ return {k: v for k, v in part.items() if v is not None}
148
+
149
+
150
+ def text_part(text: Any) -> Optional[dict]:
151
+ text = _text(text)
152
+ return {"type": "text", "content": truncate(text)} if text else None
153
+
154
+
155
+ def reasoning_part(text: Any) -> Optional[dict]:
156
+ """A model's thinking, as the conventions' ``reasoning`` part.
157
+
158
+ Only the readable thinking text is recorded. A provider's opaque companion
159
+ payload -- Anthropic's ``signature``, a redacted block's ciphertext -- is
160
+ deliberately dropped: it is unreadable downstream and would spend the size
161
+ cap on bytes no one can use. A block that carries *only* such a payload
162
+ therefore yields no part at all.
163
+ """
164
+ text = _text(text)
165
+ return {"type": "reasoning", "content": truncate(text)} if text else None
166
+
167
+
168
+ def tool_call_part(call_id: Any, name: Any, arguments: Any) -> dict:
169
+ return _strip(
170
+ {
171
+ "type": "tool_call",
172
+ "id": str(call_id) if call_id is not None else None,
173
+ "name": str(name) if name is not None else None,
174
+ "arguments": _bounded(arguments),
175
+ }
176
+ )
177
+
178
+
179
+ def tool_call_response_part(call_id: Any, response: Any) -> dict:
180
+ return _strip(
181
+ {
182
+ "type": "tool_call_response",
183
+ "id": str(call_id) if call_id is not None else None,
184
+ "response": _bounded(response),
185
+ }
186
+ )
187
+
188
+
189
+ _ROLES = {
190
+ "human": "user",
191
+ "ai": "assistant",
192
+ "model": "assistant",
193
+ "function": "tool",
194
+ "developer": "system",
195
+ }
196
+
197
+
198
+ def _role(raw: Any, default: str) -> str:
199
+ raw = _plain(raw)
200
+ if not isinstance(raw, str) or not raw:
201
+ return default
202
+ raw = raw.strip().lower()
203
+ return _ROLES.get(raw, raw)
204
+
205
+
206
+ def message(role: str, parts, **extra) -> Optional[dict]:
207
+ """One semconv message; ``None`` when it would carry no parts."""
208
+ parts = [p for p in (parts or []) if p]
209
+ if not parts:
210
+ return None
211
+ # Tool results arrive under the *user* role in Anthropic- and
212
+ # OpenAI-shaped histories; the conventions file them under ``tool``.
213
+ if role == "user" and all(p.get("type") == "tool_call_response" for p in parts):
214
+ role = "tool"
215
+ return {"role": role, "parts": parts, **extra}
216
+
217
+
218
+ def user_text(text: Any) -> Optional[dict]:
219
+ return message("user", [text_part(text)])
220
+
221
+
222
+ def assistant_text(text: Any) -> Optional[dict]:
223
+ return message("assistant", [text_part(text)])
224
+
225
+
226
+ # Block ``type`` spellings (underscores removed, lower-cased) for the two tool
227
+ # part kinds. Class names double as types for typed SDK blocks (``ToolUseBlock``,
228
+ # ``FunctionCallContent``) whose instances carry no ``type`` field.
229
+ _TOOL_CALL_KINDS = {
230
+ "tooluse",
231
+ "tooluseblock",
232
+ "toolcall",
233
+ "functioncall",
234
+ "functioncallcontent",
235
+ }
236
+ _REASONING_KINDS = {
237
+ "thinking",
238
+ "thinkingblock",
239
+ "redactedthinking",
240
+ "redactedthinkingblock",
241
+ "reasoning",
242
+ "reasoningcontent",
243
+ "reasoningcontentblock",
244
+ "reasoningtext",
245
+ }
246
+ _TOOL_RESPONSE_KINDS = {
247
+ "toolresult",
248
+ "toolresultblock",
249
+ "toolcallresponse",
250
+ "toolresponse",
251
+ "functioncalloutput",
252
+ "functionresponse",
253
+ # Microsoft Agent Framework discriminates one ``Content`` class by a
254
+ # ``type`` field ("function_result"); older releases used a class per kind
255
+ # ("FunctionResultContent"), which carried no ``type`` at all.
256
+ "functionresult",
257
+ "functionresultcontent",
258
+ }
259
+
260
+
261
+ def _kind(block: Any) -> str:
262
+ btype = _plain(_field(block, "type"))
263
+ if not isinstance(btype, str) or not btype:
264
+ btype = type(block).__name__
265
+ return btype.replace("_", "").strip().lower()
266
+
267
+
268
+ def _part_from(block: Any) -> Optional[dict]:
269
+ """Map one framework content block to a semconv part."""
270
+ if block is None:
271
+ return None
272
+ if isinstance(block, str):
273
+ return text_part(block)
274
+ kind = _kind(block)
275
+ if kind in _REASONING_KINDS:
276
+ # ``thinking`` (Anthropic ``ThinkingBlock``), ``summary`` (OpenAI
277
+ # Responses reasoning items, a list of summary_text blocks),
278
+ # ``reasoningText`` (Bedrock). Never ``signature``/``data``.
279
+ return reasoning_part(
280
+ _first_field(block, "thinking", "reasoningText", "summary", "text", "content")
281
+ )
282
+ if kind in _TOOL_CALL_KINDS:
283
+ return tool_call_part(
284
+ # Responses items have an item id as well as the call id their
285
+ # function_call_output refers to. Prefer the latter for pairing.
286
+ _first_field(block, "call_id", "tool_call_id", "id"),
287
+ _field(block, "name"),
288
+ _first_field(block, "input", "arguments", "args"),
289
+ )
290
+ if kind in _TOOL_RESPONSE_KINDS:
291
+ return tool_call_response_part(
292
+ _first_field(block, "tool_use_id", "call_id", "tool_call_id", "id"),
293
+ _first_field(block, "content", "output", "response", "result"),
294
+ )
295
+ # Bedrock / Strands blocks identify their kind with an outer key instead
296
+ # of a type field. Arguments and results still use the shared size bounds.
297
+ reasoning = _field(block, "reasoningContent")
298
+ if reasoning is not None:
299
+ return reasoning_part(_first_field(reasoning, "reasoningText", "text"))
300
+ call = _field(block, "toolUse")
301
+ if call is not None:
302
+ return tool_call_part(
303
+ _field(call, "toolUseId"), _field(call, "name"), _field(call, "input")
304
+ )
305
+ response = _field(block, "toolResult")
306
+ if response is not None:
307
+ return tool_call_response_part(
308
+ _field(response, "toolUseId"), _field(response, "content")
309
+ )
310
+ # Google GenAI / ADK ``Part``: text, function_call or function_response
311
+ # live side by side as optional fields.
312
+ call = _field(block, "function_call")
313
+ if call is not None:
314
+ return tool_call_part(
315
+ _field(call, "id"), _field(call, "name"), _first_field(call, "args", "arguments")
316
+ )
317
+ response = _field(block, "function_response")
318
+ if response is not None:
319
+ return tool_call_response_part(_field(response, "id"), _field(response, "response"))
320
+ text = _first_field(block, "text", "content")
321
+ if isinstance(text, str):
322
+ # Gemini/ADK mark a thinking part with ``thought`` beside its text.
323
+ if _field(block, "thought") is True:
324
+ return reasoning_part(text)
325
+ return text_part(text)
326
+ return text_part(_text(block))
327
+
328
+
329
+ def _parts_from(content: Any) -> List[dict]:
330
+ if content is None:
331
+ return []
332
+ if isinstance(content, str):
333
+ part = text_part(content)
334
+ return [part] if part else []
335
+ if isinstance(content, (list, tuple)):
336
+ parts = [_part_from(block) for block in content]
337
+ return [p for p in parts if p]
338
+ part = _part_from(content)
339
+ return [part] if part else []
340
+
341
+
342
+ _LANGCHAIN_TYPES = {"human", "ai", "system", "tool", "function", "chat"}
343
+
344
+
345
+ def _langchain_type(obj: Any) -> Optional[str]:
346
+ """``BaseMessage.type`` -- LangChain's messages have no ``role`` attribute."""
347
+ kind = getattr(obj, "type", None)
348
+ if isinstance(kind, str) and kind in _LANGCHAIN_TYPES and hasattr(obj, "content"):
349
+ return kind
350
+ return None
351
+
352
+
353
+ def _is_message(obj: Any) -> bool:
354
+ if isinstance(obj, dict):
355
+ return "role" in obj
356
+ if isinstance(obj, (str, bytes, list, tuple)) or obj is None:
357
+ return False
358
+ return getattr(obj, "role", None) is not None or _langchain_type(obj) is not None
359
+
360
+
361
+ def _message_from(obj: Any, default_role: str) -> Optional[dict]:
362
+ role = _role(_field(obj, "role") or _langchain_type(obj), default_role)
363
+ content = _first_field(obj, "content", "parts", "contents", "text")
364
+ # ChatAgent/OpenAI and LangChain tool messages put the association on the
365
+ # message itself, not inside a content block. Preserve empty results too.
366
+ call_id = _field(obj, "tool_call_id")
367
+ if role == "tool" and call_id is not None:
368
+ return message(role, [tool_call_response_part(call_id, content)])
369
+ parts = _parts_from(content)
370
+ # LangChain tool calls carry name/args directly; OpenAI Chat and MLflow
371
+ # ChatAgent put name/arguments under function, keeping id on the outer call.
372
+ tool_calls = _field(obj, "tool_calls")
373
+ if isinstance(tool_calls, (list, tuple)):
374
+ # An Anthropic-backed AIMessage reports each call *twice*: once as a
375
+ # ``tool_use`` content block and again in ``tool_calls``. Skip a call
376
+ # whose id (or, unidentified, whose whole part) is already recorded.
377
+ seen = {p.get("id") for p in parts if p.get("type") == "tool_call"}
378
+ for call in tool_calls:
379
+ function = _field(call, "function")
380
+ payload = function if function is not None else call
381
+ part = tool_call_part(
382
+ _first_field(call, "id", "call_id"),
383
+ _field(payload, "name"),
384
+ _first_field(payload, "args", "arguments", "input"),
385
+ )
386
+ call_id = part.get("id")
387
+ if call_id is not None and call_id in seen:
388
+ continue
389
+ if call_id is None and part in parts:
390
+ continue
391
+ seen.add(call_id)
392
+ parts.append(part)
393
+ return message(role, parts)
394
+
395
+
396
+ def _item_to_message(item: Any, default_role: str) -> Optional[dict]:
397
+ """A list item that is either a whole message or a bare block."""
398
+ if _is_message(item):
399
+ return _message_from(item, default_role)
400
+ part = _part_from(item)
401
+ if not part:
402
+ return None
403
+ role = {"tool_call": "assistant", "tool_call_response": "tool"}.get(
404
+ part["type"], default_role
405
+ )
406
+ return message(role, [part])
407
+
408
+
409
+ def _messages(obj: Any, default_role: str) -> List[dict]:
410
+ if isinstance(obj, str):
411
+ return [message(default_role, [text_part(obj)])]
412
+ if isinstance(obj, (list, tuple)):
413
+ if any(_is_message(item) for item in obj):
414
+ # A history, possibly interleaved with bare tool items (OpenAI
415
+ # Responses ``function_call`` / ``function_call_output``).
416
+ return [_item_to_message(item, default_role) for item in obj]
417
+ # A list of content blocks (or strings) forming one message.
418
+ return [message(default_role, _parts_from(obj))]
419
+ if _is_message(obj):
420
+ return [_message_from(obj, default_role)]
421
+ return [message(default_role, [text_part(_text(obj))])]
422
+
423
+
424
+ def coalesce_turns(messages: List[dict]) -> List[dict]:
425
+ """Merge consecutive same-role messages into one message per turn.
426
+
427
+ A provider reports one model turn as several blocks or streamed items -- a
428
+ thinking block, then text, then a tool call -- while the conventions model
429
+ that turn as a *single* message holding several parts. Without this, a
430
+ consumer counting assistant messages sees three turns where there was one.
431
+
432
+ Only plain ``{"role", "parts"}`` messages merge: one that already carries a
433
+ field of its own (``name``) marks a boundary the framework was explicit
434
+ about, so it is left standing. Roles never merge
435
+ across, so a real history (``assistant`` -> ``tool`` -> ``assistant``)
436
+ keeps its turns.
437
+ """
438
+ merged: List[dict] = []
439
+ for msg in messages:
440
+ previous = merged[-1] if merged else None
441
+ if (
442
+ previous is not None
443
+ and previous["role"] == msg["role"]
444
+ and set(previous) == {"role", "parts"}
445
+ and set(msg) == {"role", "parts"}
446
+ ):
447
+ previous["parts"] = previous["parts"] + msg["parts"]
448
+ else:
449
+ merged.append(dict(msg))
450
+ return merged
451
+
452
+
453
+ def cap_messages(messages: List[dict]) -> List[dict]:
454
+ """Keep only the most recent ``OTELZERO_CONTENT_MAX_MESSAGES`` messages."""
455
+ limit = max_messages()
456
+ if limit and len(messages) > limit:
457
+ return messages[-limit:]
458
+ return messages
459
+
460
+
461
+ def messages_from(obj: Any, default_role: str = "user") -> Optional[List[dict]]:
462
+ """Fold a framework prompt or response into semconv messages.
463
+
464
+ Accepts a bare string, a list of content blocks, a single message, or a
465
+ list of messages (dicts with ``role``/``content``, LangChain
466
+ ``BaseMessage`` objects, Anthropic/OpenAI/Bedrock/Gemini shapes, ...).
467
+ ``default_role`` labels content that carries no role of its own -- ``user``
468
+ for prompts, ``assistant`` for responses. ``None`` when not opted in or
469
+ when nothing recognisable was found.
470
+
471
+ A response (``default_role="assistant"``) additionally has its consecutive
472
+ same-role messages folded into one message per turn, see
473
+ :func:`coalesce_turns`. A prompt is left alone: two ``user`` messages in a
474
+ row there are two real turns of a history, not one turn's blocks.
475
+ """
476
+ if not enabled() or obj is None:
477
+ return None
478
+ try:
479
+ messages = [m for m in _messages(obj, default_role) if m]
480
+ if default_role == "assistant":
481
+ messages = coalesce_turns(messages)
482
+ except Exception:
483
+ return None
484
+ return cap_messages(messages) or None
485
+
486
+
487
+ # --- Agent SDK prompts -------------------------------------------------------
488
+ #
489
+ # The Claude and Cortex agent SDKs take a prompt in two forms: a string, or an
490
+ # *async iterable* of message dicts (their streaming input mode) that the SDK
491
+ # reads itself. The helpers below record either without getting in the way.
492
+
493
+
494
+ def _unwrap_envelope(item: Any) -> Any:
495
+ """The message inside an SDK wire envelope, ``{"type": "user", "message": {...}}``.
496
+
497
+ A streamed prompt's items come in the shape the SDK writes to its CLI --
498
+ the same envelope a string prompt is wrapped in -- and only the ``message``
499
+ inside is what the model sees. A typed item carrying no message (a control
500
+ request, say) is not a prompt at all and yields ``None``; anything else
501
+ passes through untouched.
502
+ """
503
+ if isinstance(item, dict) and "role" not in item:
504
+ inner = item.get("message")
505
+ if isinstance(inner, dict):
506
+ return inner
507
+ if "type" in item:
508
+ return None
509
+ return item
510
+
511
+
512
+ def prompt_messages(prompt: Any) -> Optional[List[dict]]:
513
+ """A materialised agent SDK prompt (a string or message list) as input messages.
514
+
515
+ An async iterable is deliberately left unread: consuming it here would
516
+ swallow the very messages the SDK is about to send. Recording nothing beats
517
+ breaking the run -- :class:`PromptTee` is how a streamed prompt is captured.
518
+ """
519
+ if isinstance(prompt, str):
520
+ return messages_from(prompt)
521
+ if isinstance(prompt, (list, tuple)):
522
+ items = [_unwrap_envelope(item) for item in prompt]
523
+ return messages_from([item for item in items if item is not None])
524
+ return None
525
+
526
+
527
+ class PromptTee:
528
+ """Observe a streamed prompt on its way to an SDK, without consuming it.
529
+
530
+ The SDK iterates the tee in place of the caller's async iterable; the tee
531
+ yields every item through untouched and keeps a shaped copy of each, so
532
+ :meth:`result` holds the input messages once the run is over.
533
+
534
+ Three things keep this safe to put in the SDK's path:
535
+
536
+ * items are shaped as they pass, never kept by reference -- the SDK mutates
537
+ the dicts it is handed (it stamps a ``session_id`` on each);
538
+ * the record is capped while it grows (``OTELZERO_CONTENT_MAX_MESSAGES``),
539
+ so a long stream never accumulates unbounded;
540
+ * recording never raises, and closing the tee closes the source, so an SDK
541
+ that stops reading early leaves nothing suspended.
542
+
543
+ :meth:`wrap` returns ``None`` -- leave the prompt alone -- unless content
544
+ capture is opted in and the prompt is an async iterable.
545
+ """
546
+
547
+ def __init__(self, source: Any):
548
+ self._source = source
549
+ self._messages: List[dict] = []
550
+
551
+ @classmethod
552
+ def wrap(cls, prompt: Any) -> Optional["PromptTee"]:
553
+ if not enabled() or not hasattr(prompt, "__aiter__"):
554
+ return None
555
+ return cls(prompt)
556
+
557
+ def __aiter__(self):
558
+ return self._relay()
559
+
560
+ async def _relay(self):
561
+ iterator = self._source.__aiter__()
562
+ try:
563
+ async for item in iterator:
564
+ self._record(item)
565
+ yield item
566
+ finally:
567
+ # ``async for`` does not close the iterator it leaves early; when
568
+ # the SDK drops the tee, the caller's generator must not outlive it.
569
+ aclose = getattr(iterator, "aclose", None)
570
+ if aclose is not None:
571
+ try:
572
+ await aclose()
573
+ except Exception:
574
+ pass
575
+
576
+ def _record(self, item: Any) -> None:
577
+ try:
578
+ item = _unwrap_envelope(item)
579
+ shaped = [m for m in _messages(item, "user") if m] if item is not None else []
580
+ except Exception:
581
+ return
582
+ self._messages.extend(shaped)
583
+ limit = max_messages()
584
+ if limit and len(self._messages) > limit:
585
+ del self._messages[:-limit]
586
+
587
+ def result(self) -> Optional[List[dict]]:
588
+ if not enabled():
589
+ return None
590
+ return cap_messages(list(self._messages)) or None
591
+
592
+
593
+ def replace_prompt(args: tuple, kwargs: dict, prompt: Any) -> Tuple[tuple, dict]:
594
+ """The ``args``/``kwargs`` of a ``query(prompt, ...)`` call with ``prompt`` swapped in."""
595
+ if "prompt" in kwargs:
596
+ return args, {**kwargs, "prompt": prompt}
597
+ if args:
598
+ return (prompt, *args[1:]), kwargs
599
+ return args, kwargs
600
+
601
+
602
+ _PENDING_PROMPTS = "_otelzero_pending_prompts"
603
+
604
+
605
+ def queue_prompt(client: Any, messages: Optional[List[dict]]) -> None:
606
+ """Remember what an SDK client's ``query()`` sent, for the responses that follow.
607
+
608
+ An interactive client takes prompts and responses through two different
609
+ calls, so a prompt has to be carried from one to the other. Prompts queue
610
+ up in the order they were sent and :func:`take_prompt` hands them out one
611
+ response at a time. Past ``OTELZERO_CONTENT_MAX_MESSAGES`` unanswered
612
+ prompts the newest are dropped, not the oldest: the queue's order is what
613
+ pairs a prompt with its response, and a hole at the end costs only the
614
+ last few pairings.
615
+ """
616
+ if not messages:
617
+ return
618
+ try:
619
+ pending = getattr(client, _PENDING_PROMPTS, None)
620
+ if pending is None:
621
+ pending = []
622
+ setattr(client, _PENDING_PROMPTS, pending)
623
+ pending.extend(messages)
624
+ limit = max_messages()
625
+ if limit and len(pending) > limit:
626
+ del pending[limit:]
627
+ except Exception:
628
+ pass
629
+
630
+
631
+ def take_prompt(client: Any) -> Optional[List[dict]]:
632
+ """The oldest prompt :func:`queue_prompt` stored: the response being read's input.
633
+
634
+ One prompt per response, oldest first. The SDK's CLI runs one turn per
635
+ user message it is sent, however many were sent before a reply was read,
636
+ and ``receive_response()`` yields one turn -- so the reply being read now
637
+ answers the oldest message still unanswered. (Seen live with the Claude
638
+ Agent SDK: two ``query()`` calls before one ``receive_response()`` gave
639
+ two results, one per prompt, the second turn carrying the first's history.)
640
+ """
641
+ try:
642
+ pending = getattr(client, _PENDING_PROMPTS, None)
643
+ if not pending:
644
+ return None
645
+ return [pending.pop(0)]
646
+ except Exception:
647
+ return None
648
+
649
+
650
+ def as_messages(value: Any, role: str) -> Optional[List[dict]]:
651
+ """What ``RunContext.set_input`` / ``set_output`` accept: a shaped list or text."""
652
+ if not enabled() or not value:
653
+ return None
654
+ if isinstance(value, str):
655
+ return messages_from(value, role)
656
+ return list(value)
657
+
658
+
659
+ def as_parts(value: Any) -> Optional[List[dict]]:
660
+ """What ``RunContext.set_system`` accepts: a shaped part list or text."""
661
+ if not enabled() or not value:
662
+ return None
663
+ if isinstance(value, str):
664
+ part = text_part(value)
665
+ return [part] if part else None
666
+ return list(value)
667
+
668
+
669
+ # --- Finish reasons ----------------------------------------------------------
670
+
671
+ _FINISH_REASONS = {
672
+ "end_turn": "stop",
673
+ "stop_sequence": "stop",
674
+ "stop": "stop",
675
+ "completed": "stop",
676
+ "complete": "stop",
677
+ "end": "stop",
678
+ "max_tokens": "length",
679
+ "max_output_tokens": "length",
680
+ "length": "length",
681
+ "tool_use": "tool_call",
682
+ "tool_calls": "tool_call",
683
+ "tool_call": "tool_call",
684
+ "function_call": "tool_call",
685
+ "content_filter": "content_filter",
686
+ "guardrail_intervened": "content_filter",
687
+ "safety": "content_filter",
688
+ "recitation": "content_filter",
689
+ }
690
+
691
+
692
+ def finish_reason(raw: Any) -> Optional[str]:
693
+ """Normalise a provider's stop reason to the conventions' spelling."""
694
+ raw = _plain(raw)
695
+ if raw is None:
696
+ return None
697
+ if not isinstance(raw, str):
698
+ raw = getattr(raw, "name", None) or str(raw)
699
+ key = raw.strip().lower()
700
+ return _FINISH_REASONS.get(key, key) or None
701
+
702
+
703
+ # --- Streams -----------------------------------------------------------------
704
+
705
+
706
+ class OutputAccumulator:
707
+ """Collect a streamed run's response into output messages, one per turn.
708
+
709
+ Mirrors :class:`otelzero.hooks._usage.UsageAccumulator`: a hook feeds it each
710
+ streamed item and reads :meth:`result` once. Three kinds of item are
711
+ understood, covering the SDKs this repo instruments:
712
+
713
+ * a message with list content (Claude SDK ``AssistantMessage`` /
714
+ ``UserMessage``, an ADK event's ``Content``) -- its blocks join the
715
+ message being built, or start the next one;
716
+ * a result carrying the final text (Claude SDK ``ResultMessage.result``)
717
+ -- ends the turn; its text stands in only when no messages were seen;
718
+ * a text chunk (:meth:`add_text`, Agent Framework stream updates) --
719
+ concatenated into one assistant message.
720
+
721
+ Messages are folded as they arrive, so the record never holds more than one
722
+ message per turn, nor more than ``OTELZERO_CONTENT_MAX_MESSAGES`` of them.
723
+ Blocks join the open message while the role holds and the generation is the
724
+ same: an SDK that stamps a ``message_id`` on each block (the Claude SDK) is
725
+ followed by it, and elsewhere a block carrying a stop reason, or a result
726
+ item, closes the message. Two turns that touch in the stream -- a streamed
727
+ prompt's second message answered right after the first -- therefore stay
728
+ two messages.
729
+
730
+ Every stop reason the stream reports goes into :attr:`finish_reasons` in
731
+ order, once per generation, as ``gen_ai.response.finish_reasons`` wants one
732
+ reason per generation received. What a framework reports is what is
733
+ recorded: the Claude SDK's CLI names one per turn, on its result item; ADK
734
+ names one per model response; a chunked stream names one on the last chunk
735
+ of each generation (Agent Framework: ``tool_calls``, then ``stop``). The
736
+ reasons are not content, so they are collected whether or not content
737
+ capture is opted in; the messages only when it is.
738
+ """
739
+
740
+ def __init__(self):
741
+ self._messages: List[dict] = []
742
+ self._open = False # the last message still takes blocks
743
+ self._generation: Optional[str] = None # its message_id, when stamped
744
+ self._reported: set = set() # generations whose reason is recorded
745
+ self.finish_reasons: List[str] = []
746
+ self._chunks: List[str] = []
747
+ self._final: Optional[str] = None
748
+
749
+ def add(self, item: Any) -> None:
750
+ if item is None:
751
+ return
752
+ try:
753
+ self._add(item)
754
+ except Exception: # pragma: no cover - defensive
755
+ pass
756
+
757
+ def _add(self, item: Any) -> None:
758
+ name = type(item).__name__.lower()
759
+ if name.startswith("system"):
760
+ return # SDK init/session info, not model output
761
+ generation = _field(item, "message_id")
762
+ generation = str(generation) if generation else None
763
+ msg = self._message_of(item, name) if enabled() else None
764
+ if msg:
765
+ self._append(msg, generation)
766
+ reason = self._note_reason(item, generation)
767
+ if msg is None or (reason and generation is None):
768
+ # A result item ends the turn; a stop reason on a block no
769
+ # generation id places ends the generation. A generation the SDK
770
+ # does identify is ended by the next id instead, so a reason
771
+ # repeated on each of its blocks cannot split it.
772
+ self._open = False
773
+
774
+ def _message_of(self, item: Any, name: str) -> Optional[dict]:
775
+ if _is_message(item):
776
+ return _message_from(item, "assistant")
777
+ content = _first_field(item, "content", "parts")
778
+ if isinstance(content, (list, tuple)):
779
+ role = "user" if name.startswith("user") else "assistant"
780
+ return message(role, _parts_from(content))
781
+ if _is_message(content):
782
+ return _message_from(content, "assistant")
783
+ final = _field(item, "result")
784
+ if isinstance(final, str) and final:
785
+ self._final = final
786
+ return None
787
+
788
+ def _append(self, msg: dict, generation: Optional[str]) -> None:
789
+ last = self._messages[-1] if self._messages else None
790
+ joins = (
791
+ last is not None
792
+ and last["role"] == msg["role"]
793
+ and set(last) == {"role", "parts"} == set(msg)
794
+ and (
795
+ generation == self._generation
796
+ if generation is not None
797
+ else self._open and self._generation is None
798
+ )
799
+ )
800
+ if joins:
801
+ last["parts"] = last["parts"] + msg["parts"]
802
+ else:
803
+ self._messages.append(dict(msg))
804
+ self._generation = generation
805
+ limit = max_messages()
806
+ if limit and len(self._messages) > limit:
807
+ del self._messages[:-limit]
808
+ self._open = True
809
+
810
+ @staticmethod
811
+ def _reason_of(item: Any) -> Optional[str]:
812
+ raw = _first_field(item, "stop_reason", "finish_reason")
813
+ if raw is None or isinstance(raw, (list, tuple, dict)):
814
+ return None
815
+ return finish_reason(raw)
816
+
817
+ def _note_reason(self, item: Any, generation: Optional[str]) -> Optional[str]:
818
+ reason = self._reason_of(item)
819
+ if not reason:
820
+ return None
821
+ if generation is not None:
822
+ if generation in self._reported:
823
+ return None
824
+ self._reported.add(generation)
825
+ self.finish_reasons.append(reason)
826
+ return reason
827
+
828
+ def add_text(self, chunk: Any) -> None:
829
+ if chunk is None:
830
+ return
831
+ text = chunk if isinstance(chunk, str) else _first_field(chunk, "text", "content")
832
+ reason = None
833
+ if type(chunk) is not str: # an update object, or a str subclass with fields
834
+ try:
835
+ reason = self._reason_of(chunk)
836
+ except Exception: # pragma: no cover - defensive
837
+ pass
838
+ if enabled() and isinstance(text, str) and text:
839
+ self._chunks.append(text)
840
+ if reason:
841
+ # The chunk that names a reason is a generation's last: the text
842
+ # so far is that generation's message, and what follows is the
843
+ # next generation's.
844
+ self._flush_chunks()
845
+ self.finish_reasons.append(reason)
846
+
847
+ def _flush_chunks(self) -> None:
848
+ text = "".join(self._chunks)
849
+ self._chunks = []
850
+ msg = assistant_text(text) if text else None
851
+ if msg:
852
+ self._append(msg, None)
853
+ self._open = False
854
+
855
+ def result(self) -> Optional[List[dict]]:
856
+ if not enabled():
857
+ return None
858
+ messages = list(self._messages)
859
+ remaining = assistant_text("".join(self._chunks)) if self._chunks else None
860
+ if remaining:
861
+ messages.append(remaining)
862
+ if messages:
863
+ return messages
864
+ final = assistant_text(self._final) if self._final else None
865
+ return [final] if final else None