feat: add FastAPI manager API compatibility baseline

This commit is contained in:
Tyke Chen
2026-07-20 17:00:13 +08:00
parent 7c58fa37b2
commit 804ddb51f2
140 changed files with 47169 additions and 1 deletions
@@ -0,0 +1 @@
"""Outbound integrations used by the FastAPI manager service."""
@@ -0,0 +1,68 @@
from __future__ import annotations
from typing import Any
import httpx
SUMMARY_PROMPT = """你是一个经验丰富的记忆总结者,擅长将对话内容进行总结摘要,遵循以下规则:
1、总结用户的重要信息,以便在未来的对话中提供更个性化的服务
2、不要重复总结,不要遗忘之前记忆,除非原来的记忆超过了1800字,否则不要遗忘、不要压缩用户的历史记忆
3、用户操控的设备音量、播放音乐、天气、退出、不想对话等和用户本身无关的内容,这些信息不需要加入到总结中
4、聊天内容中的今天的日期时间、今天的天气情况与用户事件无关的数据,这些信息如果当成记忆存储会影响后续对话,这些信息不需要加入到总结中
5、不要把设备操控的成果结果和失败结果加入到总结中,也不要把用户的一些废话加入到总结中
6、不要为了总结而总结,如果用户的聊天没有意义,请返回原来的历史记录也是可以的
7、只需要返回总结摘要,严格控制在1800字内
8、不要包含代码、xml,不需要解释、注释和说明,保存记忆时仅从对话提取信息,不要混入示例内容
9、如果提供了历史记忆,请将新对话内容与历史记忆进行智能合并,保留有价值的历史信息,同时添加新的重要信息
历史记忆:
{history_memory}
新对话内容:
{conversation}"""
TITLE_PROMPT = (
"请根据以下对话内容,生成一个简洁的会话标题(约15字以内),只返回标题,不要包含任何解释或标点符号:\n{conversation}"
)
def _apply_thinking_policy(base_url: str, request: dict[str, Any]) -> None:
if "aliyuncs.com" in base_url:
request["enable_thinking"] = False
elif any(domain in base_url for domain in ("bigmodel.cn", "moonshot.cn", "volces.com")):
request["thinking"] = {"type": "disabled"}
async def openai_completion(
config: dict[str, Any],
prompt: str,
*,
temperature: float,
max_tokens: int,
timeout: float,
) -> str | None:
base_url = str(config.get("base_url") or "")
api_key = str(config.get("api_key") or "")
if not base_url.strip() or not api_key.strip():
return None
api_url = base_url if base_url.endswith("/chat/completions") else f"{base_url.rstrip('/')}/chat/completions"
request: dict[str, Any] = {
"model": config.get("model_name") or "gpt-3.5-turbo",
"messages": [{"role": "user", "content": prompt}],
"temperature": temperature,
"max_tokens": max_tokens,
}
_apply_thinking_policy(base_url, request)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(
api_url,
json=request,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
)
response.raise_for_status()
payload = response.json()
choices = payload.get("choices") if isinstance(payload, dict) else None
if not isinstance(choices, list) or not choices:
return None
message = choices[0].get("message") if isinstance(choices[0], dict) else None
content = message.get("content") if isinstance(message, dict) else None
return str(content) if content is not None else None
@@ -0,0 +1,106 @@
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
from typing import Any
from urllib.parse import quote_plus, urlsplit, urlunsplit
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from websockets.asyncio.client import connect
def _java_aes_key(value: str) -> bytes:
raw = value.encode("utf-8")
if len(raw) in {16, 24, 32}:
return raw
return raw[:32].ljust(32, b"\x00")
def encrypt_agent_token(agent_id: str, key: str) -> str:
digest = hashlib.md5(agent_id.encode("utf-8"), usedforsecurity=False).hexdigest()
plain_text = f'{{"agentId": "{digest}"}}'.encode()
padder = padding.PKCS7(128).padder()
padded = padder.update(plain_text) + padder.finalize()
# ECB is required for byte-for-byte compatibility with Java AES/ECB/PKCS5Padding.
encryptor = Cipher(algorithms.AES(_java_aes_key(key)), modes.ECB()).encryptor() # noqa: S305
encrypted = encryptor.update(padded) + encryptor.finalize()
return base64.b64encode(encrypted).decode("ascii")
def build_agent_mcp_address(endpoint: str | None, agent_id: str) -> str | None:
if endpoint is None or not endpoint.strip() or endpoint == "null":
return None
parsed = urlsplit(endpoint)
if not parsed.scheme or not parsed.netloc:
raise ValueError("mcp的地址存在错误,请进入参数管理修改mcp接入点地址")
marker = "key="
marker_index = parsed.query.find(marker)
# Java takes everything following the first key= marker, including subsequent query text.
key = parsed.query[marker_index + len(marker) :] if marker_index >= 0 else parsed.query[3:]
ws_scheme = "wss" if parsed.scheme == "https" else "ws"
path = parsed.path
parent = path[: path.rfind("/")] if "/" in path else ""
base = urlunsplit((ws_scheme, parsed.netloc, parent, "", "")).rstrip("/")
token = quote_plus(encrypt_agent_token(agent_id, key), safe="")
return f"{base}/mcp/?token={token}"
INITIALIZE_REQUEST = {
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {"roots": {"listChanged": False}, "sampling": {}},
"clientInfo": {"name": "xz-mcp-broker", "version": "0.0.1"},
},
"id": 1,
}
INITIALIZED_NOTIFICATION = {"jsonrpc": "2.0", "method": "notifications/initialized"}
TOOLS_REQUEST = {"jsonrpc": "2.0", "method": "tools/list", "params": None, "id": 2}
async def _receive_matching(websocket: Any, request_id: int, timeout: float) -> dict[str, Any] | None:
async def receive() -> dict[str, Any] | None:
async for message in websocket:
try:
value = json.loads(message)
except (TypeError, json.JSONDecodeError):
continue
if isinstance(value, dict) and value.get("id") == request_id:
return value
return None
return await asyncio.wait_for(receive(), timeout=timeout)
async def list_mcp_tools(address: str, *, connect_timeout: float = 8.0, session_timeout: float = 10.0) -> list[str]:
call_address = address.replace("/mcp/", "/call/")
try:
async with connect(
call_address,
open_timeout=connect_timeout,
max_size=1024 * 1024,
close_timeout=1,
) as websocket:
await websocket.send(json.dumps(INITIALIZE_REQUEST, ensure_ascii=False, separators=(",", ":")))
initialized = await _receive_matching(websocket, 1, session_timeout)
if not initialized or "result" not in initialized or "error" in initialized:
return []
await websocket.send(json.dumps(INITIALIZED_NOTIFICATION, separators=(",", ":")))
await websocket.send(json.dumps(TOOLS_REQUEST, separators=(",", ":")))
response = await _receive_matching(websocket, 2, session_timeout)
if not response or "error" in response:
return []
result = response.get("result")
tools = result.get("tools") if isinstance(result, dict) else None
if not isinstance(tools, list):
return []
return sorted(
item["name"] for item in tools if isinstance(item, dict) and isinstance(item.get("name"), str)
)
# Java treats every connect/protocol/parse failure as an empty tool list.
except Exception:
return []
@@ -0,0 +1,63 @@
from __future__ import annotations
import hashlib
import json
from datetime import date, datetime, timedelta, timezone
from typing import Any
import httpx
class MqttGatewayError(RuntimeError):
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def daily_authorization_tokens(signature_key: str, now: datetime | None = None) -> list[str]:
if not signature_key.strip() or signature_key.strip().lower() == "null":
raise MqttGatewayError("MQTT Gateway signature key is empty")
instant = now or datetime.now(tz=timezone.utc)
utc_date = instant.astimezone(timezone.utc).date()
dates: tuple[date, date, date] = (utc_date, utc_date - timedelta(days=1), utc_date + timedelta(days=1))
return [hashlib.sha256(f"{value.isoformat()}{signature_key}".encode()).hexdigest() for value in dates]
async def post_json(
url: str,
body: Any,
signature_key: str,
*,
timeout_seconds: float,
now: datetime | None = None,
client: httpx.AsyncClient | None = None,
) -> str:
encoded = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
owns_client = client is None
selected = client or httpx.AsyncClient()
last_unauthorized: int | None = None
try:
for token in daily_authorization_tokens(signature_key, now):
response = await selected.post(
url,
content=encoded,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
timeout=timeout_seconds,
)
if response.status_code == 401:
last_unauthorized = response.status_code
continue
if not 200 <= response.status_code < 300:
raise MqttGatewayError(
f"MQTT Gateway request failed with HTTP status {response.status_code}",
response.status_code,
)
return response.text
finally:
if owns_client:
await selected.aclose()
raise MqttGatewayError(
"MQTT Gateway rejected all daily authorization tokens"
+ ("" if last_unauthorized is None else f" (HTTP {last_unauthorized})"),
last_unauthorized,
)
@@ -0,0 +1,490 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
import httpx
from fastapi import UploadFile
from app.core.errors import AppError
_DOCUMENT_CHUNK_METHODS = {
"naive",
"manual",
"qa",
"table",
"paper",
"book",
"laws",
"presentation",
"picture",
"one",
"knowledge_graph",
"email",
}
_RUN_STATUSES = {"UNSTART", "RUNNING", "CANCEL", "DONE", "FAIL"}
_DOCUMENT_PARSER_FIELDS = (
"chunk_token_num",
"delimiter",
"layout_recognize",
"html4excel",
"auto_keywords",
"auto_questions",
"topn_tags",
"raptor",
"graphrag",
)
_DATASET_PARSER_FIELDS = (
"chunk_token_num",
"delimiter",
"layout_recognize",
"html4excel",
"auto_keywords",
"auto_questions",
)
class RAGFlowClient:
"""Async equivalent of the Java RAGFlow adapter and its wire contract."""
def __init__(self, config: Mapping[str, Any]):
self.config = dict(config)
self.base_url = str(config.get("base_url") or config.get("baseUrl") or "").rstrip("/")
self.api_key = str(config.get("api_key") or config.get("apiKey") or "")
raw_timeout = config.get("timeout")
if raw_timeout is None:
self.timeout = 30.0
else:
try:
self.timeout = float(int(str(raw_timeout)))
except (TypeError, ValueError):
self.timeout = 30.0
self._validate(config)
def _validate(self, config: Mapping[str, Any]) -> None:
if not config:
raise AppError(10164)
if not self.base_url.strip():
raise AppError(10171)
if not self.api_key.strip():
raise AppError(10172)
if "" in self.api_key:
raise AppError(10173)
if not self.base_url.startswith(("http://", "https://")):
raise AppError(10174)
adapter_type = "ragflow" if "type" not in config else str(config.get("type"))
if adapter_type != "ragflow":
raise AppError(10184, params=(f"适配器类型未注册: {adapter_type}",))
async def request(
self,
method: str,
endpoint: str,
*,
params: Mapping[str, Any] | None = None,
json_body: Any = None,
files: Mapping[str, Any] | None = None,
data: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
headers = {"Authorization": f"Bearer {self.api_key}"}
if files is None:
headers["Content-Type"] = "application/json"
headers["Accept-Charset"] = "utf-8"
normalized_params = {
key: self._query_value(value) for key, value in (params or {}).items() if value is not None
}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.request(
method,
self.base_url + endpoint,
params=normalized_params,
json=json_body,
files=files,
data=data,
headers=headers,
)
response.raise_for_status()
payload = response.json()
except (httpx.HTTPError, ValueError) as exc:
raise AppError(10167, params=(f"Request Failed: {exc}",)) from exc
if not isinstance(payload, dict):
raise AppError(10167, params=("Invalid Response",))
code = payload.get("code")
if code is not None:
if isinstance(code, bool) or not isinstance(code, int):
raise AppError(10167, params=("Request Failed: invalid response code type",))
if code != 0:
message = payload.get("message")
if message is not None and not isinstance(message, str):
raise AppError(10167, params=("Request Failed: invalid response message type",))
raise AppError(10167, params=(message or "Unknown RAGFlow Error",))
return dict(payload)
@staticmethod
def _query_value(value: Any) -> Any:
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, list):
# Java List.toString() is what the baseline URL builder sends.
return "[" + ", ".join(str(item) for item in value) + "]"
return value
async def dataset_info(self, dataset_id: str) -> dict[str, Any] | None:
payload = await self.request(
"GET", "/api/v1/datasets", params={"id": dataset_id, "page": 1, "page_size": 1}
)
data = payload.get("data")
if isinstance(data, list) and data and isinstance(data[0], dict):
return _normalize_dataset_info(data[0])
return None
async def create_dataset(self, body: dict[str, Any]) -> dict[str, Any]:
body = dict(body)
body["permission"] = "me" if _blank(body.get("permission")) else body.get("permission")
body["chunk_method"] = "naive" if _blank(body.get("chunk_method")) else body.get("chunk_method")
if _blank(body.get("embedding_model")):
configured_model = self.config.get("embedding_model", self.config.get("embeddingModel"))
body["embedding_model"] = None if _blank(configured_model) else configured_model
body["avatar"] = body.get("avatar") if not _blank(body.get("avatar")) else (
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
body["parser_config"] = _normalize_parser_config(
body.get("parser_config"), fields=_DATASET_PARSER_FIELDS
)
payload = await self.request("POST", "/api/v1/datasets", json_body=body)
data = payload.get("data")
if not isinstance(data, dict) or not data.get("id"):
raise AppError(10167, params=("Invalid response from createDataset: missing data object",))
return _normalize_dataset_info(data)
async def update_dataset(self, dataset_id: str, body: dict[str, Any]) -> dict[str, Any] | None:
body = dict(body)
body["parser_config"] = _normalize_parser_config(
body.get("parser_config"), fields=_DATASET_PARSER_FIELDS
)
payload = await self.request("PUT", f"/api/v1/datasets/{dataset_id}", json_body=body)
return _normalize_dataset_info(payload["data"]) if isinstance(payload.get("data"), dict) else None
async def delete_datasets(self, ids: list[str]) -> Any:
return (await self.request("DELETE", "/api/v1/datasets", json_body={"ids": ids})).get("data")
async def documents(
self,
dataset_id: str,
*,
page: int = 1,
page_size: int = 10,
name: str | None = None,
status: str | None = None,
document_id: str | None = None,
) -> tuple[list[dict[str, Any]], int]:
params: dict[str, Any] = {"page": page, "page_size": page_size}
if name:
params["name"] = name
if status:
status_number = int(status) if status.lstrip("-").isdigit() else None
names = {0: "UNSTART", 1: "RUNNING", 2: "CANCEL", 3: "DONE", 4: "FAIL"}
params["run"] = [names[status_number]] if status_number in names else []
if document_id:
params["id"] = document_id
payload = await self.request("GET", f"/api/v1/datasets/{dataset_id}/documents", params=params)
data = payload.get("data")
if not isinstance(data, dict):
return [], 0
docs = data.get("docs")
rows: list[dict[str, Any]] = []
if isinstance(docs, list):
for item in docs:
if not isinstance(item, dict):
continue
try:
rows.append(_normalize_upload_document(item))
except AppError:
# The Java adapter skips an individual document whose
# strong DTO conversion fails and keeps the rest of page.
continue
return rows, int(data.get("total") or 0)
async def upload_document(
self,
dataset_id: str,
file: UploadFile,
content: bytes,
*,
name: str,
meta_fields: dict[str, Any] | None,
chunk_method: str | None,
parser_config: dict[str, Any] | None,
) -> dict[str, Any]:
import json
form: dict[str, Any] = {"name": name}
if meta_fields:
form["meta"] = json.dumps(meta_fields, ensure_ascii=False, separators=(",", ":"))
if not _blank(chunk_method):
normalized_method = str(chunk_method).lower()
if normalized_method in _DOCUMENT_CHUNK_METHODS:
form["chunk_method"] = normalized_method
normalized_parser = (
_normalize_parser_config(
parser_config,
fields=_DOCUMENT_PARSER_FIELDS,
validate_layout=True,
)
if parser_config
else None
)
if normalized_parser is not None:
form["parser_config"] = json.dumps(normalized_parser, ensure_ascii=False, separators=(",", ":"))
payload = await self.request(
"POST",
f"/api/v1/datasets/{dataset_id}/documents",
files={"file": (file.filename or name, content, file.content_type or "application/octet-stream")},
data=form,
)
data = payload.get("data")
if isinstance(data, list) and data and isinstance(data[0], dict):
return _normalize_upload_document(data[0])
if isinstance(data, dict):
return _normalize_upload_document(data)
raise AppError(10167, params=("远程上传成功但未返回有效 DocumentID",))
async def delete_documents(self, dataset_id: str, ids: list[str]) -> None:
await self.request(
"DELETE", f"/api/v1/datasets/{dataset_id}/documents", json_body={"ids": ids}
)
async def parse_documents(self, dataset_id: str, document_ids: list[str]) -> None:
await self.request(
"POST",
f"/api/v1/datasets/{dataset_id}/chunks",
json_body={"document_ids": document_ids},
)
async def chunks(
self, dataset_id: str, document_id: str, params: Mapping[str, Any]
) -> dict[str, Any]:
payload = await self.request(
"GET", f"/api/v1/datasets/{dataset_id}/documents/{document_id}/chunks", params=params
)
data = payload.get("data")
if not isinstance(data, dict):
return {"chunks": [], "doc": None, "total": 0}
try:
return _normalize_chunk_list(data)
except (TypeError, ValueError) as exc:
raise AppError(10167, params=(str(exc),)) from exc
async def retrieval(self, body: dict[str, Any]) -> dict[str, Any]:
payload = await self.request("POST", "/api/v1/retrieval", json_body=body)
data = payload.get("data")
if not isinstance(data, dict):
return {"chunks": [], "doc_aggs": [], "total": 0}
try:
return _normalize_retrieval_result(data)
except (TypeError, ValueError) as exc:
raise AppError(10167, params=(str(exc),)) from exc
def _blank(value: Any) -> bool:
return value is None or (isinstance(value, str) and not value.strip())
def _normalize_parser_config(
value: Any,
*,
fields: tuple[str, ...],
validate_layout: bool = False,
) -> dict[str, Any] | None:
if value is None:
return None
if not isinstance(value, Mapping):
raise ValueError("parser_config must be an object")
result = {key: value.get(key) for key in fields}
if validate_layout and result.get("layout_recognize") not in {None, "DeepDOC", "Simple"}:
raise ValueError("invalid layout_recognize")
if "raptor" in result and result["raptor"] is not None:
nested = result["raptor"]
if not isinstance(nested, Mapping):
raise ValueError("raptor must be an object")
result["raptor"] = {"use_raptor": nested.get("use_raptor")}
if "graphrag" in result and result["graphrag"] is not None:
nested = result["graphrag"]
if not isinstance(nested, Mapping):
raise ValueError("graphrag must be an object")
result["graphrag"] = {"use_graphrag": nested.get("use_graphrag")}
return result
def _normalize_upload_document(value: Mapping[str, Any]) -> dict[str, Any]:
result = dict(value)
try:
if result.get("parser_config") is not None:
result["parser_config"] = _normalize_parser_config(
result["parser_config"], fields=_DOCUMENT_PARSER_FIELDS, validate_layout=True
)
except (TypeError, ValueError) as exc:
raise AppError(10167, params=("远程上传成功但未返回有效 DocumentID",)) from exc
chunk_method = result.get("chunk_method")
if chunk_method is not None:
normalized_method = str(chunk_method).lower()
if normalized_method not in _DOCUMENT_CHUNK_METHODS:
raise AppError(10167, params=("远程上传成功但未返回有效 DocumentID",))
result["chunk_method"] = normalized_method
run = result.get("run")
if run is not None and str(run) not in _RUN_STATUSES:
raise AppError(10167, params=("远程上传成功但未返回有效 DocumentID",))
return result
def _normalize_dataset_info(value: Mapping[str, Any]) -> dict[str, Any]:
"""Apply Jackson's DatasetDTO.InfoVO unknown-field and type boundary."""
fields = (
"id",
"name",
"avatar",
"tenant_id",
"description",
"embedding_model",
"permission",
"chunk_method",
"parser_config",
"chunk_count",
"document_count",
"create_time",
"update_time",
"token_num",
"create_date",
"update_date",
)
try:
result = {field: value.get(field) for field in fields}
result["parser_config"] = _normalize_parser_config(
result.get("parser_config"), fields=_DATASET_PARSER_FIELDS
)
for field in ("chunk_count", "document_count", "create_time", "update_time", "token_num"):
raw_value = result[field]
if raw_value is not None:
result[field] = int(raw_value)
return result
except (TypeError, ValueError) as exc:
raise AppError(10167, params=(str(exc),)) from exc
def _nullable_object(value: Any, fields: tuple[str, ...]) -> dict[str, Any] | None:
if value is None:
return None
if not isinstance(value, Mapping):
raise TypeError("response object has an invalid shape")
return {field: value.get(field) for field in fields}
def _normalize_chunk_list(data: Mapping[str, Any]) -> dict[str, Any]:
chunk_fields = (
"id",
"content",
"document_id",
"docnm_kwd",
"important_keywords",
"questions",
"image_id",
"dataset_id",
"available",
"positions",
"token",
)
raw_chunks = data.get("chunks")
if raw_chunks is None:
chunks: list[dict[str, Any]] = []
elif isinstance(raw_chunks, list):
chunks = []
for item in raw_chunks:
normalized = _nullable_object(item, chunk_fields)
if normalized is not None:
chunks.append(normalized)
else:
raise TypeError("chunks must be an array")
doc_fields = (
"id",
"thumbnail",
"dataset_id",
"chunk_method",
"pipeline_id",
"parser_config",
"source_type",
"type",
"created_by",
"name",
"location",
"size",
"token_count",
"chunk_count",
"progress",
"progress_msg",
"process_begin_at",
"process_duration",
"meta_fields",
"suffix",
"run",
"status",
"create_time",
"create_date",
"update_time",
"update_date",
)
doc = _nullable_object(data.get("doc"), doc_fields)
if doc is not None:
doc["parser_config"] = _normalize_parser_config(
doc.get("parser_config"), fields=_DOCUMENT_PARSER_FIELDS, validate_layout=True
)
if doc.get("chunk_count") is not None:
# DocumentDTO.InfoVO.chunkCount is Long, unlike the Integer field
# on KnowledgeFilesDTO used by the document-list endpoint.
doc["chunk_count"] = str(doc["chunk_count"])
if doc.get("run") is not None and str(doc["run"]) not in _RUN_STATUSES:
raise ValueError("invalid document run status")
# ChunkDTO.ListVO.total is Long and therefore uses the Java global Long
# serializer even for small values (including the adapter's default 0L).
return {"chunks": chunks, "doc": doc, "total": str(int(data.get("total") or 0))}
def _normalize_retrieval_result(data: Mapping[str, Any]) -> dict[str, Any]:
hit_fields = (
"id",
"content",
"document_id",
"dataset_id",
"document_name",
"document_keyword",
"similarity",
"vector_similarity",
"term_similarity",
"index",
"highlight",
"important_keywords",
"questions",
"image_id",
"positions",
)
agg_fields = ("doc_name", "doc_id", "count")
def normalize_list(raw: Any, fields: tuple[str, ...], name: str) -> list[dict[str, Any]]:
if raw is None:
return []
if not isinstance(raw, list):
raise TypeError(f"{name} must be an array")
values: list[dict[str, Any]] = []
for item in raw:
normalized = _nullable_object(item, fields)
if normalized is not None:
values.append(normalized)
return values
return {
"chunks": normalize_list(data.get("chunks"), hit_fields, "chunks"),
"doc_aggs": normalize_list(data.get("doc_aggs"), agg_fields, "doc_aggs"),
# RetrievalDTO.ResultVO.total is also Long.
"total": str(int(data.get("total") or 0)),
}
@@ -0,0 +1,96 @@
from __future__ import annotations
import base64
import json
from dataclasses import dataclass
from typing import Any
import httpx
@dataclass(slots=True)
class VoiceCloneProviderError(Exception):
code: int
message: str
def __str__(self) -> str:
return self.message
class VoiceCloneIntegration:
ENDPOINT = "https://openspeech.bytedance.com/api/v1/mega_tts/audio/upload"
def __init__(
self,
*,
timeout_seconds: float,
client: httpx.AsyncClient | None = None,
endpoint: str | None = None,
):
self.timeout_seconds = timeout_seconds
self.client = client
self.endpoint = endpoint or self.ENDPOINT
async def train_huoshan(
self,
*,
appid: str,
access_token: str,
voice: bytes,
speaker_id: str,
) -> str:
request_body: dict[str, Any] = {
"appid": appid,
"audios": [
{
"audio_bytes": base64.b64encode(voice).decode("ascii"),
"audio_format": "wav",
}
],
"source": 2,
"language": 0,
"model_type": 1,
"speaker_id": speaker_id,
}
owns_client = self.client is None
client = self.client or httpx.AsyncClient()
try:
response = await client.post(
self.endpoint,
content=json.dumps(request_body, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer;{access_token}",
"Resource-Id": "seed-icl-1.0",
},
timeout=self.timeout_seconds,
)
try:
payload = response.json()
except (json.JSONDecodeError, ValueError) as exc:
raise VoiceCloneProviderError(10157, str(exc)) from exc
except httpx.HTTPError as exc:
raise VoiceCloneProviderError(10157, str(exc)) from exc
finally:
if owns_client:
await client.aclose()
if not isinstance(payload, dict):
raise VoiceCloneProviderError(10156, "响应格式错误,缺少BaseResp字段")
base_response = payload.get("BaseResp")
if isinstance(base_response, dict):
raw_status = base_response.get("StatusCode")
try:
status_code = int(raw_status) if raw_status is not None else None
except (TypeError, ValueError):
status_code = None
returned_speaker = payload.get("speaker_id")
if status_code == 0 and isinstance(returned_speaker, str) and returned_speaker.strip():
return returned_speaker
status_message = base_response.get("StatusMessage")
message = str(status_message) if status_message not in (None, "") else "训练失败"
raise VoiceCloneProviderError(500, message)
payload_message = payload.get("message")
if payload_message not in (None, ""):
raise VoiceCloneProviderError(500, str(payload_message))
raise VoiceCloneProviderError(10156, "响应格式错误,缺少BaseResp字段")
@@ -0,0 +1,102 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from urllib.parse import urlsplit
import httpx
class VoicePrintIntegrationError(RuntimeError):
def __init__(self, code: int, message: str | None = None, params: tuple[object, ...] = ()):
super().__init__(message or str(code))
self.code = code
self.message = message
self.params = params
@dataclass(slots=True, frozen=True)
class VoicePrintEndpoint:
base_url: str
authorization: str
@classmethod
def parse(cls, configured_url: str | None) -> VoicePrintEndpoint:
if configured_url is None:
raise VoicePrintIntegrationError(10084)
parsed = urlsplit(configured_url)
if not parsed.scheme or not parsed.hostname:
raise VoicePrintIntegrationError(10084)
marker = "key="
marker_index = parsed.query.find(marker)
key = parsed.query[marker_index + len(marker) :] if marker_index >= 0 else parsed.query[3:]
port = f":{parsed.port}" if parsed.port is not None else ""
return cls(f"{parsed.scheme}://{parsed.hostname}{port}", f"Bearer {key}")
class VoicePrintClient:
def __init__(self, configured_url: str, *, timeout: float = 10.0, client: httpx.AsyncClient | None = None):
self.endpoint = VoicePrintEndpoint.parse(configured_url)
self.timeout = timeout
self._client = client
async def _request(
self,
method: str,
path: str,
*,
data: Mapping[str, str] | None = None,
files: Mapping[str, tuple[str, bytes, str]] | None = None,
) -> httpx.Response:
headers = {"Authorization": self.endpoint.authorization}
if self._client is not None:
return await self._client.request(
method, f"{self.endpoint.base_url}{path}", headers=headers, data=data, files=files
)
async with httpx.AsyncClient(timeout=self.timeout) as client:
return await client.request(
method, f"{self.endpoint.base_url}{path}", headers=headers, data=data, files=files
)
async def identify(self, speaker_ids: list[str], audio: bytes) -> tuple[str | None, float | None] | None:
if not speaker_ids:
return None
response = await self._request(
"POST",
"/voiceprint/identify",
data={"speaker_ids": ",".join(speaker_ids)},
files={"file": ("VoicePrint.WAV", audio, "application/octet-stream")},
)
if response.status_code != 200:
raise VoicePrintIntegrationError(10091)
try:
payload = response.json()
except ValueError as exc:
raise VoicePrintIntegrationError(10091) from exc
if not isinstance(payload, dict):
return None
speaker_id = payload.get("speaker_id")
score = payload.get("score")
return (
str(speaker_id) if speaker_id is not None else None,
float(score) if isinstance(score, int | float) else None,
)
async def register(self, speaker_id: str, audio: bytes) -> None:
response = await self._request(
"POST",
"/voiceprint/register",
data={"speaker_id": speaker_id},
files={"file": ("VoicePrint.WAV", audio, "application/octet-stream")},
)
if response.status_code != 200:
raise VoicePrintIntegrationError(10087)
if "true" not in response.text:
raise VoicePrintIntegrationError(10088)
async def cancel(self, speaker_id: str) -> None:
response = await self._request("DELETE", f"/voiceprint/{speaker_id}")
if response.status_code != 200:
raise VoicePrintIntegrationError(10089)
if "true" not in response.text:
raise VoicePrintIntegrationError(10090)