agent-observability-trace-cli 0.1.2__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.
- agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
- agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
- agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
- agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
- agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
- agent_trace/__init__.py +1182 -0
- agent_trace/_cli.py +1377 -0
- agent_trace/_inspect.py +2020 -0
- agent_trace/_replay/__init__.py +1 -0
- agent_trace/_replay/engine.py +268 -0
- agent_trace/_replay/fixture.py +868 -0
- agent_trace/core/__init__.py +1 -0
- agent_trace/core/clock.py +91 -0
- agent_trace/core/exceptions.py +51 -0
- agent_trace/core/span.py +271 -0
- agent_trace/core/trace.py +92 -0
- agent_trace/exporters/__init__.py +1 -0
- agent_trace/exporters/file.py +80 -0
- agent_trace/exporters/otlp.py +199 -0
- agent_trace/exporters/remote_fixture.py +307 -0
- agent_trace/exporters/stdout.py +258 -0
- agent_trace/integrations/__init__.py +69 -0
- agent_trace/integrations/agno.py +489 -0
- agent_trace/integrations/autogen.py +581 -0
- agent_trace/integrations/crewai.py +486 -0
- agent_trace/integrations/google_genai.py +731 -0
- agent_trace/integrations/haystack.py +254 -0
- agent_trace/integrations/langchain_core.py +361 -0
- agent_trace/integrations/langgraph.py +2093 -0
- agent_trace/integrations/langgraph_checkpoint.py +763 -0
- agent_trace/integrations/langgraph_state_diff.py +345 -0
- agent_trace/integrations/langgraph_stream_debug.py +202 -0
- agent_trace/integrations/llama_index.py +472 -0
- agent_trace/integrations/mcp.py +281 -0
- agent_trace/integrations/openai_agents.py +811 -0
- agent_trace/integrations/pydantic_ai.py +504 -0
- agent_trace/integrations/streaming.py +218 -0
- agent_trace/interceptor/__init__.py +64 -0
- agent_trace/interceptor/aiohttp_hook.py +152 -0
- agent_trace/interceptor/botocore_hook.py +223 -0
- agent_trace/interceptor/grpc_hook.py +651 -0
- agent_trace/interceptor/httpx_hook.py +866 -0
- agent_trace/interceptor/logging_hook.py +137 -0
- agent_trace/interceptor/requests_patch.py +184 -0
- agent_trace/interceptor/sse.py +176 -0
- agent_trace/interceptor/stdio_hook.py +245 -0
- agent_trace/interceptor/warnings_hook.py +132 -0
- agent_trace/interceptor/websocket_hook.py +323 -0
- agent_trace/plugins/__init__.py +35 -0
- agent_trace/plugins/base.py +111 -0
- agent_trace/py.typed +0 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Logging-capture layer for non-HTTP, non-callback runtime warnings.
|
|
3
|
+
|
|
4
|
+
Some real, reproducible agent failures never raise an exception and never
|
|
5
|
+
touch the network — e.g. LangGraph's pregel scheduler logging ``"wrote to
|
|
6
|
+
unknown channel X, ignoring it"`` when ``push_ui_message`` targets an
|
|
7
|
+
unregistered state channel (langgraph#5464). Neither the HTTP interceptor
|
|
8
|
+
(``agent_trace.interceptor.httpx_hook``/``requests_patch``) nor
|
|
9
|
+
``LangGraphTracer``'s callback handlers
|
|
10
|
+
(``agent_trace.integrations.langgraph``) can ever see this class of
|
|
11
|
+
failure — nothing in agent-trace previously attached to Python's own
|
|
12
|
+
``logging`` module at all.
|
|
13
|
+
|
|
14
|
+
Usage::
|
|
15
|
+
|
|
16
|
+
from agent_trace import tracer
|
|
17
|
+
from agent_trace.interceptor.logging_hook import capture_logging
|
|
18
|
+
|
|
19
|
+
with tracer.start_trace("my_graph") as trace:
|
|
20
|
+
with capture_logging(tracer, logger_names=["langgraph"]):
|
|
21
|
+
graph.invoke(state, config={"callbacks": [...]})
|
|
22
|
+
|
|
23
|
+
Every ``WARNING``-or-above record emitted by any of the named loggers while
|
|
24
|
+
the ``with`` block is active is persisted as a ``runtime_log`` event on a
|
|
25
|
+
dedicated ``logging:capture`` span — independent of, and in addition to,
|
|
26
|
+
whatever HTTP/callback capture is also active.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import contextlib
|
|
32
|
+
import logging
|
|
33
|
+
from collections.abc import Generator, Sequence
|
|
34
|
+
from typing import TYPE_CHECKING
|
|
35
|
+
|
|
36
|
+
from agent_trace.core.span import Span, SpanStatus
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from agent_trace import Tracer
|
|
40
|
+
|
|
41
|
+
__all__ = ["capture_logging"]
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# Bounds — this is diagnostic capture, not a full log mirror. A pathological
|
|
46
|
+
# hot loop logging thousands of warnings must not grow a single span's event
|
|
47
|
+
# list unboundedly; logging.captured_count keeps counting past the cap.
|
|
48
|
+
_MAX_ATTR_LEN = 4_000
|
|
49
|
+
_MAX_LOG_EVENTS = 200
|
|
50
|
+
|
|
51
|
+
# Loggers known to emit real, reproducible non-exception runtime warnings
|
|
52
|
+
# relevant to agent failures — e.g. langgraph#5464's pregel-scheduler
|
|
53
|
+
# "wrote to unknown channel" line. Callers can pass their own logger_names
|
|
54
|
+
# to widen or narrow this.
|
|
55
|
+
_DEFAULT_LOGGER_NAMES: tuple[str, ...] = ("langgraph", "langchain_core")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _SpanLoggingHandler(logging.Handler):
|
|
59
|
+
"""logging.Handler that persists each record as a SpanEvent on *span*.
|
|
60
|
+
|
|
61
|
+
Formatting/attribute-extraction failures on a single record must never
|
|
62
|
+
break the caller's real logging pipeline — every step is best-effort.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, span: Span, level: int = logging.WARNING) -> None:
|
|
66
|
+
super().__init__(level=level)
|
|
67
|
+
self._span = span
|
|
68
|
+
self.captured_count = 0
|
|
69
|
+
|
|
70
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
71
|
+
self.captured_count += 1
|
|
72
|
+
if self.captured_count > _MAX_LOG_EVENTS:
|
|
73
|
+
return
|
|
74
|
+
try:
|
|
75
|
+
message = self.format(record)
|
|
76
|
+
except Exception:
|
|
77
|
+
message = record.getMessage()
|
|
78
|
+
try:
|
|
79
|
+
self._span.add_event(
|
|
80
|
+
"runtime_log",
|
|
81
|
+
attributes={
|
|
82
|
+
"log.level": record.levelname,
|
|
83
|
+
"log.logger": record.name,
|
|
84
|
+
"log.message": str(message)[:_MAX_ATTR_LEN],
|
|
85
|
+
"log.filename": str(record.pathname),
|
|
86
|
+
"log.lineno": int(record.lineno),
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
except Exception:
|
|
90
|
+
logger.debug(
|
|
91
|
+
"agent-trace: failed to record captured log event",
|
|
92
|
+
exc_info=True,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@contextlib.contextmanager
|
|
97
|
+
def capture_logging(
|
|
98
|
+
tracer: Tracer,
|
|
99
|
+
*,
|
|
100
|
+
logger_names: Sequence[str] = _DEFAULT_LOGGER_NAMES,
|
|
101
|
+
level: int = logging.WARNING,
|
|
102
|
+
span_name: str = "logging:capture",
|
|
103
|
+
) -> Generator[Span, None, None]:
|
|
104
|
+
"""Attach a logging.Handler to *logger_names* for the lifetime of the
|
|
105
|
+
``with`` block, persisting each captured record onto a dedicated
|
|
106
|
+
``logging:capture`` span (opened via *tracer*).
|
|
107
|
+
|
|
108
|
+
*logger_names* defaults to LangGraph/LangChain's own logger namespaces
|
|
109
|
+
— the loggers a pregel-scheduler warning like langgraph#5464's actually
|
|
110
|
+
goes through. Pass an explicit list to widen/narrow the set.
|
|
111
|
+
|
|
112
|
+
The span closes ``OK`` when the block exits normally (log capture is a
|
|
113
|
+
diagnostic side-channel, not a success/failure signal in itself) or
|
|
114
|
+
``ERROR`` if the block itself raises (the exception is recorded onto the
|
|
115
|
+
span, then re-raised unchanged).
|
|
116
|
+
"""
|
|
117
|
+
span = tracer.start_span(span_name)
|
|
118
|
+
handler = _SpanLoggingHandler(span, level=level)
|
|
119
|
+
attached: list[logging.Logger] = []
|
|
120
|
+
try:
|
|
121
|
+
for name in logger_names:
|
|
122
|
+
target = logging.getLogger(name)
|
|
123
|
+
target.addHandler(handler)
|
|
124
|
+
attached.append(target)
|
|
125
|
+
yield span
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
span.record_exception(exc)
|
|
128
|
+
if span.end_time is None:
|
|
129
|
+
span.end(SpanStatus.ERROR)
|
|
130
|
+
raise
|
|
131
|
+
else:
|
|
132
|
+
span.set_attribute("logging.captured_count", handler.captured_count)
|
|
133
|
+
if span.end_time is None:
|
|
134
|
+
span.end(SpanStatus.OK)
|
|
135
|
+
finally:
|
|
136
|
+
for target in attached:
|
|
137
|
+
target.removeHandler(handler)
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
requests adapters for recording and replaying HTTP exchanges.
|
|
3
|
+
|
|
4
|
+
These are the requests-library equivalents of httpx_hook.py's transports.
|
|
5
|
+
RecordingAdapter wraps the real HTTPAdapter and saves each exchange.
|
|
6
|
+
ReplayAdapter serves responses from the fixture without touching the network.
|
|
7
|
+
|
|
8
|
+
Why two separate modules (httpx_hook + requests_patch)?
|
|
9
|
+
Many AI SDKs use httpx (e.g. the Anthropic Python SDK); others use requests
|
|
10
|
+
(e.g. OpenAI's legacy client). Supporting both lets agent-trace intercept
|
|
11
|
+
the full HTTP layer regardless of which SDK the user chooses.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import io
|
|
17
|
+
import logging
|
|
18
|
+
import time
|
|
19
|
+
import warnings
|
|
20
|
+
from typing import TYPE_CHECKING, Any
|
|
21
|
+
|
|
22
|
+
from requests import PreparedRequest, Response
|
|
23
|
+
from requests.adapters import BaseAdapter, HTTPAdapter
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from agent_trace._replay.fixture import Fixture
|
|
27
|
+
|
|
28
|
+
from agent_trace.core.exceptions import NetworkGuardError, guard_active
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"NetworkGuardError",
|
|
32
|
+
"RecordingAdapter",
|
|
33
|
+
"ReplayAdapter",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RecordingAdapter(HTTPAdapter):
|
|
40
|
+
"""requests HTTPAdapter that persists each exchange to a Fixture.
|
|
41
|
+
|
|
42
|
+
Mount this adapter on a requests.Session before making calls:
|
|
43
|
+
|
|
44
|
+
session = requests.Session()
|
|
45
|
+
session.mount("https://", RecordingAdapter(fixture))
|
|
46
|
+
|
|
47
|
+
Pass *inner* to wrap an existing adapter (preserves connection pooling
|
|
48
|
+
and custom adapters) instead of creating a fresh HTTPAdapter pool.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
fixture: Fixture,
|
|
54
|
+
inner: BaseAdapter | None = None,
|
|
55
|
+
**kwargs: Any,
|
|
56
|
+
) -> None:
|
|
57
|
+
super().__init__(**kwargs)
|
|
58
|
+
self._fixture = fixture
|
|
59
|
+
self._inner = inner
|
|
60
|
+
|
|
61
|
+
def send(
|
|
62
|
+
self,
|
|
63
|
+
request: PreparedRequest,
|
|
64
|
+
*args: Any,
|
|
65
|
+
**kwargs: Any,
|
|
66
|
+
) -> Response:
|
|
67
|
+
"""Send the request, record the exchange, return the response.
|
|
68
|
+
|
|
69
|
+
A pre-response exception (connection refused, DNS failure, TLS
|
|
70
|
+
failure, ...) is recorded too — as a failed-before-response exchange
|
|
71
|
+
(error_type/error_message, no response_status) — instead of being
|
|
72
|
+
silently lost, then re-raised unchanged so the caller sees the exact
|
|
73
|
+
same failure it would without recording active.
|
|
74
|
+
"""
|
|
75
|
+
url = str(request.url or "")
|
|
76
|
+
method = str(request.method or "GET").upper()
|
|
77
|
+
req_headers = dict(request.headers or {})
|
|
78
|
+
# PreparedRequest.body can be bytes, str, or None.
|
|
79
|
+
body = request.body
|
|
80
|
+
if isinstance(body, bytes):
|
|
81
|
+
req_body = body.decode("utf-8", errors="replace")
|
|
82
|
+
elif isinstance(body, str):
|
|
83
|
+
req_body = body
|
|
84
|
+
else:
|
|
85
|
+
req_body = ""
|
|
86
|
+
|
|
87
|
+
start = time.monotonic()
|
|
88
|
+
try:
|
|
89
|
+
if self._inner is not None:
|
|
90
|
+
response: Response = self._inner.send(request, *args, **kwargs)
|
|
91
|
+
else:
|
|
92
|
+
response = super().send(request, *args, **kwargs)
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
95
|
+
self._fixture.record_exchange(
|
|
96
|
+
url=url,
|
|
97
|
+
method=method,
|
|
98
|
+
request_headers=req_headers,
|
|
99
|
+
request_body=req_body,
|
|
100
|
+
duration_ms=duration_ms,
|
|
101
|
+
error_type=type(exc).__qualname__,
|
|
102
|
+
error_message=str(exc),
|
|
103
|
+
)
|
|
104
|
+
raise
|
|
105
|
+
duration_ms = (time.monotonic() - start) * 1000
|
|
106
|
+
|
|
107
|
+
resp_headers = dict(response.headers)
|
|
108
|
+
resp_body = response.text # reads and caches the body
|
|
109
|
+
|
|
110
|
+
self._fixture.record_exchange(
|
|
111
|
+
url=url,
|
|
112
|
+
method=method,
|
|
113
|
+
request_headers=req_headers,
|
|
114
|
+
request_body=req_body,
|
|
115
|
+
response_status=response.status_code,
|
|
116
|
+
response_headers=resp_headers,
|
|
117
|
+
response_body=resp_body,
|
|
118
|
+
duration_ms=duration_ms,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return response
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ReplayAdapter(BaseAdapter):
|
|
125
|
+
"""requests adapter that serves responses from a Fixture without network I/O.
|
|
126
|
+
|
|
127
|
+
Mount this adapter on a requests.Session to intercept all outbound calls:
|
|
128
|
+
|
|
129
|
+
session = requests.Session()
|
|
130
|
+
session.mount("https://", ReplayAdapter(fixture))
|
|
131
|
+
session.mount("http://", ReplayAdapter(fixture))
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, fixture: Fixture) -> None:
|
|
135
|
+
super().__init__()
|
|
136
|
+
self._fixture = fixture
|
|
137
|
+
|
|
138
|
+
def close(self) -> None:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
def send(
|
|
142
|
+
self,
|
|
143
|
+
request: PreparedRequest,
|
|
144
|
+
*args: Any,
|
|
145
|
+
**kwargs: Any,
|
|
146
|
+
) -> Response:
|
|
147
|
+
"""Return the next recorded response for this request without I/O."""
|
|
148
|
+
url = str(request.url or "")
|
|
149
|
+
method = str(request.method or "GET").upper()
|
|
150
|
+
|
|
151
|
+
exchange: dict[str, Any] | None = self._fixture.next_exchange(url, method)
|
|
152
|
+
|
|
153
|
+
if exchange is None:
|
|
154
|
+
if guard_active():
|
|
155
|
+
raise NetworkGuardError(
|
|
156
|
+
f"No recorded exchange for {method} {url} and "
|
|
157
|
+
"AGENT_TRACE_NETWORK_GUARD=1 is set. "
|
|
158
|
+
"Run in recording mode first to capture this request."
|
|
159
|
+
)
|
|
160
|
+
warnings.warn(
|
|
161
|
+
f"agent-trace: no fixture entry for {method} {url}; "
|
|
162
|
+
"falling through to live network. Set AGENT_TRACE_NETWORK_GUARD=1 "
|
|
163
|
+
"to make this an error.",
|
|
164
|
+
stacklevel=2,
|
|
165
|
+
)
|
|
166
|
+
fallback = HTTPAdapter()
|
|
167
|
+
try:
|
|
168
|
+
return fallback.send(request, *args, **kwargs)
|
|
169
|
+
finally:
|
|
170
|
+
fallback.close()
|
|
171
|
+
|
|
172
|
+
response = Response()
|
|
173
|
+
response.status_code = int(exchange["response_status"])
|
|
174
|
+
response.headers.update(exchange["response_headers"])
|
|
175
|
+
# _content must be set as bytes so that response.text works correctly.
|
|
176
|
+
content = exchange["response_body"].encode("utf-8")
|
|
177
|
+
response._content = content
|
|
178
|
+
response.encoding = "utf-8"
|
|
179
|
+
response.url = url
|
|
180
|
+
response.request = request
|
|
181
|
+
# Wrap bytes in a BytesIO so raw.read() works if callers check it.
|
|
182
|
+
response.raw = io.BytesIO(content)
|
|
183
|
+
|
|
184
|
+
return response
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SSE-aware parsing of streamed HTTP response bodies stored in a Fixture.
|
|
3
|
+
|
|
4
|
+
RecordingTransport/AsyncRecordingTransport already capture the full raw
|
|
5
|
+
Server-Sent-Events text off the wire — framework-agnostic, regardless of
|
|
6
|
+
provider (OpenAI/Azure/Groq-style ``data: {...}`` chunks, Anthropic's
|
|
7
|
+
``event: ...`` + ``data: ...`` pairs, ...). What's missing is turning that
|
|
8
|
+
opaque text into diffable, addressable per-chunk data: today a developer has
|
|
9
|
+
to hand-parse ``response_body`` themselves to see what each streamed delta
|
|
10
|
+
actually contained.
|
|
11
|
+
|
|
12
|
+
This module is deliberately read-only and operates purely on an already
|
|
13
|
+
-recorded ``response_body`` string (or a fixture exchange dict) — it does not
|
|
14
|
+
change how RecordingTransport captures bytes off the wire. Use it against
|
|
15
|
+
``Fixture.all_exchanges()``/``Fixture.next_exchange()`` output, e.g.:
|
|
16
|
+
|
|
17
|
+
from agent_trace.interceptor.sse import (
|
|
18
|
+
is_sse_exchange,
|
|
19
|
+
parse_sse_events,
|
|
20
|
+
reconstruct_streamed_message,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
for exchange in fixture.all_exchanges():
|
|
24
|
+
if is_sse_exchange(exchange):
|
|
25
|
+
events = parse_sse_events(exchange["response_body"])
|
|
26
|
+
merged = reconstruct_streamed_message(events)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"is_sse_exchange",
|
|
36
|
+
"parse_sse_events",
|
|
37
|
+
"reconstruct_streamed_message",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
# Sentinel value some providers (OpenAI-style) send as the final SSE data
|
|
41
|
+
# line to signal "no more chunks" — not JSON, must be special-cased rather
|
|
42
|
+
# than fed to json.loads.
|
|
43
|
+
_DONE_SENTINEL = "[DONE]"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_sse_exchange(exchange: dict[str, Any]) -> bool:
|
|
47
|
+
"""True if *exchange* (as returned by Fixture.all_exchanges()/
|
|
48
|
+
next_exchange()) looks like a captured Server-Sent-Events response.
|
|
49
|
+
|
|
50
|
+
Checks the recorded ``Content-Type`` response header first (the
|
|
51
|
+
authoritative signal); falls back to sniffing the body for an SSE
|
|
52
|
+
``data: `` line when headers are missing/lowercased differently than
|
|
53
|
+
expected, since some proxies/test fixtures don't preserve casing.
|
|
54
|
+
"""
|
|
55
|
+
headers = exchange.get("response_headers") or {}
|
|
56
|
+
for key, value in headers.items():
|
|
57
|
+
if str(key).lower() != "content-type":
|
|
58
|
+
continue
|
|
59
|
+
if "text/event-stream" in str(value).lower():
|
|
60
|
+
return True
|
|
61
|
+
body = exchange.get("response_body") or ""
|
|
62
|
+
return _looks_like_sse_body(body)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _looks_like_sse_body(body: str) -> bool:
|
|
66
|
+
"""Best-effort sniff: at least one line starts with the SSE ``data: ``
|
|
67
|
+
field prefix. Deliberately conservative — a false negative just means a
|
|
68
|
+
caller has to fall back to treating the body as an opaque string, which
|
|
69
|
+
is the pre-existing behavior anyway."""
|
|
70
|
+
for line in body.splitlines():
|
|
71
|
+
if line.startswith("data:"):
|
|
72
|
+
return True
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_sse_events(body: str) -> list[dict[str, Any] | str]:
|
|
77
|
+
"""Split a raw SSE response body on ``data: `` boundaries and parse each
|
|
78
|
+
event's payload.
|
|
79
|
+
|
|
80
|
+
Returns an ordered list — one entry per ``data:`` line found, in the
|
|
81
|
+
order they appear in *body* (the order they were streamed). Each entry
|
|
82
|
+
is either:
|
|
83
|
+
|
|
84
|
+
- a parsed JSON object, for the common ``data: {...}`` shape every major
|
|
85
|
+
provider's streaming chat-completions endpoint uses, or
|
|
86
|
+
- the raw string payload, when the line isn't valid JSON (e.g. the
|
|
87
|
+
``[DONE]`` sentinel, or a provider using a non-JSON SSE payload).
|
|
88
|
+
|
|
89
|
+
A blank body, or a body with no ``data:`` lines at all, returns ``[]`` —
|
|
90
|
+
callers should treat that as "not an SSE payload" rather than an error.
|
|
91
|
+
"""
|
|
92
|
+
events: list[dict[str, Any] | str] = []
|
|
93
|
+
for line in body.splitlines():
|
|
94
|
+
if not line.startswith("data:"):
|
|
95
|
+
continue
|
|
96
|
+
payload = line[len("data:") :].strip()
|
|
97
|
+
if not payload or payload == _DONE_SENTINEL:
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
events.append(json.loads(payload))
|
|
101
|
+
except (json.JSONDecodeError, TypeError):
|
|
102
|
+
events.append(payload)
|
|
103
|
+
return events
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _delta_from_event(event: dict[str, Any] | str) -> dict[str, Any] | None:
|
|
107
|
+
"""Extract the OpenAI-style ``choices[0].delta`` dict from one parsed SSE
|
|
108
|
+
event, or None if this event isn't that shape (a non-dict event, a
|
|
109
|
+
provider using a different streaming schema, etc.)."""
|
|
110
|
+
if not isinstance(event, dict):
|
|
111
|
+
return None
|
|
112
|
+
choices = event.get("choices")
|
|
113
|
+
if not choices or not isinstance(choices, list):
|
|
114
|
+
return None
|
|
115
|
+
first = choices[0]
|
|
116
|
+
if not isinstance(first, dict):
|
|
117
|
+
return None
|
|
118
|
+
delta = first.get("delta")
|
|
119
|
+
return delta if isinstance(delta, dict) else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def reconstruct_streamed_message(
|
|
123
|
+
events: list[dict[str, Any] | str],
|
|
124
|
+
) -> dict[str, Any]:
|
|
125
|
+
"""Merge an ordered list of parsed SSE events (from parse_sse_events())
|
|
126
|
+
into one addressable, reassembled message.
|
|
127
|
+
|
|
128
|
+
Concatenates every ``delta.content`` fragment into ``content``, and
|
|
129
|
+
merges every ``delta.tool_calls[]`` fragment — keyed by its ``index``
|
|
130
|
+
field, the same index OpenAI-style providers use to identify which
|
|
131
|
+
parallel tool call a given argument fragment belongs to — into
|
|
132
|
+
``tool_calls``, concatenating each tool call's ``function.arguments``
|
|
133
|
+
string across chunks in the order they arrived.
|
|
134
|
+
|
|
135
|
+
Returns ``{"content": str, "tool_calls": {index: {...}}}``. Events that
|
|
136
|
+
don't match the OpenAI-style ``choices[0].delta`` shape are ignored
|
|
137
|
+
(not raised on) — this reconstruction targets that one well-known wire
|
|
138
|
+
shape; a caller working with a different provider's streaming schema
|
|
139
|
+
should read ``events`` directly instead.
|
|
140
|
+
"""
|
|
141
|
+
content_parts: list[str] = []
|
|
142
|
+
tool_calls: dict[int, dict[str, Any]] = {}
|
|
143
|
+
|
|
144
|
+
for event in events:
|
|
145
|
+
delta = _delta_from_event(event)
|
|
146
|
+
if delta is None:
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
content_piece = delta.get("content")
|
|
150
|
+
if isinstance(content_piece, str):
|
|
151
|
+
content_parts.append(content_piece)
|
|
152
|
+
|
|
153
|
+
for tc_fragment in delta.get("tool_calls") or []:
|
|
154
|
+
if not isinstance(tc_fragment, dict):
|
|
155
|
+
continue
|
|
156
|
+
index = tc_fragment.get("index", 0)
|
|
157
|
+
try:
|
|
158
|
+
index = int(index)
|
|
159
|
+
except (TypeError, ValueError):
|
|
160
|
+
index = 0
|
|
161
|
+
entry = tool_calls.setdefault(
|
|
162
|
+
index,
|
|
163
|
+
{"id": None, "function": {"name": "", "arguments": ""}},
|
|
164
|
+
)
|
|
165
|
+
if tc_fragment.get("id"):
|
|
166
|
+
entry["id"] = tc_fragment["id"]
|
|
167
|
+
fn_fragment = tc_fragment.get("function") or {}
|
|
168
|
+
if fn_fragment.get("name"):
|
|
169
|
+
entry["function"]["name"] += fn_fragment["name"]
|
|
170
|
+
if fn_fragment.get("arguments"):
|
|
171
|
+
entry["function"]["arguments"] += fn_fragment["arguments"]
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
"content": "".join(content_parts),
|
|
175
|
+
"tool_calls": tool_calls,
|
|
176
|
+
}
|