e9d2831850
CI Build & Deploy Pipeline / Build Staging API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (push) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m30s
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (push) Successful in 3m13s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 3m18s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 16m16s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 16m20s
CI/CD Pipeline / Integration Tests (push) Successful in 2m30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m24s
701 lines
24 KiB
Python
Executable File
701 lines
24 KiB
Python
Executable File
"""CosyVoice 语音服务 — 适配阿里云百炼 DashScope API.
|
||
|
||
封装阿里云百炼 CosyVoice 语音合成 API,提供:
|
||
- 预置音色列表查询
|
||
- 音色克隆(提交 + 轮询状态)
|
||
- 语音合成(同步非流式调用)
|
||
|
||
API 文档:
|
||
- 音色克隆: https://help.aliyun.com/document_detail/3027318.html
|
||
- 语音合成: https://help.aliyun.com/zh/model-studio/cosyvoice-tts-http-api
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import time
|
||
from dataclasses import dataclass
|
||
from typing import Any, Callable, Optional
|
||
|
||
import httpx
|
||
|
||
from packages.domain.preset_voices import PresetVoice, get_preset_voices
|
||
from packages.shared.config import get_shared_settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class CosyVoiceError(Exception):
|
||
"""CosyVoice API 调用异常。"""
|
||
|
||
pass
|
||
|
||
|
||
class CosyVoiceTimeoutError(CosyVoiceError):
|
||
"""CosyVoice API 超时。"""
|
||
|
||
pass
|
||
|
||
|
||
class CosyVoiceAuthError(CosyVoiceError):
|
||
"""CosyVoice API 认证失败。"""
|
||
|
||
pass
|
||
|
||
|
||
@dataclass
|
||
class CloneResult:
|
||
"""音色克隆结果。"""
|
||
|
||
voice_id: str
|
||
request_id: str = ""
|
||
|
||
|
||
@dataclass
|
||
class SynthesizeResult:
|
||
"""语音合成结果。"""
|
||
|
||
audio_url: str
|
||
duration: float = 0.0
|
||
file_size: int = 0
|
||
request_id: str = ""
|
||
|
||
|
||
class CosyVoiceService:
|
||
"""CosyVoice 语音服务.
|
||
|
||
封装阿里云百炼 CosyVoice API,提供音色克隆和语音合成功能.
|
||
|
||
接口总览:
|
||
- 音色克隆: POST /services/audio/tts/customization (model=voice-enrollment)
|
||
- action=create_voice: 创建克隆音色,返回 voice_id(状态 DEPLOYING)
|
||
- action=query_voice: 查询音色状态(DEPLOYING / OK / UNDEPLOYED)
|
||
- 语音合成: POST /services/audio/tts/SpeechSynthesizer (model=cosyvoice-v3-flash)
|
||
- 非流式: 同步返回音频 URL
|
||
|
||
使用示例:
|
||
service = CosyVoiceService(
|
||
api_key="your-api-key",
|
||
base_url="https://dashscope.aliyuncs.com/api/v1",
|
||
model="cosyvoice-v3-flash",
|
||
)
|
||
|
||
# 音色克隆
|
||
result = service.clone_voice(audio_url="https://example.com/audio.mp3")
|
||
|
||
# 语音合成
|
||
result = service.synthesize_speech(text="你好世界", voice_id="longxiaochun_v3")
|
||
"""
|
||
|
||
# 音色状态轮询配置
|
||
CLONE_POLL_INTERVAL = 5.0 # 秒
|
||
CLONE_MAX_POLL_ATTEMPTS = 60 # 最多轮询 60 次(5分钟)
|
||
|
||
# 重试配置
|
||
MAX_RETRIES = 3
|
||
RETRY_BACKOFF = 1.0 # 秒,指数退避基数
|
||
|
||
def __init__(
|
||
self,
|
||
api_key: str = "",
|
||
base_url: str = "",
|
||
model: str = "",
|
||
clone_model: str = "",
|
||
http_client: Optional[httpx.Client] = None,
|
||
audio_url_signer: Optional[Callable[[str], str]] = None,
|
||
) -> None:
|
||
"""初始化 CosyVoice 服务.
|
||
|
||
Args:
|
||
api_key: DashScope API Key,为空时从配置读取
|
||
base_url: DashScope API Base URL,为空时从配置读取
|
||
model: 语音合成模型名称,为空时从配置读取
|
||
clone_model: 音色克隆模型名称,为空时从配置读取
|
||
http_client: 可选的 HTTP 客户端(用于测试注入)
|
||
audio_url_signer: 可选的音频URL预签名函数,签名式 fn(url) -> str.
|
||
用于私有 bucket 下,将裸 URL 转为预签名 URL,
|
||
确保 CosyVoice 服务器能下载参考音频.
|
||
"""
|
||
settings = get_shared_settings()
|
||
|
||
self._api_key = api_key or settings.cosyvoice_api_key
|
||
self._base_url = base_url or settings.cosyvoice_base_url
|
||
self._model = model or settings.cosyvoice_model
|
||
self._clone_model = clone_model or getattr(settings, "cosyvoice_clone_model", "voice-enrollment")
|
||
self._audio_url_signer = audio_url_signer
|
||
|
||
# base_url 规范化:去掉末尾的路径残留(兼容旧版配置)
|
||
# 旧版 .env 模板中 base_url 包含 /services/aigc/text2audio 完整路径,
|
||
# 新版只需 /api/v1,具体路径由代码拼接。这里自动修正,避免配置滞后导致418。
|
||
if "/services/aigc/text2audio" in self._base_url:
|
||
old_url = self._base_url
|
||
# 截取到 /api/v1 为止
|
||
idx = self._base_url.find("/api/v1")
|
||
if idx >= 0:
|
||
self._base_url = self._base_url[: idx + len("/api/v1")]
|
||
logger.warning(
|
||
"[CosyVoice Config] base_url包含旧版text2audio路径,已自动修正: " "%s -> %s",
|
||
old_url,
|
||
self._base_url,
|
||
)
|
||
|
||
self._client = http_client or httpx.Client(
|
||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||
)
|
||
self._owns_client = http_client is None
|
||
|
||
# 启动时打印配置(脱敏),方便排查环境变量覆盖问题
|
||
if self._owns_client:
|
||
masked_key = ""
|
||
if self._api_key:
|
||
if len(self._api_key) > 8:
|
||
masked_key = f"{self._api_key[:4]}...{self._api_key[-4:]}"
|
||
else:
|
||
masked_key = "***"
|
||
logger.info(
|
||
"[CosyVoice Config] 初始化配置: "
|
||
"model=%s, base_url=%s, default_voice=%s, "
|
||
"sample_rate=%d, format=%s, api_key=%s",
|
||
self._model,
|
||
self._base_url,
|
||
getattr(settings, "cosyvoice_voice", "(unset)"),
|
||
settings.cosyvoice_sample_rate,
|
||
settings.cosyvoice_format,
|
||
masked_key or "(empty)",
|
||
)
|
||
|
||
def __enter__(self) -> CosyVoiceService:
|
||
return self
|
||
|
||
def __exit__(self, *args: Any) -> None:
|
||
self.close()
|
||
|
||
def close(self) -> None:
|
||
"""关闭 HTTP 客户端。"""
|
||
if self._owns_client and self._client:
|
||
self._client.close()
|
||
|
||
# ── 预置音色 ─────────────────────────────────────────
|
||
|
||
def list_preset_voices(self) -> list[PresetVoice]:
|
||
"""获取预置音色列表.
|
||
|
||
Returns:
|
||
预置音色列表
|
||
"""
|
||
return get_preset_voices()
|
||
|
||
# ── 音色克隆 ─────────────────────────────────────────
|
||
|
||
def submit_clone_task(
|
||
self,
|
||
audio_url: str,
|
||
voice_name: str = "",
|
||
language: str = "zh-CN",
|
||
target_model: str = "",
|
||
) -> dict:
|
||
"""提交音色克隆任务(非阻塞).
|
||
|
||
调用百炼 voice-enrollment API 创建克隆音色.
|
||
创建后音色状态为 DEPLOYING,需通过 query_voice_status 轮询直到 OK.
|
||
|
||
Args:
|
||
audio_url: 参考音频 URL(必须公网可访问)
|
||
voice_name: 音色名称前缀(字母数字,最多10字符)
|
||
language: 语言代码(zh-CN 会转换为 zh)
|
||
target_model: 目标合成模型,默认使用当前 model
|
||
|
||
Returns:
|
||
dict: {"voice_id": str, "status": str, "request_id": str}
|
||
voice_id 非空,status 通常为 DEPLOYING
|
||
|
||
Raises:
|
||
CosyVoiceError: API 调用失败
|
||
CosyVoiceAuthError: 认证失败
|
||
ValueError: 参数无效
|
||
"""
|
||
if not audio_url:
|
||
raise ValueError("audio_url 不能为空")
|
||
if not self._api_key:
|
||
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
|
||
|
||
# voice_name 作为 prefix,限制字母数字,最多10字符
|
||
# 不符合要求的做清洗
|
||
prefix = self._sanitize_prefix(voice_name) if voice_name else "clone"
|
||
|
||
# 语言转换:zh-CN → zh,保留 ISO 639-1 格式
|
||
lang_code = language.split("-")[0].lower() if language else "zh"
|
||
|
||
target = target_model or self._model
|
||
|
||
# 如果配置了 audio_url_signer,对音频URL做预签名
|
||
# (私有 bucket 下 CosyVoice 服务器无法直接访问裸 URL)
|
||
signed_audio_url = audio_url
|
||
if self._audio_url_signer:
|
||
try:
|
||
signed_audio_url = self._audio_url_signer(audio_url)
|
||
logger.info("音频URL已预签名: original=%s signed_prefix=%s", audio_url[:80], signed_audio_url[:80])
|
||
except Exception as e:
|
||
logger.warning("音频URL预签名失败,使用原始URL: %s", e)
|
||
|
||
payload = {
|
||
"model": self._clone_model,
|
||
"input": {
|
||
"action": "create_voice",
|
||
"target_model": target,
|
||
"prefix": prefix,
|
||
"url": signed_audio_url,
|
||
"language_hints": [lang_code],
|
||
},
|
||
}
|
||
|
||
response = self._call_api(
|
||
method="POST",
|
||
path="/services/audio/tts/customization",
|
||
json=payload,
|
||
timeout=60.0,
|
||
)
|
||
|
||
output = response.get("output", {})
|
||
voice_id = output.get("voice_id", "")
|
||
status = output.get("status", "DEPLOYING")
|
||
request_id = response.get("request_id", "")
|
||
|
||
if not voice_id:
|
||
raise CosyVoiceError(f"CosyVoice API 未返回 voice_id: {response}")
|
||
|
||
return {
|
||
"voice_id": voice_id,
|
||
"status": status,
|
||
"request_id": request_id,
|
||
}
|
||
|
||
def query_voice_status(self, voice_id: str) -> dict:
|
||
"""查询音色状态(单次查询,不轮询).
|
||
|
||
Args:
|
||
voice_id: 音色 ID
|
||
|
||
Returns:
|
||
dict: {"status": str, "target_model": str, "gmt_create": str,
|
||
"gmt_modified": str, "resource_link": str}
|
||
status 为 DEPLOYING / OK / UNDEPLOYED
|
||
|
||
Raises:
|
||
CosyVoiceError: API 调用失败
|
||
CosyVoiceAuthError: 认证失败
|
||
"""
|
||
if not self._api_key:
|
||
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
|
||
|
||
if not voice_id:
|
||
raise ValueError("voice_id 不能为空")
|
||
|
||
payload = {
|
||
"model": self._clone_model,
|
||
"input": {
|
||
"action": "query_voice",
|
||
"voice_id": voice_id,
|
||
},
|
||
}
|
||
|
||
response = self._call_api(
|
||
method="POST",
|
||
path="/services/audio/tts/customization",
|
||
json=payload,
|
||
timeout=30.0,
|
||
)
|
||
|
||
output = response.get("output", {})
|
||
return {
|
||
"status": output.get("status", ""),
|
||
"target_model": output.get("target_model", ""),
|
||
"gmt_create": output.get("gmt_create", ""),
|
||
"gmt_modified": output.get("gmt_modified", ""),
|
||
"resource_link": output.get("resource_link", ""),
|
||
}
|
||
|
||
def check_task_status(self, task_id: str) -> dict:
|
||
"""查询克隆任务状态(兼容旧接口,实际用 voice_id 查询).
|
||
|
||
为了兼容旧代码,task_id 参数名保留,但实际传的是 voice_id.
|
||
|
||
Args:
|
||
task_id: 音色 ID(兼容旧接口名)
|
||
|
||
Returns:
|
||
dict: {"status": str, "voice_id": str, "message": str}
|
||
"""
|
||
result = self.query_voice_status(task_id)
|
||
return {
|
||
"status": result["status"],
|
||
"voice_id": task_id,
|
||
"message": "",
|
||
}
|
||
|
||
def poll_clone_task(self, voice_id: str, timeout: float = 300.0) -> dict:
|
||
"""轮询音色克隆状态直到完成或超时.
|
||
|
||
供 Celery 后台任务调用,轮询直到状态变为 OK 或 UNDEPLOYED.
|
||
|
||
Args:
|
||
voice_id: 音色 ID
|
||
timeout: 超时时间(秒),默认 300
|
||
|
||
Returns:
|
||
dict: {"voice_id": str}
|
||
|
||
Raises:
|
||
CosyVoiceError: 任务失败(状态 UNDEPLOYED)
|
||
CosyVoiceTimeoutError: 超时
|
||
"""
|
||
start_time = time.time()
|
||
attempts = 0
|
||
|
||
while attempts < self.CLONE_MAX_POLL_ATTEMPTS:
|
||
elapsed = time.time() - start_time
|
||
if elapsed > timeout:
|
||
raise CosyVoiceTimeoutError(f"音色克隆任务超时({timeout}秒): voice_id={voice_id}")
|
||
|
||
result = self.query_voice_status(voice_id)
|
||
status = result.get("status", "").upper()
|
||
|
||
if status == "OK":
|
||
return {"voice_id": voice_id}
|
||
elif status == "UNDEPLOYED":
|
||
raise CosyVoiceError(f"音色克隆任务失败(审核未通过): voice_id={voice_id}")
|
||
elif status in ("DEPLOYING", "PENDING", "PROCESSING", ""):
|
||
# 继续轮询
|
||
time.sleep(self.CLONE_POLL_INTERVAL)
|
||
attempts += 1
|
||
else:
|
||
logger.warning("未知的音色状态: %s (voice_id=%s)", status, voice_id)
|
||
time.sleep(self.CLONE_POLL_INTERVAL)
|
||
attempts += 1
|
||
|
||
raise CosyVoiceTimeoutError(f"音色克隆任务轮询次数超限: voice_id={voice_id}")
|
||
|
||
def clone_voice(
|
||
self,
|
||
audio_url: str,
|
||
voice_name: str = "",
|
||
language: str = "zh-CN",
|
||
timeout: float = 300.0,
|
||
target_model: str = "",
|
||
) -> CloneResult:
|
||
"""克隆音色(阻塞,直到完成或超时).
|
||
|
||
提交音色克隆到百炼 API,并轮询直到状态变为 OK 或超时.
|
||
|
||
Args:
|
||
audio_url: 参考音频 URL(必须公网可访问)
|
||
voice_name: 音色名称前缀
|
||
language: 语言代码
|
||
timeout: 超时时间(秒)
|
||
target_model: 目标合成模型
|
||
|
||
Returns:
|
||
CloneResult: 克隆结果,包含 voice_id
|
||
|
||
Raises:
|
||
CosyVoiceError: API 调用失败或克隆失败
|
||
CosyVoiceTimeoutError: 超时
|
||
CosyVoiceAuthError: 认证失败
|
||
ValueError: 参数无效
|
||
"""
|
||
submit_result = self.submit_clone_task(
|
||
audio_url=audio_url,
|
||
voice_name=voice_name,
|
||
language=language,
|
||
target_model=target_model,
|
||
)
|
||
|
||
voice_id = submit_result["voice_id"]
|
||
request_id = submit_result["request_id"]
|
||
|
||
# 如果创建时已经是 OK 状态,直接返回
|
||
if submit_result.get("status", "").upper() == "OK":
|
||
return CloneResult(voice_id=voice_id, request_id=request_id)
|
||
|
||
# 否则轮询
|
||
result = self.poll_clone_task(voice_id, timeout=timeout)
|
||
return CloneResult(voice_id=result["voice_id"], request_id=request_id)
|
||
|
||
# ── 语音合成 ─────────────────────────────────────────
|
||
|
||
def submit_synthesize_task(
|
||
self,
|
||
text: str,
|
||
voice_id: str = "",
|
||
sample_rate: int = 0,
|
||
format: str = "",
|
||
speed: float = 1.0,
|
||
volume: int = 50,
|
||
) -> dict:
|
||
"""提交语音合成任务(同步非流式,直接返回结果).
|
||
|
||
CosyVoice SpeechSynthesizer 非流式接口是同步的,
|
||
调用后直接返回音频 URL. 此方法保持与旧接口兼容.
|
||
|
||
Args:
|
||
text: 要合成的文本
|
||
voice_id: 音色 ID(预置音色或克隆音色)
|
||
sample_rate: 采样率(Hz),0 表示使用配置默认值
|
||
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
|
||
speed: 语速(0.5-2.0),1.0 为正常速度
|
||
volume: 音量(0-100),默认 50
|
||
|
||
Returns:
|
||
dict: {"audio_url": str, "request_id": str,
|
||
"duration": float, "file_size": int}
|
||
|
||
Raises:
|
||
CosyVoiceError: API 调用失败
|
||
CosyVoiceAuthError: 认证失败
|
||
ValueError: 参数无效
|
||
"""
|
||
if not text:
|
||
raise ValueError("text 不能为空")
|
||
if not voice_id:
|
||
raise ValueError("voice_id 不能为空")
|
||
if not self._api_key:
|
||
raise CosyVoiceAuthError("CosyVoice API Key 未配置")
|
||
|
||
settings = get_shared_settings()
|
||
|
||
payload = {
|
||
"model": self._model,
|
||
"input": {
|
||
"text": text,
|
||
"voice": voice_id,
|
||
"format": format or settings.cosyvoice_format,
|
||
"sample_rate": sample_rate or settings.cosyvoice_sample_rate,
|
||
"rate": speed,
|
||
"volume": volume,
|
||
},
|
||
}
|
||
|
||
response = self._call_api(
|
||
method="POST",
|
||
path="/services/audio/tts/SpeechSynthesizer",
|
||
json=payload,
|
||
timeout=120.0,
|
||
)
|
||
|
||
output = response.get("output", {})
|
||
audio = output.get("audio", {})
|
||
audio_url = audio.get("url", "")
|
||
request_id = response.get("request_id", "")
|
||
|
||
if not audio_url:
|
||
raise CosyVoiceError(f"CosyVoice API 未返回 audio_url: {response}")
|
||
|
||
return {
|
||
"task_id": "", # 同步接口无 task_id,兼容旧接口
|
||
"audio_url": audio_url,
|
||
"duration": 0.0, # 同步接口不返回 duration
|
||
"file_size": 0, # 同步接口不返回 file_size
|
||
"request_id": request_id,
|
||
}
|
||
|
||
def poll_synthesize_task(self, task_id: str, timeout: float = 120.0) -> dict:
|
||
"""轮询合成任务(同步接口无需轮询,保留兼容).
|
||
|
||
CosyVoice SpeechSynthesizer 非流式接口是同步的,
|
||
此方法仅为保持接口兼容,实际调用时 task_id 应该为空.
|
||
|
||
Raises:
|
||
CosyVoiceError: 同步接口无需轮询
|
||
"""
|
||
raise CosyVoiceError("CosyVoice 非流式合成接口是同步的,无需轮询. " "请直接使用 submit_synthesize_task().")
|
||
|
||
def synthesize_speech(
|
||
self,
|
||
text: str,
|
||
voice_id: str = "",
|
||
sample_rate: int = 0,
|
||
format: str = "",
|
||
speed: float = 1.0,
|
||
volume: int = 50,
|
||
timeout: float = 120.0,
|
||
) -> SynthesizeResult:
|
||
"""语音合成(同步非流式).
|
||
|
||
调用百炼 CosyVoice SpeechSynthesizer 非流式接口,
|
||
直接返回合成音频 URL.
|
||
|
||
Args:
|
||
text: 要合成的文本
|
||
voice_id: 音色 ID(预置音色或克隆音色)
|
||
sample_rate: 采样率(Hz),0 表示使用配置默认值
|
||
format: 输出格式(mp3/wav/pcm),空表示使用配置默认值
|
||
speed: 语速(0.5-2.0),1.0 为正常速度
|
||
volume: 音量(0-100),默认 50
|
||
timeout: 超时时间(秒),保留参数兼容
|
||
|
||
Returns:
|
||
SynthesizeResult: 合成结果,包含 audio_url
|
||
|
||
Raises:
|
||
CosyVoiceError: API 调用失败
|
||
CosyVoiceAuthError: 认证失败
|
||
ValueError: 参数无效
|
||
"""
|
||
result = self.submit_synthesize_task(
|
||
text=text,
|
||
voice_id=voice_id,
|
||
sample_rate=sample_rate,
|
||
format=format,
|
||
speed=speed,
|
||
volume=volume,
|
||
)
|
||
|
||
return SynthesizeResult(
|
||
audio_url=result["audio_url"],
|
||
duration=result.get("duration", 0.0),
|
||
file_size=result.get("file_size", 0),
|
||
request_id=result.get("request_id", ""),
|
||
)
|
||
|
||
# ── 内部方法 ─────────────────────────────────────────
|
||
|
||
def _sanitize_prefix(self, name: str) -> str:
|
||
"""清洗音色名称为合法的 prefix(字母数字,最多10字符).
|
||
|
||
Args:
|
||
name: 原始音色名称
|
||
|
||
Returns:
|
||
清洗后的 prefix
|
||
"""
|
||
# 只保留字母和数字
|
||
cleaned = "".join(c for c in name if c.isalnum())
|
||
# 最多10字符
|
||
cleaned = cleaned[:10]
|
||
# 如果清洗后为空,用默认值
|
||
if not cleaned:
|
||
cleaned = "clone"
|
||
return cleaned
|
||
|
||
def _call_api(
|
||
self,
|
||
method: str,
|
||
path: str,
|
||
json: Optional[dict] = None,
|
||
timeout: float = 30.0,
|
||
) -> dict:
|
||
"""调用 DashScope API.
|
||
|
||
支持重试和错误处理.
|
||
|
||
Args:
|
||
method: HTTP 方法(GET/POST)
|
||
path: API 路径(以 / 开头)
|
||
json: 请求体
|
||
timeout: 超时时间(秒)
|
||
|
||
Returns:
|
||
API 响应字典
|
||
|
||
Raises:
|
||
CosyVoiceError: API 调用失败
|
||
CosyVoiceAuthError: 认证失败
|
||
CosyVoiceTimeoutError: 超时
|
||
"""
|
||
url = f"{self._base_url}{path}"
|
||
headers = {
|
||
"Authorization": f"Bearer {self._api_key}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
# DEBUG: 打印完整请求信息,用于排查418错误
|
||
import json as json_lib
|
||
|
||
safe_headers = {k: v for k, v in headers.items()}
|
||
if "Authorization" in safe_headers:
|
||
token = safe_headers["Authorization"]
|
||
if len(token) > 20:
|
||
safe_headers["Authorization"] = token[:13] + "..." + token[-4:]
|
||
logger.info(
|
||
"[CosyVoice Debug] 请求详情: " "method=%s, url=%s, headers=%s, body=%s",
|
||
method,
|
||
url,
|
||
safe_headers,
|
||
json_lib.dumps(json, ensure_ascii=False) if json else "None",
|
||
)
|
||
|
||
last_error: Optional[Exception] = None
|
||
|
||
for attempt in range(self.MAX_RETRIES):
|
||
try:
|
||
response = self._client.request(
|
||
method=method,
|
||
url=url,
|
||
headers=headers,
|
||
json=json,
|
||
timeout=timeout,
|
||
)
|
||
|
||
# DEBUG: 打印响应状态和完整响应体
|
||
logger.info(
|
||
"[CosyVoice Debug] 响应详情: " "status=%d, body=%s",
|
||
response.status_code,
|
||
response.text[:2000], # 最多2000字符,避免日志过大
|
||
)
|
||
|
||
# 处理响应
|
||
if response.status_code == 200:
|
||
return response.json()
|
||
elif response.status_code in (401, 403):
|
||
raise CosyVoiceAuthError(f"CosyVoice API 认证失败: HTTP {response.status_code}")
|
||
elif response.status_code == 400:
|
||
# 客户端错误,不重试
|
||
body_text = response.text
|
||
try:
|
||
body = response.json()
|
||
code = body.get("code", "")
|
||
message = body.get("message", "")
|
||
raise CosyVoiceError(f"CosyVoice API 参数错误: HTTP 400, " f"code={code}, message={message}")
|
||
except ValueError as _e:
|
||
raise CosyVoiceError(f"CosyVoice API 调用失败: HTTP 400, body={body_text}") from _e
|
||
elif response.status_code >= 500:
|
||
# 服务端错误,可重试
|
||
last_error = CosyVoiceError(f"CosyVoice API 服务端错误: HTTP {response.status_code}")
|
||
logger.warning(
|
||
"CosyVoice API 失败 (尝试 %d/%d): HTTP %d",
|
||
attempt + 1,
|
||
self.MAX_RETRIES,
|
||
response.status_code,
|
||
)
|
||
else:
|
||
# 其他客户端错误,不重试
|
||
raise CosyVoiceError(
|
||
f"CosyVoice API 调用失败: HTTP {response.status_code}, " f"body={response.text}"
|
||
)
|
||
|
||
except httpx.TimeoutException as e:
|
||
last_error = CosyVoiceTimeoutError(f"请求超时: {e}")
|
||
logger.warning(
|
||
"CosyVoice API 超时 (尝试 %d/%d)",
|
||
attempt + 1,
|
||
self.MAX_RETRIES,
|
||
)
|
||
except httpx.RequestError as e:
|
||
last_error = CosyVoiceError(f"请求错误: {e}")
|
||
logger.warning(
|
||
"CosyVoice API 请求错误 (尝试 %d/%d): %s",
|
||
attempt + 1,
|
||
self.MAX_RETRIES,
|
||
e,
|
||
)
|
||
|
||
# 指数退避
|
||
if attempt < self.MAX_RETRIES - 1:
|
||
sleep_time = self.RETRY_BACKOFF * (2**attempt)
|
||
time.sleep(sleep_time)
|
||
|
||
# 所有重试都失败
|
||
if last_error:
|
||
raise last_error
|
||
raise CosyVoiceError("CosyVoice API 调用失败,未知错误")
|