Skip to content

Commit e151a7c

Browse files
author
Mateusz
committed
feat: validate model alias replacements at startup with deep syntax and backend checks
Model aliases previously had only YAML syntax validation. This adds startup-time validation of alias replacement routing strings using CompositeSelectorParser to enforce composite grammar (|, ^, [weight=N]), and verifies explicit backend names against the discovered backend registry. Startup now aborts loudly with ConfigurationError on: - Invalid regex patterns in alias definitions - Invalid composite routing syntax in replacement strings - Unknown backend names in explicit backend:model leaf selectors - Empty backend or model segments in explicit selectors Validation runs after backend discovery and before DI config registration so that the backend registry is fully populated.
1 parent 505d049 commit e151a7c

5 files changed

Lines changed: 785 additions & 0 deletions

File tree

src/core/app/application_builder.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,12 +328,14 @@ async def build(self, config: AppConfig) -> FastAPI:
328328
from src.core.config.semantic_validation import (
329329
validate_constrained_backend_instances,
330330
validate_extracted_backend_references,
331+
validate_model_aliases,
331332
validate_static_route,
332333
)
333334

334335
validate_static_route(config)
335336
validate_extracted_backend_references(config)
336337
validate_constrained_backend_instances(config)
338+
validate_model_aliases(config)
337339

338340
# Replace DI-registered AppConfig and IConfig with runtime config instance
339341
# This ensures validation services see the same config that the builder was given

src/core/config/semantic_validation.py

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from __future__ import annotations
99

1010
import logging
11+
import re
1112
from pathlib import Path
1213
from typing import Any
1314

@@ -25,11 +26,21 @@
2526
is_constrained_connector_family,
2627
)
2728
from src.core.config.models.backends import BackendConfig, BackendSettings
29+
from src.core.domain.composite_routing import (
30+
CompositeFailoverGroupNode,
31+
CompositeLeafNode,
32+
CompositeRoutePlan,
33+
CompositeRoutingInput,
34+
CompositeSelectorValidationError,
35+
CompositeWeightedGroupNode,
36+
RoutingSurface,
37+
)
2838
from src.core.domain.model_utils import (
2939
has_explicit_backend_selector,
3040
parse_model_backend,
3141
)
3242
from src.core.services.backend_registry import backend_registry
43+
from src.core.services.composite_selector_parser import CompositeSelectorParser
3344

3445
logger = logging.getLogger(__name__)
3546

