3232 CompositeRoutePlan ,
3333 CompositeRoutingInput ,
3434 CompositeSelectorValidationError ,
35+ CompositeValidationErrorCode ,
3536 CompositeWeightedGroupNode ,
3637 RoutingSurface ,
3738)
4344from src .core .services .composite_selector_parser import CompositeSelectorParser
4445
4546logger = logging .getLogger (__name__ )
47+ _MIXED_OPERATOR_ERROR_MESSAGE = "Composite selector cannot mix failover ('|') and weighted ('^') operators in one string."
4648
4749
4850class 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
0 commit comments