Skip to content

Commit c46b645

Browse files
committed
feature: 支持用户传入http client 到 OpenAIModel 中来控制连接的生命周期
- 修改trace的调用方式避免上传trace不全
1 parent 32d7af9 commit c46b645

7 files changed

Lines changed: 441 additions & 41 deletions

File tree

Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Langfuse reporting regression tests tied to the active pytest interpreter.
7+
8+
Expected behaviour (same test file, different venv):
9+
- ``./venv/bin/pytest tests/langfuse/tracing/test_langfuse_reporting_fixtures.py`` → PASS
10+
(dev env: no broken site-packages copy, or fixed source).
11+
- ``./examples/quickstart/venv/bin/pytest ...`` → FAIL
12+
(quickstart venv ships an older ``trpc_agent_sdk`` in site-packages with detached spans).
13+
14+
The probe reads span-creation code from **site-packages when present**, matching
15+
``run_agent.py`` under ``examples/quickstart/``. Repo-root ``sys.path`` is ignored
16+
for that detection so pytest at repo root still exercises the installed wheel.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import json
22+
import sys
23+
from pathlib import Path
24+
from typing import Callable
25+
from typing import Iterable
26+
from unittest.mock import MagicMock
27+
28+
import pytest
29+
from opentelemetry import trace
30+
from opentelemetry.sdk.trace import TracerProvider
31+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
32+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
33+
34+
import trpc_agent_sdk.server.langfuse.tracing.opentelemetry as otel_module
35+
from trpc_agent_sdk.events import Event
36+
from trpc_agent_sdk.server.langfuse.tracing.opentelemetry import LangfuseConfig, _LangfuseMixin
37+
from trpc_agent_sdk.telemetry._trace import trace_agent
38+
from trpc_agent_sdk.telemetry._trace import trace_runner
39+
from trpc_agent_sdk.telemetry._trace import tracer
40+
from trpc_agent_sdk.types import Content
41+
from trpc_agent_sdk.types import Part
42+
43+
SPAN_PREFIX = "trpc.python.agent"
44+
45+
SYSTEM_INSTRUCTION = (
46+
"You are an agent who's name is [assistant].\n\n"
47+
"You are a helpful assistant for query weather."
48+
)
49+
TOOLS = [
50+
{
51+
"function_declarations": [
52+
{
53+
"description": "get weather information for the specified city",
54+
"name": "get_weather_report",
55+
"parameters": {
56+
"properties": {"city": {"type": "STRING"}},
57+
"type": "OBJECT",
58+
},
59+
}
60+
]
61+
}
62+
]
63+
LLM_REQUEST = {
64+
"model": "glm-5.0-w4afp8",
65+
"config": {
66+
"system_instruction": SYSTEM_INSTRUCTION,
67+
"tools": TOOLS,
68+
},
69+
"contents": [
70+
{
71+
"parts": [{"text": "What's the weather like today?"}],
72+
"role": "user",
73+
}
74+
],
75+
}
76+
LLM_RESPONSE = {
77+
"content": {
78+
"parts": [
79+
{"text": "assistant reply", "thought": False},
80+
],
81+
"role": "model",
82+
},
83+
"partial": False,
84+
"usage_metadata": {
85+
"candidates_token_count": 107,
86+
"prompt_token_count": 185,
87+
"total_token_count": 292,
88+
},
89+
}
90+
91+
92+
def _iter_site_packages() -> list[Path]:
93+
paths: list[Path] = []
94+
prefix = Path(sys.prefix)
95+
for lib_dir in ("lib64", "lib"):
96+
base = prefix / lib_dir
97+
if not base.is_dir():
98+
continue
99+
for child in sorted(base.glob("python*/site-packages")):
100+
if child.is_dir():
101+
paths.append(child)
102+
return paths
103+
104+
105+
def _resolve_sdk_file(relative: str) -> Path:
106+
"""Resolve a SDK file, preferring site-packages over repo-source import."""
107+
for site in _iter_site_packages():
108+
candidate = site / "trpc_agent_sdk" / relative
109+
if candidate.is_file():
110+
return candidate
111+
module_path = "trpc_agent_sdk." + relative.replace("/", ".").removesuffix(".py")
112+
module = __import__(module_path, fromlist=["_"])
113+
return Path(module.__file__)
114+
115+
116+
def _span_pattern_from_source(
117+
source: str,
118+
*,
119+
detached_needles: Iterable[str],
120+
current_needles: Iterable[str],
121+
) -> str:
122+
"""Return ``current`` or ``detached`` based on how a span is opened in source text."""
123+
detached_needles = tuple(detached_needles)
124+
current_needles = tuple(current_needles)
125+
has_current = any(needle in source for needle in current_needles)
126+
has_detached = any(needle in source for needle in detached_needles)
127+
if has_current and not has_detached:
128+
return "current"
129+
if has_detached and not has_current:
130+
return "detached"
131+
if has_current:
132+
return "current"
133+
if has_detached:
134+
return "detached"
135+
raise AssertionError(
136+
f"cannot detect span pattern (needles detached={detached_needles!r} "
137+
f"current={current_needles!r})"
138+
)
139+
140+
141+
def _agent_run_span_pattern() -> str:
142+
source = _resolve_sdk_file("agents/_base_agent.py").read_text(encoding="utf-8")
143+
return _span_pattern_from_source(
144+
source,
145+
detached_needles=('span = tracer.start_span(f"agent_run',),
146+
current_needles=('with tracer.start_as_current_span(f"agent_run',),
147+
)
148+
149+
150+
def _invocation_span_pattern() -> str:
151+
source = _resolve_sdk_file("runners.py").read_text(encoding="utf-8")
152+
return _span_pattern_from_source(
153+
source,
154+
detached_needles=('span = tracer.start_span("invocation")',),
155+
current_needles=(
156+
'with tracer.start_as_current_span("invocation")',
157+
'with tracer.start_as_current_span(f"invocation")',
158+
),
159+
)
160+
161+
162+
_EXPORTER: InMemorySpanExporter | None = None
163+
164+
165+
@pytest.fixture(scope="session", autouse=True)
166+
def _init_otel_tracer_once():
167+
"""OpenTelemetry allows only one TracerProvider; share it across this module."""
168+
global _EXPORTER # noqa: PLW0603
169+
_EXPORTER = InMemorySpanExporter()
170+
provider = TracerProvider()
171+
provider.add_span_processor(SimpleSpanProcessor(_EXPORTER))
172+
trace.set_tracer_provider(provider)
173+
yield
174+
_EXPORTER = None
175+
176+
177+
def _clear_finished_spans() -> None:
178+
assert _EXPORTER is not None
179+
_EXPORTER.clear()
180+
181+
182+
def _run_with_span_pattern(pattern: str, span_name: str, callback: Callable[[], None]) -> None:
183+
if pattern == "current":
184+
with tracer.start_as_current_span(span_name):
185+
callback()
186+
return
187+
if pattern == "detached":
188+
span = tracer.start_span(span_name)
189+
try:
190+
callback()
191+
finally:
192+
span.end()
193+
return
194+
raise ValueError(f"unknown span pattern: {pattern}")
195+
196+
197+
def _finished_span_attributes(name_substring: str) -> dict:
198+
assert _EXPORTER is not None
199+
spans = _EXPORTER.get_finished_spans()
200+
matched = [span for span in spans if name_substring in span.name]
201+
assert matched, (
202+
f"no finished span containing {name_substring!r}; "
203+
f"got span names: {[span.name for span in spans]}"
204+
)
205+
return dict(matched[0].attributes or {})
206+
207+
208+
def _map_to_langfuse(raw_attributes: dict) -> dict:
209+
mixin = _LangfuseMixin()
210+
otel_module._langfuse_config = LangfuseConfig()
211+
return mixin._map_attributes_to_langfuse(raw_attributes)
212+
213+
214+
def _make_invocation_context() -> MagicMock:
215+
ctx = MagicMock()
216+
ctx.agent.name = "assistant"
217+
ctx.user_content = Content(role="user", parts=[Part(text="What's the weather like today?")])
218+
ctx.override_messages = None
219+
ctx.session.id = "a252d252-4b55-4713-80e4-90abb177c433"
220+
ctx.session.user_id = "demo_user"
221+
ctx.invocation_id = "e-d5a9872c-80e3-43ea-b2a8-0091257a1616"
222+
return ctx
223+
224+
225+
def probe_agent_run_langfuse_mapping() -> dict:
226+
_clear_finished_spans()
227+
ctx = _make_invocation_context()
228+
pattern = _agent_run_span_pattern()
229+
230+
def _callback() -> None:
231+
trace_agent(
232+
invocation_context=ctx,
233+
agent_action="Could you please tell me the city you're interested in?",
234+
state_begin={"user_name": "demo_user"},
235+
state_end={"user_name": "demo_user"},
236+
)
237+
238+
_run_with_span_pattern(pattern, "agent_run [assistant]", _callback)
239+
return _map_to_langfuse(_finished_span_attributes("agent_run"))
240+
241+
242+
def probe_invocation_langfuse_mapping() -> dict:
243+
_clear_finished_spans()
244+
ctx = _make_invocation_context()
245+
pattern = _invocation_span_pattern()
246+
user_message = Content(role="user", parts=[Part(text="What's the weather like today?")])
247+
last_event = Event(
248+
content=Content(role="model", parts=[Part(text="Could you please tell me the city you're interested in?")]),
249+
)
250+
251+
def _callback() -> None:
252+
trace_runner(
253+
app_name="weather_agent_demo",
254+
user_id="demo_user",
255+
session_id="a252d252-4b55-4713-80e4-90abb177c433",
256+
invocation_context=ctx,
257+
new_message=user_message,
258+
last_event=last_event,
259+
state_begin={"user_name": "demo_user"},
260+
state_end={"user_name": "demo_user"},
261+
)
262+
263+
_run_with_span_pattern(pattern, "invocation", _callback)
264+
return _map_to_langfuse(_finished_span_attributes("invocation"))
265+
266+
267+
def assert_valid_call_llm_langfuse_mapping(result: dict) -> None:
268+
assert result["langfuse.observation.type"] == "generation", result
269+
llm_input = json.loads(result["langfuse.observation.input"])
270+
config = llm_input.get("config", {})
271+
assert config.get("system_instruction"), result
272+
assert config.get("tools"), result
273+
model_params = json.loads(result["langfuse.observation.model.parameters"])
274+
assert model_params.get("system_instruction"), result
275+
assert model_params.get("tools"), result
276+
assert result["langfuse.observation.output"] != "unknown", result
277+
278+
279+
def assert_valid_run_agent_langfuse_mapping(result: dict) -> None:
280+
assert result.get("langfuse.observation.type") == "span", result
281+
assert result.get("langfuse.observation.input") == "What's the weather like today?", result
282+
assert result.get("langfuse.observation.output"), result
283+
284+
285+
def assert_valid_run_runner_langfuse_mapping(result: dict) -> None:
286+
assert result.get("langfuse.trace.name") == "[trpc-agent]: weather_agent_demo/assistant", result
287+
assert result.get("langfuse.user.id") == "demo_user", result
288+
assert result.get("langfuse.session.id") == "a252d252-4b55-4713-80e4-90abb177c433", result
289+
assert result.get("langfuse.observation.input") == "What's the weather like today?", result
290+
assert result.get("langfuse.observation.output"), result
291+
assert "langfuse.trace.metadata" in result, result
292+
293+
294+
@pytest.fixture(autouse=True)
295+
def _langfuse_config():
296+
original = otel_module._langfuse_config
297+
otel_module._langfuse_config = LangfuseConfig()
298+
yield
299+
otel_module._langfuse_config = original
300+
301+
302+
@pytest.fixture
303+
def mixin():
304+
return _LangfuseMixin()
305+
306+
307+
class TestLangfuseReportingSpanContext:
308+
"""End-to-end: telemetry must land on the span Langfuse exports (ok.txt vs error.txt)."""
309+
310+
def test_trace_agent_reaches_agent_run_span(self):
311+
result = probe_agent_run_langfuse_mapping()
312+
assert_valid_run_agent_langfuse_mapping(result)
313+
314+
def test_trace_runner_reaches_invocation_span(self):
315+
result = probe_invocation_langfuse_mapping()
316+
assert_valid_run_runner_langfuse_mapping(result)
317+
318+
319+
class TestLangfuseReportingCallLlmMapping:
320+
"""call_llm mapping must always include system prompt and tools (ok.txt generation)."""
321+
322+
def test_call_llm_mapping_includes_system_instruction_and_tools(self, mixin):
323+
attrs = {
324+
"gen_ai.operation.name": "call_llm",
325+
f"{SPAN_PREFIX}.llm_request": json.dumps(LLM_REQUEST, ensure_ascii=False),
326+
f"{SPAN_PREFIX}.llm_response": json.dumps(LLM_RESPONSE, ensure_ascii=False),
327+
"gen_ai.usage.input_tokens": 185,
328+
"gen_ai.usage.output_tokens": 107,
329+
"gen_ai.request.model": "glm-5.0-w4afp8",
330+
}
331+
result = mixin._map_attributes_to_langfuse(attrs)
332+
assert_valid_call_llm_langfuse_mapping(result)

0 commit comments

Comments
 (0)