Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-llm/src/lexigram/ai/llm/routing/ab_split.py: 56%
34 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
1from __future__ import annotations
3from dataclasses import dataclass
4import hashlib
5from typing import Any
7from lexigram.contracts.ai.llm import LLMClientProtocol
8from lexigram.logging import (
9 get_logger,
10)
12logger = get_logger(__name__)
14__all__ = ["ABSplitConfig", "ABSplitStrategy"]
17@dataclass(frozen=True)
18class ABSplitConfig:
19 """Configuration for A/B traffic splitting between two LLM providers.
21 Attributes:
22 control_key: Container key for the control (baseline) provider.
23 treatment_key: Container key for the treatment (experimental) provider.
24 treatment_percentage: 0–100, percentage of traffic to route to treatment.
25 split_key_field: Request field used to deterministically assign traffic.
26 """
28 control_key: str
29 treatment_key: str
30 treatment_percentage: int = 10
31 split_key_field: str = "user_id"
33 def __post_init__(self) -> None:
34 """Validate percentage range.
36 Raises:
37 ValueError: If treatment_percentage is not in [0, 100].
38 """
39 if not 0 <= self.treatment_percentage <= 100:
40 raise ValueError(
41 f"treatment_percentage must be 0-100, got {self.treatment_percentage}"
42 )
45class ABSplitStrategy:
46 """Routes LLM requests between control and treatment providers using deterministic hashing.
48 Uses MD5 hashing on the split key to ensure consistent assignment:
49 the same user always gets the same variant for a given configuration.
51 Args:
52 config: A/B split configuration.
53 control: The control (baseline) LLM provider.
54 treatment: The treatment (experimental) LLM provider.
55 """
57 def __init__(
58 self,
59 config: ABSplitConfig,
60 control: LLMClientProtocol,
61 treatment: LLMClientProtocol,
62 ) -> None:
63 self._config = config
64 self._control = control
65 self._treatment = treatment
67 def _should_use_treatment(self, request: Any) -> bool:
68 """Deterministically assign request to control or treatment.
70 Hashes ``split_key_field`` from the request (or ``str(request)`` as
71 fallback) to a value in [0, 99], then compares against
72 ``treatment_percentage``.
74 Args:
75 request: The model request (any object with a split_key_field attribute).
77 Returns:
78 True if request should go to treatment, False for control.
79 """
80 split_value = str(getattr(request, self._config.split_key_field, ""))
81 if not split_value:
82 return False
84 hash_bytes = hashlib.md5( # noqa: S324
85 f"{split_value}:{self._config.treatment_key}".encode(),
86 usedforsecurity=False,
87 ).digest()
88 bucket = int.from_bytes(hash_bytes[:4], "big") % 100
89 return bucket < self._config.treatment_percentage
91 async def route(self, request: Any) -> LLMClientProtocol:
92 """Select the appropriate provider for this request.
94 Args:
95 request: The model request to route.
97 Returns:
98 Either the control or treatment LLM provider.
99 """
100 use_treatment = self._should_use_treatment(request)
101 variant = "treatment" if use_treatment else "control"
102 logger.debug("ab_split_routed", variant=variant)
103 return self._treatment if use_treatment else self._control