Coverage for qreward / client / model_router.py: 98.11%

76 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-23 19:32 +0000

1"""Model-based routing for directing requests to specific proxy groups.""" 

2 

3import fnmatch 

4import threading 

5from dataclasses import dataclass, field 

6from typing import Optional 

7 

8from qreward.client.load_balancer import ( 

9 LoadBalanceStrategy, 

10 RoundRobinSelector, 

11 WeightedRoundRobinSelector, 

12) 

13from qreward.client.openai import OpenAIChatProxy 

14 

15 

16@dataclass 

17class ProxyGroup: 

18 """A group of proxies serving a specific model or model pattern. 

19 

20 Attributes: 

21 pattern: Model name or glob pattern (e.g. "gpt-4" or "gpt-*"). 

22 proxies: Mapping of key → proxy instance. 

23 insertion_order: Keys in insertion order for round-robin. 

24 healthy_keys: Set of currently healthy proxy keys. 

25 weights: Per-key weights for weighted round-robin. 

26 strategy: Load balancing strategy for this group. 

27 """ 

28 

29 pattern: str 

30 proxies: dict[str, OpenAIChatProxy] = field(default_factory=dict) 

31 insertion_order: list[str] = field(default_factory=list) 

32 healthy_keys: set[str] = field(default_factory=set) 

33 weights: dict[str, int] = field(default_factory=dict) 

34 strategy: LoadBalanceStrategy = LoadBalanceStrategy.ROUND_ROBIN 

35 rr_selector: RoundRobinSelector = field(default_factory=RoundRobinSelector) 

36 wrr_selector: WeightedRoundRobinSelector = field( 

37 default_factory=WeightedRoundRobinSelector 

38 ) 

39 

40 

41class ModelRouter: 

42 """Routes model names to dedicated proxy groups. 

43 

44 Matching priority: exact match → glob/wildcard match → None (fallback). 

45 

46 Example: 

47 >>> router = ModelRouter() 

48 >>> router.register("gpt-4", group) 

49 >>> router.register("gpt-*", wildcard_group) 

50 >>> router.resolve("gpt-4") # exact match 

51 >>> router.resolve("gpt-3.5") # wildcard match 

52 >>> router.resolve("claude-3") # None (no match) 

53 """ 

54 

55 def __init__(self) -> None: 

56 self._exact: dict[str, ProxyGroup] = {} 

57 self._wildcards: list[ProxyGroup] = [] 

58 self._lock = threading.Lock() 

59 

60 def register( 

61 self, 

62 pattern: str, 

63 proxy_map: dict[str, OpenAIChatProxy], 

64 weights: Optional[dict[str, int]] = None, 

65 strategy: LoadBalanceStrategy = LoadBalanceStrategy.ROUND_ROBIN, 

66 ) -> None: 

67 """Register a proxy group for a model pattern. 

68 

69 Args: 

70 pattern: Exact model name or glob pattern (e.g. "gpt-*"). 

71 proxy_map: Mapping of key → OpenAIChatProxy. 

72 weights: Optional per-key weights for weighted round-robin. 

73 strategy: Load balancing strategy for this group. 

74 """ 

75 group = ProxyGroup( 

76 pattern=pattern, 

77 proxies=dict(proxy_map), 

78 insertion_order=list(proxy_map.keys()), 

79 healthy_keys=set(proxy_map.keys()), 

80 weights=weights or {k: 1 for k in proxy_map}, 

81 strategy=strategy, 

82 ) 

83 group.wrr_selector.update_weights(group.weights) 

84 

85 with self._lock: 

86 if _is_glob_pattern(pattern): 

87 self._wildcards.append(group) 

88 else: 

89 self._exact[pattern] = group 

90 

91 def resolve(self, model: str) -> Optional[ProxyGroup]: 

92 """Find the best matching proxy group for a model name. 

93 

94 Args: 

95 model: The model name to route. 

96 

97 Returns: 

98 Matching ProxyGroup or None if no route matches. 

99 """ 

