|
8 | 8 | from __future__ import annotations |
9 | 9 |
|
10 | 10 | import logging |
| 11 | +import re |
11 | 12 | from pathlib import Path |
12 | 13 | from typing import Any |
13 | 14 |
|
|
25 | 26 | is_constrained_connector_family, |
26 | 27 | ) |
27 | 28 | 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 | +) |
28 | 38 | from src.core.domain.model_utils import ( |
29 | 39 | has_explicit_backend_selector, |
30 | 40 | parse_model_backend, |
31 | 41 | ) |
32 | 42 | from src.core.services.backend_registry import backend_registry |
| 43 | +from src.core.services.composite_selector_parser import CompositeSelectorParser |
33 | 44 |
|
34 | 45 | logger = logging.getLogger(__name__) |
35 | 46 |
|
@@ -561,3 +572,190 @@ def validate_constrained_backend_instances(config: AppConfig) -> None: |
561 | 572 | ], |
562 | 573 | }, |
563 | 574 | ) |
| 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