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.
- otelzero/__init__.py +62 -0
- otelzero/__main__.py +140 -0
- otelzero/context.py +86 -0
- otelzero/detection.py +58 -0
- otelzero/hooks/__init__.py +96 -0
- otelzero/hooks/_content.py +865 -0
- otelzero/hooks/_langsmith.py +504 -0
- otelzero/hooks/_native.py +517 -0
- otelzero/hooks/_usage.py +166 -0
- otelzero/hooks/adk.py +128 -0
- otelzero/hooks/base.py +546 -0
- otelzero/hooks/claude_sdk.py +267 -0
- otelzero/hooks/crewai.py +338 -0
- otelzero/hooks/databricks.py +260 -0
- otelzero/hooks/langchain.py +56 -0
- otelzero/hooks/langgraph.py +57 -0
- otelzero/hooks/llama_index.py +724 -0
- otelzero/hooks/microsoft.py +160 -0
- otelzero/hooks/openai_agents.py +313 -0
- otelzero/hooks/pydantic_ai.py +173 -0
- otelzero/hooks/smolagents.py +331 -0
- otelzero/hooks/snowflake.py +271 -0
- otelzero/hooks/strands.py +173 -0
- otelzero/otel.py +372 -0
- otelzero/semconv.py +327 -0
- otelzero/telemetry.py +238 -0
- otelzero/translate/__init__.py +214 -0
- otelzero/translate/langsmith.py +401 -0
- otelzero-0.2.0.dist-info/METADATA +1345 -0
- otelzero-0.2.0.dist-info/RECORD +33 -0
- otelzero-0.2.0.dist-info/WHEEL +5 -0
- otelzero-0.2.0.dist-info/licenses/LICENSE +202 -0
- otelzero-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|