agentx-python 0.8.26__tar.gz → 0.8.28__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. {agentx_python-0.8.26 → agentx_python-0.8.28}/PKG-INFO +7 -1
  2. {agentx_python-0.8.26 → agentx_python-0.8.28}/README.md +6 -0
  3. agentx_python-0.8.28/agentx/integrations/_traced_call.py +446 -0
  4. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/anthropic.py +181 -18
  5. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/nvidia_nim.py +8 -4
  6. agentx_python-0.8.28/agentx/integrations/openai.py +293 -0
  7. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/__init__.py +4 -0
  8. agentx_python-0.8.28/agentx/monitor/alert_rules.py +223 -0
  9. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/client.py +5 -0
  10. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/tracing/tracer.py +10 -1
  11. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/version.py +2 -2
  12. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/PKG-INFO +7 -1
  13. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/SOURCES.txt +2 -0
  14. agentx_python-0.8.28/tests/test_alert_rules.py +140 -0
  15. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_integrations.py +505 -28
  16. agentx_python-0.8.26/agentx/integrations/_traced_call.py +0 -175
  17. agentx_python-0.8.26/agentx/integrations/openai.py +0 -173
  18. {agentx_python-0.8.26 → agentx_python-0.8.28}/LICENSE +0 -0
  19. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/__init__.py +0 -0
  20. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/agentx.py +0 -0
  21. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/cli.py +0 -0
  22. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/__init__.py +0 -0
  23. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/_term.py +0 -0
  24. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/adapters/__init__.py +0 -0
  25. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/adapters/http_endpoint.py +0 -0
  26. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/adapters/precomputed.py +0 -0
  27. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/adapters/raw.py +0 -0
  28. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/client.py +0 -0
  29. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/datasets.py +0 -0
  30. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/evaluation_settings.py +0 -0
  31. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/models.py +0 -0
  32. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/prompts.py +0 -0
  33. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/reporting.py +0 -0
  34. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/results.py +0 -0
  35. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/runner.py +0 -0
  36. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/tool_schemas.py +0 -0
  37. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/tracing.py +0 -0
  38. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/exceptions.py +0 -0
  39. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/export.py +0 -0
  40. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/feedback.py +0 -0
  41. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/__init__.py +0 -0
  42. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/autogen.py +0 -0
  43. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/crewai.py +0 -0
  44. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/databricks.py +0 -0
  45. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/google_adk.py +0 -0
  46. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/google_genai.py +0 -0
  47. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/langchain.py +0 -0
  48. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/litellm.py +0 -0
  49. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/llamaindex.py +0 -0
  50. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/moveworks.py +0 -0
  51. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/openai_agents.py +0 -0
  52. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/_transport.py +0 -0
  53. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/agents.py +0 -0
  54. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/improvement_groups.py +0 -0
  55. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/judge_scorers.py +0 -0
  56. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/models.py +0 -0
  57. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/online_evaluators.py +0 -0
  58. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/patterns.py +0 -0
  59. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/profile.py +0 -0
  60. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/review_queue.py +0 -0
  61. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/rules.py +0 -0
  62. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/scorer_groups.py +0 -0
  63. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/scorers.py +0 -0
  64. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/sessions.py +0 -0
  65. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/signals.py +0 -0
  66. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/outcomes.py +0 -0
  67. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/projects.py +0 -0
  68. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/py.typed +0 -0
  69. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/resources/__init__.py +0 -0
  70. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/resources/agent.py +0 -0
  71. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/resources/conversation.py +0 -0
  72. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/resources/workforce.py +0 -0
  73. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/testing.py +0 -0
  74. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/traces.py +0 -0
  75. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/tracing/__init__.py +0 -0
  76. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/tracing/ci_types.py +0 -0
  77. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/tracing/eval_scope.py +0 -0
  78. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/tracing/framework_detect.py +0 -0
  79. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/tracing/ingest_client.py +0 -0
  80. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/util.py +0 -0
  81. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/dependency_links.txt +0 -0
  82. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/entry_points.txt +0 -0
  83. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/not-zip-safe +0 -0
  84. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/requires.txt +0 -0
  85. {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/top_level.txt +0 -0
  86. {agentx_python-0.8.26 → agentx_python-0.8.28}/setup.cfg +0 -0
  87. {agentx_python-0.8.26 → agentx_python-0.8.28}/setup.py +0 -0
  88. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_cli_launcher.py +0 -0
  89. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_deep_dive_fixes.py +0 -0
  90. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_docs_match_sdk.py +0 -0
  91. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_error_taxonomy.py +0 -0
  92. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_eval_scope.py +0 -0
  93. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_framework_detect.py +0 -0
  94. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_integration.py +0 -0
  95. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_judge_scorers.py +0 -0
  96. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_multi_judge.py +0 -0
  97. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_pairwise.py +0 -0
  98. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_pattern_update_merge.py +0 -0
  99. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_review_queue.py +0 -0
  100. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_runner_features.py +0 -0
  101. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_selfhost_analysis_fallback.py +0 -0
  102. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_selfhost_compat.py +0 -0
  103. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_span_tree.py +0 -0
  104. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_testing.py +0 -0
  105. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_wire_models.py +0 -0
  106. {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_workforce_binding.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentx-python
3
- Version: 0.8.26
3
+ Version: 0.8.28
4
4
  Summary: Official Python SDK for AgentX (https://www.agentx.so/)
5
5
  Home-page: https://github.com/AgentX-ai/AgentX-python
6
6
  Author: Robin Wang and AgentX Team
@@ -260,6 +260,12 @@ extra:
260
260
  | LlamaIndex | `pip install "agentx-python[llamaindex]"` | `AgentXLlamaIndexHandler` |
261
261
  | AutoGen | `pip install "agentx-python[autogen]"` | `AgentXAutoGenObserver` |
262
262
 
263
+ Raw-client patches (`patch_openai_client`, `patch_nim_client`, `patch_anthropic_client`) trace
264
+ streaming calls too: the returned stream is a transparent proxy that assembles the reply from the
265
+ chunks you consume, with latency measured to the last chunk and the time to first token in the
266
+ trace metadata. OpenAI-compatible endpoints only send token usage on streams when you pass
267
+ `stream_options={"include_usage": True}`.
268
+
263
269
  > **Warning: pick ONE instrumentation layer per LLM call.** Do not combine
264
270
  > `AgentXCallbackHandler` (or any framework integration) with a patched provider client
265
271
  > (`patch_openai_client`, `patch_anthropic_client`, `patch_genai_client`) on the same code
@@ -193,6 +193,12 @@ extra:
193
193
  | LlamaIndex | `pip install "agentx-python[llamaindex]"` | `AgentXLlamaIndexHandler` |
194
194
  | AutoGen | `pip install "agentx-python[autogen]"` | `AgentXAutoGenObserver` |
195
195
 
196
+ Raw-client patches (`patch_openai_client`, `patch_nim_client`, `patch_anthropic_client`) trace
197
+ streaming calls too: the returned stream is a transparent proxy that assembles the reply from the
198
+ chunks you consume, with latency measured to the last chunk and the time to first token in the
199
+ trace metadata. OpenAI-compatible endpoints only send token usage on streams when you pass
200
+ `stream_options={"include_usage": True}`.
201
+
196
202
  > **Warning: pick ONE instrumentation layer per LLM call.** Do not combine
197
203
  > `AgentXCallbackHandler` (or any framework integration) with a patched provider client
198
204
  > (`patch_openai_client`, `patch_anthropic_client`, `patch_genai_client`) on the same code
@@ -0,0 +1,446 @@
1
+ """
2
+ Shared helper for tracing a raw SDK client call that may be sync or async.
3
+
4
+ Anthropic's and Google GenAI's raw clients expose the same method names for
5
+ both their sync and async client variants (``client.messages.create``,
6
+ ``client.models.generate_content``) - the only way to tell them apart is to
7
+ call the method and check whether the result is awaitable.
8
+ ``inspect.iscoroutinefunction`` is unreliable for this: it returns ``False``
9
+ even for ``AsyncAnthropic().messages.create``, since these SDKs don't
10
+ implement the async variant as a plain top-level ``async def``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import inspect
16
+ import json
17
+ import logging
18
+ import threading
19
+ import time
20
+ from typing import Any, Callable, Dict, Optional
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Sentinel for finish_llm_call's `active_span`: "not passed" is distinct from "passed None".
25
+ _UNSET: Any = object()
26
+
27
+ from agentx.tracing.tracer import Tracer, _safe_serialize
28
+
29
+
30
+ def call_and_trace(
31
+ original: Callable[..., Any],
32
+ args: tuple,
33
+ kwargs: dict,
34
+ on_finish: Callable[[Optional[Any], Optional[str]], None],
35
+ ) -> Any:
36
+ """
37
+ Call ``original(*args, **kwargs)``.
38
+
39
+ If the result is awaitable (async client), return a coroutine that awaits
40
+ it and calls ``on_finish(response, error)`` only after the real await
41
+ completes, so timing/output/tokens reflect the actual call rather than
42
+ the moment the coroutine object was constructed. If the result is a
43
+ normal value (sync client), call ``on_finish`` immediately.
44
+
45
+ Either way, the original call's own return value / exception behavior is
46
+ unchanged for the caller - this only affects when/how the trace is built.
47
+ """
48
+ try:
49
+ result = original(*args, **kwargs)
50
+ except Exception as exc:
51
+ on_finish(None, str(exc))
52
+ raise
53
+
54
+ if asyncio.iscoroutine(result) or inspect.isawaitable(result):
55
+ return _await_and_finish(result, on_finish)
56
+
57
+ on_finish(result, None)
58
+ return result
59
+
60
+
61
+ async def _await_and_finish(
62
+ awaitable: Any,
63
+ on_finish: Callable[[Optional[Any], Optional[str]], None],
64
+ ) -> Any:
65
+ try:
66
+ response = await awaitable
67
+ except Exception as exc:
68
+ on_finish(None, str(exc))
69
+ raise
70
+ on_finish(response, None)
71
+ return response
72
+
73
+
74
+ # The engine's unregistered-tool surfacing (Tools & MCPs -> Unregistered) reads a trace's
75
+ # metadata "tools" key to show the REAL definition instead of one inferred from observed
76
+ # arguments (see AgentX-trace-eval's toolSchemas.ts draftFromMetadata). Raw-client patches see
77
+ # the request's tools=[...] right in kwargs, so capture it - capped so a huge toolbox never
78
+ # blows up the trace's metadata budget.
79
+ _MAX_TOOL_DEFINITIONS = 20
80
+ _MAX_TOOL_DEFINITIONS_BYTES = 12_000
81
+
82
+
83
+ def capture_tool_definitions(tools: Any) -> Optional[list]:
84
+ """Return a metadata-ready copy of a request's ``tools=[...]`` list, or None."""
85
+ if not isinstance(tools, list) or not tools:
86
+ return None
87
+ # A plain JSON round-trip preserves nested schema objects exactly (default=str catches the
88
+ # odd SDK object inside); _safe_serialize would repr-stringify nested dicts, turning a
89
+ # parameters schema into an unusable string.
90
+ try:
91
+ text = json.dumps(tools[:_MAX_TOOL_DEFINITIONS], default=str)
92
+ if len(text) > _MAX_TOOL_DEFINITIONS_BYTES:
93
+ return None
94
+ serialized = json.loads(text)
95
+ except (TypeError, ValueError):
96
+ return None
97
+ return serialized if isinstance(serialized, list) else None
98
+
99
+
100
+ def finish_llm_call(
101
+ tracer: Tracer,
102
+ *,
103
+ name: str,
104
+ framework: str,
105
+ metadata: Optional[Dict[str, Any]],
106
+ session_id: Optional[str],
107
+ start_t: float,
108
+ end_t: float,
109
+ input_repr: Any,
110
+ output: Optional[str],
111
+ model: Optional[str],
112
+ input_tokens: Optional[int],
113
+ output_tokens: Optional[int],
114
+ error: Optional[str],
115
+ cache_read_tokens: Optional[int] = None,
116
+ cache_write_tokens: Optional[int] = None,
117
+ tool_definitions: Optional[list] = None,
118
+ call_metadata: Optional[Dict[str, Any]] = None,
119
+ active_span: Any = _UNSET,
120
+ ) -> None:
121
+ """
122
+ Close out one raw-client LLM call - shared by the ``on_finish``/exit
123
+ callbacks of every integration that patches a raw provider client
124
+ (``anthropic.py``, ``google_genai.py``, ``openai.py``, ``litellm.py``) rather than a
125
+ framework-level callback/plugin system.
126
+
127
+ If the call happened inside a ``with tracer.trace(...)`` block, it becomes that span's own
128
+ real child span (via _record_llm_call) instead of an independent trace - the same "part of a
129
+ multi-call agentic loop" behavior ``anthropic.py`` already had; folded in here so every
130
+ raw-client integration gets it instead of each having to remember to check
131
+ ``tracer.current_span`` itself. Otherwise it becomes its own real root span, opened/closed
132
+ directly here (not via ``tracer._send()``) so it still gets a real span_id/session_id and the
133
+ call's exact timing rather than wall-clock "now".
134
+ """
135
+ latency_ms = int((end_t - start_t) * 1000)
136
+
137
+ if tool_definitions:
138
+ metadata = {**(metadata or {}), "tools": tool_definitions}
139
+
140
+ # The parent is the span that was active when the CALL was made. Streaming patches pass it
141
+ # explicitly: a stream finalizes later (exhaustion, close, or garbage collection), by which
142
+ # time a different span may be active, and the call must not be grafted onto it.
143
+ if active_span is _UNSET:
144
+ active_span = tracer.current_span
145
+ if active_span is not None:
146
+ # The definitions describe the whole call's toolbox - attach them to the enclosing
147
+ # span's metadata (first capture wins) so the ROOT trace carries them for the
148
+ # unregistered-tool listing, same as the standalone-trace path below.
149
+ if tool_definitions and not (active_span._metadata or {}).get("tools"):
150
+ active_span._metadata = {**(active_span._metadata or {}), "tools": tool_definitions}
151
+ if error is not None:
152
+ active_span.set_error(error)
153
+ active_span._record_llm_call(
154
+ duration_ms=latency_ms,
155
+ start_time=start_t,
156
+ end_time=end_t,
157
+ input=input_repr,
158
+ output=output,
159
+ model=model,
160
+ # Stamp the provider literal on a span opened without one (adoption keeps an
161
+ # explicit framework= or a framework integration's label winning over this).
162
+ framework=framework,
163
+ input_tokens=input_tokens,
164
+ output_tokens=output_tokens,
165
+ cache_read_tokens=cache_read_tokens,
166
+ cache_write_tokens=cache_write_tokens,
167
+ metadata=call_metadata,
168
+ )
169
+ return
170
+
171
+ # A patched provider call outside any active span becomes its own root trace - it is a bare
172
+ # model call, so stamp it "llm" rather than leaving the kind unset.
173
+ span = tracer.trace(
174
+ name,
175
+ metadata={**(metadata or {}), **(call_metadata or {})} if (metadata or call_metadata) else None,
176
+ framework=framework,
177
+ model=model,
178
+ session_id=session_id,
179
+ span_kind="llm",
180
+ )
181
+ span.__enter__()
182
+ span._start = start_t
183
+ # The call ended at end_t (a stream's last chunk), not at whatever later moment this
184
+ # runs - __exit__ honors the override instead of measuring to time.time().
185
+ span._end_override = end_t
186
+ span.input = input_repr
187
+ span.output = output
188
+ if error:
189
+ span.set_error(error)
190
+ if input_tokens:
191
+ span._input_tokens = input_tokens
192
+ if output_tokens:
193
+ span._output_tokens = output_tokens
194
+ if cache_read_tokens:
195
+ span._cache_read_tokens = cache_read_tokens
196
+ if cache_write_tokens:
197
+ span._cache_write_tokens = cache_write_tokens
198
+ span.__exit__(None, None, None)
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Streaming: wrap a provider's chunk stream so the trace is built from what
203
+ # was actually streamed, without touching the caller's consumption of it.
204
+ # ---------------------------------------------------------------------------
205
+
206
+ class StreamAccumulator:
207
+ """
208
+ What a streaming patch feeds each chunk into. Subclasses collect the
209
+ provider-specific pieces (text deltas, tool-call deltas, the usage block
210
+ that only arrives on the final chunk) and hand back the finished picture
211
+ in ``result()``.
212
+ """
213
+
214
+ def feed(self, chunk: Any) -> None: # pragma: no cover - interface
215
+ raise NotImplementedError
216
+
217
+ def result(self) -> Dict[str, Any]: # pragma: no cover - interface
218
+ raise NotImplementedError
219
+
220
+
221
+ class TracedStream:
222
+ """
223
+ Transparent proxy over a provider ``Stream``/``AsyncStream``: iterates the
224
+ real object, feeds every chunk to the accumulator, and calls ``on_finish``
225
+ exactly once when the stream is exhausted, raises, is closed (``close()``,
226
+ ``with``/``async with`` exit), or is dropped part-way and garbage
227
+ collected - so an abandoned stream still records what it streamed.
228
+
229
+ Latency is measured to the LAST chunk (the response as the caller saw it),
230
+ and the time to the FIRST chunk is reported separately as
231
+ ``time_to_first_token_ms`` - the two numbers a streaming call is judged by.
232
+
233
+ Attribute access falls through to the wrapped stream (``.response``,
234
+ provider helpers), and ``__iter__``/``__aiter__`` return ``self`` so early
235
+ ``break`` leaves no half-driven generator behind. It is a proxy, not a
236
+ subclass: ``isinstance(stream, openai.Stream)`` is False and ``repr()``
237
+ shows the proxy - branch on ``stream=True`` in your own code, not on type.
238
+
239
+ Tracing never breaks the caller: a failure while building or sending the
240
+ trace is logged and swallowed, and the stream's own iteration/close
241
+ semantics are untouched.
242
+ """
243
+
244
+ def __init__(
245
+ self,
246
+ stream: Any,
247
+ accumulator: StreamAccumulator,
248
+ on_finish: Callable[[Dict[str, Any], Optional[str]], None],
249
+ ) -> None:
250
+ self._stream = stream
251
+ self._accumulator = accumulator
252
+ self._on_finish = on_finish
253
+ self._done = False
254
+ # A watchdog close() racing the reader's StopIteration must not finalize twice.
255
+ self._done_lock = threading.Lock()
256
+ self._first_chunk_t: Optional[float] = None
257
+ self._last_chunk_t: Optional[float] = None
258
+ self._sync_iter: Any = None
259
+ self._async_iter: Any = None
260
+
261
+ # -- bookkeeping ---------------------------------------------------------
262
+
263
+ def _observe(self, chunk: Any) -> None:
264
+ now = time.time()
265
+ if self._first_chunk_t is None:
266
+ self._first_chunk_t = now
267
+ self._last_chunk_t = now
268
+ try:
269
+ self._accumulator.feed(chunk)
270
+ except Exception:
271
+ # A malformed chunk must never break the caller's stream; it just
272
+ # goes uncounted in the trace.
273
+ pass
274
+
275
+ def _finish(self, error: Optional[str]) -> None:
276
+ with self._done_lock:
277
+ if self._done:
278
+ return
279
+ self._done = True
280
+ try:
281
+ result = self._accumulator.result()
282
+ except Exception:
283
+ result = {}
284
+ start_t = result.pop("_start_t", None)
285
+ result["time_to_first_token_ms"] = (
286
+ int((self._first_chunk_t - start_t) * 1000) if self._first_chunk_t is not None and start_t is not None else None
287
+ )
288
+ # The response "ended" at its last chunk, not at whatever later moment the caller closed
289
+ # or dropped the stream - that is the latency the user experienced.
290
+ result["end_t"] = self._last_chunk_t if self._last_chunk_t is not None else time.time()
291
+ try:
292
+ self._on_finish(result, error)
293
+ except Exception:
294
+ # Building or sending the trace failed. The caller's stream ended normally and must
295
+ # see it end normally - tracing is never allowed to raise into inference code.
296
+ logger.debug("Streamed call could not be traced", exc_info=True)
297
+
298
+ @property
299
+ def first_chunk_at(self) -> Optional[float]:
300
+ return self._first_chunk_t
301
+
302
+ # -- sync iteration ------------------------------------------------------
303
+
304
+ def __iter__(self) -> "TracedStream":
305
+ return self
306
+
307
+ def __next__(self) -> Any:
308
+ if self._sync_iter is None:
309
+ self._sync_iter = iter(self._stream)
310
+ try:
311
+ chunk = next(self._sync_iter)
312
+ except StopIteration:
313
+ self._finish(None)
314
+ raise
315
+ except Exception as exc:
316
+ self._finish(str(exc))
317
+ raise
318
+ except BaseException:
319
+ # KeyboardInterrupt / GeneratorExit: a cancellation, not the provider failing -
320
+ # record what streamed so far without inventing an error message.
321
+ self._finish(None)
322
+ raise
323
+ self._observe(chunk)
324
+ return chunk
325
+
326
+ # -- async iteration -----------------------------------------------------
327
+
328
+ def __aiter__(self) -> "TracedStream":
329
+ return self
330
+
331
+ async def __anext__(self) -> Any:
332
+ if self._async_iter is None:
333
+ self._async_iter = self._stream.__aiter__()
334
+ try:
335
+ chunk = await self._async_iter.__anext__()
336
+ except StopAsyncIteration:
337
+ self._finish(None)
338
+ raise
339
+ except Exception as exc:
340
+ self._finish(str(exc))
341
+ raise
342
+ except BaseException:
343
+ self._finish(None)
344
+ raise
345
+ self._observe(chunk)
346
+ return chunk
347
+
348
+ # -- context managers / close --------------------------------------------
349
+
350
+ def __enter__(self) -> "TracedStream":
351
+ enter = getattr(self._stream, "__enter__", None)
352
+ if enter is not None:
353
+ enter()
354
+ return self
355
+
356
+ def __exit__(self, exc_type, exc_val, tb) -> Any:
357
+ exit_ = getattr(self._stream, "__exit__", None)
358
+ result = exit_(exc_type, exc_val, tb) if exit_ is not None else None
359
+ self._finish(str(exc_val) if exc_val else None)
360
+ return result
361
+
362
+ async def __aenter__(self) -> "TracedStream":
363
+ enter = getattr(self._stream, "__aenter__", None)
364
+ if enter is not None:
365
+ await enter()
366
+ return self
367
+
368
+ async def __aexit__(self, exc_type, exc_val, tb) -> Any:
369
+ exit_ = getattr(self._stream, "__aexit__", None)
370
+ result = await exit_(exc_type, exc_val, tb) if exit_ is not None else None
371
+ self._finish(str(exc_val) if exc_val else None)
372
+ return result
373
+
374
+ def close(self) -> None:
375
+ close = getattr(self._stream, "close", None)
376
+ try:
377
+ if close is not None:
378
+ result = close()
379
+ if inspect.isawaitable(result):
380
+ # openai's AsyncStream spells its close `async def close()`. A sync close()
381
+ # on it (an easy slip inside async code) would drop the coroutine and leak
382
+ # the connection; run it on the loop when there is one, else at least don't
383
+ # leave an un-awaited coroutine behind.
384
+ try:
385
+ asyncio.get_running_loop().create_task(result)
386
+ except RuntimeError:
387
+ result.close() # type: ignore[union-attr]
388
+ logger.warning("close() called on an async stream outside an event loop - use aclose()")
389
+ finally:
390
+ self._finish(None)
391
+
392
+ async def aclose(self) -> None:
393
+ # openai's AsyncStream spells its close as `async def close()`; httpx-style streams
394
+ # spell it `aclose()`. Await whichever one answers with an awaitable.
395
+ close = getattr(self._stream, "aclose", None) or getattr(self._stream, "close", None)
396
+ try:
397
+ if close is not None:
398
+ result = close()
399
+ if inspect.isawaitable(result):
400
+ await result
401
+ finally:
402
+ self._finish(None)
403
+
404
+ def __getattr__(self, item: str) -> Any:
405
+ # Only public attributes delegate. Private names must resolve on the proxy itself, or a
406
+ # half-constructed instance (no _stream yet) would recurse forever looking for it.
407
+ if item.startswith("_"):
408
+ raise AttributeError(item)
409
+ return getattr(self._stream, item)
410
+
411
+ def __del__(self) -> None:
412
+ # Best effort only: a stream the caller stopped reading and dropped still records the
413
+ # chunks it did see. Never raises - a destructor exception is unactionable noise.
414
+ try:
415
+ self._finish(None)
416
+ except Exception:
417
+ pass
418
+
419
+
420
+ def trace_stream(
421
+ result: Any,
422
+ accumulator: StreamAccumulator,
423
+ on_finish: Callable[[Dict[str, Any], Optional[str]], None],
424
+ ) -> Any:
425
+ """
426
+ Wrap the value a patched ``create(..., stream=True)`` returned. A sync
427
+ client hands back the stream object directly; an async client hands back
428
+ a coroutine that resolves to it, so the wrapping is deferred until the
429
+ real stream exists - the caller's ``await`` is unchanged either way.
430
+ """
431
+ if asyncio.iscoroutine(result) or inspect.isawaitable(result):
432
+ return _await_and_wrap(result, accumulator, on_finish)
433
+ return TracedStream(result, accumulator, on_finish)
434
+
435
+
436
+ async def _await_and_wrap(
437
+ awaitable: Any,
438
+ accumulator: StreamAccumulator,
439
+ on_finish: Callable[[Dict[str, Any], Optional[str]], None],
440
+ ) -> Any:
441
+ try:
442
+ stream = await awaitable
443
+ except Exception as exc:
444
+ on_finish({}, str(exc))
445
+ raise
446
+ return TracedStream(stream, accumulator, on_finish)