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.
- {agentx_python-0.8.26 → agentx_python-0.8.28}/PKG-INFO +7 -1
- {agentx_python-0.8.26 → agentx_python-0.8.28}/README.md +6 -0
- agentx_python-0.8.28/agentx/integrations/_traced_call.py +446 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/anthropic.py +181 -18
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/nvidia_nim.py +8 -4
- agentx_python-0.8.28/agentx/integrations/openai.py +293 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/__init__.py +4 -0
- agentx_python-0.8.28/agentx/monitor/alert_rules.py +223 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/client.py +5 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/tracing/tracer.py +10 -1
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/version.py +2 -2
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/PKG-INFO +7 -1
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/SOURCES.txt +2 -0
- agentx_python-0.8.28/tests/test_alert_rules.py +140 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_integrations.py +505 -28
- agentx_python-0.8.26/agentx/integrations/_traced_call.py +0 -175
- agentx_python-0.8.26/agentx/integrations/openai.py +0 -173
- {agentx_python-0.8.26 → agentx_python-0.8.28}/LICENSE +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/__init__.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/agentx.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/cli.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/__init__.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/_term.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/adapters/__init__.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/adapters/http_endpoint.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/adapters/precomputed.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/adapters/raw.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/client.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/datasets.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/evaluation_settings.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/models.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/prompts.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/reporting.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/results.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/runner.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/tool_schemas.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/evaluations/tracing.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/exceptions.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/export.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/feedback.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/__init__.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/autogen.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/crewai.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/databricks.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/google_adk.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/google_genai.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/langchain.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/litellm.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/llamaindex.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/moveworks.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/integrations/openai_agents.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/_transport.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/agents.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/improvement_groups.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/judge_scorers.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/models.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/online_evaluators.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/patterns.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/profile.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/review_queue.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/rules.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/scorer_groups.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/scorers.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/sessions.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/monitor/signals.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/outcomes.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/projects.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/py.typed +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/resources/__init__.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/resources/agent.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/resources/conversation.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/resources/workforce.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/testing.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/traces.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/tracing/__init__.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/tracing/ci_types.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/tracing/eval_scope.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/tracing/framework_detect.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/tracing/ingest_client.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx/util.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/dependency_links.txt +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/entry_points.txt +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/not-zip-safe +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/requires.txt +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/agentx_python.egg-info/top_level.txt +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/setup.cfg +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/setup.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_cli_launcher.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_deep_dive_fixes.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_docs_match_sdk.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_error_taxonomy.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_eval_scope.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_framework_detect.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_integration.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_judge_scorers.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_multi_judge.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_pairwise.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_pattern_update_merge.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_review_queue.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_runner_features.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_selfhost_analysis_fallback.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_selfhost_compat.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_span_tree.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_testing.py +0 -0
- {agentx_python-0.8.26 → agentx_python-0.8.28}/tests/test_wire_models.py +0 -0
- {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.
|
|
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)
|