100 with self._lock: 

101 if model in self._exact: 

102 return self._exact[model] 

103 for group in self._wildcards: 

104 if fnmatch.fnmatch(model, group.pattern): 

105 return group 

106 return None 

107 

108 def select_from_group(self, group: ProxyGroup) -> OpenAIChatProxy: 

109 """Select a proxy from a group using its load balancing strategy. 

110 

111 Args: 

112 group: The proxy group to select from. 

113 

114 Returns: 

115 Selected OpenAIChatProxy instance. 

116 

117 Raises: 

118 RuntimeError: When no healthy proxy is available in the group. 

119 """ 

120 selected_key: Optional[str] = None 

121 

122 if group.strategy == LoadBalanceStrategy.ROUND_ROBIN: 

123 selected_key = group.rr_selector.select( 

124 group.insertion_order, group.healthy_keys 

125 ) 

126 elif group.strategy == LoadBalanceStrategy.WEIGHTED_ROUND_ROBIN: 

127 selected_key = group.wrr_selector.select( 

128 group.insertion_order, group.healthy_keys, group.weights 

129 ) 

130 elif group.strategy == LoadBalanceStrategy.LEAST_CONNECTIONS: 130 ↛ 135line 130 didn't jump to line 135 because the condition on line 130 was always true

131 selected_key = group.rr_selector.select( 

132 group.insertion_order, group.healthy_keys 

133 ) 

134 

135 if selected_key is None: 

136 raise RuntimeError( 

137 f"No healthy proxy in group '{group.pattern}'." 

138 ) 

139 return group.proxies[selected_key] 

140 

141 def mark_unhealthy(self, pattern: str, key: str) -> None: 

142 """Mark a proxy as unhealthy within a specific group. 

143 

144 Args: 

145 pattern: The group pattern. 

146 key: The proxy key within the group. 

147 """ 

148 group = self._find_group(pattern) 

149 if group and key in group.proxies: 

150 group.healthy_keys.discard(key) 

151 

152 def mark_healthy(self, pattern: str, key: str) -> None: 

153 """Mark a proxy as healthy within a specific group. 

154 

155 Args: 

156 pattern: The group pattern. 

157 key: The proxy key within the group. 

158 """ 

159 group = self._find_group(pattern) 

160 if group and key in group.proxies: 

161 group.healthy_keys.add(key) 

162 

163 def list_routes(self) -> list[dict]: 

164 """List all registered routes with their status. 

165 

166 Returns: 

167 List of dicts with pattern, strategy, proxy_count, healthy_count. 

168 """ 

169 routes = [] 

170 with self._lock: 

171 for pattern, group in self._exact.items(): 

172 routes.append(self._group_info(group)) 

173 for group in self._wildcards: 

174 routes.append(self._group_info(group)) 

175 return routes 

176 

177 def _find_group(self, pattern: str) -> Optional[ProxyGroup]: 

178 """Find a group by its exact pattern string.""" 

179 with self._lock: 

180 if pattern in self._exact: 

181 return self._exact[pattern] 

182 for group in self._wildcards: 

183 if group.pattern == pattern: 183 ↛ 182line 183 didn't jump to line 182 because the condition on line 183 was always true

184 return group 

185 return None 

186 

187 @staticmethod 

188 def _group_info(group: ProxyGroup) -> dict: 

189 """Build info dict for a proxy group.""" 

190 return { 

191 "pattern": group.pattern, 

192 "strategy": group.strategy.value, 

193 "proxy_count": len(group.proxies), 

194 "healthy_count": len(group.healthy_keys), 

195 "keys": list(group.proxies.keys()), 

196 } 

197 

198 

199def _is_glob_pattern(pattern: str) -> bool: 

200 """Check if a pattern contains glob wildcards.""" 

201 return any(char in pattern for char in ("*", "?", "["))