chore(translate): 降频 2秒/次 + 改 spark 为 wss WebSocket 鉴权(智谱/zhipu=第一)

This commit is contained in:
Mavis
2026-06-11 09:34:01 +08:00
parent 4b8d776aac
commit 6293f82a3a
3 changed files with 147 additions and 69 deletions

View File

@@ -1,19 +1,27 @@
"""星火 Spark 翻译(OpenAI 兼容协议)。
"""讯飞星火 Spark Lite 翻译后端(WebSocket 协议,用户 6/11 要求改回 wss)。
- 端点:https://spark-api-open.xf-yun.com/v1/chat/completions
- 模型:lite(也支持 generalv3.5 等,但该项目用 Lite,免费额度)
- 鉴权:Bearer token(APIPassword 直接当 Bearer 用)
- 请求:POST /chat/completions,system prompt 告诉模型做翻译
- 端点:wss://spark-api.xf-yun.com/v1.1/chat
- 模型:Spark Lite(v1.1 通用轻量版,限时免费)
- 鉴权:URL QueryString 带 authorization(HMAC-SHA256 签名)
- 需要 APPID + APIKey + APISecret
- 签名算法见 _build_auth_url()
设计上独立于 LlmClient,专门走 spark_* 配置,避免和 LLM 智能增强共用 client 的节流。
设计上独立于 LlmClient(不在通用 OpenAI 协议内),
鉴权 URL 每次调用前重算(因为 date 是当前时间)。
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import hmac
import json
import logging
import random
from datetime import datetime
from urllib.parse import urlencode
import httpx
import websockets
from app.config import settings
from app.services.translation.base import BaseTranslator, TranslationResult
@@ -21,84 +29,108 @@ from app.services.translation.base import BaseTranslator, TranslationResult
logger = logging.getLogger("news.translate.spark")
# 经过实测,这套 prompt 在 Spark Lite 上输出稳定(不夹带 reasoning / 注释)。
# Lite 不支持 system 角色(早期 Lite 模型),但 v1 OpenAI 兼容的 Lite 接受 system;
# 保留 system prompt 走 4.0Ultra/Max/Pro 时也通用。
_SYSTEM_PROMPT = """你是一个即时翻译助手。对于用户输入的英文或日文文章,请直接输出对应的简体中文译文。严格遵守:
# 讯飞 v1.1 域
_SPARK_HOST = "spark-api.xf-yun.com"
_SPARK_PATH = "/v1.1/chat"
不要进行任何分步思考、不要输出分析、不要添加注释或说明。
只输出中文译文本身,不包含任何额外文字(如"翻译结果:""以下是中文:"等)。
# 讯飞做翻译的 system / user prompt 包装
_SYSTEM_PROMPT = (
"你是一个翻译助手。请将用户输入的英文或日文文本翻译成简体中文。"
"严格遵守:不要输出分析、不要输出注释、不要添加任何包裹文字,只输出译文本身。"
)
如果输入内容既非英文也非日文,仅回复:"仅支持英文或日文翻译为中文。" """
def _build_auth_url(api_key: str, api_secret: str) -> str:
"""构造带鉴权 query 的 WebSocket URL。
算法来自讯飞开放平台官方文档:
1) date = 当前 GMT 时间 (RFC 1123 格式)
2) signature_origin = "host: {host}\\ndate: {date}\\n GET {path} HTTP/1.1"
3) signature_sha = HMAC-SHA256(api_secret, signature_origin)
4) signature = base64(signature_sha)
5) authorization_origin = "api_key=\\"{api_key}\\", algorithm=\\"hmac-sha256\\", "
"headers=\\"host date request-line\\", signature=\\"{signature}\\""
6) authorization = base64(authorization_origin)
7) 最终 URL: wss://{host}{path}?host={host}&date={urlencoded_date}&authorization={urlencoded_authorization}
"""
now = datetime.utcnow()
date = now.strftime("%a, %d %b %Y %H:%M:%S GMT")
signature_origin = f"host: {_SPARK_HOST}\ndate: {date}\n GET {_SPARK_PATH} HTTP/1.1"
signature_sha = hmac.new(
api_secret.encode("utf-8"),
signature_origin.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
signature = base64.b64encode(signature_sha).decode("utf-8")
authorization_origin = (
f'api_key="{api_key}", algorithm="hmac-sha256", '
f'headers="host date request-line", signature="{signature}"'
)
authorization = base64.b64encode(authorization_origin.encode("utf-8")).decode("utf-8")
# 注意 date 里带冒号 / 逗号,必须 url-encode
return f"wss://{_SPARK_HOST}{_SPARK_PATH}?{urlencode({'host': _SPARK_HOST, 'date': date, 'authorization': authorization})}"
class SparkTranslator(BaseTranslator):
"""星火 Spark 翻译(OpenAI 兼容协议,模型 lite / generalv3.5 等)。"""
"""讯飞星火 Spark Lite 翻译后端(WebSocket)。"""
name = "spark"
def __init__(self):
if not settings.spark_api_password:
raise RuntimeError("Spark APIPassword missing")
self.api_password = settings.spark_api_password
self.base_url = settings.spark_base_url.rstrip("/")
self.model = settings.spark_model
if not settings.spark_appid or not settings.spark_api_key or not settings.spark_api_secret:
raise RuntimeError("讯飞星火 APPID / APIKey / APISecret 未配置(需要 WSS 鉴权三件套)")
self.appid = settings.spark_appid
self.api_key = settings.spark_api_key
self.api_secret = settings.spark_api_secret
self.domain = settings.spark_domain
self.interval_sec = settings.spark_interval_sec
def is_configured(self) -> bool:
return bool(self.api_password)
return bool(self.appid and self.api_key and self.api_secret)
async def translate(
self, text: str, source: str = "auto", target: str = "zh"
) -> TranslationResult:
"""翻译接口。source/target 当前固定 EN/JA → ZH(由 prompt 控制)。"""
if not text.strip():
return TranslationResult(text=text, engine=self.name, chars=0)
if not self.is_configured():
raise RuntimeError("Spark APIPassword missing")
raise RuntimeError("讯飞星火未配置(APPID/APIKey/APISecret)")
system = _SYSTEM_PROMPT
user = text
# 长度截断:Spark Lite 单轮 8K context,留余量
max_input = 4000
truncated = text[:max_input] if len(text) > max_input else text
url = f"{self.base_url}/chat/completions"
# 构造请求体
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"temperature": 0.0,
"max_tokens": max(256, len(text) * 3),
}
headers = {
"Authorization": f"Bearer {self.api_password}",
"Content-Type": "application/json",
"header": {"app_id": self.appid, "uid": "translator"},
"parameter": {
"chat": {
"domain": self.domain,
"temperature": 0.1, # 翻译低温度
"max_tokens": 4096,
}
},
"payload": {
"message": {
"text": [
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": truncated},
]
}
},
}
# 简单串行 + 重试 1 次
# 鉴权 URL(每次重算)
auth_url = _build_auth_url(self.api_key, self.api_secret)
# 简单重试 1 次
last_exc: Exception | None = None
for attempt in range(2):
try:
async with httpx.AsyncClient(timeout=60.0) as client:
r = await client.post(url, json=payload, headers=headers)
if r.status_code >= 500:
raise RuntimeError(f"Spark 5xx: {r.status_code} {r.text[:200]}")
if r.status_code != 200:
raise RuntimeError(f"Spark {r.status_code}: {r.text[:300]}")
data = r.json()
# 错误响应:{"error": {...}}
if "error" in data:
raise RuntimeError(f"Spark error: {data['error']}")
content = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
.strip()
)
if not content:
raise RuntimeError(f"Spark empty content: {r.text[:300]}")
content = await self._send_once(auth_url, payload)
# 节流(避免被限流 — 2 秒/次)
await asyncio.sleep(self.interval_sec)
return TranslationResult(
text=content, engine=self.name, chars=len(text), cached=False
@@ -112,3 +144,39 @@ class SparkTranslator(BaseTranslator):
raise
assert last_exc is not None
raise last_exc
async def _send_once(self, auth_url: str, payload: dict) -> str:
"""单次 WebSocket 调用,聚合流式响应,返回完整文本。"""
# websockets 13+ 异步上下文(关闭旧版 serve / connect 双 API 模糊)
async with websockets.connect(auth_url, ping_interval=None) as ws:
await ws.send(json.dumps(payload, ensure_ascii=False))
collected: list[str] = []
while True:
raw = await ws.recv()
data = json.loads(raw)
# 错误响应(header.code != 0)
header = data.get("header", {})
code = header.get("code", 0)
if code != 0:
msg = header.get("message", "")
raise RuntimeError(f"Spark 错误 {code}: {msg}")
# 流式增量
choices = data.get("payload", {}).get("choices", {})
status = choices.get("status", 0)
text_parts = choices.get("text", [])
if text_parts:
for t in text_parts:
content = t.get("content", "")
if content:
collected.append(content)
# status=2 表示结束
if status == 2:
break
full = "".join(collected).strip()
if not full:
raise RuntimeError("Spark 返回空 content")
return full