Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/strands_evals/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from .evaluation_data_store import EvaluationDataStore
from .experiment import Experiment
from .local_file_task_result_store import LocalFileTaskResultStore
from .model_router import ModelRouter, RoutingRule

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 strands_evals.ModelRouter collides with the core SDK's strands.models.ModelRouter — same name, unrelated semantics, and evals users import both packages.

The SDK class is a failover plugin among candidate models within one invocation; this one picks a judge model per evaluation. RoutingRule also lands next to the SDK's RoutingStrategy/RoutingCandidate, so the shared vocabulary now means two different things. Anyone using both packages hits this from their first import.

Suggestion: rename to something that says what it selects (e.g. EvaluatorModelPolicy / JudgeModelSelector), or drop the new nouns entirely in favor of a callable parameter (see Questions in the summary). Cost: a mechanical rename across 4 files today; after release it's a deprecation cycle.

from .simulation import ActorSimulator, UserSimulator
from .telemetry import StrandsEvalsTelemetry, get_tracer
from .types.detector import DiagnosisConfig
Expand All @@ -13,6 +14,8 @@
"DiagnosisConfig",
"Experiment",
"Case",
"ModelRouter",
"RoutingRule",
"LocalFileTaskResultStore",
"EvaluationDataStore",
"EvaluationReport",
Expand Down
7 changes: 7 additions & 0 deletions src/strands_evals/experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from .evaluators.tool_parameter_accuracy_evaluator import ToolParameterAccuracyEvaluator
from .evaluators.tool_selection_accuracy_evaluator import ToolSelectionAccuracyEvaluator
from .evaluators.trajectory_evaluator import TrajectoryEvaluator
from .model_router import ModelRouter
from .telemetry import get_tracer, serialize
from .telemetry._cloudwatch_logger import _send_to_cloudwatch
from .types.detector import DiagnosisConfig
Expand Down Expand Up @@ -93,6 +94,8 @@ class Experiment(Generic[InputT, OutputT]):
Attributes:
cases: A list of test cases in the experiment.
evaluators: The list of evaluators to be used on the test cases.
model_router: Optional ModelRouter that selects a model per (evaluator, case)
pair. Only applies to evaluators constructed without an explicit model.