@@ -561,3 +572,190 @@ def validate_constrained_backend_instances(config: AppConfig) -> None:
561572
],
562573
},
563574
)
575+
576+
577+
def _validate_alias_replacement_backends(
578+
plan: CompositeRoutePlan,
579+
registered_backends: set[str],
580+
replacement: str,
581+
alias_pattern: str,
582+
available_backends: list[str],
583+
) -> None:
584+
def _collect_leaf_nodes(node):
585+
if isinstance(node, CompositeLeafNode):
586+
return [node]
587+
if isinstance(node, CompositeFailoverGroupNode | CompositeWeightedGroupNode):
588+
leaves: list[CompositeLeafNode] = []
589+
for child in node.children:
590+
leaves.extend(_collect_leaf_nodes(child))
591+
return leaves
592+
return []
593+
594+
leaf_nodes = _collect_leaf_nodes(plan.root_node)
595+
596+
for leaf_node in leaf_nodes:
597+
leaf = leaf_node.leaf_selector
598+
normalized = leaf.normalized_selector
599+
600+
if not has_explicit_backend_selector(normalized):
601+
continue
602+
603+
parsed = parse_model_backend(normalized, "")
604+
backend_name = parsed.backend_type.strip()
605+
606+
if not backend_name:
607+
raise ConfigurationError(
608+
message=(
609+
f"Model alias replacement has empty backend name in branch '{normalized}'. "
610+
f"Alias pattern: '{alias_pattern}', replacement: '{replacement}'. "
611+
f"Explicit backend selectors must specify a non-empty backend."
612+
),
613+
details={
614+
"error_code": "invalid_alias_backend",
615+
"alias_pattern": alias_pattern,
616+
"replacement": replacement,
617+
"failing_branch": normalized,
618+
"reason": "empty_backend_name",
619+
},
620+
)
621+
622+
if not parsed.model_name.strip():
623+
raise ConfigurationError(
624+
message=(
625+
f"Model alias replacement has empty model name in branch '{normalized}'. "
626+
f"Alias pattern: '{alias_pattern}', replacement: '{replacement}'. "
627+
f"Explicit backend selectors must specify a non-empty model."
628+
),
629+
details={
630+
"error_code": "invalid_alias_backend",
631+
"alias_pattern": alias_pattern,
632+
"replacement": replacement,
633+
"failing_branch": normalized,
634+
"reason": "empty_model_name",
635+
},
636+
)
637+
638+
if backend_name not in registered_backends and not is_extracted_backend_name(
639+
backend_name
640+
):
641+
raise ConfigurationError(
642+
message=(
643+
f"Model alias replacement references unknown backend '{backend_name}' "
644+
f"in branch '{normalized}'. "
645+
f"Alias pattern: '{alias_pattern}', replacement: '{replacement}'. "
646+
f"Available backends: {', '.join(available_backends)}"
647+
),
648+
details={
649+
"error_code": "unknown_alias_backend",
650+
"alias_pattern": alias_pattern,
651+
"replacement": replacement,
652+
"failing_branch": normalized,
653+
"invalid_backend": backend_name,
654+
"available_backends": available_backends,
655+
},
656+
)
657+
658+
659+
def validate_model_aliases(config: AppConfig) -> None:
660+
"""Validate model alias patterns and replacement routing strings at startup.
661+
662+
Runs after backend discovery so that explicit backend names in replacement
663+
strings can be verified against the registered backend registry.
664+
665+
Validates:
666+
- Regex pattern syntax is valid.
667+
- Replacement string is valid composite routing grammar (|, ^, [weight=N]).
668+
- No raw separator characters in query-param values.
669+
- Explicit backend names reference registered backends.
670+
671+
Raises:
672+
ConfigurationError: If any alias fails validation.
673+
"""
674+
aliases = getattr(config, "model_aliases", [])
675+
if not aliases:
676+
return
677+
678+
registered_backends = set(backend_registry.get_registered_backends())
679+
available_backends = sorted(registered_backends)
680+
parser = CompositeSelectorParser()
681+
682+
for idx, alias in enumerate(aliases):
683+
pattern = getattr(alias, "pattern", None)
684+
replacement = getattr(alias, "replacement", None)
685+
686+
if not pattern:
687+
raise ConfigurationError(
688+
message=(
689+
f"Model alias at index {idx} has empty pattern. "
690+
f"Each alias must define a non-empty regex pattern."
691+
),
692+
details={
693+
"error_code": "invalid_alias_pattern",
694+
"alias_index": idx,
695+
"reason": "empty_pattern",
696+
},
697+
)
698+
699+
if not replacement:
700+
raise ConfigurationError(
701+
message=(
702+
f"Model alias at index {idx} has empty replacement. "
703+
f"Each alias must define a non-empty replacement string."
704+
),
705+
details={
706+
"error_code": "invalid_alias_replacement",
707+
"alias_index": idx,
708+
"alias_pattern": pattern,
709+
"reason": "empty_replacement",
710+
},
711+
)
712+
713+
try:
714+
re.compile(pattern)
715+
except re.error as e:
716+
raise ConfigurationError(
717+
message=(
718+
f"Model alias at index {idx} has invalid regex pattern: '{pattern}'. "
719+
f"Error: {e}"
720+
),
721+
details={
722+
"error_code": "invalid_alias_regex",
723+
"alias_index": idx,
724+
"alias_pattern": pattern,
725+
"regex_error": str(e),
726+
},
727+
)
728+
729+
routing_input = CompositeRoutingInput(
730+
selector=replacement,
731+
surface=RoutingSurface.MAIN,
732+
require_explicit_backend=False,
733+
)
734+
735+
try:
736+
plan = parser.parse(routing_input)
737+
except CompositeSelectorValidationError as e:
738+
raise ConfigurationError(
739+
message=(
740+
f"Model alias at index {idx} has invalid replacement syntax: '{replacement}'. "
741+
f"Alias pattern: '{pattern}'. "
742+
f"Error: {e.message}. "
743+
f"Note: raw separator characters (^, |) in query values must be URL-encoded "
744+
f"(use %5E for ^, %7C for |)."
745+
),
746+
details={
747+
"error_code": "invalid_alias_replacement_syntax",
748+
"alias_index": idx,
749+
"alias_pattern": pattern,
750+
"replacement": replacement,
751+
"parser_error": e.envelope.message,
752+
},
753+
)
754+
755+
_validate_alias_replacement_backends(
756+
plan,
757+
registered_backends,
758+
replacement,
759+
pattern,
760+
available_backends,
761+
)

0 commit comments

Comments
 (0)