Coverage for qreward / client / openai.py: 99.49%
146 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 logging
3import os
4import warnings
5from collections.abc import Callable
6from datetime import datetime
7from typing import (
8 Any,
9 AsyncIterator,
10 Optional,
11 Sequence,
12 Union,
13)
15from aiolimiter import AsyncLimiter
16from httpx import Limits, Timeout, TimeoutException
17from openai import (
18 AsyncOpenAI,
19 APIConnectionError,
20 APIStatusError,
21 APITimeoutError,
22 DefaultAioHttpClient,
23 RateLimitError,
24)
25from openai.types.chat import ChatCompletion
26from tenacity import (
27 retry,
28 retry_if_exception_type,
29 stop_after_attempt,
30 wait_exponential,
31)
33from qreward.client.patch_openai import patch_openai_embeddings
34from qreward.types import RequestHook, ResponseHook
35from qreward.utils import patch_httpx
37logger = logging.getLogger(__name__)
39MAX_RETRIES = 5
40_ERROR_GROUP = (
41 TimeoutError,
42 asyncio.TimeoutError,
43 TimeoutException,
44 RateLimitError,
45 APIStatusError,
46 APITimeoutError,
47 APIConnectionError,
48)
51class OpenAIChatProxy:
52 def __init__(
53 self,
54 base_url: str,
55 *,
56 api_key: str | None = None,
57 debug: bool = False,
58 max_concurrent: int = 64,
59 chat_process_func: Callable | None = None,
60 error_process_func: Callable | None = None,
61 chat_process_fuc: Callable | None = None,
62 error_process_fuc: Callable | None = None,
63 timeout: float | Timeout = None,
64 rate_limiter_bucket_size: float = 50.0,
65 rate_limiter_bucket_period: float = 1.0,
66 httpx_request_hook: RequestHook | None = None,
67 httpx_response_hook: ResponseHook | None = None,
68 is_hack_embedding_method: bool = False,
69 verify_ssl: bool = False,
70 ):
71 """初始化 OpenAI Chat 代理。"""
72 # Lazy patch: apply httpx JSON optimization on first proxy creation
73 patch_httpx()
75 # Handle deprecated parameters
76 if chat_process_fuc is not None:
77 warnings.warn(
78 "chat_process_fuc is deprecated, use "
79 "chat_process_func instead",
80 DeprecationWarning,
81 stacklevel=2,
82 )
83 if chat_process_func is None:
84 chat_process_func = chat_process_fuc
86 if error_process_fuc is not None:
87 warnings.warn(
88 "error_process_fuc is deprecated, use "
89 "error_process_func instead",
90 DeprecationWarning,
91 stacklevel=2,
92 )
93 if error_process_func is None:
94 error_process_func = error_process_fuc
96 self.debug = debug
97 self._max_concurrent = max_concurrent
98 self._api_key = api_key or self.get_openai_key()
99 self.client = AsyncOpenAI(
100 api_key=self._api_key,
101 base_url=base_url,
102 timeout=timeout,
103 max_retries=0,
104 http_client=self._default_http_client(
105 request_hook=httpx_request_hook,
106 response_hook=httpx_response_hook,
107 verify=verify_ssl,
108 ),
109 )
111 self.semaphore = asyncio.Semaphore(value=self._max_concurrent)
113 # default token bucket size is 50 QPS
114 self.rate_limiter = AsyncLimiter(
115 max_rate=rate_limiter_bucket_size,
116 time_period=rate_limiter_bucket_period,
117 )
119 self._default_temperature = 0.0
120 self._default_timeout = 60
121 self._default_chat_process_func = chat_process_func
122 self._default_error_process_func = error_process_func
124 # for hacking embedding
125 self._is_hack_embedding = is_hack_embedding_method
126 if self._is_hack_embedding:
127 patch_openai_embeddings()
129 async def __aenter__(self):
130 return self
132 async def __aexit__(self, exc_type, exc_val, exc_tb):
133 await self.client.close()
135 def _default_http_client(
136 self,
137 request_hook: RequestHook | None = None,
138 response_hook: ResponseHook | None = None,
139 verify: bool = False,
140 ) -> DefaultAioHttpClient:
141 _http_client = DefaultAioHttpClient(
142 verify=verify,
143 # 默认值是 100 / 20,现在需要根据 self._max_concurrent 的值来调整配比
144 limits=Limits(
145 max_connections=self._max_concurrent,
146 max_keepalive_connections=self._max_concurrent,
147 ),
148 )
150 if request_hook and isinstance(request_hook, Callable):
151 _http_client.event_hooks.get("request").append(request_hook)
153 if response_hook and isinstance(response_hook, Callable):
154 _http_client.event_hooks.get("response").append(response_hook)
156 return _http_client
158 @staticmethod
159 def get_openai_key() -> str | None:
160 return os.getenv("OPENAI_API_KEY")
162 def with_max_concurrent(self, max_concurrent: int):
163 self._max_concurrent = max_concurrent
164 return self
166 def with_temperature(self, temperature: float):
167 self._default_temperature = temperature
168 return self
170 def with_timeout(self, timeout: int):
171 self.client.timeout = timeout
172 self._default_timeout = timeout
173 return self
175 def with_error_process_func(self, error_process_func: Callable):
176 self._default_error_process_func = error_process_func
177 return self
179 def with_error_process_fuc(self, error_process_fuc: Callable):
180 warnings.warn(
181 "with_error_process_fuc is deprecated, use "
182 "with_error_process_func instead",
183 DeprecationWarning,
184 stacklevel=2,
185 )
186 return self.with_error_process_func(error_process_fuc)
188 @staticmethod
189 def _default_chat_completion_func(completion: ChatCompletion):
190 return completion.choices[0].message.content
192 @retry(
193 retry=retry_if_exception_type(exception_types=_ERROR_GROUP),
194 wait=wait_exponential(multiplier=1, min=2, max=4),
195 stop=stop_after_attempt(max_attempt_number=MAX_RETRIES),
196 reraise=True,
197 )
198 async def chat_completion(
199 self,
200 messages: list[dict[str, Any]],
201 model: str,
202 **kwargs: Any,
203 ) -> str:
204 """调用 OpenAI Chat 接口。"""
205 if self.debug:
206 logger.debug(
207 "[time: %s] - [Begin] - Call model: %s",
208 datetime.now(),
209 model,
210 )
212 async with self.semaphore, self.rate_limiter:
213 completion: ChatCompletion = await asyncio.wait_for(
214 self.client.chat.completions.create(
215 model=model,
216 messages=messages,
217 temperature=kwargs.get(
218 "temperature",
219 self._default_temperature,
220 ),
221 stream=kwargs.get("stream"),
222 ),
223 timeout=kwargs.get("timeout", self._default_timeout),
224 )
225 if self.debug:
226 logger.debug(
227 "[time: %s] - [End] - Call model: %s success!",
228 datetime.now(),
229 model,
230 )
232 if self._default_chat_process_func:
233 completion = self._default_chat_process_func(completion)
235 return self._default_chat_completion_func(completion=completion)
237 async def batch_chat_completion(
238 self,
239 batch_messages: list[list[dict[str, Any]]],
240 model: str,
241 **kwargs: Any,
242 ) -> list[Any]:
243 """批量调用 OpenAI Chat 接口。"""
244 tasks = []
245 for i in range(len(batch_messages)):
246 tasks.append(
247 asyncio.create_task(
248 self.chat_completion(
249 model=model,
250 messages=batch_messages[i],
251 **kwargs,
252 ),
253 ),
254 )
256 results = await asyncio.gather(*tasks, return_exceptions=True)
258 processed_results = []
259 for index, result in enumerate(results):
260 if isinstance(result, Exception):
261 logger.warning(
262 "batch_chat_completion task %d/%d failed: %s: %s",
263 index + 1,
264 len(results),
265 type(result).__name__,
266 result,
267 )
268 if self._default_error_process_func:
269 result = self._default_error_process_func(result)
271 processed_results.append(result)
273 return processed_results
275 async def stream_chat_completion(
276 self,
277 messages: list[dict[str, Any]],
278 model: str,
279 **kwargs: Any,
280 ) -> AsyncIterator[str]:
281 """流式聊天补全,逐 token 返回内容。
283 Args:
284 messages: 消息列表
285 model: 使用的模型
286 **kwargs: 其他参数,支持 temperature / timeout 等
288 Yields:
289 每个 chunk 的文本内容
290 """
291 if self.debug:
292 logger.debug(
293 "[time: %s] - [Begin] - Stream model: %s",
294 datetime.now(),
295 model,
296 )
298 async with self.semaphore, self.rate_limiter:
299 stream = await self.client.chat.completions.create(
300 model=model,
301 messages=messages,
302 temperature=kwargs.get(
303 "temperature", self._default_temperature
304 ),
305 stream=True,
306 timeout=kwargs.get("timeout", self._default_timeout),
307 )
308 try:
309 async for chunk in stream:
310 if (
311 chunk.choices
312 and chunk.choices[0].delta.content is not None
313 ):
314 yield chunk.choices[0].delta.content
315 finally:
316 await stream.close()
318 if self.debug:
319 logger.debug(
320 "[time: %s] - [End] - Stream model: %s done!",
321 datetime.now(),
322 model,
323 )
325 @retry(
326 retry=retry_if_exception_type(exception_types=_ERROR_GROUP),
327 wait=wait_exponential(multiplier=1, min=2, max=4),
328 stop=stop_after_attempt(max_attempt_number=MAX_RETRIES),
329 reraise=True,
330 )
331 async def embeddings(
332 self,
333 *,
334 sentences: Optional[Union[str, Sequence]] = None,
335 model: str | None = None,
336 extra_body: object | None = None,
337 **kwargs: Any,
338 ) -> list[Any]:
339 """调用 OpenAI Embeddings 接口。"""
340 if self.debug:
341 logger.debug(
342 "[time: %s] - [Begin] - Call embedding: %s",
343 datetime.now(),
344 model,
345 )
347 embedding_resp = await asyncio.wait_for(
348 self.client.embeddings.create(
349 input=sentences,
350 model=model,
351 extra_body=extra_body,
352 ),
353 timeout=kwargs.get("timeout", self._default_timeout),
354 )
356 if self.debug:
357 logger.debug(
358 "[time: %s] - [End] - Call embedding: %s success!",
359 datetime.now(),
360 model,
361 )
363 return (
364 embedding_resp.embeddings
365 if self._is_hack_embedding
366 else embedding_resp.data
367 )
369 async def batch_stream_chat_completion(
370 self,
371 batch_messages: list[list[dict[str, Any]]],
372 model: str,
373 max_concurrent_streams: int = 0,
374 on_stream_error: Callable[[int, Exception], None] | None = None,
375 **kwargs: Any,
376 ) -> AsyncIterator[tuple[int, str]]:
377 """并发多个流式请求,yield (batch_index, token) 元组。"""
378 if not batch_messages:
379 return
380 queue: asyncio.Queue[tuple[int, str] | None] = asyncio.Queue()
381 sem = (
382 asyncio.Semaphore(max_concurrent_streams)
383 if max_concurrent_streams > 0
384 else None
385 )
387 async def _run(idx: int, msgs: list[dict[str, Any]]) -> None:
388 try:
389 if sem:
390 await sem.acquire()
391 async for tok in self.stream_chat_completion(
392 msgs, model, **kwargs
393 ):
394 await queue.put((idx, tok))
395 except Exception as exc:
396 if on_stream_error: 396 ↛ 399line 396 didn't jump to line 399 because the condition on line 396 was always true
397 on_stream_error(idx, exc)
398 finally:
399 if sem:
400 sem.release()
402 tasks = [
403 asyncio.create_task(_run(i, m))
404 for i, m in enumerate(batch_messages)
405 ]
407 async def _drain() -> None:
408 await asyncio.gather(*tasks, return_exceptions=True)
409 await queue.put(None)
411 sentinel = asyncio.create_task(_drain())
412 while (item := await queue.get()) is not None:
413 yield item
414 await sentinel
416 async def batch_embeddings(
417 self,
418 *,
419 batch_sentences: list[Union[str, Sequence]],
420 model: str | None = None,
421 extra_bodies: list[object] | None = None,
422 **kwargs: Any,
423 ) -> list[list[Any]]:
424 """批量调用 OpenAI Embeddings 接口。"""
425 tasks = [
426 asyncio.create_task(
427 self.embeddings(
428 model=model,
429 sentences=batch_sentences[i],
430 extra_body=extra_bodies[i] if extra_bodies else None,
431 **kwargs,
432 )
433 )
434 for i in range(len(batch_sentences))
435 ]
436 return await asyncio.gather(*tasks)