Example:
experiment = Experiment[str, str](
Expand Down Expand Up @@ -123,13 +126,15 @@ def __init__(
cases: list[Case[InputT, OutputT]] | None = None,
evaluators: list[Evaluator[InputT, OutputT]] | None = None,
diagnosis_config: DiagnosisConfig | None = None,
model_router: ModelRouter | None = None,
):
self._cases = cases or []
self._evaluators = evaluators or [Evaluator()]
self._tracer = get_tracer()

self._config_id = os.environ.get("EVALUATION_RESULTS_LOG_GROUP", "default-strands-evals")
self._diagnosis_config = diagnosis_config
self._model_router = model_router

@property
def cases(self) -> list[Case[InputT, OutputT]]:
Expand Down Expand Up @@ -338,6 +343,8 @@ async def _run_evaluator(
Returns:
A dict with evaluator_name, test_pass, score, reason, and detailed_results.
"""
if self._model_router is not None:
evaluator = self._model_router.route(evaluator, evaluation_context)
Comment on lines +346 to +347

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A failure inside routing fails every evaluator for the case and drops the task output from the report — including discarding a healthy evaluator's already-computed result.

Reproduced: a control run (no router) passes 2/2 evaluators with actual_output='hi'; the identical run with a router reports both as pass=False, "An error occurred: property 'model' of ... has no setter" and the report row loses actual_output. Reached by a user-defined evaluator (a documented extension point) whose model isn't a plain settable attribute or that isn't copy.copy-able; no built-in evaluator triggers it.

Suggestion: keep routing advisory, per the module's own "opt-in and advisory" contract — degrade to the unrouted evaluator with a warning (this must stay above the retry closure, which default-binds evaluator). Cost: a genuine routing bug surfaces as a warning rather than a loud failure.

Suggested change
if self._model_router is not None:
evaluator = self._model_router.route(evaluator, evaluation_context)
if self._model_router is not None:
try:
evaluator = self._model_router.route(evaluator, evaluation_context)
except Exception as e:
logger.warning(
"evaluator=<%s>, error=<%s> | model routing failed, running evaluator as configured",
evaluator.get_name(),
e,
)


@retry(
retry=retry_if_exception(is_throttling_error),
Expand Down
151 changes: 151 additions & 0 deletions src/strands_evals/model_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Model routing for evaluators.

Lets an Experiment match model tier to evaluation complexity: cheap/fast models
for structural or rubric-style checks, stronger models for nuanced judgment.

Routing is opt-in and advisory:
- An evaluator constructed with an explicit ``model=`` always keeps that model.
- Evaluators whose ``model`` is None (the default) are routed through the
router's rules; the first matching rule wins.
- If no rule matches, the router's ``default_model`` (if any) is used;
otherwise the evaluator keeps its default model resolution.

Example::

from strands_evals import Experiment, ModelRouter, RoutingRule

router = ModelRouter(rules=[
# Structural checks -> fast model
RoutingRule(
evaluator_types=["ToolSelectionAccuracyEvaluator", "ToolParameterAccuracyEvaluator"],
model="us.anthropic.claude-haiku-4-5-20251001-v1:0",
),
# Complex judgment on long traces -> strongest model
RoutingRule(
evaluator_types=["GoalSuccessRateEvaluator"],
model="us.anthropic.claude-opus-4-1-20250805-v1:0",
condition=lambda case: len(str(case.actual_trajectory or "")) > 10_000,
),
])

experiment = Experiment(cases=cases, evaluators=evaluators, model_router=router)
"""

import copy
import logging
from typing import TYPE_CHECKING, Callable

from strands.models.model import Model

if TYPE_CHECKING:
from .evaluators.evaluator import Evaluator
from .types.evaluation import EvaluationData

logger = logging.getLogger(__name__)


class RoutingRule:
"""A rule mapping evaluator types (and optionally case properties) to a model.

Attributes:
evaluator_types: Evaluator classes or class names this rule applies to.
model: The model (Model instance or Bedrock model-id string) to use when
this rule matches.
condition: Optional predicate over the evaluation data. When provided,
the rule only matches cases for which it returns True. This enables
complexity-based routing (e.g., trace length, span count).
"""
Comment on lines +50 to +57

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This public docstring promises things the code doesn't do: evaluator_types is documented as an attribute but never stored (hasattr is False); the predicate is presented as taking a case when it receives an EvaluationData (a different exported type — no session_id here); exact class-name matching is unstated, so a rule for OutputEvaluator silently doesn't cover the shipped MultimodalOutputEvaluator; and a raising condition doesn't just "skip" — it falls through to later rules and default_model, silently downgrading an escalation rule like the module's own example.

Suggestion (verified against ruff/format; also rename case → data in the module example above):

Suggested change
Attributes:
evaluator_types: Evaluator classes or class names this rule applies to.
model: The model (Model instance or Bedrock model-id string) to use when
this rule matches.
condition: Optional predicate over the evaluation data. When provided,
the rule only matches cases for which it returns True. This enables
complexity-based routing (e.g., trace length, span count).
"""
Attributes:
model: The model (Model instance or Bedrock model-id string) to use when
this rule matches.
condition: Optional predicate over the case's `EvaluationData` (note: not
a `Case`). When provided, the rule only matches cases for which it
returns True, which enables complexity-based routing (e.g., trace
length, span count). It must be synchronous and side-effect-free: the
`EvaluationData` it receives is the live object that later lands in
the report. A predicate that raises is logged and the rule is skipped,
so matching falls through to the next rule and then to the router's
`default_model` - which can silently downgrade the model tier.
Note:
`evaluator_types` is matched by exact class name (`get_type_name()`), so a
rule for `OutputEvaluator` does not cover subclasses such as
`MultimodalOutputEvaluator` - list those explicitly. The constructor
argument is not retained as an attribute.
"""


def __init__(
self,
evaluator_types: list[type | str],
model: Model | str,
condition: Callable[["EvaluationData"], bool] | None = None,
):
self._type_names = {t if isinstance(t, str) else t.__name__ for t in evaluator_types}
self.model = model
self.condition = condition

def matches(self, evaluator: "Evaluator", evaluation_data: "EvaluationData") -> bool:
"""Check whether this rule applies to the given evaluator and case.

Args:
evaluator: The evaluator about to run.
evaluation_data: The evaluation context for the current case.

Returns:
True if the evaluator's type is covered by this rule and the
condition (if any) holds for the case.
"""
if evaluator.get_type_name() not in self._type_names:
return False
if self.condition is not None:
try:
return bool(self.condition(evaluation_data))
except Exception as e:
logger.warning(
"rule_types=<%s>, error=<%s> | routing condition raised, skipping rule", self._type_names, e
)
return False
return True
Comment on lines +82 to +90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 An async def condition makes the rule match every case — the exact inverse of a predicate that returns False.

bool(coroutine) is always True, so an async predicate (an easy mistake in a framework whose evaluators and runner are all async) routes every case to that rule's model, with only a RuntimeWarning buried in the logs — a silently wrong, possibly far more expensive judge on all cases.

Suggestion (also add import inspect to the imports):

Suggested change
if self.condition is not None:
try:
return bool(self.condition(evaluation_data))
except Exception as e:
logger.warning(
"rule_types=<%s>, error=<%s> | routing condition raised, skipping rule", self._type_names, e
)
return False
return True
if self.condition is not None:
try:
result = self.condition(evaluation_data)
except Exception as e:
logger.warning(
"rule_types=<%s>, error=<%s> | routing condition raised, skipping rule", self._type_names, e
)
return False
if inspect.isawaitable(result):
if inspect.iscoroutine(result):
result.close()
logger.warning(
"rule_types=<%s> | routing condition must be synchronous, skipping rule", self._type_names
)
return False
return bool(result)
return True



class ModelRouter:
"""Selects a model for each (evaluator, case) pair using an ordered rule list.

Attributes:
rules: Ordered list of RoutingRule; the first matching rule wins.
default_model: Model used when no rule matches. None means "leave the
evaluator's own model resolution untouched".
"""

def __init__(self, rules: list[RoutingRule] | None = None, default_model: Model | str | None = None):
self.rules = rules or []
self.default_model = default_model

def select_model(self, evaluator: "Evaluator", evaluation_data: "EvaluationData") -> Model | str | None:
"""Select a model for an evaluator run, or None to keep the evaluator's default.

Args:
evaluator: The evaluator about to run.
evaluation_data: The evaluation context for the current case.

Returns:
The routed model, the router default, or None when routing does not apply.
"""
for rule in self.rules:
if rule.matches(evaluator, evaluation_data):
return rule.model
return self.default_model

def route(self, evaluator: "Evaluator", evaluation_data: "EvaluationData") -> "Evaluator":
"""Return the evaluator to run, applying model routing when appropriate.

An evaluator with an explicit model (its ``model`` attribute is set) is
returned unchanged — user configuration always wins. Evaluators without
a ``model`` attribute (e.g., deterministic evaluators) are also returned
unchanged. Otherwise a shallow copy with the routed model is returned so
the shared evaluator instance is never mutated across concurrent workers.

Args:
evaluator: The evaluator about to run.
evaluation_data: The evaluation context for the current case.

Returns:
Either the original evaluator or a shallow copy carrying the routed model.
"""
if getattr(evaluator, "model", None) is not None or not hasattr(evaluator, "model"):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Routing is a silent total no-op for any experiment loaded from a file, and nothing observable records whether routing happened.

Evaluator.to_dict() writes a default model=None out as a concrete model_id, and from_dict maps it back to an explicit model= — so after any to_file/from_file round trip this guard sees every LLM evaluator as "explicitly configured" and does nothing. That's the whole CLI path (strands-evals run/validate) plus anyone who persists experiments, and the only trace of a routing decision is a DEBUG log — nothing in the report or spans says which model judged a case.

Suggestion: in this PR, document the limitation here and on Experiment(model_router=...), pin it with a characterization test, and put the resolved model on the eval span (or a report field) so "did it route?" is answerable. The underlying serializer behavior is pre-existing — filed as #385, since fixing it changes behavior an existing test asserts.

return evaluator

model = self.select_model(evaluator, evaluation_data)
if model is None:
return evaluator

routed = copy.copy(evaluator)
routed.model = model
logger.debug(
"evaluator=<%s>, model=<%s> | routed evaluator to model",
evaluator.get_name(),
model if isinstance(model, str) else type(model).__name__,
)
return routed
180 changes: 180 additions & 0 deletions tests/strands_evals/test_model_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
from unittest.mock import Mock, patch

import pytest

from strands_evals import Case, Experiment, ModelRouter, RoutingRule
from strands_evals.evaluators import OutputEvaluator, TrajectoryEvaluator
from strands_evals.evaluators.deterministic import Contains
from strands_evals.types import EvaluationData, EvaluationOutput

FAST_MODEL = "us.anthropic.claude-haiku-4-5-20251001-v1:0"
STRONG_MODEL = "us.anthropic.claude-opus-4-1-20250805-v1:0"


@pytest.fixture
def evaluation_data():
return EvaluationData(input="What is 2+2?", actual_output="4", expected_output="4", name="math_test")


class TestRoutingRule:
def test_matches_by_class_name_string(self, evaluation_data):
rule = RoutingRule(evaluator_types=["OutputEvaluator"], model=FAST_MODEL)
evaluator = OutputEvaluator(rubric="Test rubric")

assert rule.matches(evaluator, evaluation_data) is True

def test_matches_by_class_object(self, evaluation_data):
rule = RoutingRule(evaluator_types=[OutputEvaluator], model=FAST_MODEL)
evaluator = OutputEvaluator(rubric="Test rubric")

assert rule.matches(evaluator, evaluation_data) is True

def test_does_not_match_other_evaluator_types(self, evaluation_data):
rule = RoutingRule(evaluator_types=[TrajectoryEvaluator], model=FAST_MODEL)
evaluator = OutputEvaluator(rubric="Test rubric")

assert rule.matches(evaluator, evaluation_data) is False

def test_condition_gates_matching(self, evaluation_data):
rule = RoutingRule(
evaluator_types=[OutputEvaluator],
model=STRONG_MODEL,
condition=lambda case: len(str(case.actual_output)) > 100,
)
evaluator = OutputEvaluator(rubric="Test rubric")

assert rule.matches(evaluator, evaluation_data) is False

long_output = EvaluationData(input="q", actual_output="x" * 200)
assert rule.matches(evaluator, long_output) is True

def test_condition_exception_skips_rule(self, evaluation_data):
def broken_condition(case):
raise RuntimeError("boom")

rule = RoutingRule(evaluator_types=[OutputEvaluator], model=FAST_MODEL, condition=broken_condition)
evaluator = OutputEvaluator(rubric="Test rubric")

assert rule.matches(evaluator, evaluation_data) is False


class TestModelRouter:
def test_first_matching_rule_wins(self, evaluation_data):
router = ModelRouter(
rules=[
RoutingRule(evaluator_types=[OutputEvaluator], model=FAST_MODEL),
RoutingRule(evaluator_types=[OutputEvaluator], model=STRONG_MODEL),
]
)
evaluator = OutputEvaluator(rubric="Test rubric")

assert router.select_model(evaluator, evaluation_data) == FAST_MODEL

def test_default_model_when_no_rule_matches(self, evaluation_data):
router = ModelRouter(
rules=[RoutingRule(evaluator_types=[TrajectoryEvaluator], model=FAST_MODEL)],
default_model=STRONG_MODEL,
)
evaluator = OutputEvaluator(rubric="Test rubric")

assert router.select_model(evaluator, evaluation_data) == STRONG_MODEL

def test_no_rule_and_no_default_returns_none(self, evaluation_data):
router = ModelRouter(rules=[RoutingRule(evaluator_types=[TrajectoryEvaluator], model=FAST_MODEL)])
evaluator = OutputEvaluator(rubric="Test rubric")

assert router.select_model(evaluator, evaluation_data) is None

def test_route_returns_copy_with_routed_model(self, evaluation_data):
router = ModelRouter(rules=[RoutingRule(evaluator_types=[OutputEvaluator], model=FAST_MODEL)])
evaluator = OutputEvaluator(rubric="Test rubric")

routed = router.route(evaluator, evaluation_data)

assert routed is not evaluator
assert routed.model == FAST_MODEL
# Original instance is never mutated
assert evaluator.model is None

def test_route_preserves_explicit_model(self, evaluation_data):
"""An evaluator constructed with an explicit model must never be re-routed."""
router = ModelRouter(rules=[RoutingRule(evaluator_types=[OutputEvaluator], model=FAST_MODEL)])
evaluator = OutputEvaluator(rubric="Test rubric", model=STRONG_MODEL)

routed = router.route(evaluator, evaluation_data)

assert routed is evaluator
assert routed.model == STRONG_MODEL

def test_route_leaves_evaluator_without_model_attribute_unchanged(self, evaluation_data):
"""Deterministic evaluators have no model attribute and must pass through."""
router = ModelRouter(
rules=[RoutingRule(evaluator_types=["Contains"], model=FAST_MODEL)], default_model=FAST_MODEL
)
evaluator = Contains(value="4")

routed = router.route(evaluator, evaluation_data)

assert routed is evaluator

def test_route_without_match_returns_original(self, evaluation_data):
router = ModelRouter(rules=[RoutingRule(evaluator_types=[TrajectoryEvaluator], model=FAST_MODEL)])
evaluator = OutputEvaluator(rubric="Test rubric")

routed = router.route(evaluator, evaluation_data)

assert routed is evaluator
assert routed.model is None


class TestExperimentIntegration:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The feature's headline — per-case, condition-driven routing — is never exercised through the runner. Both integration tests use one case and a condition-free rule, so a refactor that memoizes model selection per evaluator type, or routes on pre-execution data, passes the whole suite while silently mis-routing (checked by mutation: both survive).

Suggestion: one test closes this — two cases, a condition on actual_output length, max_workers=2; assert the two Agent(model=...) kwargs via call_args_list are the fast and strong models respectively, and evaluator.model is None afterwards (no leak onto the shared instance). Such a test was written and confirmed to pass on this head and kill both surviving mutants. While here: Mock() without spec=Agent, the dead mock_agent.return_value = mock_result (the runner goes through invoke_async), and call_args reading only the last call all become traps the moment a second case exists — a small shared _agent_mock() fixture removes the duplication too.

@patch("strands_evals.evaluators.output_evaluator.Agent")
def test_experiment_routes_evaluator_model(self, mock_agent_class):
mock_agent = Mock()
mock_result = Mock()
mock_result.structured_output = EvaluationOutput(score=1.0, test_pass=True, reason="ok")
mock_agent.return_value = mock_result

async def mock_invoke_async(*args, **kwargs):
return mock_result

mock_agent.invoke_async = mock_invoke_async
mock_agent_class.return_value = mock_agent

router = ModelRouter(rules=[RoutingRule(evaluator_types=[OutputEvaluator], model=FAST_MODEL)])
evaluator = OutputEvaluator(rubric="Output must be correct.")
experiment = Experiment(
cases=[Case(name="math", input="What is 2+2?", expected_output="4")],
evaluators=[evaluator],
model_router=router,
)

report = experiment.run_evaluations(lambda case: "4")

# The judge agent was constructed with the routed model
assert mock_agent_class.call_args[1]["model"] == FAST_MODEL
assert report.test_passes == [True]
# The user's evaluator instance is untouched
assert evaluator.model is None

@patch("strands_evals.evaluators.output_evaluator.Agent")
def test_experiment_without_router_keeps_default_model(self, mock_agent_class):
mock_agent = Mock()
mock_result = Mock()
mock_result.structured_output = EvaluationOutput(score=1.0, test_pass=True, reason="ok")
mock_agent.return_value = mock_result

async def mock_invoke_async(*args, **kwargs):
return mock_result

mock_agent.invoke_async = mock_invoke_async
mock_agent_class.return_value = mock_agent

experiment = Experiment(
cases=[Case(name="math", input="What is 2+2?", expected_output="4")],
evaluators=[OutputEvaluator(rubric="Output must be correct.")],
)

experiment.run_evaluations(lambda case: "4")

assert mock_agent_class.call_args[1]["model"] is None
Loading