Skip to content

Commit f3d8654

Browse files
committed
feat: 支持通过opentelemetry上报metrics
- 也支持custom_metrics方便在解析远程Agent响应到框架上报指标中使用
1 parent 74f880e commit f3d8654

13 files changed

Lines changed: 1350 additions & 16 deletions

File tree

tests/models/test_llm_response.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,3 +296,47 @@ def test_create_with_candidate_no_finish_reason(self):
296296
assert llm_response.content is not None
297297
assert llm_response.content.parts[0].text == "Response"
298298
# finish_reason=None should not cause error when content exists
299+
300+
301+
class TestLlmResponseHasContent:
302+
"""Test suite for :meth:`LlmResponse.has_content`."""
303+
304+
def test_none_content_is_false(self):
305+
assert LlmResponse(content=None).has_content() is False
306+
307+
def test_empty_parts_is_false(self):
308+
assert LlmResponse(content=Content(parts=[], role="model")).has_content() is False
309+
310+
def test_text_part_is_true(self):
311+
content = Content(parts=[Part.from_text(text="hello")], role="model")
312+
assert LlmResponse(content=content).has_content() is True
313+
314+
def test_function_call_part_is_true(self):
315+
content = Content(
316+
parts=[Part.from_function_call(name="calc", args={"x": 1})],
317+
role="model",
318+
)
319+
assert LlmResponse(content=content).has_content() is True
320+
321+
def test_function_response_only_is_false(self):
322+
"""Function responses are not user-visible content per the contract."""
323+
content = Content(
324+
parts=[Part.from_function_response(name="calc", response={"ok": True})],
325+
role="tool",
326+
)
327+
assert LlmResponse(content=content).has_content() is False
328+
329+
def test_empty_text_with_function_call_is_true(self):
330+
content = Content(
331+
parts=[
332+
Part(text=""),
333+
Part.from_function_call(name="calc", args={}),
334+
],
335+
role="model",
336+
)
337+
assert LlmResponse(content=content).has_content() is True
338+
339+
def test_whitespace_text_only_is_true(self):
340+
"""Any non-empty text counts as visible content."""
341+
content = Content(parts=[Part(text=" ")], role="model")
342+
assert LlmResponse(content=content).has_content() is True
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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+
"""Unit tests for :class:`trpc_agent_sdk.telemetry.CustomMetricsReporter`.
7+
8+
Verifies the event-routing state machine:
9+
* partial events bump TTFT only
10+
* function-call events close an LLM segment and start tool timers
11+
* function-response events close tool timers and reopen an LLM segment
12+
* plain content events close an LLM segment
13+
14+
All ``report_*`` functions are patched to record the calls instead of emitting
15+
to OTel, so the tests are hermetic.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from types import SimpleNamespace
21+
from typing import Any
22+
from typing import Dict
23+
from typing import List
24+
from unittest.mock import patch
25+
26+
import pytest
27+
28+
from trpc_agent_sdk.events import Event
29+
from trpc_agent_sdk.telemetry import CustomMetricsReporter
30+
from trpc_agent_sdk.types import Content
31+
from trpc_agent_sdk.types import Part
32+
33+
34+
def _ctx():
35+
return SimpleNamespace(
36+
app_name="demo",
37+
user_id="alice",
38+
agent_name="asst",
39+
agent=SimpleNamespace(model=None),
40+
)
41+
42+
43+
def _text_event(text: str, *, partial: bool = False) -> Event:
44+
return Event(
45+
invocation_id="inv-1",
46+
author="asst",
47+
partial=partial,
48+
content=Content(parts=[Part.from_text(text=text)], role="model"),
49+
)
50+
51+
52+
def _function_call_event(call_id: str, name: str) -> Event:
53+
return Event(
54+
invocation_id="inv-1",
55+
author="asst",
56+
content=Content(
57+
parts=[Part(function_call={
58+
"id": call_id,
59+
"name": name,
60+
"args": {
61+
"x": 1
62+
},
63+
})],
64+
role="model",
65+
),
66+
)
67+
68+
69+
def _function_response_event(call_id: str, name: str, *, error: bool = False) -> Event:
70+
ev = Event(
71+
invocation_id="inv-1",
72+
author="tool",
73+
content=Content(
74+
parts=[Part(function_response={
75+
"id": call_id,
76+
"name": name,
77+
"response": {
78+
"ok": not error
79+
},
80+
})],
81+
role="tool",
82+
),
83+
)
84+
if error:
85+
ev.error_code = "500"
86+
return ev
87+
88+
89+
class _Capture:
90+
"""Helper to capture kwargs from patched ``report_*`` functions."""
91+
92+
def __init__(self):
93+
self.calls: List[Dict[str, Any]] = []
94+
95+
def __call__(self, *args, **kwargs):
96+
self.calls.append({"args": args, "kwargs": kwargs})
97+
98+
99+
@pytest.fixture()
100+
def patched_reporters():
101+
"""Patch the two ``report_*`` functions imported into ``_custom_metrics``."""
102+
llm = _Capture()
103+
tool = _Capture()
104+
with patch("trpc_agent_sdk.telemetry._custom_metrics.report_call_llm",
105+
new=llm), patch("trpc_agent_sdk.telemetry._custom_metrics.report_execute_tool", new=tool):
106+
yield llm, tool
107+
108+
109+
class TestCustomMetricsReporterRouting:
110+
111+
def test_plain_content_event_emits_call_llm(self, patched_reporters):
112+
llm, tool = patched_reporters
113+
reporter = CustomMetricsReporter(agent_name="asst", model_prefix="claude")
114+
115+
reporter.report_event(_ctx(), _text_event("hello"))
116+
117+
assert len(llm.calls) == 1
118+
assert len(tool.calls) == 0
119+
kw = llm.calls[0]["kwargs"]
120+
req = kw["llm_request"]
121+
assert req.model == "claude:asst"
122+
assert kw["is_stream"] is True # default
123+
assert kw["duration_s"] >= 0.0
124+
assert kw["ttft_s"] >= 0.0
125+
126+
def test_partial_event_does_not_emit(self, patched_reporters):
127+
llm, tool = patched_reporters
128+
reporter = CustomMetricsReporter(agent_name="asst")
129+
130+
reporter.report_event(_ctx(), _text_event("chunk", partial=True))
131+
reporter.report_event(_ctx(), _text_event("chunk 2", partial=True))
132+
133+
assert llm.calls == []
134+
assert tool.calls == []
135+
# TTFT is latched as soon as any content event arrives, partial or not.
136+
assert reporter._llm_ttft is not None
137+
138+
def test_function_call_closes_llm_and_opens_tool_timers(self, patched_reporters):
139+
llm, tool = patched_reporters
140+
reporter = CustomMetricsReporter(agent_name="asst")
141+
142+
reporter.report_event(_ctx(), _function_call_event("c1", "search"))
143+
144+
assert len(llm.calls) == 1, "function-call event must emit the open LLM segment"
145+
assert len(tool.calls) == 0
146+
assert reporter._pending_tool_starts.keys() == {"c1"}
147+
assert reporter._pending_tool_starts["c1"][0] == "search"
148+
assert reporter._llm_segment_start is None
149+
150+
def test_function_response_emits_execute_tool_and_reopens_segment(self, patched_reporters):
151+
llm, tool = patched_reporters
152+
reporter = CustomMetricsReporter(agent_name="asst")
153+
154+
reporter.report_event(_ctx(), _function_call_event("c1", "search"))
155+
reporter.report_event(_ctx(), _function_response_event("c1", "search"))
156+
157+
assert len(tool.calls) == 1
158+
kw = tool.calls[0]["kwargs"]
159+
assert kw["duration_s"] >= 0.0
160+
assert kw["error_type"] is None
161+
assert tool.calls[0]["args"][1].name == "search"
162+
assert reporter._pending_tool_starts == {}
163+
assert reporter._llm_segment_start is not None
164+
165+
def test_tool_error_type_propagates(self, patched_reporters):
166+
llm, tool = patched_reporters
167+
reporter = CustomMetricsReporter(agent_name="asst")
168+
169+
reporter.report_event(_ctx(), _function_call_event("c1", "search"))
170+
reporter.report_event(_ctx(), _function_response_event("c1", "search", error=True))
171+
172+
assert tool.calls[0]["kwargs"]["error_type"] == "500"
173+
174+
def test_unmatched_function_response_is_ignored(self, patched_reporters):
175+
llm, tool = patched_reporters
176+
reporter = CustomMetricsReporter(agent_name="asst")
177+
178+
# No matching function_call beforehand.
179+
reporter.report_event(_ctx(), _function_response_event("unknown", "search"))
180+
181+
assert tool.calls == []
182+
# Segment was still reopened.
183+
assert reporter._llm_segment_start is not None
184+
185+
def test_full_round_trip_chat_tool_chat(self, patched_reporters):
186+
"""LLM call -> tool call -> tool result -> final LLM chunk."""
187+
llm, tool = patched_reporters
188+
reporter = CustomMetricsReporter(agent_name="asst", model_prefix="a2a")
189+
190+
reporter.report_event(_ctx(), _function_call_event("c1", "search"))
191+
reporter.report_event(_ctx(), _function_response_event("c1", "search"))
192+
reporter.report_event(_ctx(), _text_event("final answer"))
193+
194+
assert len(llm.calls) == 2, "two LLM segments: before tool + after tool"
195+
assert len(tool.calls) == 1
196+
for call in llm.calls:
197+
assert call["kwargs"]["llm_request"].model == "a2a:asst"
198+
199+
def test_extra_attributes_forwarded(self, patched_reporters):
200+
llm, tool = patched_reporters
201+
reporter = CustomMetricsReporter(
202+
agent_name="asst",
203+
extra_attributes={"gen_ai.system": "openai"},
204+
)
205+
reporter.report_event(_ctx(), _function_call_event("c1", "search"))
206+
reporter.report_event(_ctx(), _function_response_event("c1", "search"))
207+
208+
assert llm.calls[0]["kwargs"]["extra_attributes"] == {"gen_ai.system": "openai"}
209+
assert tool.calls[0]["kwargs"]["extra_attributes"] == {"gen_ai.system": "openai"}
210+
211+
212+
class TestCustomMetricsReporterReset:
213+
214+
def test_reset_clears_pending_state(self, patched_reporters):
215+
_, _ = patched_reporters
216+
reporter = CustomMetricsReporter(agent_name="asst")
217+
reporter.report_event(_ctx(), _function_call_event("c1", "search"))
218+
assert reporter._pending_tool_starts
219+
220+
reporter.reset()
221+
222+
assert reporter._pending_tool_starts == {}
223+
assert reporter._llm_segment_start is None
224+
assert reporter._llm_ttft is None

0 commit comments

Comments
 (0)