diff --git a/README.md b/README.md index 6190b4e..02b6ab4 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,3 @@ -

-

- English -

-

- - - - # DashScope Python SDK The DashScope Python SDK provides a comprehensive interface to [Alibaba Cloud Model Studio (Bailian)](https://www.alibabacloud.com/help/en/model-studio/) APIs, covering text generation, multi-modal understanding, embeddings, reranking, image/video generation, speech synthesis & recognition, and more. diff --git a/dashscope/__init__.py b/dashscope/__init__.py index 637d5de..672f24c 100644 --- a/dashscope/__init__.py +++ b/dashscope/__init__.py @@ -21,6 +21,7 @@ HttpSpeechSynthesizer, ) from dashscope.audio.tts.speech_synthesizer import SpeechSynthesizer +from dashscope.api_entities.aio_session import close_shared_aio_session from dashscope.common.api_key import save_api_key from dashscope.common.env import ( api_key, @@ -46,7 +47,7 @@ from dashscope.files import Files from dashscope.models import Models from dashscope.nlp.understanding import Understanding -from dashscope.rerank.text_rerank import TextReRank +from dashscope.rerank import AioTextReRank, TextReRank from dashscope.threads import ( MessageFile, Messages, @@ -75,6 +76,7 @@ "api_key", "api_key_file_path", "save_api_key", + "close_shared_aio_session", "AioGeneration", "Conversation", "Generation", @@ -106,6 +108,7 @@ "list_tokenizers", "Application", "TextReRank", + "AioTextReRank", "Assistants", "Threads", "Messages", diff --git a/dashscope/aigc/generation.py b/dashscope/aigc/generation.py index 6ec2a39..054a4f4 100644 --- a/dashscope/aigc/generation.py +++ b/dashscope/aigc/generation.py @@ -179,11 +179,13 @@ def call( # pylint: disable=arguments-renamed,too-many-branches,too-many-statem to_merge_incremental_output = True parameters["incremental_output"] = True - # Pass incremental_to_full flag via headers user-agent - if "headers" not in parameters: - parameters["headers"] = {} + # Pass incremental_to_full flag via user_agent parameter flag = "1" if to_merge_incremental_output else "0" - parameters["headers"]["user-agent"] = f"incremental_to_full/{flag}" + existing_ua = parameters.get("user_agent", "") + new_ua = f"incremental_to_full/{flag}" + parameters["user_agent"] = ( + f"{existing_ua}; {new_ua}".strip() if existing_ua else new_ua + ) response = super().call( model=model, @@ -434,11 +436,13 @@ async def call( # type: ignore[override] # pylint: disable=arguments-renamed,to to_merge_incremental_output = True parameters["incremental_output"] = True - # Pass incremental_to_full flag via headers user-agent - if "headers" not in parameters: - parameters["headers"] = {} + # Pass incremental_to_full flag via user_agent parameter flag = "1" if to_merge_incremental_output else "0" - parameters["headers"]["user-agent"] = f"incremental_to_full/{flag}" + existing_ua = parameters.get("user_agent", "") + new_ua = f"incremental_to_full/{flag}" + parameters["user_agent"] = ( + f"{existing_ua}; {new_ua}".strip() if existing_ua else new_ua + ) response = await super().call( model=model, diff --git a/dashscope/aigc/image_generation.py b/dashscope/aigc/image_generation.py index 7dbfbf0..b830495 100644 --- a/dashscope/aigc/image_generation.py +++ b/dashscope/aigc/image_generation.py @@ -99,14 +99,15 @@ def call( # type: ignore[override] to_merge_incremental_output = True kwargs["incremental_output"] = True - # Pass incremental_to_full flag via headers user-agent - if "headers" not in kwargs: - kwargs["headers"] = {} - + # Pass incremental_to_full flag via user_agent parameter flag = "1" if to_merge_incremental_output else "0" - kwargs["headers"]["user-agent"] = f"incremental_to_full/{flag}" + existing_ua = kwargs.get("user_agent", "") + new_ua = f"incremental_to_full/{flag}" + kwargs["user_agent"] = ( + f"{existing_ua}; {new_ua}".strip() if existing_ua else new_ua + ) if kwargs.get("is_async", False): - kwargs["headers"]["X-DashScope-Async"] = "enable" + kwargs.setdefault("headers", {})["X-DashScope-Async"] = "enable" task = cls.async_task else: task = cls.sync_task @@ -177,6 +178,7 @@ def wait( task: Union[str, ImageGenerationResponse], # type: ignore[override] api_key: str = None, workspace: str = None, + wait_timeout: int = -1, **kwargs, ) -> DashScopeAPIResponse: """Wait for image(s) synthesis task to complete, and return the result. @@ -186,11 +188,18 @@ def wait( ImageGenerationResponse return by async_call(). api_key (str, optional): The api api_key. Defaults to None. workspace (str): The dashscope workspace id. + wait_timeout (int, optional): The maximum seconds to wait. + Default is -1 (no timeout). Returns: DashScopeAPIResponse: The task result. """ - response = super().wait(task, api_key, workspace=workspace) + response = super().wait( + task, + api_key, + workspace=workspace, + wait_timeout=wait_timeout, + ) return ImageGenerationResponse.from_api_response(response) @classmethod @@ -408,14 +417,15 @@ async def call( # type: ignore[override] to_merge_incremental_output = True kwargs["incremental_output"] = True - # Pass incremental_to_full flag via headers user-agent - if "headers" not in kwargs: - kwargs["headers"] = {} - + # Pass incremental_to_full flag via user_agent parameter flag = "1" if to_merge_incremental_output else "0" - kwargs["headers"]["user-agent"] = f"incremental_to_full/{flag}" + existing_ua = kwargs.get("user_agent", "") + new_ua = f"incremental_to_full/{flag}" + kwargs["user_agent"] = ( + f"{existing_ua}; {new_ua}".strip() if existing_ua else new_ua + ) if kwargs.get("is_async", False): - kwargs["headers"]["X-DashScope-Async"] = "enable" + kwargs.setdefault("headers", {})["X-DashScope-Async"] = "enable" task = cls.async_task else: task = cls.sync_task @@ -487,6 +497,7 @@ async def wait( task: Union[str, ImageGenerationResponse], # type: ignore[override] api_key: str = None, workspace: str = None, + wait_timeout: int = -1, **kwargs, ) -> DashScopeAPIResponse: """Wait for image(s) synthesis task to complete, and return the result. @@ -496,11 +507,18 @@ async def wait( ImageGenerationResponse return by async_call(). api_key (str, optional): The api api_key. Defaults to None. workspace (str): The dashscope workspace id. + wait_timeout (int, optional): The maximum seconds to wait. + Default is -1 (no timeout). Returns: DashScopeAPIResponse: The task result. """ - response = await super().wait(task, api_key, workspace=workspace) + response = await super().wait( + task, + api_key, + workspace=workspace, + wait_timeout=wait_timeout, + ) return ImageGenerationResponse.from_api_response(response) @classmethod diff --git a/dashscope/aigc/image_synthesis.py b/dashscope/aigc/image_synthesis.py index 41bd060..b9843b1 100644 --- a/dashscope/aigc/image_synthesis.py +++ b/dashscope/aigc/image_synthesis.py @@ -261,16 +261,16 @@ def _get_input( # pylint: disable=too-many-branches kwargs["headers"] = headers def __get_i2i_task(task, model) -> str: - # 处理task参数:优先使用有效的task值 + # Handle task parameter: prefer valid task value if task is not None and task != "": return task - # 根据model确定任务类型 + # Determine task type based on model if model is not None and model != "": if "imageedit" in model or "wan2.5-i2i" in model: return "image2image" - # 默认返回文本到图像任务 + # Default to text-to-image task return ImageSynthesis.task task = __get_i2i_task(task, model) @@ -387,6 +387,7 @@ def wait( # type: ignore[override] task: Union[str, ImageSynthesisResponse], api_key: str = None, workspace: str = None, + **kwargs, ) -> ImageSynthesisResponse: """Wait for image(s) synthesis task to complete, and return the result. @@ -399,7 +400,12 @@ def wait( # type: ignore[override] Returns: ImageSynthesisResponse: The task result. """ - response = super().wait(task, api_key, workspace=workspace) + response = super().wait( + task, + api_key, + workspace=workspace, + **kwargs, + ) return ImageSynthesisResponse.from_api_response(response) @classmethod @@ -733,6 +739,7 @@ async def wait( task: Union[str, ImageSynthesisResponse], # type: ignore[override] api_key: str = None, workspace: str = None, + wait_timeout: int = -1, **kwargs, ) -> ImageSynthesisResponse: """Wait for image(s) synthesis task to complete, and return the result. @@ -742,11 +749,18 @@ async def wait( ImageSynthesisResponse return by async_call(). api_key (str, optional): The api api_key. Defaults to None. workspace (str): The dashscope workspace id. + wait_timeout (int, optional): The maximum seconds to wait. + Default is -1 (no timeout). Returns: ImageSynthesisResponse: The task result. """ - response = await super().wait(task, api_key, workspace=workspace) + response = await super().wait( + task, + api_key, + workspace=workspace, + wait_timeout=wait_timeout, + ) return ImageSynthesisResponse.from_api_response(response) @classmethod diff --git a/dashscope/aigc/multimodal_conversation.py b/dashscope/aigc/multimodal_conversation.py index cac2141..8243e77 100644 --- a/dashscope/aigc/multimodal_conversation.py +++ b/dashscope/aigc/multimodal_conversation.py @@ -163,11 +163,13 @@ def call( # pylint: disable=arguments-renamed,too-many-branches,too-many-statem to_merge_incremental_output = True kwargs["incremental_output"] = True - # Pass incremental_to_full flag via headers user-agent - if "headers" not in kwargs: - kwargs["headers"] = {} + # Pass incremental_to_full flag via user_agent parameter flag = "1" if to_merge_incremental_output else "0" - kwargs["headers"]["user-agent"] = f"incremental_to_full/{flag}" + existing_ua = kwargs.get("user_agent", "") + new_ua = f"incremental_to_full/{flag}" + kwargs["user_agent"] = ( + f"{existing_ua}; {new_ua}".strip() if existing_ua else new_ua + ) response = super().call( model=model, diff --git a/dashscope/aigc/video_synthesis.py b/dashscope/aigc/video_synthesis.py index 0bc5032..f13c155 100644 --- a/dashscope/aigc/video_synthesis.py +++ b/dashscope/aigc/video_synthesis.py @@ -509,6 +509,7 @@ def wait( # type: ignore[override] task: Union[str, VideoSynthesisResponse], api_key: str = None, workspace: str = None, + **kwargs, ) -> VideoSynthesisResponse: """Wait for video synthesis task to complete, and return the result. @@ -521,7 +522,12 @@ def wait( # type: ignore[override] Returns: VideoSynthesisResponse: The task result. """ - response = super().wait(task, api_key, workspace=workspace) + response = super().wait( + task, + api_key, + workspace=workspace, + **kwargs, + ) return VideoSynthesisResponse.from_api_response(response) @classmethod @@ -888,6 +894,7 @@ async def wait( task: Union[str, VideoSynthesisResponse], # type: ignore[override] api_key: str = None, workspace: str = None, + wait_timeout: int = -1, **kwargs, ) -> VideoSynthesisResponse: """Wait for video synthesis task to complete, and return the result. @@ -897,11 +904,18 @@ async def wait( VideoSynthesisResponse return by async_call(). api_key (str, optional): The api api_key. Defaults to None. workspace (str): The dashscope workspace id. + wait_timeout (int, optional): The maximum seconds to wait. + Default is -1 (no timeout). Returns: VideoSynthesisResponse: The task result. """ - response = await super().wait(task, api_key, workspace=workspace) + response = await super().wait( + task, + api_key, + workspace=workspace, + wait_timeout=wait_timeout, + ) return VideoSynthesisResponse.from_api_response(response) @classmethod diff --git a/dashscope/api_entities/aio_session.py b/dashscope/api_entities/aio_session.py new file mode 100644 index 0000000..2757f3c --- /dev/null +++ b/dashscope/api_entities/aio_session.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. +"""Shared aiohttp session pool with cached SSL context. + +Provides connection reuse across async API calls. Each event loop gets +its own ClientSession (aiohttp sessions are loop-bound). The SSL context +is created once and shared across all sessions. +""" +import asyncio +import ssl +import threading +import weakref +from typing import Optional + +import aiohttp +import certifi + +_shared_ssl_context: Optional[ssl.SSLContext] = None +_aio_sessions: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary() +_lock = threading.RLock() + + +def get_ssl_context() -> ssl.SSLContext: + global _shared_ssl_context + with _lock: + if _shared_ssl_context is None: + _shared_ssl_context = ssl.create_default_context( + cafile=certifi.where(), + ) + return _shared_ssl_context + + +async def get_shared_aio_session() -> aiohttp.ClientSession: + """Return a shared aiohttp.ClientSession bound to the running event loop. + + The session is lazily created on first use and reused for all + subsequent calls on the same event loop. Connection pooling (keep-alive) + is handled by the underlying TCPConnector. + """ + loop = asyncio.get_running_loop() + + with _lock: + session = _aio_sessions.get(loop) + if session is not None and not session.closed: + return session + + connector = aiohttp.TCPConnector(ssl=get_ssl_context()) + session = aiohttp.ClientSession(connector=connector, trust_env=True) + _aio_sessions[loop] = session + return session + + +async def close_shared_aio_session() -> None: + """Close the shared session for the current event loop.""" + loop = asyncio.get_running_loop() + with _lock: + session = _aio_sessions.pop(loop, None) + if session is not None and not session.closed: + await session.close() diff --git a/dashscope/api_entities/aiohttp_request.py b/dashscope/api_entities/aiohttp_request.py index 75b3965..e5487b5 100644 --- a/dashscope/api_entities/aiohttp_request.py +++ b/dashscope/api_entities/aiohttp_request.py @@ -3,9 +3,11 @@ import json from http import HTTPStatus +from typing import Optional import aiohttp +from dashscope.api_entities.aio_session import get_shared_aio_session from dashscope.api_entities.base_request import AioBaseRequest from dashscope.api_entities.dashscope_response import DashScopeAPIResponse from dashscope.common.constants import ( @@ -30,6 +32,7 @@ def __init__( timeout: int = DEFAULT_REQUEST_TIMEOUT_SECONDS, task_id: str = None, user_agent: str = "", + session: Optional[aiohttp.ClientSession] = None, ) -> None: """HttpSSERequest, processing http server sent event stream. @@ -38,20 +41,26 @@ def __init__( api_key (str): The api key. method (str): The http method(GET|POST). stream (bool, optional): Is stream request. Defaults to True. - timeout (int, optional): Total request timeout. + timeout (int, optional): Request timeout in seconds. For streaming + requests, this is the idle timeout between chunks (sock_read); + for non-streaming requests, this is the total request timeout. Defaults to DEFAULT_REQUEST_TIMEOUT_SECONDS. user_agent (str, optional): Additional user agent string to append. Defaults to ''. + session (aiohttp.ClientSession, optional): External aiohttp + session to use instead of the shared session. The caller is + responsible for closing it. Defaults to None. """ super().__init__(user_agent=user_agent) self.url = url self.async_request = async_request + self._external_aio_session = session self.headers = { "Accept": "application/json", "Authorization": f"Bearer {api_key}", "Cache-Control": "no-cache", - **self.headers, + **self.headers, # type: ignore[has-type] } self.query = query if self.async_request and self.query is False: @@ -244,21 +253,37 @@ async def _handle_response( # pylint: disable=too-many-branches message=msg.decode("utf-8"), ) + # pylint: disable=too-many-branches async def _handle_request(self): try: - async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=self.timeout), - headers=self.headers, - ) as session: + if self._external_aio_session is not None: + session = self._external_aio_session + should_close = False + else: + session = await get_shared_aio_session() + should_close = False + + if self.stream: + request_timeout = aiohttp.ClientTimeout( + total=None, + sock_read=self.timeout, + ) + else: + request_timeout = aiohttp.ClientTimeout(total=self.timeout) + + try: logger.debug("Starting request: %s", self.url) if self.method == HTTPMethod.POST: - is_form, obj = self.data.get_aiohttp_payload() + is_form, obj = False, {} + if hasattr(self, "data") and self.data is not None: + is_form, obj = self.data.get_aiohttp_payload() if is_form: headers = {**self.headers, **obj.headers} response = await session.post( url=self.url, data=obj, headers=headers, + timeout=request_timeout, ) else: response = await session.request( @@ -266,12 +291,17 @@ async def _handle_request(self): url=self.url, json=obj, headers=self.headers, + timeout=request_timeout, ) elif self.method == HTTPMethod.GET: + params = {} + if hasattr(self, "data") and self.data is not None: + params = getattr(self.data, "parameters", {}) response = await session.get( url=self.url, - params=self.data.parameters, + params=params, headers=self.headers, + timeout=request_timeout, ) else: raise UnsupportedHTTPMethod( @@ -281,9 +311,9 @@ async def _handle_request(self): async with response: async for rsp in self._handle_response(response): yield rsp - except aiohttp.ClientConnectorError as e: - logger.error(e) - raise e + finally: + if should_close: + await session.close() except Exception as e: - logger.error(e) + logger.debug(e) raise e diff --git a/dashscope/api_entities/api_request_data.py b/dashscope/api_entities/api_request_data.py index 37a8593..aee5664 100644 --- a/dashscope/api_entities/api_request_data.py +++ b/dashscope/api_entities/api_request_data.py @@ -157,7 +157,12 @@ def get_batch_binary_data(self) -> bytes: # type: ignore[return] return content def _only_parameters(self) -> str: + temp_input = None + if "raw_input" in self.parameters: + temp_input = self.parameters.pop("raw_input") obj = {"model": self.model, "parameters": self.parameters, "input": {}} + if temp_input is not None: + obj["input"] = temp_input if self.task is not None: obj["task"] = self.task if self.task_group is not None: diff --git a/dashscope/api_entities/api_request_factory.py b/dashscope/api_entities/api_request_factory.py index cc58479..c231287 100644 --- a/dashscope/api_entities/api_request_factory.py +++ b/dashscope/api_entities/api_request_factory.py @@ -38,10 +38,16 @@ def _get_protocol_params(kwargs): extra_url_parameters = kwargs.pop("extra_url_parameters", None) session = kwargs.pop("session", None) - # Extract user-agent from headers if present - user_agent = "" + # Extract user_agent from kwargs (preferred) or from headers["user-agent"] + user_agent = kwargs.pop("user_agent", "") if headers and "user-agent" in headers: - user_agent = headers.pop("user-agent") + header_ua = headers.pop("user-agent") + if user_agent: + user_agent = ( + f"{header_ua}; {user_agent}" if header_ua else user_agent + ) + else: + user_agent = header_ua return ( api_protocol, diff --git a/dashscope/api_entities/dashscope_response.py b/dashscope/api_entities/dashscope_response.py index 71f16b8..58ade6b 100644 --- a/dashscope/api_entities/dashscope_response.py +++ b/dashscope/api_entities/dashscope_response.py @@ -59,7 +59,12 @@ def setattr(self, attr, value): return super().__setitem__(attr, value) def __getattr__(self, attr): - return self[attr] + try: + return self[attr] + except KeyError: + raise AttributeError( + f"{type(self).__name__!r} object has no attribute {attr!r}", + ) from None def __setattr__(self, attr, value): self[attr] = value diff --git a/dashscope/api_entities/encryption.py b/dashscope/api_entities/encryption.py index 266a8c4..59698e4 100644 --- a/dashscope/api_entities/encryption.py +++ b/dashscope/api_entities/encryption.py @@ -115,69 +115,69 @@ def _generate_iv(): @staticmethod def _encrypt_text_with_aes(plaintext, key, iv): - """使用AES-GCM加密数据""" + """Encrypt data with AES-GCM""" - # 创建AES-GCM加密器 + # Create AES-GCM encryptor aes_gcm = Cipher( algorithms.AES(key), modes.GCM(iv, tag=None), backend=default_backend(), ).encryptor() - # 关联数据设为空(根据需求可调整) + # Set associated data to empty (adjustable as needed) aes_gcm.authenticate_additional_data(b"") - # 加密数据 + # Encrypt data ciphertext = ( aes_gcm.update(plaintext.encode("utf-8")) + aes_gcm.finalize() ) - # 获取认证标签 + # Get authentication tag tag = aes_gcm.tag - # 组合密文和标签 + # Combine ciphertext and tag encrypted_data = ciphertext + tag - # 返回Base64编码结果 + # Return Base64 encoded result return base64.b64encode(encrypted_data).decode("utf-8") @staticmethod def _decrypt_text_with_aes(base64_ciphertext, aes_key, iv): - """使用AES-GCM解密响应""" + """Decrypt response with AES-GCM""" - # 解码Base64数据 + # Decode Base64 data encrypted_data = base64.b64decode(base64_ciphertext) - # 分离密文和标签(标签长度16字节) + # Separate ciphertext and tag (tag length is 16 bytes) ciphertext = encrypted_data[:-16] tag = encrypted_data[-16:] - # 创建AES-GCM解密器 + # Create AES-GCM decryptor aes_gcm = Cipher( algorithms.AES(aes_key), modes.GCM(iv, tag), backend=default_backend(), ).decryptor() - # 验证关联数据(与加密时一致) + # Verify associated data (same as during encryption) aes_gcm.authenticate_additional_data(b"") - # 解密数据 + # Decrypt data decrypted_bytes = aes_gcm.update(ciphertext) + aes_gcm.finalize() - # 明文 + # Plaintext plaintext = decrypted_bytes.decode("utf-8") return json.loads(plaintext) @staticmethod def _encrypt_aes_key_with_rsa(aes_key, public_key_str): - """使用RSA公钥加密AES密钥""" + """Encrypt AES key with RSA public key""" - # 解码Base64格式的公钥 + # Decode Base64 formatted public key public_key_bytes = base64.b64decode(public_key_str) - # 加载公钥 + # Load public key public_key = serialization.load_der_public_key( public_key_bytes, backend=default_backend(), @@ -185,7 +185,7 @@ def _encrypt_aes_key_with_rsa(aes_key, public_key_str): base64_aes_key = base64.b64encode(aes_key).decode("utf-8") - # 使用RSA加密 + # Encrypt with RSA encrypted_bytes = public_key.encrypt( base64_aes_key.encode("utf-8"), padding.PKCS1v15(), diff --git a/dashscope/api_entities/http_request.py b/dashscope/api_entities/http_request.py index 85ee959..1208bbc 100644 --- a/dashscope/api_entities/http_request.py +++ b/dashscope/api_entities/http_request.py @@ -2,14 +2,13 @@ # Copyright (c) Alibaba, Inc. and its affiliates. import datetime import json -import ssl from http import HTTPStatus from typing import Optional, Dict, Union import aiohttp -import certifi import requests +from dashscope.api_entities.aio_session import get_shared_aio_session from dashscope.api_entities.base_request import AioBaseRequest from dashscope.api_entities.dashscope_response import DashScopeAPIResponse from dashscope.common.constants import ( @@ -53,7 +52,9 @@ def __init__( api_key (str): The api key. method (str): The http method(GET|POST). stream (bool, optional): Is stream request. Defaults to True. - timeout (int, optional): Total request timeout. + timeout (int, optional): Request timeout in seconds. For streaming + requests, this is the idle timeout between chunks (sock_read); + for non-streaming requests, this is the total request timeout. Defaults to DEFAULT_REQUEST_TIMEOUT_SECONDS. user_agent (str, optional): Additional user agent string to append. Defaults to ''. @@ -162,24 +163,23 @@ async def aio_call(self): async def _handle_aio_request(self): # pylint: disable=too-many-branches try: # Use external aio_session if provided, - # otherwise create temporary session + # otherwise use shared session with connection pooling if self._external_aio_session is not None: session = self._external_aio_session should_close = False else: - connector = aiohttp.TCPConnector( - ssl=ssl.create_default_context( - cafile=certifi.where(), - ), - ) - session = aiohttp.ClientSession( - connector=connector, - timeout=aiohttp.ClientTimeout(total=self.timeout), - headers=self.headers, - ) - should_close = True + session = await get_shared_aio_session() + should_close = False try: + if self.stream: + request_timeout = aiohttp.ClientTimeout( + total=None, + sock_read=self.timeout, + ) + else: + request_timeout = aiohttp.ClientTimeout(total=self.timeout) + logger.debug("Starting request: %s", self.url) if self.method == HTTPMethod.POST: is_form, obj = False, {} @@ -191,6 +191,7 @@ async def _handle_aio_request(self): # pylint: disable=too-many-branches url=self.url, data=obj, headers=headers, + timeout=request_timeout, ) else: response = await session.request( @@ -198,9 +199,9 @@ async def _handle_aio_request(self): # pylint: disable=too-many-branches url=self.url, json=obj, headers=self.headers, + timeout=request_timeout, ) elif self.method == HTTPMethod.GET: - # 添加条件判断 params = {} if hasattr(self, "data") and self.data is not None: params = getattr(self.data, "parameters", {}) @@ -210,6 +211,7 @@ async def _handle_aio_request(self): # pylint: disable=too-many-branches url=self.url, params=params, headers=self.headers, + timeout=request_timeout, ) else: raise UnsupportedHTTPMethod( @@ -220,14 +222,10 @@ async def _handle_aio_request(self): # pylint: disable=too-many-branches async for rsp in self._handle_aio_response(response): yield rsp finally: - # Only close if we created the session if should_close: await session.close() - except aiohttp.ClientConnectorError as e: - logger.error(e) - raise e - except BaseException as e: - logger.error(e) + except Exception as e: + logger.debug(e) raise e @staticmethod @@ -457,7 +455,7 @@ def _handle_response( # pylint: disable=too-many-branches else: yield _handle_http_failed_response(response) - def _handle_request(self): + def _handle_request(self): # pylint: disable=too-many-branches try: # Use external session if provided, # otherwise create temporary session @@ -470,7 +468,9 @@ def _handle_request(self): try: if self.method == HTTPMethod.POST: - is_form, form, obj = self.data.get_http_payload() + is_form, form, obj = False, None, {} + if hasattr(self, "data") and self.data is not None: + is_form, form, obj = self.data.get_http_payload() if is_form: headers = {**self.headers} headers.pop("Content-Type") @@ -491,9 +491,12 @@ def _handle_request(self): timeout=self.timeout, ) elif self.method == HTTPMethod.GET: + params = {} + if hasattr(self, "data") and self.data is not None: + params = getattr(self.data, "parameters", {}) response = session.get( url=self.url, - params=self.data.parameters, + params=params, headers=self.headers, timeout=self.timeout, ) @@ -507,6 +510,6 @@ def _handle_request(self): # Only close if we created the session if should_close: session.close() - except BaseException as e: - logger.error(e) + except Exception as e: + logger.debug(e) raise e diff --git a/dashscope/api_entities/websocket_request.py b/dashscope/api_entities/websocket_request.py index 0473709..249b8d8 100644 --- a/dashscope/api_entities/websocket_request.py +++ b/dashscope/api_entities/websocket_request.py @@ -119,6 +119,7 @@ async def connection_handler(self): # pylint: disable=too-many-branches timeout=aiohttp.ClientTimeout( total=self.timeout, ), + trust_env=True, ) as session: async with session.ws_connect( self.url, diff --git a/dashscope/app/application.py b/dashscope/app/application.py index 78a77a0..0926cbd 100644 --- a/dashscope/app/application.py +++ b/dashscope/app/application.py @@ -21,6 +21,7 @@ ) from dashscope.common.error import InputRequired, InvalidInput from dashscope.common.logging import logger +from dashscope.utils.message_utils import merge_single_response class Application(BaseApi): @@ -149,6 +150,25 @@ def call( # type: ignore[override] headers["X-DashScope-WorkSpace"] = workspace kwargs["headers"] = headers + # Check if we need to merge incremental output (compute once) + is_stream = kwargs.get("stream", False) + is_incremental_output = kwargs.get("incremental_output", None) + to_merge_incremental_output = ( + is_stream and is_incremental_output is False + ) + + if to_merge_incremental_output: + kwargs["incremental_output"] = True + + # Pass incremental_to_full flag via user_agent parameter to avoid + # overwriting the default SDK user-agent + flag = "1" if to_merge_incremental_output else "0" + existing_ua = kwargs.get("user_agent", "") + new_ua = f"incremental_to_full/{flag}" + kwargs["user_agent"] = ( + f"{existing_ua}; {new_ua}".strip() if existing_ua else new_ua + ) + ( input, # pylint: disable=redefined-builtin parameters, @@ -171,12 +191,15 @@ def call( # type: ignore[override] ) # call request service. response = request.call() - is_stream = kwargs.get("stream", False) if is_stream: - return ( - ApplicationResponse.from_api_response(rsp) for rsp in response - ) + if to_merge_incremental_output: + return cls._merge_application_response(response) + else: + return ( + ApplicationResponse.from_api_response(rsp) + for rsp in response + ) else: return ApplicationResponse.from_api_response(response) @@ -238,3 +261,33 @@ def _build_input_parameters( # pylint: disable=too-many-branches input_param["file_list"] = file_list return input_param, {**parameters, **kwargs} + + @classmethod + def _merge_application_response(cls, response): + """Merge incremental application response chunks. + + Simulate non-incremental output by accumulating text. + """ + accumulated_data = {} + + for rsp in response: + parsed_response = ApplicationResponse.from_api_response(rsp) + if parsed_response.output and not hasattr( + parsed_response.output, + "choices", + ): + parsed_response.output.choices = None + result = merge_single_response( + parsed_response, + accumulated_data, + ) + if result is True: + yield parsed_response + elif isinstance(result, list): + for resp in result: + yield resp + else: + logger.warning( + "Unexpected merge result type: %s, skipping", + type(result).__name__, + ) diff --git a/dashscope/audio/asr/recognition.py b/dashscope/audio/asr/recognition.py index f243d44..7dc7bc4 100644 --- a/dashscope/audio/asr/recognition.py +++ b/dashscope/audio/asr/recognition.py @@ -444,8 +444,8 @@ def call( # type: ignore[override] # noqa: E501 f.close() self._stop_stream_timestamp = time.time() * 1000 except Exception as e: - logger.error(e) - raise e + logger.debug(e) + raise if not self._stream_data.empty(): self._running = True @@ -600,6 +600,6 @@ def get_last_package_delay(self): """Last Package Delay is the time between stop sending audio and receive last words package""" # noqa: E501 # pylint: disable=line-too-long return self._on_complete_timestamp - self._stop_stream_timestamp - # 获取上一个任务的taskId + # Get the requestId of the last task def get_last_request_id(self): return self.last_request_id diff --git a/dashscope/audio/asr/transcription.py b/dashscope/audio/asr/transcription.py index 480b328..88631ac 100644 --- a/dashscope/audio/asr/transcription.py +++ b/dashscope/audio/asr/transcription.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -import asyncio import time from typing import List, Union -import aiohttp +import requests from dashscope.api_entities.dashscope_response import ( DashScopeAPIResponse, @@ -148,8 +147,8 @@ def fetch( workspace=workspace, **kwargs, ) - except (asyncio.TimeoutError, aiohttp.ClientConnectorError) as e: - logger.error(e) + except (requests.Timeout, requests.ConnectionError) as e: + logger.debug(e) try_count += 1 if try_count <= Transcription.MAX_QUERY_TRY_COUNT: time.sleep(2) @@ -167,6 +166,7 @@ def wait( task: Union[str, TranscriptionResponse], # type: ignore[override] api_key: str = None, workspace: str = None, + wait_timeout: int = -1, **kwargs, ) -> TranscriptionResponse: """Poll task until the final results of transcription is obtained. @@ -174,7 +174,12 @@ def wait( Args: task (Union[str, TranscriptionResponse]): The task_id or response including task_id returned from async_call(). + api_key (str, optional): The api_key. Defaults to None. workspace (str): The dashscope workspace id. + wait_timeout (int, optional): The timeout for waiting. + Defaults to -1.That means no timeout. + If set to a value > 0, the task does not complete + within this time, a timeout error response will be returned. Returns: TranscriptionResponse: The result of batch transcription. @@ -183,6 +188,7 @@ def wait( task, api_key=api_key, workspace=workspace, + wait_timeout=wait_timeout, **kwargs, ) return TranscriptionResponse.from_api_response(response) @@ -223,8 +229,8 @@ def _launch_request( workspace=workspace, **kwargs, ) - except (asyncio.TimeoutError, aiohttp.ClientConnectorError) as e: - logger.error(e) + except (requests.Timeout, requests.ConnectionError) as e: + logger.debug(e) try_count += 1 if try_count <= Transcription.MAX_QUERY_TRY_COUNT: time.sleep(2) diff --git a/dashscope/audio/asr/translation_recognizer.py b/dashscope/audio/asr/translation_recognizer.py index 83dcaa5..2f3405c 100644 --- a/dashscope/audio/asr/translation_recognizer.py +++ b/dashscope/audio/asr/translation_recognizer.py @@ -582,8 +582,8 @@ def call( # type: ignore[override] f.close() self._stop_stream_timestamp = time.time() * 1000 except Exception as e: - logger.error(e) - raise e + logger.debug(e) + raise if not self._stream_data.empty(): self._running = True @@ -744,7 +744,7 @@ def get_last_package_delay(self): """Last Package Delay is the time between stop sending audio and receive last words package""" # noqa: E501 # pylint: disable=line-too-long return self._on_complete_timestamp - self._stop_stream_timestamp - # 获取上一个任务的taskId + # Get the taskId of the last task def get_last_request_id(self): return self.last_request_id @@ -1087,6 +1087,6 @@ def get_last_package_delay(self): """Last Package Delay is the time between stop sending audio and receive last words package""" # noqa: E501 # pylint: disable=line-too-long return self._on_complete_timestamp - self._stop_stream_timestamp - # 获取上一个任务的taskId + # Get the taskId of the last task def get_last_request_id(self): return self.last_request_id diff --git a/dashscope/audio/asr/vocabulary.py b/dashscope/audio/asr/vocabulary.py index 1e96e28..9e8510b 100644 --- a/dashscope/audio/asr/vocabulary.py +++ b/dashscope/audio/asr/vocabulary.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -import asyncio import time from typing import List -import aiohttp +import requests from dashscope.client.base_api import BaseApi from dashscope.common.constants import ApiProtocol, HTTPMethod @@ -68,8 +67,8 @@ def __call_with_input(self, input): # pylint: disable=redefined-builtin workspace=self._workspace, **self._kwargs, ) - except (asyncio.TimeoutError, aiohttp.ClientConnectorError) as e: - logger.error(e) + except (requests.Timeout, requests.ConnectionError) as e: + logger.debug(e) try_count += 1 if try_count <= VocabularyService.MAX_QUERY_TRY_COUNT: time.sleep(2) @@ -86,11 +85,12 @@ def create_vocabulary( vocabulary: List[dict], ) -> str: """ - 创建热词表 - param: target_model 热词表对应的语音识别模型版本 - param: prefix 热词表自定义前缀,仅允许数字和小写字母,小于十个字符。 - param: vocabulary 热词表字典 - return: 热词表标识符 vocabulary_id + Create a hot word table. + param: target_model ASR model version for the hot word table + param: prefix Custom hot word table prefix, only digits and + lowercase letters allowed, less than 10 characters. + param: vocabulary Hot word table dictionary + return: Hot word table identifier vocabulary_id """ # pylint: disable=no-value-for-parameter response = self.__call_with_input( @@ -119,11 +119,12 @@ def list_vocabularies( page_size: int = 10, ) -> List[dict]: """ - 查询已创建的所有热词表 - param: prefix 自定义前缀,如果设定则只返回指定前缀的热词表标识符列表。 - param: page_index 查询的页索引 - param: page_size 查询页大小 - return: 热词表标识符列表 + List all created hot word tables. + param: prefix Custom prefix, if set only returns hot word table + identifiers with the specified prefix. + param: page_index Page index for query + param: page_size Page size + return: List of hot word table identifiers """ if prefix: # pylint: disable=no-value-for-parameter @@ -157,9 +158,9 @@ def list_vocabularies( def query_vocabulary(self, vocabulary_id: str) -> List[dict]: """ - 获取热词表内容 - param: vocabulary_id 热词表标识符 - return: 热词表 + Get hot word table contents. + param: vocabulary_id Hot word table identifier + return: Hot word table """ # pylint: disable=no-value-for-parameter response = self.__call_with_input( @@ -185,9 +186,9 @@ def update_vocabulary( vocabulary: List[dict], ) -> None: """ - 用新的热词表替换已有热词表 - param: vocabulary_id 需要替换的热词表标识符 - param: vocabulary 热词表 + Replace existing hot word table with a new one. + param: vocabulary_id Hot word table identifier to replace + param: vocabulary Hot word table """ # pylint: disable=no-value-for-parameter response = self.__call_with_input( @@ -210,8 +211,8 @@ def update_vocabulary( def delete_vocabulary(self, vocabulary_id: str) -> None: """ - 删除热词表 - param: vocabulary_id 需要删除的热词表标识符 + Delete hot word table. + param: vocabulary_id Hot word table identifier to delete """ # pylint: disable=no-value-for-parameter response = self.__call_with_input( diff --git a/dashscope/audio/qwen_asr/qwen_transcription.py b/dashscope/audio/qwen_asr/qwen_transcription.py index 9ebed8d..f731ff9 100644 --- a/dashscope/audio/qwen_asr/qwen_transcription.py +++ b/dashscope/audio/qwen_asr/qwen_transcription.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -import asyncio import time from typing import Union -import aiohttp +import requests from dashscope.api_entities.dashscope_response import ( DashScopeAPIResponse, @@ -108,8 +107,8 @@ def fetch( workspace=workspace, **kwargs, ) - except (asyncio.TimeoutError, aiohttp.ClientConnectorError) as e: - logger.error(e) + except (requests.Timeout, requests.ConnectionError) as e: + logger.debug(e) try_count += 1 if try_count <= QwenTranscription.MAX_QUERY_TRY_COUNT: time.sleep(2) @@ -127,6 +126,7 @@ def wait( task: Union[str, TranscriptionResponse], # type: ignore[override] api_key: str = None, workspace: str = None, + wait_timeout: int = -1, **kwargs, ) -> TranscriptionResponse: """Poll task until the final results of transcription is obtained. @@ -135,6 +135,8 @@ def wait( task (Union[str, TranscriptionResponse]): The task_id or response including task_id returned from async_call(). workspace (str): The dashscope workspace id. + wait_timeout (int, optional): The maximum seconds to wait. + Default is -1 (no timeout). Returns: TranscriptionResponse: The result of batch transcription. @@ -143,6 +145,7 @@ def wait( task, api_key=api_key, workspace=workspace, + wait_timeout=wait_timeout, **kwargs, ) return TranscriptionResponse.from_api_response(response) @@ -182,8 +185,8 @@ def _launch_request( workspace=workspace, **kwargs, ) - except (asyncio.TimeoutError, aiohttp.ClientConnectorError) as e: - logger.error(e) + except (requests.Timeout, requests.ConnectionError) as e: + logger.debug(e) try_count += 1 if try_count <= QwenTranscription.MAX_QUERY_TRY_COUNT: time.sleep(2) diff --git a/dashscope/audio/qwen_omni/omni_realtime.py b/dashscope/audio/qwen_omni/omni_realtime.py index dc7cda6..4f15735 100644 --- a/dashscope/audio/qwen_omni/omni_realtime.py +++ b/dashscope/audio/qwen_omni/omni_realtime.py @@ -151,8 +151,9 @@ def __init__( self.last_first_text_delay = None self.last_first_audio_delay = None self.metrics = [] - # 添加用于同步等待连接关闭的事件 + # Add event for synchronously waiting on connection close self.disconnect_event = None + self._disconnect_error = None def _generate_event_id(self): """ @@ -189,13 +190,13 @@ def connect(self) -> None: self.thread = threading.Thread(target=self.ws.run_forever) self.thread.daemon = True self.thread.start() - timeout = 5 # 最长等待时间(秒) + timeout = 5 # max wait time in seconds start_time = time.time() while ( not (self.ws.sock and self.ws.sock.connected) and (time.time() - start_time) < timeout ): - time.sleep(0.1) # 短暂休眠,避免密集轮询 + time.sleep(0.1) # Brief sleep to avoid busy polling if not (self.ws.sock and self.ws.sock.connected): raise TimeoutError( "websocket connection could not established within 5s. " @@ -350,6 +351,7 @@ def end_session(self, timeout: int = 20) -> None: # create the event self.disconnect_event = threading.Event() + self._disconnect_error = None self.__send_str( json.dumps( @@ -362,10 +364,14 @@ def end_session(self, timeout: int = 20) -> None: # wait for the event to be set finish_success = self.disconnect_event.wait(timeout) - # clear the event + error = self._disconnect_error self.disconnect_event = None + self._disconnect_error = None - # if the event is not set, close the connection + # if the server returned an error or timed out, close the connection + if error is not None: + self.close() + raise RuntimeError(f"Session ended with error: {error}") if not finish_success: self.close() raise TimeoutError( @@ -376,7 +382,7 @@ def end_session_async(self) -> None: """ end session asynchronously. you need close the connection manually """ - # 发送结束会话消息 + # Send end session message self.__send_str( json.dumps( { @@ -511,7 +517,7 @@ def close(self) -> None: """ self.ws.close() - # 监听消息的回调函数 + # Callback for listening to messages def _on_message( # pylint: disable=unused-argument,too-many-branches self, ws, @@ -523,7 +529,7 @@ def _on_message( # pylint: disable=unused-argument,too-many-branches message[:1024], ) try: - # 尝试将消息解析为JSON + # Attempt to parse message as JSON json_data = json.loads(message) self.last_message = json_data self.callback.on_event(json_data) @@ -536,6 +542,14 @@ def _on_message( # pylint: disable=unused-argument,too-many-branches logger.info("[omni realtime] session finished") if self.disconnect_event is not None: self.disconnect_event.set() + elif "error" == json_data.get("type"): + if self.disconnect_event is not None: + self._disconnect_error = json_data.get("error") + logger.warning( + "[omni realtime] error during end_session: %s", + self._disconnect_error, + ) + self.disconnect_event.set() if "response.created" == json_data["type"]: self.last_response_id = json_data["response"]["id"] self.last_response_create_time = time.time() * 1000 @@ -574,7 +588,7 @@ def _on_message( # pylint: disable=unused-argument,too-many-branches # pylint: disable=broad-exception-raised,raise-missing-from raise Exception("Failed to parse message as JSON.") elif isinstance(message, (bytes, bytearray)): - # 如果失败,认为是二进制消息 + # If parsing fails, treat as binary message logger.error( "should not receive binary message in omni realtime api", ) @@ -591,13 +605,13 @@ def _on_close( # pylint: disable=unused-argument ): self.callback.on_close(close_status_code, close_msg) - # WebSocket发生错误的回调函数 + # Callback for WebSocket error def _on_error(self, ws, error): # pylint: disable=unused-argument # pylint: disable=broad-exception-raised logger.error("websocket closed due to %s", error) raise Exception(f"websocket closed due to {error}") - # 获取上一个任务的taskId + # Get the taskId of the last task def get_session_id(self) -> str: return self.session_id # type: ignore[return-value] diff --git a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py b/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py index 7142a66..415c098 100644 --- a/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py +++ b/dashscope/audio/qwen_tts_realtime/qwen_tts_realtime.py @@ -141,13 +141,13 @@ def connect(self) -> None: self.thread = threading.Thread(target=self.ws.run_forever) self.thread.daemon = True self.thread.start() - timeout = 5 # 最长等待时间(秒) + timeout = 5 # max wait time in seconds start_time = time.time() while ( not (self.ws.sock and self.ws.sock.connected) and (time.time() - start_time) < timeout ): - time.sleep(0.1) # 短暂休眠,避免密集轮询 + time.sleep(0.1) # Brief sleep to avoid busy polling if not (self.ws.sock and self.ws.sock.connected): raise TimeoutError( "websocket connection could not established within 5s. " @@ -215,14 +215,16 @@ def update_session( "response_format": response_format.format, "sample_rate": response_format.sample_rate, } - if sample_rate is not None: # 如果配置,则更新 + if sample_rate is not None: # update if configured self.config["sample_rate"] = sample_rate if volume is not None: self.config["volume"] = volume if speech_rate is not None: self.config["speech_rate"] = speech_rate if audio_format is not None: - self.config["response_format"] = audio_format # 如果配置,则更新 + self.config[ + "response_format" + ] = audio_format # update if configured if pitch_rate is not None: self.config["pitch_rate"] = pitch_rate if bit_rate is not None: @@ -332,7 +334,7 @@ def close(self) -> None: """ self.ws.close() - # 监听消息的回调函数 + # Callback for listening to messages def on_message( # pylint: disable=unused-argument self, ws, @@ -344,7 +346,7 @@ def on_message( # pylint: disable=unused-argument message[:1024], ) try: - # 尝试将消息解析为JSON + # Attempt to parse message as JSON json_data = json.loads(message) self.last_message = json_data self.callback.on_event(json_data) @@ -372,7 +374,7 @@ def on_message( # pylint: disable=unused-argument # pylint: disable=broad-exception-raised,raise-missing-from raise Exception("Failed to parse message as JSON.") elif isinstance(message, (bytes, bytearray)): - # 如果失败,认为是二进制消息 + # If parsing fails, treat as binary message logger.error( "should not receive binary message in omni realtime api", ) @@ -394,13 +396,13 @@ def on_close( # pylint: disable=unused-argument ) self.callback.on_close(close_status_code, close_msg) - # WebSocket发生错误的回调函数 + # Callback for WebSocket error def on_error(self, ws, error): # pylint: disable=unused-argument print(f"websocket closed due to {error}") # pylint: disable=broad-exception-raised raise Exception(f"websocket closed due to {error}") - # 获取上一个任务的taskId + # Get the taskId of the last task def get_session_id(self): return self.session_id diff --git a/dashscope/audio/tts_v2/enrollment.py b/dashscope/audio/tts_v2/enrollment.py index 2483862..239606a 100644 --- a/dashscope/audio/tts_v2/enrollment.py +++ b/dashscope/audio/tts_v2/enrollment.py @@ -1,11 +1,10 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -import asyncio import time from typing import List -import aiohttp +import requests from dashscope.client.base_api import BaseApi from dashscope.common.constants import ApiProtocol, HTTPMethod @@ -71,8 +70,8 @@ def __call_with_input( # pylint: disable=redefined-builtin workspace=self._workspace, **self._kwargs, ) - except (asyncio.TimeoutError, aiohttp.ClientConnectorError) as e: - logger.error(e) + except (requests.Timeout, requests.ConnectionError) as e: + logger.debug(e) try_count += 1 if try_count <= VoiceEnrollmentService.MAX_QUERY_TRY_COUNT: time.sleep(2) @@ -92,13 +91,15 @@ def create_voice( **kwargs, ) -> str: """ - 创建新克隆音色 - param: target_model 克隆音色对应的语音合成模型版本 - param: prefix 音色自定义前缀,仅允许数字和小写字母,小于十个字符。 - param: url 用于克隆的音频文件url - param: language_hints 克隆音色目标语言 - param: max_prompt_audio_length 音频预处理输出的prompt audio最长长度。单位为秒。默认为10s。 - param: kwargs 额外参数 + Create a new cloned voice. + param: target_model TTS model version for the cloned voice + param: prefix Custom voice prefix, only digits and lowercase + letters allowed, less than 10 characters. + param: url Audio file URL for voice cloning + param: language_hints Target language for the cloned voice + param: max_prompt_audio_length Max length of prompt audio output + from audio preprocessing, in seconds. Default is 10s. + param: kwargs Additional parameters return: voice_id """ @@ -133,10 +134,11 @@ def list_voices( page_size: int = 10, ) -> List[dict]: """ - 查询已创建的所有音色 - param: page_index 查询的页索引 - param: page_size 查询页大小 - return: List[dict] 音色列表,包含每个音色的id,创建时间,修改时间,状态。 + List all created voices. + param: page_index Page index for query + param: page_size Page size + return: List[dict] Voice list, including id, creation time, + modification time, and status for each voice. """ if prefix: # pylint: disable=no-value-for-parameter @@ -170,9 +172,9 @@ def list_voices( def query_voice(self, voice_id: str) -> List[str]: """ - 查询已创建的所有音色 - param: voice_id 需要查询的音色 - return: bytes 注册音色使用的音频 + Query voice details. + param: voice_id Voice ID to query + return: bytes Audio used for voice registration """ # pylint: disable=no-value-for-parameter response = self.__call_with_input( @@ -194,9 +196,9 @@ def query_voice(self, voice_id: str) -> List[str]: def update_voice(self, voice_id: str, url: str) -> None: """ - 更新音色 - param: voice_id 音色id - param: url 用于克隆的音频文件url + Update voice. + param: voice_id Voice ID + param: url Audio file URL for cloning """ # pylint: disable=no-value-for-parameter response = self.__call_with_input( @@ -219,8 +221,8 @@ def update_voice(self, voice_id: str, url: str) -> None: def delete_voice(self, voice_id: str) -> None: """ - 删除音色 - param: voice_id 需要删除的音色 + Delete voice. + param: voice_id Voice ID to delete """ # pylint: disable=no-value-for-parameter response = self.__call_with_input( diff --git a/dashscope/audio/tts_v2/speech_synthesizer.py b/dashscope/audio/tts_v2/speech_synthesizer.py index 5f5af14..4420456 100644 --- a/dashscope/audio/tts_v2/speech_synthesizer.py +++ b/dashscope/audio/tts_v2/speech_synthesizer.py @@ -167,7 +167,7 @@ def __init__( # pylint: disable=redefined-builtin self.language_hints = language_hints def gen_uid(self): - # 生成随机UUID + # Generate random UUID return uuid.uuid4().hex def get_websocket_headers(self, headers, workspace): @@ -403,13 +403,13 @@ def __connect(self, timeout_seconds=5) -> None: self.thread = threading.Thread(target=self.ws.run_forever) self.thread.daemon = True self.thread.start() - # 等待连接建立 + # Wait for connection to be established start_time = time.time() while ( not (self.ws.sock and self.ws.sock.connected) and (time.time() - start_time) < timeout_seconds ): - time.sleep(0.1) # 短暂休眠,避免密集轮询 + time.sleep(0.1) # Brief sleep to avoid busy polling if not (self.ws.sock and self.ws.sock.connected): raise TimeoutError( "websocket connection could not established within 5s. " @@ -540,10 +540,10 @@ def __start_stream(self): if self._is_started: raise InvalidTask("task has already started.") - # 建立ws连接 + # Establish WebSocket connection if self.ws is None: self.__connect(5) - # 发送run-task指令 + # Send run-task command request = self.request.get_start_request(self.additional_params) self.__send_str(request) if not self.start_event.wait(10): @@ -680,7 +680,7 @@ def streaming_cancel(self): self.start_event.set() self.complete_event.set() - # 监听消息的回调函数 + # Callback for listening to messages def on_message( # pylint: disable=unused-argument,too-many-branches self, ws, @@ -689,11 +689,11 @@ def on_message( # pylint: disable=unused-argument,too-many-branches if isinstance(message, str): logger.debug("<< 100: raise ValueError("max_size must be less than 100") self._pool = [] - # 如果重连中,则会将avaliable置为False,避免被使用 + # If reconnecting, set available to False to avoid being used self._avaliable = [] self._pool_size = max_size for i in range(self._pool_size): # pylint: disable=unused-variable @@ -918,7 +919,7 @@ def __auto_reconnect(self): current_time = time.time() for idx, poolObject in enumerate(self._pool): - # 如果超过固定时间没有使用对象,则重连 + # Reconnect if object has not been used for a fixed time if poolObject.connect_time == -1: objects_need_to_connect.append(poolObject) self._avaliable[idx] = False @@ -995,7 +996,8 @@ def borrow_synthesizer( # pylint: disable=unused-argument,redefined-builtin # n logger.debug("[SpeechSynthesizerObjectPool] get synthesizer") synthesizer: SpeechSynthesizer = None with self._lock: - # 遍历对象池,如果存在预建连的对象,则返回 + # Iterate over object pool, return pre-connected object + # if available for idx, poolObject in enumerate(self._pool): if ( self._avaliable[idx] @@ -1009,7 +1011,7 @@ def borrow_synthesizer( # pylint: disable=unused-argument,redefined-builtin # n self._avaliable.pop(idx) break - # 如果对象池不足,则返回未建连的新对象 + # If pool is exhausted, return a new unconnected object if synthesizer is None: synthesizer = self.__get_default_synthesizer() logger.warning( diff --git a/dashscope/client/base_api.py b/dashscope/client/base_api.py index cc52fa2..d67149b 100644 --- a/dashscope/client/base_api.py +++ b/dashscope/client/base_api.py @@ -81,9 +81,10 @@ def _handle_kwargs( @classmethod async def _handle_request(cls, request): - # 如果 aio_call 返回的是异步生成器,则需要从中获取响应 + # If aio_call returns an async generator, consume it to get + # the response response = await request.aio_call() - # 处理异步生成器的情况 + # Handle async generator case if isinstance(response, collections.abc.AsyncGenerator): result = None async for item in response: @@ -190,6 +191,7 @@ async def wait( task: Union[str, DashScopeAPIResponse], api_key: str = None, workspace: str = None, + wait_timeout: int = -1, **kwargs, ) -> DashScopeAPIResponse: """Wait for async task completion and return task result. @@ -198,6 +200,12 @@ async def wait( task (Union[str, DashScopeAPIResponse]): The task_id, or async_call response. api_key (str, optional): The api_key. Defaults to None. + workspace (str, optional): The dashscope workspace id. + wait_timeout (int, optional): The maximum seconds to wait + for the task to complete. Default is -1, which means no + timeout. When set to a value > 0, if the task does not + complete within this time, a timeout error response will + be returned. Returns: DashScopeAPIResponse: The async task information. @@ -207,6 +215,7 @@ async def wait( max_wait_seconds = 5 increment_steps = 3 step = 0 + start_time = time.time() while True: step += 1 # we start by querying once every second, and double @@ -216,6 +225,23 @@ async def wait( # (server side return immediately when ready) if wait_seconds < max_wait_seconds and step % increment_steps == 0: wait_seconds = min(wait_seconds * 2, max_wait_seconds) + if wait_timeout is not None and 0 < wait_timeout <= ( + time.time() - start_time + ): + logger.warning( + "Wait task: %s timeout after %s seconds.", + task_id, + wait_timeout, + ) + return DashScopeAPIResponse( + request_id=task_id, + status_code=HTTPStatus.REQUEST_TIMEOUT, + code="WaitTaskTimeout", + message=( + f"Wait task: {task_id} timeout after " + f"{wait_timeout} seconds." + ), + ) rsp = await cls._get( task_id, api_key, @@ -236,7 +262,7 @@ async def wait( return rsp else: logger.info("The task %s is %s", task_id, task_status) - await asyncio.sleep(wait_seconds) # 异步等待 + await asyncio.sleep(wait_seconds) # async wait elif rsp.status_code in REPEATABLE_STATUS: logger.warning( "Get task: %s temporary failure, " @@ -246,7 +272,7 @@ async def wait( rsp.code, rsp.message, ) - await asyncio.sleep(wait_seconds) # 异步等待 + await asyncio.sleep(wait_seconds) # async wait else: return rsp @@ -348,7 +374,7 @@ async def list( **_workspace_header(workspace), **default_headers(api_key), } - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=True) as session: response = await session.get( url, params=params, @@ -599,6 +625,10 @@ def call( **kwargs, ) -> DashScopeAPIResponse: """Call service and get result.""" + wait_timeout = -1 + if "wait_timeout" in kwargs: + wait_timeout = kwargs.pop("wait_timeout") + task_response = cls.async_call( # type: ignore[misc] *args, api_key=api_key, @@ -609,6 +639,7 @@ def call( task_response, api_key=api_key, workspace=workspace, + wait_timeout=wait_timeout, ) return response @@ -766,6 +797,7 @@ def wait( task: Union[str, DashScopeAPIResponse], api_key: str = None, workspace: str = None, + wait_timeout: int = -1, **kwargs, ) -> DashScopeAPIResponse: """Wait for async task completion and return task result. @@ -774,6 +806,12 @@ def wait( task (Union[str, DashScopeAPIResponse]): The task_id, or async_call response. api_key (str, optional): The api_key. Defaults to None. + workspace (str, optional): The dashscope workspace id. + wait_timeout (int, optional): The maximum seconds to wait + for the task to complete. Default is -1, which means no + timeout. When set to a value > 0, if the task does not + complete within this time, a timeout error response will + be returned. Returns: DashScopeAPIResponse: The async task information. @@ -783,6 +821,7 @@ def wait( max_wait_seconds = 5 increment_steps = 3 step = 0 + start_time = time.time() while True: step += 1 # we start by querying once every second, and double @@ -793,6 +832,23 @@ def wait( # (server side return immediately when ready) if wait_seconds < max_wait_seconds and step % increment_steps == 0: wait_seconds = min(wait_seconds * 2, max_wait_seconds) + if wait_timeout is not None and 0 < wait_timeout <= ( + time.time() - start_time + ): + logger.warning( + "Wait task: %s timeout after %s seconds.", + task_id, + wait_timeout, + ) + return DashScopeAPIResponse( + request_id=task_id, + status_code=HTTPStatus.REQUEST_TIMEOUT, + code="WaitTaskTimeout", + message=( + f"Wait task: {task_id} timeout after " + f"{wait_timeout} seconds." + ), + ) rsp = cls._get(task_id, api_key, workspace=workspace, **kwargs) if rsp.status_code == HTTPStatus.OK: if rsp.output is None: diff --git a/dashscope/embeddings/batch_text_embedding.py b/dashscope/embeddings/batch_text_embedding.py index d3af2d1..c2c9b9c 100644 --- a/dashscope/embeddings/batch_text_embedding.py +++ b/dashscope/embeddings/batch_text_embedding.py @@ -143,6 +143,7 @@ def wait( # type: ignore[override] task: Union[str, BatchTextEmbeddingResponse], api_key: str = None, workspace: str = None, + **kwargs, ) -> BatchTextEmbeddingResponse: """Wait for async text embedding task to complete, and return the result. # noqa: E501 @@ -155,7 +156,12 @@ def wait( # type: ignore[override] Returns: AsyncTextEmbeddingResponse: The task result. """ - response = super().wait(task, api_key, workspace=workspace) + response = super().wait( + task, + api_key, + workspace=workspace, + **kwargs, + ) return BatchTextEmbeddingResponse.from_api_response(response) @classmethod diff --git a/dashscope/finetune/reinforcement/common/utils.py b/dashscope/finetune/reinforcement/common/utils.py index d6ee0f7..f0915c9 100644 --- a/dashscope/finetune/reinforcement/common/utils.py +++ b/dashscope/finetune/reinforcement/common/utils.py @@ -74,6 +74,7 @@ async def _make_request() -> Dict[str, Any]: async with aiohttp.ClientSession( headers=headers, timeout=aiohttp.ClientTimeout(total=timeout), + trust_env=True, ) as session: method_upper = method.upper() @@ -897,7 +898,7 @@ def _parse_decorator_args(decorator) -> Dict[str, Any]: """Extract name and sub_weight from a decorator AST node.""" args_dict = {} if isinstance(decorator, ast.Call): - # 处理 args 和 keywords + # Process args and keywords for i, arg in enumerate(decorator.args): if i == 0: args_dict["name"] = _resolve_str_literal(arg) @@ -1003,3 +1004,46 @@ def serialize_for_output(data: Any) -> Any: # Return basic types directly return data + + +def get_fc_request_id(request) -> str: + """Extract Function Compute request ID from request headers. + + Retrieves the 'x-fc-request-id' header from a FastAPI/Starlette Request + object. This is useful for correlating logs with specific FC invocations. + + Args: + request: FastAPI/Starlette Request object (or any object with a + `.headers` mapping). + + Returns: + The FC request ID string, or "unknown" if the header is not present. + """ + if request is None: + return "unknown" + headers = getattr(request, "headers", None) + if headers is not None and hasattr(headers, "get"): + return headers.get("x-fc-request-id", "unknown") + return "unknown" + + +def get_business_summary(processor_input) -> str: + """Extract summary business information from processor input. + + Returns the string representation of request_metadata if present, + otherwise returns an empty string. + + Args: + processor_input: The processor input object (BaseDataModel subclass). + + Returns: + String representation of request_metadata, or empty string. + """ + if processor_input is None: + return "" + + request_metadata = getattr(processor_input, "request_metadata", None) + if request_metadata is None: + return "" + + return str(request_metadata) diff --git a/dashscope/finetune/reinforcement/component/server/server.py b/dashscope/finetune/reinforcement/component/server/server.py index 02330d8..599fe99 100644 --- a/dashscope/finetune/reinforcement/component/server/server.py +++ b/dashscope/finetune/reinforcement/component/server/server.py @@ -37,6 +37,7 @@ GET /health Health check """ +import asyncio import logging import os import time @@ -47,6 +48,10 @@ from fastapi.responses import JSONResponse from dashscope.finetune.reinforcement.common.log import logger +from dashscope.finetune.reinforcement.common.utils import ( + get_fc_request_id, + get_business_summary, +) from dashscope.finetune.reinforcement.common.model_types import ( FunctionType as FuncType, ) @@ -72,7 +77,7 @@ "0", "no", ) -_THREAD_POOL_WORKERS = int(os.getenv("THREAD_POOL_WORKERS", "4")) +_THREAD_POOL_WORKERS = int(os.getenv("THREAD_POOL_WORKERS", "32")) _THREAD_POOL_QUEUE = int(os.getenv("THREAD_POOL_QUEUE", "100")) if not _ENABLE_LOGGING: @@ -386,6 +391,10 @@ async def handle_endpoint(request: Request) -> JSONResponse: request body, executes business logic using configured processor, and returns serialized result. + Monitors client connection state via ASGI disconnect messages. If the + client disconnects before processing completes, the processing task is + cancelled to avoid wasting resources. + Request body format: JSON, fields determined by FuncType: - reward: See RewardInput - rollout: See RolloutInput @@ -398,8 +407,25 @@ async def handle_endpoint(request: Request) -> JSONResponse: """ start_time = time.time() success = False + cancelled = False processor_input = None + disconnect_listener = None + disconnected = asyncio.Event() + + async def _listen_for_disconnect(): + """Background listener for ASGI disconnect messages.""" + try: + while True: + message = await request.receive() + if message.get("type") == "http.disconnect": + disconnected.set() + break + except Exception: + # If receive() raises (e.g. connection already closed), + # treat as disconnected. + disconnected.set() + # Extract trace context from request headers _otel_ctx_token, _upstream_tokens = await _extract_trace_context(request) @@ -413,8 +439,38 @@ async def handle_endpoint(request: Request) -> JSONResponse: # Check thread pool queue capacity await _check_queue_capacity() - # Execute processor - result = await func_manager.processes(processor_input) + # Execute processor as a task so we can cancel it on disconnect + process_task = asyncio.create_task( + func_manager.processes(processor_input), + ) + + # Wait for either the processing to finish or client disconnect + done, _pending = await asyncio.wait( + [process_task, disconnect_listener], + return_when=asyncio.FIRST_COMPLETED, + ) + + if disconnected.is_set(): + # Client disconnected — cancel the processing task + process_task.cancel() + try: + await process_task + except asyncio.CancelledError: + pass + cancelled = True + fc_request_id = get_fc_request_id(request) + logger.warning( + "[Server] Client disconnected during processing, " + "task cancelled. x-fc-request-id: %s", + fc_request_id, + ) + return JSONResponse( + status_code=499, + content={"message": "Client disconnected, request cancelled."}, + ) + + # Processing completed normally + result = process_task.result() success = True # Serialize result @@ -425,6 +481,13 @@ async def handle_endpoint(request: Request) -> JSONResponse: except HTTPException: # Re-raise HTTP exceptions as-is raise + except asyncio.CancelledError: + cancelled = True + logger.warning("[Server] Request processing was cancelled.") + return JSONResponse( + status_code=499, + content={"message": "Request cancelled."}, + ) except Exception as ex: logger.error(f"[Server] Unexpected error: {ex}", exc_info=True) return JSONResponse( @@ -432,14 +495,27 @@ async def handle_endpoint(request: Request) -> JSONResponse: content={"message": str(ex)}, ) finally: + # Cancel the disconnect listener if still running + if disconnect_listener is not None: + disconnect_listener.cancel() + try: + await disconnect_listener + except asyncio.CancelledError: + pass + # Clean up trace context await _cleanup_trace_context(_otel_ctx_token, _upstream_tokens) # Log request metrics elapsed = round(time.time() - start_time, 4) + fc_req_id = get_fc_request_id(request) + biz_summary = get_business_summary(processor_input) + biz_part = f" | {biz_summary}" if biz_summary else "" logger.info( f"[Server] /api/v1 | func_type={func_type.value} | " - f"success={success} | elapsed={elapsed}s", + f"fc_request_id={fc_req_id} | " + f"success={success} | cancelled={cancelled} | " + f"elapsed={elapsed}s{biz_part}", ) # Best-effort force flush based on platform/internal env config diff --git a/dashscope/multimodal/dialog_state.py b/dashscope/multimodal/dialog_state.py index 1bead25..a0c76d6 100644 --- a/dashscope/multimodal/dialog_state.py +++ b/dashscope/multimodal/dialog_state.py @@ -6,13 +6,14 @@ class DialogState(Enum): """ - 对话状态枚举类,定义了对话机器人可能处于的不同状态。 + Dialog state enumeration class, defining the possible states + of a dialog bot. Attributes: - IDLE (str): 表示机器人处于空闲状态。 - LISTENING (str): 表示机器人正在监听用户输入。 - THINKING (str): 表示机器人正在思考。 - RESPONDING (str): 表示机器人正在生成或回复中。 + IDLE (str): Bot is in idle state. + LISTENING (str): Bot is listening to user input. + THINKING (str): Bot is thinking. + RESPONDING (str): Bot is generating or responding. """ IDLE = "Idle" @@ -23,36 +24,36 @@ class DialogState(Enum): class StateMachine: """ - 状态机类,用于管理机器人的状态转换。 + State machine class for managing bot state transitions. Attributes: - current_state (DialogState): 当前状态。 + current_state (DialogState): Current state. """ def __init__(self): - # 初始化状态机时设置初始状态为IDLE + # Set initial state to IDLE when initializing the state machine self.current_state = DialogState.IDLE def change_state(self, new_state: str) -> None: """ - 更改当前状态到指定的新状态。 + Change the current state to the specified new state. Args: - new_state (str): 要切换到的新状态。 + new_state (str): The new state to switch to. Raises: - ValueError: 如果尝试切换到一个无效的状态,则抛出此异常。 + ValueError: If attempting to switch to an invalid state. """ if new_state in [state.value for state in DialogState]: self.current_state = DialogState(new_state) else: - raise ValueError("无效的状态类型") + raise ValueError("Invalid state type") def get_current_state(self) -> DialogState: """ - 获取当前状态。 + Get the current state. Returns: - DialogState: 当前状态。 + DialogState: The current state. """ return self.current_state diff --git a/dashscope/multimodal/multimodal_constants.py b/dashscope/multimodal/multimodal_constants.py index 12a2cb0..86aaaeb 100644 --- a/dashscope/multimodal/multimodal_constants.py +++ b/dashscope/multimodal/multimodal_constants.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -# -*- coding: utf-8 -*- # multimodal conversation request directive @@ -21,12 +20,17 @@ class RequestToRespondType: RESPONSE_NAME_STATE_CHANGED = "DialogStateChanged" RESPONSE_NAME_REQUEST_ACCEPTED = "RequestAccepted" RESPONSE_NAME_SPEECH_STARTED = "SpeechStarted" -RESPONSE_NAME_SPEECH_ENDED = "SpeechEnded" # 服务端检测到asr语音尾点时下发此事件,可选事件 -RESPONSE_NAME_RESPONDING_STARTED = ( - "RespondingStarted" # AI语音应答开始,sdk要准备接收服务端下发的语音数据 +# Server sends this event when ASR speech endpoint is detected, +# optional event +RESPONSE_NAME_SPEECH_ENDED = "SpeechEnded" +# AI voice response starts, SDK prepares to receive audio +RESPONSE_NAME_RESPONDING_STARTED = "RespondingStarted" +RESPONSE_NAME_RESPONDING_ENDED = "RespondingEnded" # AI voice response ends +RESPONSE_NAME_SPEECH_CONTENT = ( + "SpeechContent" # User speech recognition text, full streaming output +) +RESPONSE_NAME_RESPONDING_CONTENT = ( + "RespondingContent" # System output text, full streaming output ) -RESPONSE_NAME_RESPONDING_ENDED = "RespondingEnded" # AI语音应答结束 -RESPONSE_NAME_SPEECH_CONTENT = "SpeechContent" # 用户语音识别出的文本,流式全量输出 -RESPONSE_NAME_RESPONDING_CONTENT = "RespondingContent" # 统对外输出的文本,流式全量输出 -RESPONSE_NAME_ERROR = "Error" # 服务端对话中报错 -RESPONSE_NAME_HEART_BEAT = "HeartBeat" # 心跳消息 +RESPONSE_NAME_ERROR = "Error" # Server-side error during dialog +RESPONSE_NAME_HEART_BEAT = "HeartBeat" # Heartbeat message diff --git a/dashscope/multimodal/multimodal_dialog.py b/dashscope/multimodal/multimodal_dialog.py index 6f597aa..1517e97 100644 --- a/dashscope/multimodal/multimodal_dialog.py +++ b/dashscope/multimodal/multimodal_dialog.py @@ -39,98 +39,98 @@ class MultiModalCallback: """ - 语音聊天回调类,用于处理语音聊天过程中的各种事件。 + Voice chat callback class for handling various events during voice chat. """ def on_started(self, dialog_id: str) -> None: """ - 通知对话开始 + Notify dialog started. - :param dialog_id: 回调对话ID + :param dialog_id: Callback dialog ID """ def on_stopped(self) -> None: """ - 通知对话停止 + Notify dialog stopped. """ def on_state_changed(self, state: "dialog_state.DialogState") -> None: """ - 对话状态改变 + Dialog state changed. - :param state: 新的对话状态 + :param state: New dialog state """ def on_speech_audio_data(self, data: bytes) -> None: """ - 合成音频数据回调 + Synthesized audio data callback. - :param data: 音频数据 + :param data: Audio data """ def on_error(self, error) -> None: """ - 发生错误时调用此方法。 + Called when an error occurs. - :param error: 错误信息 + :param error: Error message """ def on_connected(self) -> None: """ - 成功连接到服务器后调用此方法。 + Called after successfully connecting to the server. """ def on_responding_started(self): """ - 回复开始回调 + Response started callback. """ def on_responding_ended(self, payload): """ - 回复结束 + Response ended. """ def on_speech_started(self): """ - 检测到语音输入结束 + Speech input started. """ def on_speech_ended(self): """ - 检测到语音输入结束 + Speech input ended. """ def on_speech_content(self, payload): """ - 语音识别文本 + Speech recognition text. :param payload: text """ def on_responding_content(self, payload): """ - 大模型回复文本。 + LLM response text. :param payload: text """ def on_request_accepted(self): """ - 打断请求被接受。 + Interrupt request accepted. """ def on_close(self, close_status_code, close_msg): """ - 连接关闭时调用此方法。 + Called when connection is closed. - :param close_status_code: 关闭状态码 - :param close_msg: 关闭消息 + :param close_status_code: Close status code + :param close_msg: Close message """ class MultiModalDialog: """ - 用于管理WebSocket连接以进行语音聊天的服务类。 + Service class for managing WebSocket connections for voice chat. """ def __init__( @@ -145,17 +145,22 @@ def __init__( model: str = None, ): """ - 创建一个语音对话会话。 - - 此方法用于初始化一个新的voice_chat会话,设置必要的参数以准备开始与模型的交互。 - :param workspace_id: 客户的workspace_id 主工作空间id,非必填字段 - :param app_id: 客户在管控台创建的应用id,可以根据值规律确定使用哪个对话系统 - :param request_params: 请求参数集合 - :param url: (str) API的URL地址。 - :param multimodal_callback: (MultimodalCallback) 回调对象,用于处理来自服务器的消息。 - :param api_key: (str) 应用程序接入的唯一key - :param dialog_id:对话id,如果传入表示承接上下文继续聊 - :param model: 模型 + Create a voice dialog session. + + This method initializes a new voice_chat session, setting up + the necessary parameters to start interacting with the model. + :param workspace_id: Customer workspace_id, primary workspace ID, + optional field + :param app_id: Application ID created in the console, used to + determine which dialog system to use + :param request_params: Request parameter collection + :param url: (str) API URL address. + :param multimodal_callback: (MultimodalCallback) Callback object + for processing messages from server. + :param api_key: (str) Application unique access key + :param dialog_id: Dialog ID, if provided, continues the + conversation with previous context + :param model: Model """ if request_params is None: raise InputRequired("request_params is required!") @@ -181,7 +186,7 @@ def __init__( self.dialog_state, self._callback, self.close, - ) # 传递 self.close 作为回调 + ) # pass self.close as callback def _on_message( # pylint: disable=unused-argument self, @@ -227,11 +232,15 @@ def _on_open(self, ws): # pylint: disable=unused-argument def start(self, dialog_id, enable_voice_detection=False, task_id=None): """ - 初始化WebSocket连接并发送启动请求 - :param dialog_id: 上下位继承标志位。新对话无需设置。 - 如果继承之前的对话历史,则需要记录之前的dialog_id并传入 - :param enable_voice_detection: 是否开启语音检测,可选参数 默认False - :param task_id: 百炼请求任务 Id,默认会自动生成。您可以指定此 ID 来跟踪请求。 + Initialize WebSocket connection and send start request. + :param dialog_id: Context inheritance flag. Not needed for new + dialogs. + If inheriting previous dialog history, record and pass + the previous dialog_id + :param enable_voice_detection: Whether to enable voice detection, + optional, default False + :param task_id: DashScope request task ID, auto-generated by + default. You can specify this ID to track the request. """ self._voice_detection = enable_voice_detection self._connect(self.api_key) @@ -243,7 +252,7 @@ def start(self, dialog_id, enable_voice_detection=False, task_id=None): ) def start_speech(self): - """开始上传语音数据""" + """Start uploading speech data""" _send_speech_json = self.request.generate_common_direction_request( "SendSpeech", self.dialog_id, @@ -251,11 +260,11 @@ def start_speech(self): self._send_text_frame(_send_speech_json) def send_audio_data(self, speech_data: bytes): - """发送语音数据""" + """Send speech data""" self.__send_binary_frame(speech_data) def stop_speech(self): - """停止上传语音数据""" + """Stop uploading speech data""" _send_speech_json = self.request.generate_common_direction_request( "StopSpeech", self.dialog_id, @@ -263,7 +272,7 @@ def stop_speech(self): self._send_text_frame(_send_speech_json) def interrupt(self): - """请求服务端开始说话""" + """Request server to start speaking""" _send_speech_json = self.request.generate_common_direction_request( "RequestToSpeak", self.dialog_id, @@ -276,7 +285,7 @@ def request_to_respond( text: str, parameters: RequestToRespondParameters = None, ): - """请求服务端直接文本合成语音""" + """Request server to synthesize speech from text directly""" _send_speech_json = self.request.generate_request_to_response_json( direction_name="RequestToRespond", dialog_id=self.dialog_id, @@ -288,11 +297,11 @@ def request_to_respond( @abstractmethod def request_to_respond_prompt(self, text): - """请求服务端通过文本请求回复文本答复""" + """Request server to reply with text response via text request""" return def local_responding_started(self): - """本地tts播放开始""" + """Local TTS playback started""" _send_speech_json = self.request.generate_common_direction_request( "LocalRespondingStarted", self.dialog_id, @@ -300,7 +309,7 @@ def local_responding_started(self): self._send_text_frame(_send_speech_json) def local_responding_ended(self): - """本地tts播放结束""" + """Local TTS playback ended""" _send_speech_json = self.request.generate_common_direction_request( "LocalRespondingEnded", self.dialog_id, @@ -308,7 +317,7 @@ def local_responding_ended(self): self._send_text_frame(_send_speech_json) def send_heart_beat(self): - """发送心跳""" + """Send heartbeat""" _send_speech_json = self.request.generate_common_direction_request( "HeartBeat", self.dialog_id, @@ -316,7 +325,7 @@ def send_heart_beat(self): self._send_text_frame(_send_speech_json) def update_info(self, parameters: RequestToRespondParameters = None): - """更新信息""" + """Update information""" _send_speech_json = self.request.generate_update_info_json( direction_name="UpdateInfo", dialog_id=self.dialog_id, @@ -341,7 +350,7 @@ def get_conversation_mode(self) -> str: """get mode of conversation: support tap2talk/push2talk/duplex""" return self.request_params.upstream.mode - """内部方法""" # pylint: disable=pointless-string-statement + """Internal methods""" # pylint: disable=pointless-string-statement def _send_start_request( self, @@ -349,7 +358,7 @@ def _send_start_request( request_params: RequestParameters, task_id: str = None, ): - """发送'Start'请求""" + """Send 'Start' request""" _start_json = self.request.generate_start_request( workspace_id=self.workspace_id, direction_name="Start", @@ -366,7 +375,7 @@ def _run_forever(self): self.ws.run_forever(ping_interval=None, ping_timeout=None) def _connect(self, api_key: str): - """初始化WebSocket连接并发送启动请求。""" + """Initialize WebSocket connection and send startup request.""" self.ws = websocket.WebSocketApp( self.url, header=self.request.get_websocket_header(api_key), @@ -387,14 +396,14 @@ def close(self): self.ws.close() def _wait_for_connection(self): - """等待WebSocket连接建立""" + """Wait for WebSocket connection to be established""" timeout = 5 start_time = time.time() while ( not (self.ws.sock and self.ws.sock.connected) and (time.time() - start_time) < timeout ): - time.sleep(0.1) # 短暂休眠,避免密集轮询 + time.sleep(0.1) # Brief sleep to avoid busy polling def _send_text_frame(self, text: str): logger.info(">>>>>> send text frame : %s", text) @@ -408,14 +417,14 @@ def __del__(self): self.cleanup() def cleanup(self): - """清理所有资源""" + """Clean up all resources""" try: if self.ws: self.ws.close() if self.thread and self.thread.is_alive(): - # 设置标志位通知线程退出 + # Set flag to notify thread to exit self.thread.join(timeout=2) - # 清除引用 + # Clear references self.ws = None self.thread = None self._callback = None @@ -459,15 +468,16 @@ def generate_start_request( task_id: str = None, ) -> str: """ - 构建语音聊天服务的启动请求数据. - :param app_id: 管控台应用id - :param request_params: start请求body中的parameters + Build startup request data for voice chat service. + :param app_id: Console application ID + :param request_params: Parameters in start request body :param direction_name: - :param dialog_id: 对话ID. - :param workspace_id: 管控台工作空间id, 非必填字段。 - :param model: 模型 - :param task_id: 百炼请求任务 Id,默认会自动生成。您可以指定此 ID 来跟踪请求。 - :return: 启动请求字典. + :param dialog_id: Dialog ID. + :param workspace_id: Console workspace ID, optional field. + :param model: Model + :param task_id: DashScope request task ID, auto-generated by + default. You can specify this ID to track the request. + :return: Startup request dictionary. """ self.task_id = task_id self._get_dash_request_header(ActionType.START) @@ -492,10 +502,10 @@ def generate_common_direction_request( dialog_id: str, ) -> str: """ - 构建语音聊天服务的命令请求数据. - :param direction_name: 命令. - :param dialog_id: 对话ID. - :return: 命令请求json. + Build command request data for voice chat service. + :param direction_name: Command. + :param dialog_id: Dialog ID. + :return: Command request JSON. """ self._get_dash_request_header(ActionType.CONTINUE) self._get_dash_request_payload(direction_name, dialog_id, self.app_id) @@ -511,10 +521,10 @@ def generate_stop_request( dialog_id: str, ) -> str: """ - 构建语音聊天服务的启动请求数据. - :param direction_name:指令名称 - :param dialog_id: 对话ID. - :return: 启动请求json. + Build stop request data for voice chat service. + :param direction_name: Directive name + :param dialog_id: Dialog ID. + :return: Stop request JSON. """ self._get_dash_request_header(ActionType.FINISHED) self._get_dash_request_payload(direction_name, dialog_id, self.app_id) @@ -534,13 +544,15 @@ def generate_request_to_response_json( parameters: RequestToRespondParameters = None, ) -> str: """ - 构建语音聊天服务的命令请求数据. - :param direction_name: 命令. - :param dialog_id: 对话ID. - :param request_type: 服务应该采取的交互类型,transcript 表示直接把文本转语音,prompt 表示把文本送大模型回答 # noqa: E501 - :param text: 文本. - :param parameters: 命令请求body中的parameters - :return: 命令请求字典. + Build command request data for voice chat service. + :param direction_name: Command. + :param dialog_id: Dialog ID. + :param request_type: Interaction type the service should adopt, + transcript means convert text to speech directly, + prompt means send text to LLM for response + :param text: Text. + :param parameters: Parameters in command request body + :return: Command request dictionary. """ self._get_dash_request_header(ActionType.CONTINUE) @@ -572,10 +584,10 @@ def generate_update_info_json( parameters: RequestToRespondParameters = None, ) -> str: """ - 构建语音聊天服务的命令请求数据. - :param direction_name: 命令. - :param parameters: 命令请求body中的parameters - :return: 命令请求字典. + Build command request data for voice chat service. + :param direction_name: Command. + :param parameters: Parameters in command request body + :return: Command request dictionary. """ self._get_dash_request_header(ActionType.CONTINUE) @@ -600,8 +612,9 @@ def generate_update_info_json( def _get_dash_request_header(self, action: str): """ - 构建多模对话请求的请求协议Header - :param action: ActionType 百炼协议action 支持:run-task, continue-task, finish-task # noqa: E501 + Build request protocol header for multimodal dialog request. + :param action: ActionType DashScope protocol action, supports: + run-task, continue-task, finish-task """ if self.task_id is None: self.task_id = get_random_uuid() @@ -618,13 +631,13 @@ def _get_dash_request_payload( model: str = None, ): """ - 构建多模对话请求的请求协议payload - :param direction_name: 对话协议内部的指令名称 - :param dialog_id: 对话ID. - :param app_id: 管控台应用id - :param request_params: start请求body中的parameters - :param custom_input: 自定义输入 - :param model: 模型 + Build request protocol payload for multimodal dialog request. + :param direction_name: Internal directive name in dialog protocol + :param dialog_id: Dialog ID. + :param app_id: Console application ID + :param request_params: Parameters in start request body + :param custom_input: Custom input + :param model: Model """ if custom_input is not None: input = custom_input # pylint: disable=redefined-builtin @@ -651,20 +664,20 @@ def __init__( close_callback=None, ): super().__init__() - self.dialog_id = None # 对话ID. + self.dialog_id = None # Dialog ID self.dialog_state = state self._callback = callback - self._close_callback = close_callback # 保存关闭回调函数 + self._close_callback = close_callback # Save close callback function # pylint: disable=inconsistent-return-statements def handle_text_response(self, response_json: str): """ - 处理语音聊天服务的响应数据. - :param response_json: 从服务接收到的原始JSON字符串响应。 + Handle response data from voice chat service. + :param response_json: Original JSON string response from server. """ logger.info("<<<<<< server response: %s", response_json) try: - # 尝试将消息解析为JSON + # Attempt to parse message as JSON json_data = json.loads(response_json) if ( "status_code" in json_data["header"] @@ -760,8 +773,8 @@ def _handle_stopped(self): def _handle_state_changed(self, state: str): """ - 处理语音聊天状态流转. - :param state: 状态. + Handle voice chat state transitions. + :param state: State. """ self.dialog_state.change_state(state) self._callback.on_state_changed(self.dialog_state.get_current_state()) diff --git a/dashscope/multimodal/multimodal_request_params.py b/dashscope/multimodal/multimodal_request_params.py index fe88161..1caf609 100644 --- a/dashscope/multimodal/multimodal_request_params.py +++ b/dashscope/multimodal/multimodal_request_params.py @@ -5,7 +5,7 @@ def get_random_uuid() -> str: - """生成并返回32位UUID字符串""" + """Generate and return a 32-character UUID string""" return uuid.uuid4().hex @@ -13,7 +13,7 @@ def get_random_uuid() -> str: class DashHeader: action: str task_id: str = field(default=get_random_uuid()) - streaming: str = field(default="duplex") # 默认为 duplex + streaming: str = field(default="duplex") # default to duplex def to_dict(self): return { @@ -110,12 +110,17 @@ def to_dict(self): class Upstream: """struct for upstream""" - audio_format: str = field(default="pcm") # 上行语音格式,默认pcm.支持pcm/opus + audio_format: str = field( + default="pcm", + ) # upstream audio format, default pcm, supports pcm/opus type: str = field( default="AudioOnly", - ) # 上行类型:AudioOnly 仅语音通话; AudioAndVideo 上传视频 - mode: str = field(default="tap2talk") # 客户端交互模式 push2talk/tap2talk/duplex - sample_rate: int = field(default=16000) # 音频采样率 + ) # upstream type: AudioOnly for voice only; + # AudioAndVideo for video upload + mode: str = field( + default="tap2talk", + ) # client interaction mode: push2talk/tap2talk/duplex + sample_rate: int = field(default=16000) # audio sample rate vocabulary_id: str = field(default=None) asr_post_processing: AsrPostProcessing = field(default=None) pass_through_params: dict = field(default=None) # type: ignore[arg-type] @@ -140,18 +145,24 @@ def to_dict(self): @dataclass class Downstream: - # transcript 返回用户语音识别结果 - # dialog 返回对话系统回答中间结果 - # 可以设置多种,以逗号分割,默认为transcript - voice: str = field(default="") # 语音音色 - sample_rate: int = field(default=0) # 语音音色 # 合成音频采样率 - intermediate_text: str = field(default="transcript") # 控制返回给用户那些中间文本: - debug: bool = field(default=False) # 控制是否返回debug信息 - # type_: str = field(default="Audio", metadata={"alias": "type"}) # 下行类型:Text:不需要下发语音;Audio:输出语音,默认值 # noqa: E501 # pylint: disable=line-too-long - audio_format: str = field(default="pcm") # 下行语音格式,默认pcm 。支持pcm/mp3 - volume: int = field(default=50) # 语音音量 0-100 - pitch_rate: int = field(default=100) # 语音语调 50-200 - speech_rate: int = field(default=100) # 语音语速 50-200 + # transcript returns user speech recognition results + # dialog returns dialog system intermediate results + # Multiple values can be set, comma-separated, default is transcript + voice: str = field(default="") # voice timbre + sample_rate: int = field( + default=0, + ) # voice timbre # synthesis audio sample rate + intermediate_text: str = field( + default="transcript", + ) # Controls which intermediate text is returned to user: + debug: bool = field(default=False) # Controls whether to return debug info + # type_: str = field(default="Audio", metadata={"alias": "type"}) # downstream type: Text: no audio output; Audio: output audio, default # noqa: E501 # pylint: disable=line-too-long + audio_format: str = field( + default="pcm", + ) # downstream audio format, default pcm, supports pcm/mp3 + volume: int = field(default=50) # voice volume 0-100 + pitch_rate: int = field(default=100) # voice pitch 50-200 + speech_rate: int = field(default=100) # voice speed 50-200 pass_through_params: dict = field(default=None) # type: ignore[arg-type] def to_dict(self): diff --git a/dashscope/multimodal/tingwu/tingwu_realtime.py b/dashscope/multimodal/tingwu/tingwu_realtime.py index d553737..71ed4ab 100644 --- a/dashscope/multimodal/tingwu/tingwu_realtime.py +++ b/dashscope/multimodal/tingwu/tingwu_realtime.py @@ -129,7 +129,7 @@ def __init__( self.response = _TingWuResponse( self._callback, self.close, - ) # 传递 self.close 作为回调 + ) # pass self.close as callback def _on_message( # pylint: disable=unused-argument self, @@ -145,13 +145,13 @@ def _on_message( # pylint: disable=unused-argument def _on_error(self, ws, error): # pylint: disable=unused-argument logger.error(f"Error: {error}") if self._callback: - error_code = "" # 默认错误码 + error_code = "" # default error code if "connection" in str(error).lower(): - error_code = "1001" # 连接错误 + error_code = "1001" # connection error elif "timeout" in str(error).lower(): - error_code = "1002" # 超时错误 + error_code = "1002" # timeout error elif "authentication" in str(error).lower(): - error_code = "1003" # 认证错误 + error_code = "1003" # authentication error self._callback.on_error( error_code=error_code, error_msg=str(error), @@ -251,7 +251,7 @@ def _connect(self, api_key: str): on_close=self._on_close, ) self.thread = threading.Thread(target=self._run_forever) - # 统一心跳机制配置 + # Unified heartbeat configuration self.ws.ping_interval = 5 self.ws.ping_timeout = 4 self.thread.daemon = True @@ -272,11 +272,11 @@ def _wait_for_connection(self): not (self.ws.sock and self.ws.sock.connected) and (time.time() - start_time) < timeout ): - time.sleep(0.1) # 短暂休眠,避免密集轮询 + time.sleep(0.1) # Brief sleep to avoid busy polling def _send_text_frame(self, text: str): - # 避免在日志中记录敏感信息,如API密钥等 - # 只记录非敏感信息 + # Avoid logging sensitive information such as API keys + # Only log non-sensitive information if '"Authorization"' not in text: logger.info(">>>>>> send text frame : %s", text) else: @@ -300,9 +300,9 @@ def cleanup(self): if self.ws: self.ws.close() if self.thread and self.thread.is_alive(): - # 设置标志位通知线程退出 + # Set flag to notify thread to exit self.thread.join(timeout=2) - # 清除引用 + # Clear references self.ws = None self.thread = None self._callback = None @@ -488,9 +488,9 @@ def _get_dash_request_payload( class _TingWuResponse: def __init__(self, callback: TingWuRealtimeCallback, close_callback=None): super().__init__() - self.task_id = None # 对话ID. + self.task_id = None # Task ID self._callback = callback - self._close_callback = close_callback # 保存关闭回调函数 + self._close_callback = close_callback # Save close callback function def handle_text_response(self, response_json: str): """ @@ -558,7 +558,9 @@ def _handle_tingwu_agent_text_response( self._callback.on_recognize_result(response_json) elif action == "ai-result": self._callback.on_ai_result(response_json) - elif action == "speech-end": # ai-result事件永远会先于speech-end事件 + elif ( + action == "speech-end" + ): # ai-result event always arrives before speech-end event self._callback.on_stopped() if self._close_callback is not None: self._close_callback() @@ -598,7 +600,7 @@ def to_dict(self): class DashHeader: action: str task_id: str = field(default=get_random_uuid()) - streaming: str = field(default="duplex") # 默认为 duplex + streaming: str = field(default="duplex") # default to duplex def to_dict(self): return { diff --git a/dashscope/rerank/__init__.py b/dashscope/rerank/__init__.py index e69de29..2f0829c 100644 --- a/dashscope/rerank/__init__.py +++ b/dashscope/rerank/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from dashscope.rerank.text_rerank import AioTextReRank, TextReRank + +__all__ = ["AioTextReRank", "TextReRank"] diff --git a/dashscope/rerank/text_rerank.py b/dashscope/rerank/text_rerank.py index b152ae6..3a4b31e 100644 --- a/dashscope/rerank/text_rerank.py +++ b/dashscope/rerank/text_rerank.py @@ -1,13 +1,46 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -from typing import List +from typing import Any, Dict, List, Tuple from dashscope.api_entities.dashscope_response import ReRankResponse -from dashscope.client.base_api import BaseApi +from dashscope.client.base_api import BaseAioApi, BaseApi from dashscope.common.error import InputRequired, ModelRequired from dashscope.common.utils import _get_task_group_and_task +__all__ = ["TextReRank", "AioTextReRank"] + + +def _build_rerank_request( + model: str, + query: str, + documents: List[str], + return_documents: bool = None, + top_n: int = None, + instruct: str = None, + **kwargs, +) -> Tuple[str, str, Dict[str, Any], Dict[str, Any]]: + if query is None or documents is None or not documents: + raise InputRequired("query and documents are required!") + if model is None or not model: + raise ModelRequired("Model is required!") + + task_group, function = _get_task_group_and_task(__name__) + rerank_input = { + "query": query, + "documents": documents, + } + parameters = {} + if return_documents is not None: + parameters["return_documents"] = return_documents + if top_n is not None: + parameters["top_n"] = top_n + if instruct is not None: + parameters["instruct"] = instruct + parameters = {**parameters, **kwargs} + + return task_group, function, rerank_input, parameters + class TextReRank(BaseApi): task = "text-rerank" @@ -41,8 +74,8 @@ def call( # type: ignore[override] # pylint: disable=arguments-renamed documents (List[str]): The documents to rank. return_documents(bool, `optional`): enable return origin documents, system default is false. - top_n(int, `optional`): how many documents to return, default return # noqa: E501 - all the documents. + top_n(int, `optional`): how many documents to return, + default return all the documents. api_key (str, optional): The DashScope api key. Defaults to None. instruct (str, optional): Custom task instruction to guide ranking strategy. English recommended. @@ -55,23 +88,15 @@ def call( # type: ignore[override] # pylint: disable=arguments-renamed RerankResponse: The rerank result. """ - if query is None or documents is None or not documents: - raise InputRequired("query and documents are required!") - if model is None or not model: - raise ModelRequired("Model is required!") - task_group, function = _get_task_group_and_task(__name__) - input = { # pylint: disable=redefined-builtin - "query": query, - "documents": documents, - } - parameters = {} - if return_documents is not None: - parameters["return_documents"] = return_documents - if top_n is not None: - parameters["top_n"] = top_n - if instruct is not None: - parameters["instruct"] = instruct - parameters = {**parameters, **kwargs} + task_group, function, rerank_input, parameters = _build_rerank_request( + model=model, + query=query, + documents=documents, + return_documents=return_documents, + top_n=top_n, + instruct=instruct, + **kwargs, + ) response = super().call( model=model, @@ -79,7 +104,73 @@ def call( # type: ignore[override] # pylint: disable=arguments-renamed task=TextReRank.task, function=function, api_key=api_key, - input=input, + input=rerank_input, + **parameters, # type: ignore[arg-type] + ) + + return ReRankResponse.from_api_response(response) + + +class AioTextReRank(BaseAioApi): + task = "text-rerank" + """Async API for rerank models.""" + + Models = TextReRank.Models + + @classmethod + # pylint: disable=arguments-renamed + async def call( # type: ignore[override] + cls, + model: str, + query: str, + documents: List[str], + return_documents: bool = None, + top_n: int = None, + api_key: str = None, + workspace: str = None, + instruct: str = None, + **kwargs, + ) -> ReRankResponse: + """Calling rerank service asynchronously. + + Args: + model (str): The model to use. + query (str): The query string. + documents (List[str]): The documents to rank. + return_documents(bool, `optional`): enable return origin documents, + system default is false. + top_n(int, `optional`): how many documents to return, + default return all the documents. + api_key (str, optional): The DashScope api key. Defaults to None. + workspace (str, optional): The DashScope workspace id. + instruct (str, optional): Custom task instruction to guide + ranking strategy. English recommended. + + Raises: + InputRequired: The query and documents are required. + ModelRequired: The model is required. + + Returns: + RerankResponse: The rerank result. + """ + task_group, function, rerank_input, parameters = _build_rerank_request( + model=model, + query=query, + documents=documents, + return_documents=return_documents, + top_n=top_n, + instruct=instruct, + **kwargs, + ) + + response = await super().call( + model=model, + task_group=task_group, + task=AioTextReRank.task, + function=function, + api_key=api_key, + workspace=workspace, + input=rerank_input, **parameters, # type: ignore[arg-type] ) diff --git a/dashscope/tokenizers/qwen_tokenizer.py b/dashscope/tokenizers/qwen_tokenizer.py index bfa1119..9a538f9 100644 --- a/dashscope/tokenizers/qwen_tokenizer.py +++ b/dashscope/tokenizers/qwen_tokenizer.py @@ -33,6 +33,11 @@ ) SPECIAL_TOKENS_SET = set(t for i, t in SPECIAL_TOKENS) +# tiktoken's BPE merges tokens recursively in Rust, which can overflow the +# call stack on very long inputs (pyo3_runtime.PanicException: StackOverflow). +# Split text into chunks below this threshold before encoding. +_CHUNK_SIZE = 100_000 + class QwenTokenizer(Tokenizer): @staticmethod @@ -102,11 +107,51 @@ def encode( # type: ignore[override] disallowed_special: Union[Collection, str] = (), ) -> Union[List[List], List]: text = unicodedata.normalize("NFC", text) - return self._tokenizer.encode( - text, - allowed_special=allowed_special, - disallowed_special=disallowed_special, - ) + if len(text) <= _CHUNK_SIZE: + return self._tokenizer.encode( + text, + allowed_special=allowed_special, + disallowed_special=disallowed_special, + ) + + result = [] + for chunk in self._split_text(text): + result.extend( + self._tokenizer.encode( + chunk, + allowed_special=allowed_special, + disallowed_special=disallowed_special, + ), + ) + return result + + @staticmethod + def _split_text(text: str, chunk_size: int = _CHUNK_SIZE) -> List[str]: + """Split text into chunks at safe tokenization boundaries.""" + parts: List[str] = [] + for i, line in enumerate(text.split("\n")): + piece = line if i == 0 else "\n" + line + if len(piece) <= chunk_size: + parts.append(piece) + else: + for j in range(0, len(piece), chunk_size): + parts.append(piece[j : j + chunk_size]) + + chunks: List[str] = [] + current_chunk: List[str] = [] + current_len = 0 + for part in parts: + if current_len + len(part) <= chunk_size: + current_chunk.append(part) + current_len += len(part) + else: + if current_chunk: + chunks.append("".join(current_chunk)) + current_chunk = [part] + current_len = len(part) + if current_chunk: + chunks.append("".join(current_chunk)) + return chunks def decode( self, diff --git a/dashscope/utils/oss_utils.py b/dashscope/utils/oss_utils.py index 216333e..6b5e476 100644 --- a/dashscope/utils/oss_utils.py +++ b/dashscope/utils/oss_utils.py @@ -129,6 +129,23 @@ def get_upload_certificate( return super().get(None, api_key, params=params, **kwargs) # type: ignore[return-value] # pylint: disable=line-too-long # noqa: E501 +def _resolve_file_uri_path(file_uri: str): + parse_result = urlparse(file_uri) + if parse_result.netloc: + file_path = parse_result.netloc + unquote_plus(parse_result.path) + else: + file_path = unquote_plus(parse_result.path) + + if ( + file_path.startswith("/") + and len(file_path) > 2 + and file_path[2] == ":" + ): + file_path = file_path[1:] + + return os.path.expanduser(file_path) + + def upload_file( model: str, upload_path: str, @@ -136,11 +153,7 @@ def upload_file( upload_certificate: dict = None, ): if upload_path.startswith(FILE_PATH_SCHEMA): - parse_result = urlparse(upload_path) - if parse_result.netloc: - file_path = parse_result.netloc + unquote_plus(parse_result.path) - else: - file_path = unquote_plus(parse_result.path) + file_path = _resolve_file_uri_path(upload_path) if os.path.exists(file_path): file_url, _ = OssUtils.upload( model=model, @@ -184,11 +197,7 @@ def check_and_upload_local( is the certificate (newly obtained or passed in) """ if content.startswith(FILE_PATH_SCHEMA): - parse_result = urlparse(content) - if parse_result.netloc: - file_path = parse_result.netloc + unquote_plus(parse_result.path) - else: - file_path = unquote_plus(parse_result.path) + file_path = _resolve_file_uri_path(content) if os.path.isfile(file_path): file_url, cert = OssUtils.upload( model=model, @@ -201,9 +210,10 @@ def check_and_upload_local( f"Uploading file: {content} failed", ) return True, file_url, cert - elif content.startswith("oss://"): + raise InvalidInput(f"The file: {file_path} is not exists!") + if content.startswith("oss://"): return True, content, upload_certificate - elif not content.startswith("http"): + if not content.startswith("http"): content = os.path.expanduser(content) if os.path.isfile(content): file_url, cert = OssUtils.upload( diff --git a/dashscope/version.py b/dashscope/version.py index fbaaca6..a4c4f3f 100644 --- a/dashscope/version.py +++ b/dashscope/version.py @@ -1,4 +1,4 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. -__version__ = "1.25.21" +__version__ = "1.25.23" diff --git a/tests/unit/test_aio_session.py b/tests/unit/test_aio_session.py new file mode 100644 index 0000000..b82061a --- /dev/null +++ b/tests/unit/test_aio_session.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +""" +Shared aiohttp session pool unit tests. + +Tests the connection reuse and SSL context caching in aio_session module. +""" + +# pylint: disable=protected-access + +import asyncio +import ssl +from unittest.mock import patch + +import aiohttp +import pytest + +from dashscope.api_entities import aio_session + + +class TestSSLContextCaching: + """Test SSL context is created once and reused.""" + + def setup_method(self): + aio_session._shared_ssl_context = None + + def test_get_ssl_context_returns_ssl_context(self): + ctx = aio_session.get_ssl_context() + assert isinstance(ctx, ssl.SSLContext) + + def test_get_ssl_context_cached(self): + ctx1 = aio_session.get_ssl_context() + ctx2 = aio_session.get_ssl_context() + assert ctx1 is ctx2 + + def test_get_ssl_context_calls_create_default_context_once(self): + with patch( + "ssl.create_default_context", + wraps=ssl.create_default_context, + ) as mock_create: + aio_session._shared_ssl_context = None + aio_session.get_ssl_context() + aio_session.get_ssl_context() + aio_session.get_ssl_context() + assert mock_create.call_count == 1 + + +class TestSharedAioSession: + """Test shared session creation and reuse.""" + + def setup_method(self): + aio_session._shared_ssl_context = None + aio_session._aio_sessions.clear() + + @pytest.mark.asyncio + async def test_get_shared_session_returns_client_session(self): + session = await aio_session.get_shared_aio_session() + try: + assert isinstance(session, aiohttp.ClientSession) + assert not session.closed + finally: + await aio_session.close_shared_aio_session() + + @pytest.mark.asyncio + async def test_get_shared_session_reuses_same_session(self): + s1 = await aio_session.get_shared_aio_session() + s2 = await aio_session.get_shared_aio_session() + try: + assert s1 is s2 + finally: + await aio_session.close_shared_aio_session() + + @pytest.mark.asyncio + async def test_shared_session_has_tcp_connector(self): + session = await aio_session.get_shared_aio_session() + try: + assert isinstance(session.connector, aiohttp.TCPConnector) + finally: + await aio_session.close_shared_aio_session() + + @pytest.mark.asyncio + async def test_shared_session_uses_cached_ssl(self): + session = await aio_session.get_shared_aio_session() + try: + ssl_ctx = aio_session.get_ssl_context() + assert session.connector._ssl is ssl_ctx + finally: + await aio_session.close_shared_aio_session() + + @pytest.mark.asyncio + async def test_close_shared_session(self): + session = await aio_session.get_shared_aio_session() + assert not session.closed + await aio_session.close_shared_aio_session() + assert session.closed + + @pytest.mark.asyncio + async def test_new_session_after_close(self): + s1 = await aio_session.get_shared_aio_session() + await aio_session.close_shared_aio_session() + assert s1.closed + + s2 = await aio_session.get_shared_aio_session() + try: + assert s2 is not s1 + assert not s2.closed + finally: + await aio_session.close_shared_aio_session() + + @pytest.mark.asyncio + async def test_close_idempotent(self): + await aio_session.close_shared_aio_session() + await aio_session.close_shared_aio_session() + + @pytest.mark.asyncio + async def test_stale_sessions_cleaned_up(self): + """Test that closed sessions are replaced in the dict.""" + s1 = await aio_session.get_shared_aio_session() + loop = asyncio.get_running_loop() + + # Manually close without calling close_shared_aio_session + await s1.close() + assert s1.closed + assert loop in aio_session._aio_sessions + + # Getting a new session should replace the stale entry + s2 = await aio_session.get_shared_aio_session() + try: + assert s2 is not s1 + assert not s2.closed + assert aio_session._aio_sessions[loop] is s2 + finally: + await aio_session.close_shared_aio_session() + + +class TestSessionPerLoop: + """Test that different event loops get different sessions.""" + + def setup_method(self): + aio_session._shared_ssl_context = None + aio_session._aio_sessions.clear() + + def test_different_loops_get_different_sessions(self): + sessions = [] + + def run_in_loop(): + loop = asyncio.new_event_loop() + try: + session = loop.run_until_complete( + aio_session.get_shared_aio_session(), + ) + sessions.append(session) + # Keep loop open so session stays valid + loop.run_until_complete( + aio_session.close_shared_aio_session(), + ) + finally: + loop.close() + + run_in_loop() + run_in_loop() + + # Each loop should have gotten its own session + assert len(sessions) == 2 + assert sessions[0] is not sessions[1] diff --git a/tests/unit/test_async_custom_session.py b/tests/unit/test_async_custom_session.py index 180b478..a82f005 100644 --- a/tests/unit/test_async_custom_session.py +++ b/tests/unit/test_async_custom_session.py @@ -134,11 +134,11 @@ async def mock_handle_response(_response): mock_session.close.assert_not_called() @pytest.mark.asyncio - async def test_temporary_aio_session_is_created_when_no_custom_session( + async def test_shared_aio_session_is_used_when_no_custom_session( self, ): - """测试没有自定义 aio_session 时会创建临时 aio_session""" - # 创建 mock session + """测试没有自定义 aio_session 时使用共享 session(不被关闭)""" + # 创建 mock shared session mock_session = AsyncMock() mock_response = AsyncMock() mock_response.status = 200 @@ -172,7 +172,10 @@ async def test_temporary_aio_session_is_created_when_no_custom_session( async def mock_handle_response(_response): yield mock_response - with patch("aiohttp.ClientSession", return_value=mock_session): + with patch( + "dashscope.api_entities.http_request.get_shared_aio_session", + return_value=mock_session, + ): with patch.object( http_request, "_handle_aio_response", @@ -180,8 +183,8 @@ async def mock_handle_response(_response): ): _ = await http_request.aio_call() - # 验证临时 aio_session 被关闭 - mock_session.close.assert_called_once() + # 共享 session 不应被关闭(由 aio_session 模块管理生命周期) + mock_session.close.assert_not_called() class TestAsyncSessionResourceManagement: @@ -237,8 +240,8 @@ async def mock_handle_response(_response): custom_session.close.assert_not_called() @pytest.mark.asyncio - async def test_temporary_aio_session_closed_on_success(self): - """测试临时 aio_session 在成功后被关闭""" + async def test_shared_aio_session_not_closed_on_success(self): + """测试共享 aio_session 在成功后不被关闭(由模块管理生命周期)""" mock_session = AsyncMock() mock_response = AsyncMock() mock_response.status = 200 @@ -269,7 +272,10 @@ async def test_temporary_aio_session_closed_on_success(self): async def mock_handle_response(_response): yield mock_response - with patch("aiohttp.ClientSession", return_value=mock_session): + with patch( + "dashscope.api_entities.http_request.get_shared_aio_session", + return_value=mock_session, + ): with patch.object( http_request, "_handle_aio_response", @@ -277,12 +283,12 @@ async def mock_handle_response(_response): ): _ = await http_request.aio_call() - # 验证临时 aio_session 被关闭 - mock_session.close.assert_called_once() + # 共享 session 不应被关闭 + mock_session.close.assert_not_called() @pytest.mark.asyncio - async def test_temporary_aio_session_closed_on_exception(self): - """测试临时 aio_session 在异常时也被关闭""" + async def test_shared_aio_session_not_closed_on_exception(self): + """测试共享 aio_session 在异常时也不被关闭""" mock_session = AsyncMock() # Make request() raise an exception @@ -311,12 +317,15 @@ async def mock_request(*_args, **_kwargs): http_request.data = request_data # 执行请求应该抛出异常 - with patch("aiohttp.ClientSession", return_value=mock_session): + with patch( + "dashscope.api_entities.http_request.get_shared_aio_session", + return_value=mock_session, + ): with pytest.raises(Exception, match="Network error"): _ = await http_request.aio_call() - # 验证临时 aio_session 仍然被关闭 - mock_session.close.assert_called_once() + # 共享 session 仍然不应被关闭(由模块管理生命周期) + mock_session.close.assert_not_called() class TestAsyncSessionWithCustomConfiguration: @@ -533,8 +542,8 @@ async def test_works_without_aio_session_parameter(self): assert http_request.method == HTTPMethod.POST @pytest.mark.asyncio - async def test_default_behavior_unchanged(self): - """测试默认行为未改变""" + async def test_default_behavior_uses_shared_session(self): + """测试默认行为使用共享 session(不被关闭)""" mock_session = AsyncMock() mock_response = AsyncMock() mock_response.status = 200 @@ -566,7 +575,10 @@ async def test_default_behavior_unchanged(self): async def mock_handle_response(_response): yield mock_response - with patch("aiohttp.ClientSession", return_value=mock_session): + with patch( + "dashscope.api_entities.http_request.get_shared_aio_session", + return_value=mock_session, + ): with patch.object( http_request, "_handle_aio_response", @@ -574,8 +586,8 @@ async def mock_handle_response(_response): ): _ = await http_request.aio_call() - # 验证临时 aio_session 被关闭(原有行为) - mock_session.close.assert_called_once() + # 共享 session 不应被关闭(生命周期由 aio_session 模块管理) + mock_session.close.assert_not_called() class TestAsyncSessionLifecycle: diff --git a/tests/unit/test_conversation.py b/tests/unit/test_conversation.py index 52c734d..8dedcc0 100644 --- a/tests/unit/test_conversation.py +++ b/tests/unit/test_conversation.py @@ -234,5 +234,5 @@ def test_not_qwen(self, mock_server: MockServer): assert response.output.finish_reason == "stop" req = mock_server.requests.get(block=True) assert req["model"] == Generation.Models.dolly_12b_v2 - assert req["parameters"] == {} + assert "user_agent" not in req.get("parameters", {}) assert req["input"] == {"prompt": prompt} diff --git a/tests/unit/test_dashscope_response.py b/tests/unit/test_dashscope_response.py new file mode 100644 index 0000000..ed7a135 --- /dev/null +++ b/tests/unit/test_dashscope_response.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from dashscope.api_entities.dashscope_response import DictMixin + + +class TestDictMixin: + def test_getattr_missing_key_raises_attribute_error(self): + response = DictMixin(existing="value") + + try: + response.missing + except AttributeError: + return + + raise AssertionError("Missing attribute should raise AttributeError") + + def test_getattr_existing_key_returns_value(self): + response = DictMixin(existing="value") + + assert response.existing == "value" diff --git a/tests/unit/test_oss_utils.py b/tests/unit/test_oss_utils.py new file mode 100644 index 0000000..505d384 --- /dev/null +++ b/tests/unit/test_oss_utils.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# Copyright (c) Alibaba, Inc. and its affiliates. + +from http import HTTPStatus + +import pytest + +from dashscope.common.error import InvalidInput +from dashscope.utils import oss_utils +from dashscope.utils.oss_utils import OssUtils + + +class FakeUploadResponse: + status_code = HTTPStatus.OK + headers = {} + + +class FakeSession: + captured_file = None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + return False + + def post(self, url, files, data, headers, timeout): + assert url == "https://oss.example.com" + assert data["key"] == "test-dir/dogs.jpg" + assert headers["Accept"] == "application/json" + assert timeout == 3600 + + FakeSession.captured_file = files["file"] + assert not FakeSession.captured_file.closed + return FakeUploadResponse() + + +class TestOssUtils: + def test_upload_closes_opened_file(self, monkeypatch): + upload_certificate = { + "oss_access_key_id": "access-key-id", + "signature": "signature", + "policy": "policy", + "upload_dir": "test-dir", + "x_oss_object_acl": "private", + "x_oss_forbid_overwrite": "true", + "upload_host": "https://oss.example.com", + } + FakeSession.captured_file = None + monkeypatch.setattr(oss_utils.requests, "Session", FakeSession) + + file_url, returned_certificate = OssUtils.upload( + model="test-model", + file_path="tests/data/dogs.jpg", + api_key="test-api-key", + upload_certificate=upload_certificate, + ) + + assert file_url == "oss://test-dir/dogs.jpg" + assert returned_certificate is upload_certificate + assert FakeSession.captured_file is not None + assert FakeSession.captured_file.closed + + def test_check_and_upload_local_uploads_relative_file_uri( + self, + monkeypatch, + ): + captured_file_path = {} + + def fake_isfile(file_path): + captured_file_path["value"] = file_path + return True + + def fake_upload(model, file_path, api_key, upload_certificate): + assert model == "test-model" + assert api_key == "test-api-key" + assert upload_certificate == {"cert": "value"} + assert file_path == "test_video_frames/frame_0000.jpg" + return "oss://test-dir/frame_0000.jpg", {"cert": "value"} + + monkeypatch.setattr(oss_utils.os.path, "isfile", fake_isfile) + monkeypatch.setattr(OssUtils, "upload", fake_upload) + + is_upload, file_url, certificate = oss_utils.check_and_upload_local( + model="test-model", + content="file://test_video_frames/frame_0000.jpg", + api_key="test-api-key", + upload_certificate={"cert": "value"}, + ) + + assert is_upload + assert file_url == "oss://test-dir/frame_0000.jpg" + assert certificate == {"cert": "value"} + assert ( + captured_file_path["value"] == "test_video_frames/frame_0000.jpg" + ) + + def test_check_and_upload_local_supports_windows_absolute_file_uri( + self, + monkeypatch, + ): + captured_file_path = {} + + def fake_isfile(file_path): + captured_file_path["value"] = file_path + return True + + def fake_upload( + model, + file_path, + api_key, + upload_certificate, + ): + assert model == "test-model" + assert file_path == "C:/Users/test/frame_0000.jpg" + assert api_key == "test-api-key" + return "oss://test-dir/frame_0000.jpg", upload_certificate + + monkeypatch.setattr(oss_utils.os.path, "isfile", fake_isfile) + monkeypatch.setattr(OssUtils, "upload", fake_upload) + + is_upload, file_url, _ = oss_utils.check_and_upload_local( + model="test-model", + content="file:///C:/Users/test/frame_0000.jpg", + api_key="test-api-key", + ) + + assert is_upload + assert file_url == "oss://test-dir/frame_0000.jpg" + assert captured_file_path["value"] == "C:/Users/test/frame_0000.jpg" + + def test_check_and_upload_local_raises_when_file_uri_not_found( + self, + monkeypatch, + ): + monkeypatch.setattr( + oss_utils.os.path, + "isfile", + lambda file_path: False, + ) + + with pytest.raises(InvalidInput): + oss_utils.check_and_upload_local( + model="test-model", + content="file://missing/frame_0000.jpg", + api_key="test-api-key", + ) diff --git a/tests/unit/test_rerank.py b/tests/unit/test_rerank.py index d2afd34..b742794 100644 --- a/tests/unit/test_rerank.py +++ b/tests/unit/test_rerank.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- # Copyright (c) Alibaba, Inc. and its affiliates. +import asyncio import json import uuid -from dashscope import TextReRank +from dashscope import AioTextReRank, TextReRank from tests.unit.mock_request_base import MockServerBase from tests.unit.mock_server import MockServer @@ -62,3 +63,61 @@ def test_call(self, mock_server: MockServer): assert len(response.output["results"]) == 2 assert response.output["results"][0]["index"] == 1 assert response.output["results"][1]["document"]["text"] == "黑龙江离俄罗斯很近" + + def test_aio_call(self, mock_server: MockServer): + response_body = { + "output": { + "results": [ + { + "index": 1, + "relevance_score": 0.987654, + "document": { + "text": "哈尔滨是中国黑龙江省的省会,位于中国东北", + }, + }, + { + "index": 0, + "relevance_score": 0.876543, + "document": { + "text": "黑龙江离俄罗斯很近", + }, + }, + ], + }, + "usage": { + "input_tokens": 1279, + }, + "request_id": "b042e72d-7994-97dd-b3d2-7ee7e0140525", + } + mock_server.responses.put(json.dumps(response_body)) + model = str(uuid.uuid4()) + query = str(uuid.uuid4()) + documents = [ + str(uuid.uuid4()), + str(uuid.uuid4()), + str(uuid.uuid4()), + str(uuid.uuid4()), + ] + + response = asyncio.run( + AioTextReRank.call( + model=model, + query=query, + documents=documents, + return_documents=True, + top_n=2, + instruct="Rank the documents by relevance.", + ), + ) + + req = mock_server.requests.get(block=True) + assert req["path"] == "/api/v1/services/rerank/text-rerank/text-rerank" + assert req["body"]["parameters"] == { + "return_documents": True, + "top_n": 2, + "instruct": "Rank the documents by relevance.", + } + assert req["body"]["input"] == {"query": query, "documents": documents} + assert response.usage["input_tokens"] == 1279 + assert len(response.output["results"]) == 2 + assert response.output["results"][0]["index"] == 1 diff --git a/tests/unit/test_tokenizer.py b/tests/unit/test_tokenizer.py index 903e1f6..682c6a1 100644 --- a/tests/unit/test_tokenizer.py +++ b/tests/unit/test_tokenizer.py @@ -4,6 +4,7 @@ import os from dashscope.tokenizers.tokenizer import get_tokenizer +from dashscope.tokenizers.qwen_tokenizer import _CHUNK_SIZE class TestTokenization: @@ -47,3 +48,33 @@ def test_encode_decode(self): "<|endoftext|>", disallowed_special=set(), ) + + def test_encode_chunk_size_exceed(self): + # Test encoding functionality when text length exceeds _CHUNK_SIZE + tokenizer = get_tokenizer("qwen-7b-chat") + + # Create a long text that exceeds _CHUNK_SIZE + long_text = "Hello world! " * ( + _CHUNK_SIZE // 12 + 10 + ) # Ensure it exceeds the threshold + + # Encode long text, should not raise an exception + tokens = tokenizer.encode(long_text) + + # Decoded text should match the original string + decoded_str = tokenizer.decode(tokens) + assert decoded_str == long_text + + # Ensure the return type is a list + assert isinstance(tokens, list) + + # Test long text with special characters + long_text_with_special = ( + "<|extra_0|> " * (_CHUNK_SIZE // 12 + 5) + ) + "Normal text here." + tokens_with_special = tokenizer.encode( + long_text_with_special, + allowed_special="all", + ) + decoded_with_special = tokenizer.decode(tokens_with_special) + assert decoded_with_special == long_text_with_special