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
EvalRing/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EvalRing: a unified evaluation framework for agents across LLMs and agent versions.
|
|
3
|
+
|
|
4
|
+
The public API is re-exported here. Three abstractions carry the framework:
|
|
5
|
+
|
|
6
|
+
- a :class:`~EvalRing.dataset.base.BaseDataset` supplies
|
|
7
|
+
:class:`~EvalRing.dataset.base.DataSample` records,
|
|
8
|
+
- a :class:`~EvalRing.agent.base.BaseAgent` turns each sample into an
|
|
9
|
+
:class:`~EvalRing.agent.base.AgentResponse`,
|
|
10
|
+
- a :class:`~EvalRing.evaluator.base.BaseEvaluator` scores the responses and
|
|
11
|
+
returns an :class:`~EvalRing.evaluator.base.EvaluationResult`.
|
|
12
|
+
|
|
13
|
+
Provider credentials are resolved centrally by
|
|
14
|
+
:func:`EvalRing.config.resolve_credentials`; see ``docs/CONFIGURATION.md``.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__version__ = "0.2.0"
|
|
18
|
+
|
|
19
|
+
from .agent import (
|
|
20
|
+
AgentResponse,
|
|
21
|
+
BaseAgent,
|
|
22
|
+
ClassificationPrediction,
|
|
23
|
+
ErrorClass,
|
|
24
|
+
MockAgent,
|
|
25
|
+
MultiRoleHostOrchestrator,
|
|
26
|
+
OpenAIAgent,
|
|
27
|
+
RoleConfig,
|
|
28
|
+
RuleBasedAgent,
|
|
29
|
+
classify_error,
|
|
30
|
+
resolve_classification_prediction,
|
|
31
|
+
)
|
|
32
|
+
from .config import (
|
|
33
|
+
MissingCredentialsError,
|
|
34
|
+
ProviderCredentials,
|
|
35
|
+
resolve_credentials,
|
|
36
|
+
resolve_model_name,
|
|
37
|
+
)
|
|
38
|
+
from .dataset import BaseDataset, CSVDataset, DataFrameDataset, DataSample, JSONDataset
|
|
39
|
+
from .evaluator import (
|
|
40
|
+
BaseEvaluator,
|
|
41
|
+
ClassificationEvaluator,
|
|
42
|
+
EvalSteps,
|
|
43
|
+
EvaluationMetrics,
|
|
44
|
+
EvaluationResult,
|
|
45
|
+
JudgeMetric,
|
|
46
|
+
JudgeTemplate,
|
|
47
|
+
JudgeVerdict,
|
|
48
|
+
LLMJudge,
|
|
49
|
+
LLMJudgeEvaluator,
|
|
50
|
+
OpenAIJudge,
|
|
51
|
+
Rubric,
|
|
52
|
+
RubricLevel,
|
|
53
|
+
ScoringCriteria,
|
|
54
|
+
)
|
|
55
|
+
from .logging_utils import configure_logging, get_logger
|
|
56
|
+
from .utils import GlobalCache, generate_model_list, generate_suite_visuals, run_suite
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"__version__",
|
|
60
|
+
# Configuration
|
|
61
|
+
"MissingCredentialsError",
|
|
62
|
+
"ProviderCredentials",
|
|
63
|
+
"resolve_credentials",
|
|
64
|
+
"resolve_model_name",
|
|
65
|
+
"configure_logging",
|
|
66
|
+
"get_logger",
|
|
67
|
+
# Datasets
|
|
68
|
+
"BaseDataset",
|
|
69
|
+
"DataSample",
|
|
70
|
+
"JSONDataset",
|
|
71
|
+
"CSVDataset",
|
|
72
|
+
"DataFrameDataset",
|
|
73
|
+
# Agents
|
|
74
|
+
"BaseAgent",
|
|
75
|
+
"AgentResponse",
|
|
76
|
+
"ClassificationPrediction",
|
|
77
|
+
"MockAgent",
|
|
78
|
+
"RuleBasedAgent",
|
|
79
|
+
"OpenAIAgent",
|
|
80
|
+
"MultiRoleHostOrchestrator",
|
|
81
|
+
"RoleConfig",
|
|
82
|
+
"ErrorClass",
|
|
83
|
+
"classify_error",
|
|
84
|
+
"resolve_classification_prediction",
|
|
85
|
+
# Evaluators
|
|
86
|
+
"BaseEvaluator",
|
|
87
|
+
"EvaluationResult",
|
|
88
|
+
"EvaluationMetrics",
|
|
89
|
+
"ClassificationEvaluator",
|
|
90
|
+
# LLM-as-a-judge
|
|
91
|
+
"Rubric",
|
|
92
|
+
"RubricLevel",
|
|
93
|
+
"ScoringCriteria",
|
|
94
|
+
"JudgeVerdict",
|
|
95
|
+
"EvalSteps",
|
|
96
|
+
"JudgeTemplate",
|
|
97
|
+
"JudgeMetric",
|
|
98
|
+
"LLMJudge",
|
|
99
|
+
"OpenAIJudge",
|
|
100
|
+
"LLMJudgeEvaluator",
|
|
101
|
+
# Suite tooling
|
|
102
|
+
"GlobalCache",
|
|
103
|
+
"generate_model_list",
|
|
104
|
+
"generate_suite_visuals",
|
|
105
|
+
"run_suite",
|
|
106
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent module for EvalRing.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .base import AgentResponse, BaseAgent
|
|
6
|
+
from .classification import (
|
|
7
|
+
ClassificationPrediction,
|
|
8
|
+
aggregate_base_vs_rest_probabilities,
|
|
9
|
+
normalize_probability_distribution,
|
|
10
|
+
parse_json_object,
|
|
11
|
+
resolve_classification_prediction,
|
|
12
|
+
)
|
|
13
|
+
from .errors import ErrorClass, classify_error, format_exception
|
|
14
|
+
from .implementations import MockAgent, OpenAIAgent, RuleBasedAgent
|
|
15
|
+
from .multi_role import MultiRoleHostOrchestrator, RoleConfig
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BaseAgent",
|
|
19
|
+
"AgentResponse",
|
|
20
|
+
"ClassificationPrediction",
|
|
21
|
+
"aggregate_base_vs_rest_probabilities",
|
|
22
|
+
"normalize_probability_distribution",
|
|
23
|
+
"parse_json_object",
|
|
24
|
+
"resolve_classification_prediction",
|
|
25
|
+
"MultiRoleHostOrchestrator",
|
|
26
|
+
"RoleConfig",
|
|
27
|
+
"MockAgent",
|
|
28
|
+
"RuleBasedAgent",
|
|
29
|
+
"OpenAIAgent",
|
|
30
|
+
"ErrorClass",
|
|
31
|
+
"classify_error",
|
|
32
|
+
"format_exception",
|
|
33
|
+
]
|
EvalRing/agent/base.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base classes for agents in the EvalRing framework.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class AgentResponse:
|
|
14
|
+
"""Represents an agent's response to an input."""
|
|
15
|
+
|
|
16
|
+
input_id: str
|
|
17
|
+
input_text: str
|
|
18
|
+
output: Any
|
|
19
|
+
confidence: float | None = None
|
|
20
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
processing_time: float | None = None
|
|
22
|
+
error: str | None = None
|
|
23
|
+
|
|
24
|
+
def is_successful(self) -> bool:
|
|
25
|
+
"""Check if the response was successful."""
|
|
26
|
+
return self.error is None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BaseAgent(ABC):
|
|
30
|
+
"""
|
|
31
|
+
Abstract base class for all agents in EvalRing.
|
|
32
|
+
|
|
33
|
+
This class provides a standardized interface for different types of agents
|
|
34
|
+
(LLM-based, rule-based, ensemble, etc.) to ensure compatibility with the
|
|
35
|
+
evaluation framework.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, name: str, version: str = "1.0", description: str | None = None, **kwargs):
|
|
39
|
+
self.name = name
|
|
40
|
+
self.version = version
|
|
41
|
+
self.description = description or f"Agent: {name}"
|
|
42
|
+
self._metadata = kwargs
|
|
43
|
+
self._is_initialized = False
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def initialize(self, **kwargs) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Initialize the agent with required resources.
|
|
49
|
+
|
|
50
|
+
This method should be called before making predictions.
|
|
51
|
+
"""
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
def predict(self, input_text: str, **kwargs) -> AgentResponse:
|
|
56
|
+
"""
|
|
57
|
+
Make a prediction for a single input.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
input_text: The input text to process
|
|
61
|
+
**kwargs: Additional parameters for prediction
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
AgentResponse containing the prediction and metadata
|
|
65
|
+
"""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def predict_batch(self, inputs: list[str], **kwargs) -> list[AgentResponse]:
|
|
69
|
+
"""
|
|
70
|
+
Make predictions for multiple inputs.
|
|
71
|
+
|
|
72
|
+
Default implementation processes inputs sequentially.
|
|
73
|
+
Override this method for batch processing optimization.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
inputs: List of input texts to process
|
|
77
|
+
**kwargs: Additional parameters for prediction
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
List of AgentResponse objects
|
|
81
|
+
"""
|
|
82
|
+
responses = []
|
|
83
|
+
for i, input_text in enumerate(inputs):
|
|
84
|
+
response = self.predict(input_text, **kwargs)
|
|
85
|
+
response.input_id = str(i)
|
|
86
|
+
responses.append(response)
|
|
87
|
+
return responses
|
|
88
|
+
|
|
89
|
+
def validate_input(self, input_text: str) -> bool:
|
|
90
|
+
"""
|
|
91
|
+
Validate input before processing.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
input_text: Input text to validate
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
True if input is valid, False otherwise
|
|
98
|
+
"""
|
|
99
|
+
return isinstance(input_text, str) and len(input_text.strip()) > 0
|
|
100
|
+
|
|
101
|
+
def get_info(self) -> dict[str, Any]:
|
|
102
|
+
"""Get agent information."""
|
|
103
|
+
return {
|
|
104
|
+
"name": self.name,
|
|
105
|
+
"version": self.version,
|
|
106
|
+
"description": self.description,
|
|
107
|
+
"type": self.__class__.__name__,
|
|
108
|
+
"metadata": self._metadata,
|
|
109
|
+
"is_initialized": self._is_initialized,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def save_config(self, filepath: str | Path) -> None:
|
|
113
|
+
"""Save agent configuration to file."""
|
|
114
|
+
config = self.get_info()
|
|
115
|
+
with open(filepath, "w") as f:
|
|
116
|
+
json.dump(config, f, indent=2)
|
|
117
|
+
|
|
118
|
+
def __str__(self) -> str:
|
|
119
|
+
return f"{self.__class__.__name__}(name='{self.name}', version='{self.version}')"
|
|
120
|
+
|
|
121
|
+
def __repr__(self) -> str:
|
|
122
|
+
return self.__str__()
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reusable utilities for classification-style outputs.
|
|
3
|
+
|
|
4
|
+
These helpers let agents return either:
|
|
5
|
+
- a plain class label string, or
|
|
6
|
+
- a structured mapping of class -> confidence score.
|
|
7
|
+
|
|
8
|
+
The evaluator can then resolve the top class consistently.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class ClassificationPrediction:
|
|
19
|
+
"""Normalized prediction object used by evaluators."""
|
|
20
|
+
|
|
21
|
+
label: str | None
|
|
22
|
+
confidence: float | None = None
|
|
23
|
+
class_scores: dict[str, float] | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_json_object(raw_text: str) -> dict[str, Any] | None:
|
|
27
|
+
"""
|
|
28
|
+
Parse a JSON object from raw model text.
|
|
29
|
+
|
|
30
|
+
Supports either:
|
|
31
|
+
- exact JSON object text, or
|
|
32
|
+
- text that contains one object block.
|
|
33
|
+
"""
|
|
34
|
+
text = (raw_text or "").strip()
|
|
35
|
+
if not text:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
parsed = json.loads(text)
|
|
40
|
+
return parsed if isinstance(parsed, dict) else None
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
start = text.find("{")
|
|
45
|
+
end = text.rfind("}")
|
|
46
|
+
if start == -1 or end == -1 or end <= start:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
parsed = json.loads(text[start : end + 1])
|
|
51
|
+
return parsed if isinstance(parsed, dict) else None
|
|
52
|
+
except Exception:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def canonicalize_label(raw_label: Any, label_aliases: Mapping[str, str] | None = None) -> str:
|
|
57
|
+
"""Map a raw label to its canonical form (case-insensitive aliases)."""
|
|
58
|
+
text = str(raw_label).strip()
|
|
59
|
+
if not text:
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
if not label_aliases:
|
|
63
|
+
return text
|
|
64
|
+
|
|
65
|
+
alias_map = {k.lower(): v for k, v in label_aliases.items()}
|
|
66
|
+
return alias_map.get(text.lower(), text)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalize_class_scores(
|
|
70
|
+
output: Mapping[Any, Any],
|
|
71
|
+
*,
|
|
72
|
+
label_aliases: Mapping[str, str] | None = None,
|
|
73
|
+
) -> dict[str, float]:
|
|
74
|
+
"""Convert a raw mapping into a canonical class-score dict."""
|
|
75
|
+
merged: dict[str, float] = {}
|
|
76
|
+
for raw_label, raw_score in output.items():
|
|
77
|
+
try:
|
|
78
|
+
score = float(raw_score)
|
|
79
|
+
except (TypeError, ValueError):
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
label = canonicalize_label(raw_label, label_aliases=label_aliases)
|
|
83
|
+
if not label:
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
merged[label] = merged.get(label, 0.0) + score
|
|
87
|
+
return merged
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def normalize_probability_distribution(scores: Mapping[str, float]) -> dict[str, float]:
|
|
91
|
+
"""Normalize non-negative class scores into a probability distribution."""
|
|
92
|
+
normalized: dict[str, float] = {}
|
|
93
|
+
for label, value in scores.items():
|
|
94
|
+
try:
|
|
95
|
+
v = float(value)
|
|
96
|
+
except (TypeError, ValueError):
|
|
97
|
+
continue
|
|
98
|
+
normalized[label] = max(0.0, v)
|
|
99
|
+
|
|
100
|
+
total = sum(normalized.values())
|
|
101
|
+
if total <= 0:
|
|
102
|
+
return dict.fromkeys(normalized, 0.0)
|
|
103
|
+
|
|
104
|
+
return {label: value / total for label, value in normalized.items()}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def aggregate_base_vs_rest_probabilities(
|
|
108
|
+
*,
|
|
109
|
+
base_label: str,
|
|
110
|
+
target_vs_base_probs: Mapping[str, float],
|
|
111
|
+
all_labels: list[str] | None = None,
|
|
112
|
+
epsilon: float = 1e-6,
|
|
113
|
+
) -> dict[str, float]:
|
|
114
|
+
"""
|
|
115
|
+
Convert pairwise binary probabilities into a full multi-class distribution.
|
|
116
|
+
|
|
117
|
+
Expected input per target class is p(target | target vs base).
|
|
118
|
+
We convert each pairwise probability into odds r_t = p_t / (1-p_t), and use:
|
|
119
|
+
P(base) = 1 / (1 + sum_t r_t)
|
|
120
|
+
P(target_t) = r_t * P(base)
|
|
121
|
+
|
|
122
|
+
This makes a reusable bridge from base-vs-rest binary runs to multi-class output.
|
|
123
|
+
"""
|
|
124
|
+
if not base_label:
|
|
125
|
+
raise ValueError("base_label must be a non-empty string")
|
|
126
|
+
|
|
127
|
+
ratios: dict[str, float] = {}
|
|
128
|
+
for label, probability in target_vs_base_probs.items():
|
|
129
|
+
if label == base_label:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
p_target = float(probability)
|
|
134
|
+
except (TypeError, ValueError):
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
p_target = min(max(p_target, epsilon), 1.0 - epsilon)
|
|
138
|
+
ratios[label] = p_target / (1.0 - p_target)
|
|
139
|
+
|
|
140
|
+
base_prob = 1.0 / (1.0 + sum(ratios.values()))
|
|
141
|
+
scores: dict[str, float] = {base_label: base_prob}
|
|
142
|
+
for label, ratio in ratios.items():
|
|
143
|
+
scores[label] = ratio * base_prob
|
|
144
|
+
|
|
145
|
+
if all_labels:
|
|
146
|
+
for label in all_labels:
|
|
147
|
+
scores.setdefault(label, 0.0)
|
|
148
|
+
|
|
149
|
+
return normalize_probability_distribution(scores)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def resolve_classification_prediction(
|
|
153
|
+
output: Any,
|
|
154
|
+
*,
|
|
155
|
+
valid_labels: list[str] | None = None,
|
|
156
|
+
label_aliases: Mapping[str, str] | None = None,
|
|
157
|
+
) -> ClassificationPrediction:
|
|
158
|
+
"""
|
|
159
|
+
Resolve top-class prediction from string or class-score mapping output.
|
|
160
|
+
|
|
161
|
+
If output is a mapping, the highest-scoring class is selected.
|
|
162
|
+
Ties are resolved by valid_labels order (if provided), then alphabetically.
|
|
163
|
+
"""
|
|
164
|
+
if isinstance(output, Mapping):
|
|
165
|
+
scores = normalize_class_scores(output, label_aliases=label_aliases)
|
|
166
|
+
|
|
167
|
+
if valid_labels:
|
|
168
|
+
valid_set = set(valid_labels)
|
|
169
|
+
scores = {k: v for k, v in scores.items() if k in valid_set}
|
|
170
|
+
|
|
171
|
+
if not scores:
|
|
172
|
+
return ClassificationPrediction(label=None, confidence=None, class_scores={})
|
|
173
|
+
|
|
174
|
+
order = {label: idx for idx, label in enumerate(valid_labels or [])}
|
|
175
|
+
best_label, best_score = min(
|
|
176
|
+
scores.items(),
|
|
177
|
+
key=lambda kv: (
|
|
178
|
+
-kv[1],
|
|
179
|
+
order.get(kv[0], 10**9),
|
|
180
|
+
kv[0].lower(),
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
return ClassificationPrediction(
|
|
184
|
+
label=best_label, confidence=float(best_score), class_scores=scores
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if output is None:
|
|
188
|
+
return ClassificationPrediction(label=None, confidence=None, class_scores=None)
|
|
189
|
+
|
|
190
|
+
label = canonicalize_label(str(output), label_aliases=label_aliases)
|
|
191
|
+
return ClassificationPrediction(label=label, confidence=None, class_scores=None)
|
EvalRing/agent/errors.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Reusable error formatting and classification for model-backed agents.
|
|
2
|
+
|
|
3
|
+
Two concerns that every LLM task hits, factored out so tasks share one policy:
|
|
4
|
+
|
|
5
|
+
- :func:`format_exception` turns a raised exception (often from LiteLLM / the
|
|
6
|
+
OpenAI SDK) into a *complete* diagnostic string — exception type, full
|
|
7
|
+
message, and useful provider attributes — instead of just the class name.
|
|
8
|
+
- :func:`classify_error` labels an error message as rate-limited, transient
|
|
9
|
+
(worth patient retries), and/or terminal (retrying only wastes tokens) so
|
|
10
|
+
evaluators can share one retry policy.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def format_exception(e: BaseException) -> str:
|
|
19
|
+
"""Build a complete, diagnosable error string from an exception.
|
|
20
|
+
|
|
21
|
+
Captures the exception type and its full message (falling back to ``repr``
|
|
22
|
+
when the message is empty), plus common LiteLLM/OpenAI attributes
|
|
23
|
+
(``status_code``, ``llm_provider``, ``model``) when present.
|
|
24
|
+
"""
|
|
25
|
+
detail = str(e).strip()
|
|
26
|
+
if not detail:
|
|
27
|
+
detail = repr(e)
|
|
28
|
+
parts = [f"{type(e).__name__}: {detail}"]
|
|
29
|
+
for attr in ("status_code", "llm_provider", "model"):
|
|
30
|
+
val = getattr(e, attr, None)
|
|
31
|
+
if val not in (None, ""):
|
|
32
|
+
parts.append(f"{attr}={val}")
|
|
33
|
+
return " | ".join(parts)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Substrings (matched against a lower-cased message) for each class.
|
|
37
|
+
_RATE_LIMIT_MARKERS = ("429", "rate limit", "rate_limit", "too many requests")
|
|
38
|
+
_TRANSIENT_MARKERS = (
|
|
39
|
+
"connection error",
|
|
40
|
+
"internalservererror",
|
|
41
|
+
"internal server error",
|
|
42
|
+
"overloaded",
|
|
43
|
+
"timeout",
|
|
44
|
+
"timed out",
|
|
45
|
+
"temporarily unavailable",
|
|
46
|
+
"service unavailable",
|
|
47
|
+
"bad gateway",
|
|
48
|
+
"gateway timeout",
|
|
49
|
+
" 500",
|
|
50
|
+
" 502",
|
|
51
|
+
" 503",
|
|
52
|
+
" 504",
|
|
53
|
+
"502 ",
|
|
54
|
+
"503 ",
|
|
55
|
+
"504 ",
|
|
56
|
+
)
|
|
57
|
+
_TERMINAL_MARKERS = (
|
|
58
|
+
"empty response",
|
|
59
|
+
"content filter",
|
|
60
|
+
"content_filter",
|
|
61
|
+
"invalid request",
|
|
62
|
+
"invalid_request",
|
|
63
|
+
"badrequest",
|
|
64
|
+
"bad request",
|
|
65
|
+
"context length",
|
|
66
|
+
"maximum context",
|
|
67
|
+
"unsupported",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class ErrorClass:
|
|
73
|
+
"""Retry-relevant classification of an error message."""
|
|
74
|
+
|
|
75
|
+
is_rate_limit: bool
|
|
76
|
+
is_transient: bool
|
|
77
|
+
is_terminal: bool
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def classify_error(message: str) -> ErrorClass:
|
|
81
|
+
"""Classify an error message for retry decisions.
|
|
82
|
+
|
|
83
|
+
Precedence: an **empty response** (or other terminal condition) is terminal
|
|
84
|
+
even if its message also mentions "timed out" — retrying such a case only
|
|
85
|
+
burns another full timeout window of tokens. Rate limits take precedence
|
|
86
|
+
over generic transient handling so callers can apply a dedicated backoff.
|
|
87
|
+
"""
|
|
88
|
+
el = (message or "").lower()
|
|
89
|
+
is_rate_limit = any(m in el for m in _RATE_LIMIT_MARKERS)
|
|
90
|
+
is_transient = any(m in el for m in _TRANSIENT_MARKERS)
|
|
91
|
+
# Always-terminal conditions win over transient-looking text: these all
|
|
92
|
+
# mention "timeout"/"timed out" but retrying only wastes another full
|
|
93
|
+
# deadline — an empty response, a hard client timeout, and a soft
|
|
94
|
+
# (in-stream) timeout where the model was simply too slow for the limit.
|
|
95
|
+
forced_terminal = (
|
|
96
|
+
"empty response" in el
|
|
97
|
+
or "hard client timeout" in el
|
|
98
|
+
or "request timeout and was stopped" in el
|
|
99
|
+
)
|
|
100
|
+
is_terminal = forced_terminal or (
|
|
101
|
+
any(m in el for m in _TERMINAL_MARKERS) and not is_rate_limit and not is_transient
|
|
102
|
+
)
|
|
103
|
+
if is_terminal:
|
|
104
|
+
# A terminal error should never be treated as retryable.
|
|
105
|
+
is_transient = False
|
|
106
|
+
is_rate_limit = False
|
|
107
|
+
return ErrorClass(
|
|
108
|
+
is_rate_limit=is_rate_limit, is_transient=is_transient, is_terminal=is_terminal
|
|
109
|
+
)
|