Skip to content

Commit 5b936c4

Browse files
author
Mateusz
committed
fix: allow legacy mixed weighted aliases
1 parent a8c18eb commit 5b936c4

3 files changed

Lines changed: 138 additions & 1 deletion

File tree

src/core/config/semantic_validation.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
CompositeRoutePlan,
3333
CompositeRoutingInput,
3434
CompositeSelectorValidationError,
35+
CompositeValidationErrorCode,
3536
CompositeWeightedGroupNode,
3637
RoutingSurface,
3738
)
@@ -43,6 +44,7 @@
4344
from src.core.services.composite_selector_parser import CompositeSelectorParser
4445

4546
logger = logging.getLogger(__name__)
47+
_MIXED_OPERATOR_ERROR_MESSAGE = "Composite selector cannot mix failover ('|') and weighted ('^') operators in one string."
4648

4749

4850
class ConfigurationValidator:
@@ -739,6 +741,8 @@ def validate_model_aliases(config: AppConfig) -> None:
739741
available_backends = sorted(registered_backends)
740742
parser = CompositeSelectorParser()
741743

744+
normalized_aliases: list[Any] = []
745+
742746
for idx, alias in enumerate(aliases):
743747
pattern = getattr(alias, "pattern", None)
744748
replacement = getattr(alias, "replacement", None)
@@ -791,10 +795,54 @@ def validate_model_aliases(config: AppConfig) -> None:
791795
surface=RoutingSurface.MAIN,
792796
require_explicit_backend=False,
793797
)
798+
replacement_for_validation = replacement
794799

795800
try:
796801
plan = parser.parse(routing_input)
797802
except CompositeSelectorValidationError as e:
803+
normalized_legacy_selector = _normalize_legacy_mixed_weighted_selector(
804+
replacement
805+
)
806+
if (
807+
normalized_legacy_selector is not None
808+
and _is_mixed_operator_selector_error(e)
809+
):
810+
normalized_input = CompositeRoutingInput(
811+
selector=normalized_legacy_selector,
812+
surface=RoutingSurface.MAIN,
813+
require_explicit_backend=False,
814+
)
815+
try:
816+
plan = parser.parse(normalized_input)
817+
except CompositeSelectorValidationError:
818+
pass
819+
else:
820+
canonical_replacement = plan.normalized_selector
821+
replacement_for_validation = canonical_replacement
822+
if logger.isEnabledFor(logging.WARNING):
823+
logger.warning(
824+
"Model alias at index %s uses legacy mixed separators in weighted replacement. "
825+
"Normalizing replacement for pattern '%s': '%s' -> '%s'",
826+
idx,
827+
pattern,
828+
replacement,
829+
canonical_replacement,
830+
)
831+
normalized_aliases.append(
832+
alias.model_copy(
833+
update={
834+
"replacement": canonical_replacement,
835+
}
836+
)
837+
)
838+
_validate_alias_replacement_backends(
839+
plan,
840+
registered_backends,
841+
replacement_for_validation,
842+
pattern,
843+
available_backends,
844+
)
845+
continue
798846
raise ConfigurationError(
799847
message=(
800848
f"Model alias at index {idx} has invalid replacement syntax: '{replacement}'. "
@@ -812,10 +860,55 @@ def validate_model_aliases(config: AppConfig) -> None:
812860
},
813861
)
814862

863+
normalized_aliases.append(alias)
815864
_validate_alias_replacement_backends(
816865
plan,
817866
registered_backends,
818-
replacement,
867+
replacement_for_validation,
819868
pattern,
820869
available_backends,
821870
)
871+
872+
if normalized_aliases != aliases:
873+
config.model_aliases = normalized_aliases
874+
875+
876+
def _is_mixed_operator_selector_error(error: CompositeSelectorValidationError) -> bool:
877+
return (
878+
error.envelope.code == CompositeValidationErrorCode.UNSUPPORTED_CONSTRUCT
879+
and error.envelope.message == _MIXED_OPERATOR_ERROR_MESSAGE
880+
)
881+
882+
883+
def _normalize_legacy_mixed_weighted_selector(selector: str) -> str | None:
884+
if "[weight=" not in selector:
885+
return None
886+
887+
bracket_depth = 0
888+
saw_caret = False
889+
saw_pipe = False
890+
normalized_chars: list[str] = []
891+
892+
for char in selector:
893+
if char == "[":
894+
bracket_depth += 1
895+
elif char == "]" and bracket_depth > 0:
896+
bracket_depth -= 1
897+
898+
if bracket_depth == 0 and char in {"|", "^"}:
899+
if char == "|":
900+
saw_pipe = True
901+
else:
902+
saw_caret = True
903+
normalized_chars.append("^")
904+
continue
905+
906+
normalized_chars.append(char)
907+
908+
if not (saw_pipe and saw_caret):
909+
return None
910+
911+
normalized = "".join(normalized_chars)
912+
if normalized == selector:
913+
return None
914+
return normalized

tests/regression/test_model_alias_startup_validation_regression.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,28 @@ def test_valid_weighted_alias_with_uri_params_passes(self) -> None:
255255
)
256256
validate_model_aliases(config)
257257

258+
def test_legacy_mixed_weighted_alias_is_canonicalized(self) -> None:
259+
config = AppConfig(
260+
model_aliases=[
261+
ModelAliasRule(
262+
pattern="^alias:gpt-5.3-codex-mixed$",
263+
replacement=(
264+
"openai-codex:gpt-5.3-codex?reasoning_effort=high"
265+
"^[weight=4]openai-codex:gpt-5.3-codex?reasoning_effort=low"
266+
"|[weight=2]openai-codex:gpt-5.3-codex?reasoning_effort=medium"
267+
),
268+
),
269+
],
270+
)
271+
272+
validate_model_aliases(config)
273+
274+
assert config.model_aliases[0].replacement == (
275+
"[weight=1]openai-codex:gpt-5.3-codex?reasoning_effort=high"
276+
"^[weight=4]openai-codex:gpt-5.3-codex?reasoning_effort=low"
277+
"^[weight=2]openai-codex:gpt-5.3-codex?reasoning_effort=medium"
278+
)
279+
258280
def test_valid_failover_alias_like_configtest1_passes(self) -> None:
259281
"""Matches the pattern used in config/configtest1.yaml."""
260282
config = AppConfig(

tests/unit/core/config/test_model_alias_validation.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,28 @@ def test_mixed_operators_raises(self):
158158
== "invalid_alias_replacement_syntax"
159159
)
160160

161+
def test_mixed_weighted_alias_is_normalized_and_passes(self):
162+
config = AppConfig(
163+
model_aliases=[
164+
ModelAliasRule(
165+
pattern="^alias:gpt-5.3-codex-mixed$",
166+
replacement=(
167+
"openai-codex:gpt-5.3-codex?reasoning_effort=high"
168+
"^[weight=4]openai-codex:gpt-5.3-codex?reasoning_effort=low"
169+
"|[weight=2]openai-codex:gpt-5.3-codex?reasoning_effort=medium"
170+
),
171+
),
172+
],
173+
)
174+
175+
validate_model_aliases(config)
176+
177+
assert config.model_aliases[0].replacement == (
178+
"[weight=1]openai-codex:gpt-5.3-codex?reasoning_effort=high"
179+
"^[weight=4]openai-codex:gpt-5.3-codex?reasoning_effort=low"
180+
"^[weight=2]openai-codex:gpt-5.3-codex?reasoning_effort=medium"
181+
)
182+
161183
def test_empty_branch_in_failover_raises(self):
162184
config = AppConfig(
163185
model_aliases=[

0 commit comments

Comments
 (0)