Coverage for qreward / client / manager.py: 99.29%
108 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-23 19:32 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-23 19:32 +0000
1import asyncio
2import threading
3from typing import Any, AsyncIterator, Optional
5from qreward.client.load_balancer import (
6 LoadBalanceStrategy,
7 RoundRobinSelector,
8 WeightedRoundRobinSelector,
9)
10from qreward.client.model_router import ModelRouter
11from qreward.client.openai import OpenAIChatProxy
14class OpenAIChatProxyManager:
15 """管理多个 key -> OpenAIChatProxy 的映射。
17 提供代理的增删查改、负载均衡和故障转移功能,支持链式调用。
19 Example:
20 manager = OpenAIChatProxyManager(
21 strategy=LoadBalanceStrategy.ROUND_ROBIN
22 )
23 manager.add_proxy_with_default(
24 "key1", "http://api1.example.com", "sk-xxx"
25 )
26 manager.add_proxy_with_default(
27 "key2", "http://api2.example.com", "sk-yyy"
28 )
29 proxy = manager.select_proxy() # round-robin selection
30 """
32 def __init__(
33 self,
34 strategy: LoadBalanceStrategy = LoadBalanceStrategy.ROUND_ROBIN,
35 ) -> None:
36 self._proxies: dict[str, OpenAIChatProxy] = {}
37 self._insertion_order: list[str] = []
38 self._healthy_keys: set[str] = set()
39 self._weights: dict[str, int] = {}
40 self._strategy = strategy
41 self._lock = asyncio.Lock()
42 self._health_lock = threading.Lock()
44 self._rr_selector = RoundRobinSelector()
45 self._wrr_selector = WeightedRoundRobinSelector()
46 self._model_router: Optional[ModelRouter] = None
48 def add_proxy_with_default(
49 self, key: str, base_url: str, api_key: str
50 ) -> "OpenAIChatProxyManager":
51 """添加一个使用默认配置的代理。
53 Args:
54 key: 代理标识
55 base_url: OpenAI API 基础 URL
56 api_key: OpenAI API 密钥
58 Returns:
59 self,支持链式调用
60 """
61 self.add_proxy(
62 key=key,
63 proxy=OpenAIChatProxy(
64 base_url=base_url,
65 api_key=api_key,
66 ),
67 )
68 return self
70 def add_proxies_with_default(
71 self, proxies: dict[str, tuple[str, str]]
72 ) -> "OpenAIChatProxyManager":
73 """批量添加使用默认配置的代理。
75 Args:
76 proxies: 代理映射,key 为代理标识,value 为 (base_url, api_key) 元组
78 Returns:
79 self,支持链式调用
80 """
81 for key, (base_url, api_key) in proxies.items():
82 self.add_proxy_with_default(
83 key=key,
84 base_url=base_url,
85 api_key=api_key,
86 )
87 return self
89 def add_proxy(
90 self,
91 key: str,
92 proxy: OpenAIChatProxy,
93 weight: int = 1,
94 ) -> "OpenAIChatProxyManager":
95 """添加一个自定义配置的代理。
97 Args:
98 key: 代理标识
99 proxy: OpenAIChatProxy 实例
100 weight: 权重(用于加权轮询策略),默认为 1
102 Returns:
103 self,支持链式调用
105 Raises:
106 ValueError: 当 key 已存在时
107 """
108 if key in self._proxies:
109 raise ValueError(f"Proxy with key={key!r} already exists.")
110 self._proxies[key] = proxy
111 self._insertion_order.append(key)
112 self._weights[key] = max(1, weight)
113 with self._health_lock:
114 self._healthy_keys.add(key)
115 self._wrr_selector.update_weights(self._weights)
116 return self
118 def proxy(self, key: str) -> OpenAIChatProxy:
119 """获取指定 key 的代理。
121 Args:
122 key: 代理标识
124 Returns:
125 对应的 OpenAIChatProxy 实例
127 Raises:
128 KeyError: 当 key 不存在时
129 """
130 try:
131 return self._proxies[key]
132 except KeyError:
133 raise KeyError(f"Proxy with key={key!r} does not exist.")
135 def exist_proxy(self, key: str) -> bool:
136 """检查指定 key 的代理是否存在。
138 Args:
139 key: 代理标识
141 Returns:
142 True 如果存在,否则 False
143 """
144 return key in self._proxies
146 def proxies(self) -> dict[str, OpenAIChatProxy]:
147 """获取所有代理的字典副本。
149 返回副本以防止外部修改内部状态。
151 Returns:
152 key -> OpenAIChatProxy 的映射副本
153 """
154 return dict(self._proxies)
156 def register_model_route(
157 self,
158 pattern: str,
159 proxy_map: dict[str, OpenAIChatProxy],
160 weights: Optional[dict[str, int]] = None,
161 strategy: LoadBalanceStrategy = LoadBalanceStrategy.ROUND_ROBIN,
162 ) -> "OpenAIChatProxyManager":
163 """Register a model route for model-based proxy selection.
165 Args:
166 pattern: Model name or glob pattern (e.g. "gpt-4" or "gpt-*").
167 proxy_map: Mapping of key → OpenAIChatProxy for this model.
168 weights: Optional per-key weights for weighted round-robin.
169 strategy: Load balancing strategy for this model group.
171 Returns:
172 self, for chaining.
173 """
174 if self._model_router is None:
175 self._model_router = ModelRouter()
176 self._model_router.register(pattern, proxy_map, weights, strategy)
177 return self
179 def list_routes(self) -> list[dict]:
180 """List all registered model routes.
182 Returns:
183 List of route info dicts, or empty list if no router configured.
184 """
185 if self._model_router is None:
186 return []
187 return self._model_router.list_routes()
189 def select_proxy(self, model: Optional[str] = None) -> OpenAIChatProxy:
190 """根据负载均衡策略选择一个可用代理。
192 Args:
193 model: Optional model name for model-based routing.
195 Returns:
196 选中的 OpenAIChatProxy 实例。
198 Raises:
199 RuntimeError: 当没有可用的健康代理时。
200 """
201 if model and self._model_router is not None:
202 group = self._model_router.resolve(model)
203 if group is not None:
204 return self._model_router.select_from_group(group)
206 with self._health_lock:
207 healthy_snapshot = set(self._healthy_keys)
209 selected_key: str | None = None
211 if self._strategy == LoadBalanceStrategy.ROUND_ROBIN:
212 selected_key = self._rr_selector.select(
213 self._insertion_order, healthy_snapshot
214 )
215 elif self._strategy == LoadBalanceStrategy.WEIGHTED_ROUND_ROBIN:
216 selected_key = self._wrr_selector.select(
217 self._insertion_order, healthy_snapshot, self._weights
218 )
219 elif self._strategy == LoadBalanceStrategy.LEAST_CONNECTIONS: 219 ↛ 224line 219 didn't jump to line 224 because the condition on line 219 was always true
220 selected_key = self._rr_selector.select(
221 self._insertion_order, healthy_snapshot
222 )
224 if selected_key is None:
225 raise RuntimeError("No healthy proxy available.")
227 return self._proxies[selected_key]
229 def mark_unhealthy(self, key: str) -> None:
230 """标记代理为不健康状态。
232 Args:
233 key: 代理标识。
235 Raises:
236 KeyError: 当 key 不存在时。
237 """
238 if key not in self._proxies:
239 raise KeyError(f"Proxy with key={key!r} does not exist.")
240 with self._health_lock:
241 self._healthy_keys.discard(key)
243 def mark_healthy(self, key: str) -> None:
244 """标记代理为健康状态。
246 Args:
247 key: 代理标识。
249 Raises:
250 KeyError: 当 key 不存在时。
251 """
252 if key not in self._proxies:
253 raise KeyError(f"Proxy with key={key!r} does not exist.")
254 with self._health_lock:
255 self._healthy_keys.add(key)
257 def healthy_proxies(self) -> dict[str, OpenAIChatProxy]:
258 """获取所有健康代理的字典副本。
260 Returns:
261 key -> OpenAIChatProxy 的映射副本(仅健康代理)。
262 """
263 with self._health_lock:
264 healthy_snapshot = set(self._healthy_keys)
265 return {
266 k: v
267 for k, v in self._proxies.items()
268 if k in healthy_snapshot
269 }
271 async def remove_proxy(self, key: str) -> None:
272 """移除并关闭指定 key 的代理。
274 Args:
275 key: 代理标识
276 """
277 async with self._lock:
278 proxy = self._proxies.pop(key, None)
279 if key in self._insertion_order:
280 self._insertion_order.remove(key)
281 self._weights.pop(key, None)
282 with self._health_lock:
283 self._healthy_keys.discard(key)
284 if proxy:
285 await proxy.client.close()
287 async def batch_stream_chat_completion(
288 self,
289 batch_messages: list[list[dict[str, Any]]],
290 model: str,
291 **kwargs: Any,
292 ) -> "AsyncIterator[tuple[int, str]]":
293 """通过负载均衡选择代理并执行批量流式聊天补全。"""
294 proxy = self.select_proxy(model=model)
295 async for item in proxy.batch_stream_chat_completion(
296 batch_messages, model, **kwargs
297 ):
298 yield item
300 async def close(self) -> None:
301 """关闭所有代理并清空映射。"""
302 async with self._lock:
303 proxies_to_close = list(self._proxies.values())
304 self._proxies.clear()
305 self._insertion_order.clear()
306 self._weights.clear()
307 with self._health_lock:
308 self._healthy_keys.clear()
309 for proxy in proxies_to_close:
310 await proxy.client.close()