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.
@@ -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