evalring 0.2.0__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.
- EvalRing/__init__.py +106 -0
- EvalRing/agent/__init__.py +33 -0
- EvalRing/agent/base.py +122 -0
- EvalRing/agent/classification.py +191 -0
- EvalRing/agent/errors.py +109 -0
- EvalRing/agent/implementations.py +575 -0
- EvalRing/agent/multi_role.py +305 -0
- EvalRing/cli.py +284 -0
- EvalRing/config.py +216 -0
- EvalRing/dataset/__init__.py +8 -0
- EvalRing/dataset/ai_data_generator.py +217 -0
- EvalRing/dataset/base.py +238 -0
- EvalRing/dataset/implementations.py +179 -0
- EvalRing/evaluator/__init__.py +36 -0
- EvalRing/evaluator/base.py +165 -0
- EvalRing/evaluator/implementations.py +404 -0
- EvalRing/evaluator/llm_judge/__init__.py +32 -0
- EvalRing/evaluator/llm_judge/evaluator.py +588 -0
- EvalRing/evaluator/llm_judge/judge.py +138 -0
- EvalRing/evaluator/llm_judge/metric.py +194 -0
- EvalRing/evaluator/llm_judge/rubric.py +160 -0
- EvalRing/evaluator/llm_judge/schema.py +147 -0
- EvalRing/evaluator/llm_judge/template.py +144 -0
- EvalRing/logging_utils.py +66 -0
- EvalRing/py.typed +0 -0
- EvalRing/utils/__init__.py +16 -0
- EvalRing/utils/generate_model_list.py +155 -0
- EvalRing/utils/global_cache.py +531 -0
- EvalRing/utils/suite_runner.py +836 -0
- EvalRing/utils/timeout.py +48 -0
- EvalRing/utils/visualizations.py +560 -0
- evalring-0.2.0.dist-info/METADATA +454 -0
- evalring-0.2.0.dist-info/RECORD +37 -0
- evalring-0.2.0.dist-info/WHEEL +5 -0
- evalring-0.2.0.dist-info/entry_points.txt +2 -0
- evalring-0.2.0.dist-info/licenses/LICENSE +21 -0
- evalring-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Concrete implementations of agents.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ..config import resolve_credentials, resolve_model_name
|
|
11
|
+
from ..logging_utils import get_logger
|
|
12
|
+
from .base import AgentResponse, BaseAgent
|
|
13
|
+
from .errors import format_exception
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MockAgent(BaseAgent):
|
|
19
|
+
"""
|
|
20
|
+
A mock agent for testing and demonstration purposes.
|
|
21
|
+
|
|
22
|
+
Returns random predictions from a predefined list or a fixed response.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
name: str = "mock_agent",
|
|
28
|
+
fixed_response: str | None = None,
|
|
29
|
+
possible_outputs: list[str] | None = None,
|
|
30
|
+
delay: float = 0.1,
|
|
31
|
+
**kwargs,
|
|
32
|
+
):
|
|
33
|
+
super().__init__(name=name, **kwargs)
|
|
34
|
+
self.fixed_response = fixed_response
|
|
35
|
+
self.possible_outputs = possible_outputs or ["positive", "negative", "neutral"]
|
|
36
|
+
self.delay = delay
|
|
37
|
+
|
|
38
|
+
def initialize(self, **kwargs) -> None:
|
|
39
|
+
"""Initialize the mock agent."""
|
|
40
|
+
self._is_initialized = True
|
|
41
|
+
|
|
42
|
+
def predict(self, input_text: str, **kwargs) -> AgentResponse:
|
|
43
|
+
"""
|
|
44
|
+
Make a mock prediction.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
input_text: Input text
|
|
48
|
+
**kwargs: Additional parameters
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
AgentResponse with mock prediction
|
|
52
|
+
"""
|
|
53
|
+
if not self._is_initialized:
|
|
54
|
+
raise RuntimeError("Agent must be initialized before prediction")
|
|
55
|
+
|
|
56
|
+
start_time = time.time()
|
|
57
|
+
|
|
58
|
+
# Simulate processing time
|
|
59
|
+
if self.delay > 0:
|
|
60
|
+
time.sleep(self.delay)
|
|
61
|
+
|
|
62
|
+
# Generate response
|
|
63
|
+
if self.fixed_response is not None:
|
|
64
|
+
output = self.fixed_response
|
|
65
|
+
else:
|
|
66
|
+
output = random.choice(self.possible_outputs)
|
|
67
|
+
|
|
68
|
+
processing_time = time.time() - start_time
|
|
69
|
+
|
|
70
|
+
return AgentResponse(
|
|
71
|
+
input_id="", # Will be set by evaluator or batch processor
|
|
72
|
+
input_text=input_text,
|
|
73
|
+
output=output,
|
|
74
|
+
confidence=random.uniform(0.5, 1.0),
|
|
75
|
+
processing_time=processing_time,
|
|
76
|
+
metadata={"mock": True},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RuleBasedAgent(BaseAgent):
|
|
81
|
+
"""
|
|
82
|
+
A simple rule-based agent that uses keyword matching.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
name: str = "rule_based_agent",
|
|
88
|
+
rules: dict | None = None,
|
|
89
|
+
default_output: str = "unknown",
|
|
90
|
+
**kwargs,
|
|
91
|
+
):
|
|
92
|
+
super().__init__(name=name, **kwargs)
|
|
93
|
+
self.rules = rules or {}
|
|
94
|
+
self.default_output = default_output
|
|
95
|
+
|
|
96
|
+
def initialize(self, **kwargs) -> None:
|
|
97
|
+
"""Initialize the rule-based agent."""
|
|
98
|
+
# Convert all keywords to lowercase for case-insensitive matching
|
|
99
|
+
self._processed_rules = {}
|
|
100
|
+
for output, keywords in self.rules.items():
|
|
101
|
+
self._processed_rules[output] = [k.lower() for k in keywords]
|
|
102
|
+
|
|
103
|
+
self._is_initialized = True
|
|
104
|
+
|
|
105
|
+
def predict(self, input_text: str, **kwargs) -> AgentResponse:
|
|
106
|
+
"""
|
|
107
|
+
Make a prediction based on keyword rules.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
input_text: Input text
|
|
111
|
+
**kwargs: Additional parameters
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
AgentResponse with rule-based prediction
|
|
115
|
+
"""
|
|
116
|
+
if not self._is_initialized:
|
|
117
|
+
raise RuntimeError("Agent must be initialized before prediction")
|
|
118
|
+
|
|
119
|
+
start_time = time.time()
|
|
120
|
+
|
|
121
|
+
text_lower = input_text.lower()
|
|
122
|
+
output = self.default_output
|
|
123
|
+
confidence = 0.0
|
|
124
|
+
matched_keyword = None
|
|
125
|
+
|
|
126
|
+
# Simple keyword matching
|
|
127
|
+
for rule_output, keywords in self._processed_rules.items():
|
|
128
|
+
for keyword in keywords:
|
|
129
|
+
if keyword in text_lower:
|
|
130
|
+
output = rule_output
|
|
131
|
+
confidence = 1.0
|
|
132
|
+
matched_keyword = keyword
|
|
133
|
+
break
|
|
134
|
+
if matched_keyword:
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
processing_time = time.time() - start_time
|
|
138
|
+
|
|
139
|
+
return AgentResponse(
|
|
140
|
+
input_id="",
|
|
141
|
+
input_text=input_text,
|
|
142
|
+
output=output,
|
|
143
|
+
confidence=confidence,
|
|
144
|
+
processing_time=processing_time,
|
|
145
|
+
metadata={"matched_keyword": matched_keyword} if matched_keyword else {},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class OpenAIAgent(BaseAgent):
|
|
150
|
+
"""
|
|
151
|
+
Generic OpenAI-backed agent with streaming, TTFT, TPS, and token tracking.
|
|
152
|
+
|
|
153
|
+
Subclass this and set ``self.system_prompt`` to get a working agent.
|
|
154
|
+
Override ``_parse_output`` if you need custom output cleaning.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
name: Agent name for reporting.
|
|
158
|
+
model_name: Model identifier (e.g., ``"gpt-4o"``, ``"anthropic/claude-sonnet-4"``).
|
|
159
|
+
Defaults to ``$EVALRING_MODEL`` when unset.
|
|
160
|
+
api_key: API key. When omitted it is resolved from the environment by
|
|
161
|
+
:func:`EvalRing.config.resolve_credentials`.
|
|
162
|
+
base_url: OpenAI-compatible endpoint. When omitted it is resolved
|
|
163
|
+
alongside the API key; ``None`` means the OpenAI default.
|
|
164
|
+
system_prompt: System message sent to the model.
|
|
165
|
+
temperature: Sampling temperature. Default 0.0.
|
|
166
|
+
max_completion_tokens: Maximum tokens the model may generate.
|
|
167
|
+
**kwargs: Forwarded to BaseAgent.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(
|
|
171
|
+
self,
|
|
172
|
+
name: str = "openai-agent",
|
|
173
|
+
model_name: str | None = None,
|
|
174
|
+
api_key: str | None = None,
|
|
175
|
+
base_url: str | None = None,
|
|
176
|
+
system_prompt: str = "You are a helpful assistant.",
|
|
177
|
+
temperature: float = 0.0,
|
|
178
|
+
max_completion_tokens: int = 256,
|
|
179
|
+
reasoning_effort: str | None = None,
|
|
180
|
+
error_on_empty: bool = True,
|
|
181
|
+
**kwargs,
|
|
182
|
+
):
|
|
183
|
+
super().__init__(name=name, **kwargs)
|
|
184
|
+
self.model_name = resolve_model_name(model_name, default="gpt-4o")
|
|
185
|
+
credentials = resolve_credentials(api_key, base_url)
|
|
186
|
+
self.credentials = credentials
|
|
187
|
+
self.api_key = credentials.api_key
|
|
188
|
+
self.base_url = credentials.base_url
|
|
189
|
+
self.transport = os.environ.get("EVALRING_LLM_TRANSPORT", "litellm").strip().lower()
|
|
190
|
+
# If the underlying HTTP request hangs (provider/network), streaming can block forever.
|
|
191
|
+
# Use a bounded timeout so the evaluator can retry/record the failure and continue.
|
|
192
|
+
# Env var is intentionally generic so it works with LiteLLM-backed providers.
|
|
193
|
+
self.request_timeout_s = float(os.environ.get("OPENAI_REQUEST_TIMEOUT_S", "120"))
|
|
194
|
+
self.system_prompt = system_prompt
|
|
195
|
+
self.temperature = temperature
|
|
196
|
+
self.max_completion_tokens = max_completion_tokens
|
|
197
|
+
# "Thinking" level forwarded to providers that support it (dropped by
|
|
198
|
+
# ``drop_params`` for those that do not). None => not sent.
|
|
199
|
+
self.reasoning_effort = (reasoning_effort or "").strip() or None
|
|
200
|
+
# When True, a completion that returns no visible content is reported as
|
|
201
|
+
# an error (see ``predict``) rather than a silent empty answer.
|
|
202
|
+
self.error_on_empty = error_on_empty
|
|
203
|
+
# Both are provider SDK handles resolved lazily in initialize(); the
|
|
204
|
+
# concrete types depend on which optional extra is installed.
|
|
205
|
+
self.client: Any = None
|
|
206
|
+
self._litellm: Any = None
|
|
207
|
+
|
|
208
|
+
# ── lifecycle ──────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
def initialize(self, **kwargs) -> None:
|
|
211
|
+
"""Initialize the OpenAI client."""
|
|
212
|
+
self.credentials.require_key()
|
|
213
|
+
logger.debug(
|
|
214
|
+
"Initializing %s: model=%s provider=%s (key from %s) base_url=%s",
|
|
215
|
+
self.name,
|
|
216
|
+
self.model_name,
|
|
217
|
+
self.credentials.provider,
|
|
218
|
+
self.credentials.source,
|
|
219
|
+
self.base_url or "<provider default>",
|
|
220
|
+
)
|
|
221
|
+
if self.transport == "litellm":
|
|
222
|
+
try:
|
|
223
|
+
import litellm
|
|
224
|
+
|
|
225
|
+
self._litellm = litellm
|
|
226
|
+
# Silently drop params a given provider/model does not accept
|
|
227
|
+
# (e.g. reasoning_effort on a non-reasoning model, temperature!=1
|
|
228
|
+
# on a reasoning model) so a heterogeneous suite never hard-fails.
|
|
229
|
+
self._litellm.drop_params = True
|
|
230
|
+
except ImportError:
|
|
231
|
+
self._litellm = None
|
|
232
|
+
|
|
233
|
+
if self._litellm is None:
|
|
234
|
+
try:
|
|
235
|
+
import httpx
|
|
236
|
+
from openai import OpenAI
|
|
237
|
+
except ImportError as exc:
|
|
238
|
+
raise ImportError(
|
|
239
|
+
f"Required packages missing: {exc}. "
|
|
240
|
+
"Install them with: pip install evalring[llm]"
|
|
241
|
+
) from exc
|
|
242
|
+
|
|
243
|
+
client_kwargs: dict[str, Any] = {
|
|
244
|
+
"api_key": self.api_key,
|
|
245
|
+
"timeout": self.request_timeout_s,
|
|
246
|
+
"http_client": httpx.Client(
|
|
247
|
+
limits=httpx.Limits(max_connections=2000, max_keepalive_connections=1000),
|
|
248
|
+
timeout=httpx.Timeout(self.request_timeout_s),
|
|
249
|
+
),
|
|
250
|
+
}
|
|
251
|
+
if self.base_url:
|
|
252
|
+
client_kwargs["base_url"] = self.base_url
|
|
253
|
+
self.client = OpenAI(**client_kwargs)
|
|
254
|
+
self._is_initialized = True
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def _timeout(self) -> float | None:
|
|
258
|
+
"""Request timeout to pass to the SDK; ``None`` (unlimited) when <=0."""
|
|
259
|
+
return (
|
|
260
|
+
self.request_timeout_s
|
|
261
|
+
if (self.request_timeout_s and self.request_timeout_s > 0)
|
|
262
|
+
else None
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def _completion_extra_params(self) -> dict[str, Any]:
|
|
266
|
+
"""Optional completion params applied uniformly across transports.
|
|
267
|
+
|
|
268
|
+
``max_completion_tokens`` is omitted when non-positive so reasoning
|
|
269
|
+
models can run to natural completion instead of truncating; a positive
|
|
270
|
+
value still caps output.
|
|
271
|
+
"""
|
|
272
|
+
extra: dict[str, Any] = {}
|
|
273
|
+
if self.reasoning_effort:
|
|
274
|
+
extra["reasoning_effort"] = self.reasoning_effort
|
|
275
|
+
if self.max_completion_tokens and self.max_completion_tokens > 0:
|
|
276
|
+
extra["max_completion_tokens"] = self.max_completion_tokens
|
|
277
|
+
return extra
|
|
278
|
+
|
|
279
|
+
def _create_completion_stream(self, messages: list[dict[str, str]]):
|
|
280
|
+
extra = self._completion_extra_params()
|
|
281
|
+
if self._litellm is not None:
|
|
282
|
+
return self._litellm.completion(
|
|
283
|
+
model=self.model_name,
|
|
284
|
+
messages=messages,
|
|
285
|
+
temperature=self.temperature,
|
|
286
|
+
stream=True,
|
|
287
|
+
stream_options={"include_usage": True},
|
|
288
|
+
api_key=self.api_key,
|
|
289
|
+
api_base=self.base_url,
|
|
290
|
+
timeout=self._timeout,
|
|
291
|
+
**extra,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
return self.client.chat.completions.create(
|
|
295
|
+
model=self.model_name,
|
|
296
|
+
messages=messages,
|
|
297
|
+
temperature=self.temperature,
|
|
298
|
+
stream=True,
|
|
299
|
+
stream_options={"include_usage": True},
|
|
300
|
+
timeout=self._timeout,
|
|
301
|
+
**extra,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# ── prediction ────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
def predict(self, input_text: str, **kwargs) -> AgentResponse:
|
|
307
|
+
"""Call the chat-completions API with streaming.
|
|
308
|
+
|
|
309
|
+
On success, ``metadata`` includes: ``raw_output``, ``model``,
|
|
310
|
+
``base_url``, ``prompt_tokens``, ``completion_tokens``, ``total_tokens``,
|
|
311
|
+
``reasoning_tokens`` (hidden "thinking" tokens; estimated from streamed
|
|
312
|
+
reasoning text when the provider omits the count), ``answer_tokens``,
|
|
313
|
+
``reasoning_chars``, ``reasoning_content`` (the full reasoning text when
|
|
314
|
+
the provider streams it; empty when the provider hides it, e.g. OpenAI
|
|
315
|
+
reasoning models), ``ttft``, ``generation_time``, ``tps``,
|
|
316
|
+
``total_time`` and ``finish_reason``.
|
|
317
|
+
|
|
318
|
+
Failure handling:
|
|
319
|
+
- Exceptions are captured with their *complete* message via
|
|
320
|
+
:func:`~EvalRing.agent.errors.format_exception`.
|
|
321
|
+
- When ``error_on_empty`` is set (default) and the model returns no
|
|
322
|
+
visible content — e.g. a reasoning model that exhausts its budget or a
|
|
323
|
+
stream cut at the request timeout — the response is reported as an
|
|
324
|
+
error (``output="Error"``) rather than a silent empty answer.
|
|
325
|
+
"""
|
|
326
|
+
if not self._is_initialized:
|
|
327
|
+
raise RuntimeError("Agent not initialized. Call initialize() first.")
|
|
328
|
+
|
|
329
|
+
if not self.validate_input(input_text):
|
|
330
|
+
return AgentResponse(
|
|
331
|
+
input_id="",
|
|
332
|
+
input_text=input_text,
|
|
333
|
+
output=None,
|
|
334
|
+
error="Invalid input",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
start_time = time.time()
|
|
338
|
+
# Accumulators live outside the try so a mid-stream error or timeout can
|
|
339
|
+
# still return whatever partial thinking/answer was produced.
|
|
340
|
+
first_reasoning_time: float | None = None
|
|
341
|
+
first_content_time: float | None = None
|
|
342
|
+
raw_output = ""
|
|
343
|
+
reasoning_output = ""
|
|
344
|
+
reasoning_chars = 0
|
|
345
|
+
reasoning_tokens = 0
|
|
346
|
+
finish_reason = None
|
|
347
|
+
prompt_tokens = 0
|
|
348
|
+
completion_tokens = 0
|
|
349
|
+
total_tokens = 0
|
|
350
|
+
timed_out = False
|
|
351
|
+
stream_error: str | None = None
|
|
352
|
+
# Soft, in-loop deadline: if the model streams longer than the request
|
|
353
|
+
# timeout we stop and KEEP the partial output. (The evaluator's hard guard
|
|
354
|
+
# is a separate backstop for a totally-stalled stream that yields nothing.)
|
|
355
|
+
soft_deadline = (
|
|
356
|
+
self.request_timeout_s
|
|
357
|
+
if (self.request_timeout_s and self.request_timeout_s > 0)
|
|
358
|
+
else None
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
stream = self._create_completion_stream(
|
|
363
|
+
[
|
|
364
|
+
{"role": "system", "content": self.system_prompt},
|
|
365
|
+
{"role": "user", "content": self._build_user_message(input_text)},
|
|
366
|
+
]
|
|
367
|
+
)
|
|
368
|
+
for chunk in stream:
|
|
369
|
+
if soft_deadline and (time.time() - start_time) > soft_deadline:
|
|
370
|
+
timed_out = True
|
|
371
|
+
break
|
|
372
|
+
if chunk.choices and len(chunk.choices) > 0:
|
|
373
|
+
choice = chunk.choices[0]
|
|
374
|
+
delta = getattr(choice, "delta", None)
|
|
375
|
+
content = getattr(delta, "content", None) if delta else None
|
|
376
|
+
if content:
|
|
377
|
+
if first_content_time is None:
|
|
378
|
+
first_content_time = time.time()
|
|
379
|
+
raw_output += content
|
|
380
|
+
# Hidden reasoning ("thinking") streams as ``reasoning_content``.
|
|
381
|
+
reasoning = getattr(delta, "reasoning_content", None) if delta else None
|
|
382
|
+
if reasoning:
|
|
383
|
+
if first_reasoning_time is None:
|
|
384
|
+
first_reasoning_time = time.time()
|
|
385
|
+
reasoning_output += reasoning
|
|
386
|
+
reasoning_chars += len(reasoning)
|
|
387
|
+
fr = getattr(choice, "finish_reason", None)
|
|
388
|
+
if fr:
|
|
389
|
+
finish_reason = fr
|
|
390
|
+
if getattr(chunk, "usage", None) is not None:
|
|
391
|
+
prompt_tokens = chunk.usage.prompt_tokens
|
|
392
|
+
completion_tokens = chunk.usage.completion_tokens
|
|
393
|
+
total_tokens = chunk.usage.total_tokens
|
|
394
|
+
reasoning_tokens = (
|
|
395
|
+
self._extract_reasoning_tokens(chunk.usage) or reasoning_tokens
|
|
396
|
+
)
|
|
397
|
+
except Exception as e: # noqa: BLE001 - captured with partial output below
|
|
398
|
+
stream_error = format_exception(e)
|
|
399
|
+
|
|
400
|
+
end_time = time.time()
|
|
401
|
+
total_time = end_time - start_time
|
|
402
|
+
# Two distinct latencies: time to first *thinking* token vs first *answer*
|
|
403
|
+
# token. For a reasoning model the answer TTFT lands after all thinking.
|
|
404
|
+
ttft_reasoning = (
|
|
405
|
+
(first_reasoning_time - start_time) if first_reasoning_time is not None else None
|
|
406
|
+
)
|
|
407
|
+
ttft = (first_content_time - start_time) if first_content_time is not None else None
|
|
408
|
+
generation_time = (end_time - first_content_time) if first_content_time is not None else 0.0
|
|
409
|
+
# Throughput over TOTAL wall time (thinking + answer), not just the answer
|
|
410
|
+
# window — reasoning dominates wall time for these models.
|
|
411
|
+
tps = (completion_tokens / total_time) if (total_time > 0 and completion_tokens) else 0.0
|
|
412
|
+
|
|
413
|
+
# Prefer the provider-reported reasoning-token count; otherwise estimate
|
|
414
|
+
# from streamed reasoning text (~4 chars/token).
|
|
415
|
+
reasoning_tokens_estimated = False
|
|
416
|
+
if not reasoning_tokens and reasoning_chars:
|
|
417
|
+
reasoning_tokens = max(1, round(reasoning_chars / 4))
|
|
418
|
+
reasoning_tokens_estimated = True
|
|
419
|
+
answer_tokens = max(0, completion_tokens - reasoning_tokens) if completion_tokens else 0
|
|
420
|
+
|
|
421
|
+
metadata = {
|
|
422
|
+
"raw_output": raw_output,
|
|
423
|
+
"model": self.model_name,
|
|
424
|
+
"base_url": self.base_url,
|
|
425
|
+
"prompt_tokens": prompt_tokens,
|
|
426
|
+
"completion_tokens": completion_tokens,
|
|
427
|
+
"total_tokens": total_tokens,
|
|
428
|
+
"reasoning_tokens": reasoning_tokens,
|
|
429
|
+
"reasoning_tokens_estimated": reasoning_tokens_estimated,
|
|
430
|
+
"answer_tokens": answer_tokens,
|
|
431
|
+
"reasoning_chars": reasoning_chars,
|
|
432
|
+
"reasoning_content": reasoning_output,
|
|
433
|
+
"ttft": ttft, # time to first ANSWER token (None if none)
|
|
434
|
+
"ttft_reasoning": ttft_reasoning, # time to first THINKING token (None if none)
|
|
435
|
+
"generation_time": generation_time,
|
|
436
|
+
"tps": tps, # completion tokens / total wall time
|
|
437
|
+
"total_time": total_time,
|
|
438
|
+
"finish_reason": finish_reason,
|
|
439
|
+
"timed_out": timed_out,
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
# ── error paths (all keep the partial output captured above) ──
|
|
443
|
+
if stream_error is not None:
|
|
444
|
+
return AgentResponse(
|
|
445
|
+
input_id="",
|
|
446
|
+
input_text=input_text,
|
|
447
|
+
output="Error",
|
|
448
|
+
error=stream_error,
|
|
449
|
+
processing_time=total_time,
|
|
450
|
+
metadata=metadata,
|
|
451
|
+
)
|
|
452
|
+
if timed_out:
|
|
453
|
+
err = (
|
|
454
|
+
f"Agent stream exceeded the {soft_deadline:.0f}s request timeout and was stopped; "
|
|
455
|
+
f"partial output kept (content_chars={len(raw_output)}, reasoning_chars={reasoning_chars}, "
|
|
456
|
+
f"finish_reason={finish_reason})."
|
|
457
|
+
)
|
|
458
|
+
return AgentResponse(
|
|
459
|
+
input_id="",
|
|
460
|
+
input_text=input_text,
|
|
461
|
+
output="Error",
|
|
462
|
+
error=err,
|
|
463
|
+
processing_time=total_time,
|
|
464
|
+
metadata=metadata,
|
|
465
|
+
)
|
|
466
|
+
if self.error_on_empty and not raw_output.strip():
|
|
467
|
+
return AgentResponse(
|
|
468
|
+
input_id="",
|
|
469
|
+
input_text=input_text,
|
|
470
|
+
output="Error",
|
|
471
|
+
error=self._empty_response_error(
|
|
472
|
+
finish_reason, completion_tokens, reasoning_tokens, reasoning_chars, total_time
|
|
473
|
+
),
|
|
474
|
+
processing_time=total_time,
|
|
475
|
+
metadata=metadata,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
output = self._parse_output(raw_output)
|
|
479
|
+
self._augment_success_metadata(raw_output, metadata)
|
|
480
|
+
return AgentResponse(
|
|
481
|
+
input_id="",
|
|
482
|
+
input_text=input_text,
|
|
483
|
+
output=output,
|
|
484
|
+
confidence=1.0,
|
|
485
|
+
processing_time=total_time,
|
|
486
|
+
metadata=metadata,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# ── shared helpers ────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
def _empty_response_error(
|
|
492
|
+
self,
|
|
493
|
+
finish_reason: str | None,
|
|
494
|
+
completion_tokens: int,
|
|
495
|
+
reasoning_tokens: int,
|
|
496
|
+
reasoning_chars: int,
|
|
497
|
+
total_time: float,
|
|
498
|
+
) -> str:
|
|
499
|
+
"""Build a diagnostic message for a no-visible-content response."""
|
|
500
|
+
if self.request_timeout_s and total_time >= 0.9 * self.request_timeout_s:
|
|
501
|
+
reason = (
|
|
502
|
+
f"stream ended after {total_time:.0f}s (at/near the {self.request_timeout_s:.0f}s "
|
|
503
|
+
f"request timeout — client or provider) while still reasoning, before emitting any "
|
|
504
|
+
f"answer; raise the request timeout (OPENAI_REQUEST_TIMEOUT_S) if the provider allows "
|
|
505
|
+
f"longer streams"
|
|
506
|
+
)
|
|
507
|
+
elif finish_reason == "length":
|
|
508
|
+
reason = "exhausted its output token budget on hidden reasoning without emitting a final answer"
|
|
509
|
+
elif finish_reason == "content_filter":
|
|
510
|
+
reason = "response was blocked by a content filter"
|
|
511
|
+
else:
|
|
512
|
+
reason = "returned no visible content"
|
|
513
|
+
return (
|
|
514
|
+
f"Empty response: model {reason} "
|
|
515
|
+
f"(finish_reason={finish_reason}, completion_tokens={completion_tokens}, "
|
|
516
|
+
f"reasoning_tokens={reasoning_tokens}, reasoning_chars={reasoning_chars}, "
|
|
517
|
+
f"elapsed={total_time:.0f}s)."
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
@staticmethod
|
|
521
|
+
def _extract_reasoning_tokens(usage: Any) -> int:
|
|
522
|
+
"""Pull reasoning/thinking token count from a usage object.
|
|
523
|
+
|
|
524
|
+
LiteLLM normalises most providers to
|
|
525
|
+
``usage.completion_tokens_details.reasoning_tokens`` (OpenAI o-series /
|
|
526
|
+
gpt-5, Gemini "thinking", Anthropic, and OpenAI-compatible gateways that
|
|
527
|
+
forward it). Falls back to alternate field names, handling both
|
|
528
|
+
pydantic-style objects and plain dicts. Returns 0 when unavailable.
|
|
529
|
+
"""
|
|
530
|
+
|
|
531
|
+
def _get(obj: Any, key: str) -> Any:
|
|
532
|
+
if obj is None:
|
|
533
|
+
return None
|
|
534
|
+
if isinstance(obj, dict):
|
|
535
|
+
return obj.get(key)
|
|
536
|
+
return getattr(obj, key, None)
|
|
537
|
+
|
|
538
|
+
details = _get(usage, "completion_tokens_details")
|
|
539
|
+
for key in ("reasoning_tokens", "thoughts_token_count", "thinking_tokens"):
|
|
540
|
+
val = _get(details, key) or _get(usage, key)
|
|
541
|
+
if val:
|
|
542
|
+
try:
|
|
543
|
+
return int(val)
|
|
544
|
+
except (TypeError, ValueError):
|
|
545
|
+
pass
|
|
546
|
+
return 0
|
|
547
|
+
|
|
548
|
+
# ── hooks for subclasses ──────────────────────────────
|
|
549
|
+
|
|
550
|
+
def _build_user_message(self, input_text: str) -> str:
|
|
551
|
+
"""
|
|
552
|
+
Build the user-role message from ``input_text``.
|
|
553
|
+
|
|
554
|
+
Override this to add task-specific formatting, e.g.::
|
|
555
|
+
|
|
556
|
+
def _build_user_message(self, input_text):
|
|
557
|
+
return f"Text to classify:\\n\\n{input_text}"
|
|
558
|
+
|
|
559
|
+
Default returns the input as-is.
|
|
560
|
+
"""
|
|
561
|
+
return input_text
|
|
562
|
+
|
|
563
|
+
def _parse_output(self, raw_output: str) -> str:
|
|
564
|
+
"""
|
|
565
|
+
Post-process the model's raw text into the final output.
|
|
566
|
+
|
|
567
|
+
Override for label cleaning, JSON extraction, etc.
|
|
568
|
+
Default strips whitespace.
|
|
569
|
+
"""
|
|
570
|
+
return raw_output.strip()
|
|
571
|
+
|
|
572
|
+
def _augment_success_metadata(self, raw_output: str, metadata: dict[str, Any]) -> None:
|
|
573
|
+
"""Hook to add task-specific derived fields to a successful response's
|
|
574
|
+
metadata (e.g. an extracted final answer). Default is a no-op."""
|
|
575
|
+
return None
|