Refactor backend to support interface-definition driven model resources

- Introduce a new model structure for managing interface definitions and model resources, enhancing the backend's capability to handle various service integrations.
- Update the Makefile to reflect changes in database seeding and resource management commands.
- Remove the deprecated credentials management routes and replace them with a unified model registry API.
- Modify existing routes and schemas to align with the new model structure, ensuring seamless integration with the frontend.
- Enhance database seeding scripts to populate new model resources and their configurations.
- Update README documentation to reflect the new architecture and usage instructions for model resources and interface definitions.
This commit is contained in:
Xin Wang
2026-06-14 19:36:12 +08:00
parent e25dfd4003
commit 90e3e8a0c0
32 changed files with 2577 additions and 1765 deletions

View File

@@ -1,52 +1,63 @@
"""assistant_id → 运行时配置(把真 key 在服务端组装好)。
浏览器只传 assistant_id;真 key 在这里从 provider_credentials 取出注入。
助手按 FK(*_credential_id)引用凭证;取不到则回退该 type 默认凭证,再回退 .env。
浏览器只传 assistant_id;真 key 在这里从 model_resources 取出注入。
助手按 capability binding 引用资源;取不到则回退该能力默认资源,再回退 .env。
"""
import config
from db.models import Assistant, ProviderCredential
from db.models import Assistant, AssistantModelBinding, ModelResource
from models import AssistantConfig
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
async def _default_credential(
session: AsyncSession, type_: str
) -> ProviderCredential | None:
"""该 type 的默认凭证(is_default 优先,否则按 id 取第一条)。"""
stmt = (
select(ProviderCredential)
.where(ProviderCredential.type == type_)
.order_by(ProviderCredential.is_default.desc(), ProviderCredential.id.asc())
.limit(1)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def _resource_for(
session: AsyncSession,
assistant_id: str,
capability: str,
) -> ModelResource | None:
binding = await session.get(AssistantModelBinding, (assistant_id, capability))
resource_id = binding.model_resource_id if binding else None
resource = await session.get(ModelResource, resource_id) if resource_id else None
if resource and resource.capability != capability:
resource = None
if resource is None:
resource = (
await session.execute(
select(ModelResource)
.where(ModelResource.capability == capability, ModelResource.enabled.is_(True))
.order_by(ModelResource.is_default.desc(), ModelResource.id.asc())
.limit(1)
)
).scalar_one_or_none()
return resource
async def _resolve(
session: AsyncSession, cred_id: str | None, type_: str
) -> ProviderCredential | None:
"""按 FK id 取凭证;id 为空或失效 → 回退该 type 默认。"""
if cred_id:
cred = await session.get(ProviderCredential, cred_id)
if cred:
return cred
return await _default_credential(session, type_)
def _value(resource: ModelResource | None, key: str, default):
if not resource:
return default
value = (resource.values or {}).get(key, default)
return default if value is None else value
def _secret(resource: ModelResource | None, key: str, default: str) -> str:
if not resource:
return default
return str((resource.secrets or {}).get(key) or default)
async def resolve_runtime_config(
session: AsyncSession, assistant_id: str
) -> AssistantConfig:
"""加载助手 + 解析凭证,产出可直接交给管线的运行时配置(含真 key)。"""
"""加载助手 + 解析模型资源,产出可直接交给管线的运行时配置(含真 key)。"""
assistant = await session.get(Assistant, assistant_id)
if assistant is None:
raise ValueError(f"助手不存在: {assistant_id}")
llm = await _resolve(session, assistant.llm_credential_id, "LLM")
stt = await _resolve(session, assistant.asr_credential_id, "ASR")
tts = await _resolve(session, assistant.tts_credential_id, "TTS")
realtime = await _resolve(session, assistant.realtime_credential_id, "Realtime")
llm_resource = await _resource_for(session, assistant.id, "LLM")
stt_resource = await _resource_for(session, assistant.id, "ASR")
tts_resource = await _resource_for(session, assistant.id, "TTS")
realtime_resource = await _resource_for(session, assistant.id, "Realtime")
return AssistantConfig(
name=assistant.name,
@@ -55,21 +66,28 @@ async def resolve_runtime_config(
prompt=assistant.prompt or "你是一个有帮助的助手。",
runtimeMode=assistant.runtime_mode, # type: ignore[arg-type]
enableInterrupt=assistant.enable_interrupt,
# 模型/音色:凭证的模型ID优先
model=(llm.model_id if llm else ""),
asr=(stt.model_id if stt else ""),
tts_model=(tts.model_id if tts else ""),
voice=(tts.voice if tts else ""),
stt_language=(stt.language if stt else ""),
tts_speed=(tts.speed if tts else 1.0),
stt_interface_type=(stt.interface_type if stt else "openai"),
tts_interface_type=(tts.interface_type if tts else "openai"),
realtimeModel=(realtime.model_id if realtime else ""),
# 运行时连接信息(真 key + url):凭证优先,否则 .env 兜底
llm_api_key=(llm.api_key if llm else config.LLM_API_KEY),
llm_base_url=(llm.api_url if llm else config.LLM_BASE_URL),
stt_api_key=(stt.api_key if stt else config.STT_API_KEY),
stt_base_url=(stt.api_url if stt else config.STT_BASE_URL),
tts_api_key=(tts.api_key if tts else config.TTS_API_KEY),
tts_base_url=(tts.api_url if tts else config.TTS_BASE_URL),
# 模型/音色:模型资源中的配置优先
model=str(_value(llm_resource, "modelId", "")),
asr=str(_value(stt_resource, "modelId", "")),
tts_model=str(_value(tts_resource, "modelId", "")),
voice=str(_value(tts_resource, "voice", "")),
stt_language=str(_value(stt_resource, "language", "")),
tts_speed=float(_value(tts_resource, "speed", 1.0)),
llm_interface_type=(llm_resource.interface_type if llm_resource else "openai-llm"),
stt_interface_type=(stt_resource.interface_type if stt_resource else "openai-asr"),
tts_interface_type=(tts_resource.interface_type if tts_resource else "openai-tts"),
realtimeModel=str(_value(realtime_resource, "modelId", "")),
llm_values=(llm_resource.values or {}) if llm_resource else {},
llm_secrets=(llm_resource.secrets or {}) if llm_resource else {},
stt_values=(stt_resource.values or {}) if stt_resource else {},
stt_secrets=(stt_resource.secrets or {}) if stt_resource else {},
tts_values=(tts_resource.values or {}) if tts_resource else {},
tts_secrets=(tts_resource.secrets or {}) if tts_resource else {},
# 运行时连接信息(真 key + url):模型资源优先,否则 .env 兜底
llm_api_key=_secret(llm_resource, "apiKey", config.LLM_API_KEY),
llm_base_url=str(_value(llm_resource, "apiUrl", config.LLM_BASE_URL)),
stt_api_key=_secret(stt_resource, "apiKey", config.STT_API_KEY),
stt_base_url=str(_value(stt_resource, "apiUrl", config.STT_BASE_URL)),
tts_api_key=_secret(tts_resource, "apiKey", config.TTS_API_KEY),
tts_base_url=str(_value(tts_resource, "apiUrl", config.TTS_BASE_URL)),
)

View File

@@ -0,0 +1,155 @@
"""Built-in concrete interface definitions used by backend and dynamic forms."""
from __future__ import annotations
from typing import Any
def field(
key: str,
label: str,
*,
group: str = "values",
type_: str = "text",
required: bool = False,
default: Any = None,
options: list[str] | None = None,
) -> dict:
value = {
"key": key,
"label": label,
"group": group,
"type": type_,
"required": required,
}
if default is not None:
value["default"] = default
if options:
value["options"] = options
return value
OPENAI_COMMON = [
field("modelId", "Model ID", required=True),
field("apiUrl", "API URL", type_="url", required=True),
field("apiKey", "API Key", group="secrets", type_="password", required=True),
]
XFYUN_AUTH = [
field("apiUrl", "WebSocket URL", type_="url", required=True),
field("appId", "App ID", group="secrets", type_="password", required=True),
field("apiKey", "API Key", group="secrets", type_="password", required=True),
field("apiSecret", "API Secret", group="secrets", type_="password", required=True),
]
INTERFACE_DEFINITIONS: list[dict] = [
{
"interface_type": "openai-llm",
"name": "OpenAI Compatible LLM",
"capability": "LLM",
"fields": OPENAI_COMMON
+ [field("temperature", "Temperature", type_="number", default=0.7)],
},
{
"interface_type": "openai-asr",
"name": "OpenAI Compatible ASR",
"capability": "ASR",
"fields": OPENAI_COMMON + [field("language", "Language", default="zh")],
},
{
"interface_type": "openai-tts",
"name": "OpenAI Compatible TTS",
"capability": "TTS",
"fields": OPENAI_COMMON
+ [
field("voice", "Voice"),
field("speed", "Speed", type_="number", default=1.0),
field("sourceSampleRate", "Source Sample Rate", type_="number", default=24000),
],
},
{
"interface_type": "openai-embedding",
"name": "OpenAI Compatible Embedding",
"capability": "Embedding",
"fields": OPENAI_COMMON + [field("dimensions", "Dimensions", type_="number")],
},
{
"interface_type": "openai-realtime",
"name": "OpenAI Realtime",
"capability": "Realtime",
"fields": OPENAI_COMMON + [field("voice", "Voice")],
},
{
"interface_type": "xfyun-asr",
"name": "Xfyun Streaming ASR",
"capability": "ASR",
"fields": XFYUN_AUTH
+ [
field("language", "Language", default="zh_cn"),
field("domain", "Domain", default="iat"),
field("accent", "Accent", default="mandarin"),
field("dynamicCorrection", "Dynamic Correction", type_="boolean", default=False),
field("frameSize", "Frame Size", type_="number", default=1280),
],
},
{
"interface_type": "xfyun-tts",
"name": "Xfyun TTS",
"capability": "TTS",
"fields": XFYUN_AUTH
+ [
field("voice", "Voice"),
field("speed", "Speed", type_="number", default=50),
field("volume", "Volume", type_="number", default=50),
field("pitch", "Pitch", type_="number", default=50),
field("sourceSampleRate", "Source Sample Rate", type_="number", default=16000),
],
},
{
"interface_type": "xfyun-super-tts",
"name": "Xfyun Super TTS",
"capability": "TTS",
"fields": XFYUN_AUTH
+ [
field("voice", "Voice"),
field("speed", "Speed", type_="number", default=50),
field("volume", "Volume", type_="number", default=50),
field("pitch", "Pitch", type_="number", default=50),
field("oralLevel", "Oral Level", default="mid"),
field("sourceSampleRate", "Source Sample Rate", type_="number", default=24000),
field("textAggregationMode", "Text Aggregation Mode", default="token"),
],
},
{
"interface_type": "dashscope-llm",
"name": "DashScope LLM",
"capability": "LLM",
"fields": OPENAI_COMMON
+ [field("temperature", "Temperature", type_="number", default=0.7)],
},
{
"interface_type": "dashscope-asr",
"name": "DashScope ASR",
"capability": "ASR",
"fields": OPENAI_COMMON + [field("language", "Language", default="zh")],
},
{
"interface_type": "dashscope-tts",
"name": "DashScope TTS",
"capability": "TTS",
"fields": OPENAI_COMMON + [field("voice", "Voice")],
},
{
"interface_type": "gemini-realtime",
"name": "Gemini Realtime",
"capability": "Realtime",
"fields": OPENAI_COMMON,
},
]
def validate_fields(definition: dict, values: dict, secrets: dict) -> None:
for item in definition["fields"]:
source = secrets if item["group"] == "secrets" else values
value = source.get(item["key"])
if item.get("required") and (value is None or value == ""):
raise ValueError(f"{item['label']} is required")

View File

@@ -25,3 +25,23 @@ def resolve_incoming_key(incoming: str | None, stored: str) -> str:
if incoming is None or incoming == "" or is_masked(incoming):
return stored
return incoming
def mask_secrets(value):
"""Recursively mask every scalar in a model resource secrets object."""
if isinstance(value, dict):
return {key: mask_secrets(item) for key, item in value.items()}
if isinstance(value, list):
return [mask_secrets(item) for item in value]
return mask(str(value)) if value is not None else ""
def merge_secrets(incoming: dict, stored: dict) -> dict:
"""Merge secret fields while treating masked/empty values as keep-existing."""
result = dict(stored or {})
for key, value in (incoming or {}).items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = merge_secrets(value, result[key])
elif value not in (None, "") and not is_masked(str(value)):
result[key] = value
return result

View File

@@ -1,4 +1,4 @@
"""OpenAI 兼容模型凭证的最小连通测试。"""
"""Connectivity checks for interface-definition driven model resources."""
from __future__ import annotations
@@ -8,8 +8,8 @@ import wave
import httpx
from schemas import CredentialTestRequest, CredentialTestResult
from services.pipecat.xfyun_config import parse_xfyun_credential
import config
from schemas import ModelResourceTestResult
TEST_TIMEOUT_SECONDS = 10.0
@@ -28,7 +28,7 @@ def _silent_wav() -> bytes:
return buffer.getvalue()
def _error_detail(response: httpx.Response, api_key: str) -> str:
def _error_detail(response: httpx.Response, secrets: dict) -> str:
try:
body = response.json()
detail = (
@@ -39,110 +39,114 @@ def _error_detail(response: httpx.Response, api_key: str) -> str:
except ValueError:
detail = None
text = str(detail or response.text or response.reason_phrase).strip()
return text.replace(api_key, "***")[:300]
for secret in secrets.values():
if secret:
text = text.replace(str(secret), "***")
return text[:300]
async def test_openai_credential(
config: CredentialTestRequest,
) -> CredentialTestResult:
async def test_model_resource(
interface_type: str,
capability: str,
values: dict,
secrets: dict,
) -> ModelResourceTestResult:
if interface_type.startswith("xfyun-"):
return ModelResourceTestResult(
ok=True,
message="讯飞连接参数有效",
detail="鉴权字段和连接参数完整,请在语音测试页验证签名及音频链路",
)
if capability == "Realtime":
return ModelResourceTestResult(
ok=False,
message="暂不支持 Realtime 连接测试",
detail="请在助手语音测试页验证实时连接",
)
api_url = str(values.get("apiUrl") or "")
model_id = str(values.get("modelId") or "")
api_key = str(secrets.get("apiKey") or "")
headers = {"Authorization": f"Bearer {api_key}"}
started = time.perf_counter()
headers = {"Authorization": f"Bearer {config.api_key}"}
try:
async with httpx.AsyncClient(timeout=TEST_TIMEOUT_SECONDS) as client:
if config.type == "LLM":
if capability == "LLM":
response = await client.post(
_endpoint(config.api_url, "chat/completions"),
_endpoint(api_url, "chat/completions"),
headers=headers,
json={
"model": config.model_id,
"model": model_id,
"messages": [{"role": "user", "content": "Reply with OK."}],
"max_tokens": 1,
"stream": False,
},
)
elif config.type == "Embedding":
elif capability == "Embedding":
response = await client.post(
_endpoint(config.api_url, "embeddings"),
_endpoint(api_url, "embeddings"),
headers=headers,
json={"model": config.model_id, "input": "ping"},
json={"model": model_id, "input": "ping"},
)
elif config.type == "ASR":
elif capability == "ASR":
response = await client.post(
_endpoint(config.api_url, "audio/transcriptions"),
_endpoint(api_url, "audio/transcriptions"),
headers=headers,
data={
"model": config.model_id,
**({"language": config.language} if config.language else {}),
"model": model_id,
**(
{"language": str(values["language"])}
if values.get("language")
else {}
),
},
files={"file": ("test.wav", _silent_wav(), "audio/wav")},
)
elif config.type == "TTS":
elif capability == "TTS":
response = await client.post(
_endpoint(config.api_url, "audio/speech"),
_endpoint(api_url, "audio/speech"),
headers=headers,
json={
"model": config.model_id,
"model": model_id,
"input": "测试",
"voice": config.voice,
"voice": str(values.get("voice") or config.TTS_VOICE),
"response_format": "pcm",
"speed": config.speed,
"speed": float(values.get("speed") or 1),
},
)
else:
return CredentialTestResult(
return ModelResourceTestResult(
ok=False,
message="暂不支持该资源类型的连测试",
detail=f"当前仅支持 LLM、Embedding、ASR、TTS收到 {config.type}",
message="暂不支持该能力的连测试",
detail=f"收到能力类型 {capability}",
)
latency_ms = round((time.perf_counter() - started) * 1000)
if response.is_success:
return CredentialTestResult(
return ModelResourceTestResult(
ok=True,
latency_ms=latency_ms,
message="连接成功",
detail=f"OpenAI 兼容接口响应正常HTTP {response.status_code}",
detail=f"接口响应正常HTTP {response.status_code}",
)
return CredentialTestResult(
return ModelResourceTestResult(
ok=False,
latency_ms=latency_ms,
message=f"连接失败HTTP {response.status_code}",
detail=_error_detail(response, config.api_key),
detail=_error_detail(response, secrets),
)
except httpx.TimeoutException:
return CredentialTestResult(
return ModelResourceTestResult(
ok=False,
latency_ms=round((time.perf_counter() - started) * 1000),
message="连接超时",
detail=f"服务未在 {TEST_TIMEOUT_SECONDS:g} 秒内响应",
)
except httpx.RequestError as exc:
return CredentialTestResult(
return ModelResourceTestResult(
ok=False,
latency_ms=round((time.perf_counter() - started) * 1000),
message="无法连接到模型服务",
detail=str(exc)[:300],
)
def test_xfyun_credential(config: CredentialTestRequest) -> CredentialTestResult:
"""Validate the Xfyun credential packed into the existing api_key field.
Actual signed-WebSocket synthesis/recognition is exercised by the voice
pipeline; this check deliberately avoids consuming provider quota.
"""
try:
parse_xfyun_credential(config.api_key)
except ValueError as exc:
return CredentialTestResult(
ok=False,
message="讯飞凭证格式无效",
detail=str(exc),
)
return CredentialTestResult(
ok=True,
message="讯飞凭证格式有效",
detail="请在语音测试页验证签名、识别和合成链路",
)

View File

@@ -1,7 +1,7 @@
"""创建 STT / LLM / TTS 服务。
对应 dograh 的 service_factory.py,但只留一套国产栈(OpenAI 兼容),
provider 扩展时在这里加分支即可——这是未来接更多模型的唯一入口。
interface_type 扩展时在这里加分支即可——这是未来接更多模型的唯一入口。
"""
import config
@@ -14,13 +14,7 @@ from pipecat.services.openai.tts import VALID_VOICES, OpenAITTSService
from pipecat.transcriptions.language import Language
from services.pipecat.xfyun_asr import DEFAULT_XFYUN_ASR_URL, XfyunASRService
from services.pipecat.xfyun_config import (
is_super_tts,
parse_xfyun_credential,
websocket_url,
xfyun_language,
xfyun_speed,
)
from services.pipecat.xfyun_config import websocket_url, xfyun_language, xfyun_speed
from services.pipecat.xfyun_super_tts import (
DEFAULT_XFYUN_SUPER_TTS_URL,
XfyunSuperTTSService,
@@ -43,16 +37,21 @@ def create_stt(cfg: AssistantConfig):
连接信息优先用 cfg(由 config_resolver 从 DB 注入),为空回退 .env 默认。
"""
if cfg.stt_interface_type == "xfyun":
credential = parse_xfyun_credential(cfg.stt_api_key)
if cfg.stt_interface_type == "xfyun-asr":
return XfyunASRService(
app_id=credential.app_id,
api_key=credential.api_key,
api_secret=credential.api_secret,
app_id=str(cfg.stt_secrets.get("appId") or ""),
api_key=str(cfg.stt_secrets.get("apiKey") or ""),
api_secret=str(cfg.stt_secrets.get("apiSecret") or ""),
url=websocket_url(cfg.stt_base_url, DEFAULT_XFYUN_ASR_URL),
language=xfyun_language(cfg.stt_language),
sample_rate=16000,
domain=str(cfg.stt_values.get("domain") or "iat"),
accent=str(cfg.stt_values.get("accent") or "mandarin"),
frame_size=int(cfg.stt_values.get("frameSize") or 1280),
dynamic_correction=bool(cfg.stt_values.get("dynamicCorrection", False)),
)
if cfg.stt_interface_type not in {"openai-asr", "dashscope-asr"}:
raise ValueError(f"不支持的 ASR 接口类型: {cfg.stt_interface_type}")
return OpenAISTTService(
api_key=cfg.stt_api_key or config.STT_API_KEY,
@@ -66,6 +65,8 @@ def create_stt(cfg: AssistantConfig):
def create_llm(cfg: AssistantConfig):
"""DeepSeek 等,走 OpenAI 兼容的 /v1/chat/completions。"""
if cfg.llm_interface_type not in {"openai-llm", "dashscope-llm"}:
raise ValueError(f"不支持的 LLM 接口类型: {cfg.llm_interface_type}")
return OpenAILLMService(
api_key=cfg.llm_api_key or config.LLM_API_KEY,
base_url=cfg.llm_base_url or config.LLM_BASE_URL,
@@ -76,31 +77,39 @@ def create_llm(cfg: AssistantConfig):
def create_tts(cfg: AssistantConfig):
"""CosyVoice 等,走 OpenAI 兼容的 /v1/audio/speech。"""
voice = cfg.voice or config.TTS_VOICE
if cfg.tts_interface_type == "xfyun":
credential = parse_xfyun_credential(cfg.tts_api_key)
speed = xfyun_speed(cfg.tts_speed)
if is_super_tts(cfg.tts_model, cfg.tts_base_url):
return XfyunSuperTTSService(
app_id=credential.app_id,
api_key=credential.api_key,
api_secret=credential.api_secret,
voice=voice,
url=websocket_url(cfg.tts_base_url, DEFAULT_XFYUN_SUPER_TTS_URL),
sample_rate=16000,
source_sample_rate=24000,
speed=speed,
)
if cfg.tts_interface_type == "xfyun-super-tts":
return XfyunSuperTTSService(
app_id=str(cfg.tts_secrets.get("appId") or ""),
api_key=str(cfg.tts_secrets.get("apiKey") or ""),
api_secret=str(cfg.tts_secrets.get("apiSecret") or ""),
voice=voice,
url=websocket_url(cfg.tts_base_url, DEFAULT_XFYUN_SUPER_TTS_URL),
sample_rate=16000,
source_sample_rate=int(cfg.tts_values.get("sourceSampleRate") or 24000),
speed=xfyun_speed(cfg.tts_speed),
volume=int(cfg.tts_values.get("volume") or 50),
pitch=int(cfg.tts_values.get("pitch") or 50),
oral_level=str(cfg.tts_values.get("oralLevel") or "mid"),
text_aggregation_mode=str(
cfg.tts_values.get("textAggregationMode") or "token"
),
)
if cfg.tts_interface_type == "xfyun-tts":
return XfyunTTSService(
app_id=credential.app_id,
api_key=credential.api_key,
api_secret=credential.api_secret,
app_id=str(cfg.tts_secrets.get("appId") or ""),
api_key=str(cfg.tts_secrets.get("apiKey") or ""),
api_secret=str(cfg.tts_secrets.get("apiSecret") or ""),
voice=voice,
url=websocket_url(cfg.tts_base_url, DEFAULT_XFYUN_TTS_URL),
sample_rate=16000,
source_sample_rate=16000,
speed=speed,
source_sample_rate=int(cfg.tts_values.get("sourceSampleRate") or 16000),
speed=xfyun_speed(cfg.tts_speed),
volume=int(cfg.tts_values.get("volume") or 50),
pitch=int(cfg.tts_values.get("pitch") or 50),
push_stop_frames=True,
)
if cfg.tts_interface_type not in {"openai-tts", "dashscope-tts"}:
raise ValueError(f"不支持的 TTS 接口类型: {cfg.tts_interface_type}")
# Pipecat 默认只接受 OpenAI 官方音色。OpenAI 兼容服务常使用自定义 voice id,
# 注册为原样映射后仍由 OpenAI SDK 按字符串透传给供应商。

View File

@@ -1,46 +1,7 @@
"""Parse Xfyun's three-part credential from ProviderCredential.api_key."""
"""Shared Xfyun service value normalization."""
from __future__ import annotations
import json
from dataclasses import dataclass
@dataclass(frozen=True)
class XfyunCredential:
app_id: str
api_key: str
api_secret: str
def parse_xfyun_credential(value: str) -> XfyunCredential:
"""Accept JSON in the existing api_key column.
Example:
{"appId":"...","apiKey":"...","apiSecret":"..."}
"""
try:
payload = json.loads(value)
except json.JSONDecodeError as exc:
raise ValueError(
'Xfyun API Key must be JSON: {"appId":"...","apiKey":"...","apiSecret":"..."}'
) from exc
if not isinstance(payload, dict):
raise ValueError("Xfyun API Key JSON must be an object")
credential = XfyunCredential(
app_id=str(payload.get("appId") or payload.get("app_id") or "").strip(),
api_key=str(payload.get("apiKey") or payload.get("api_key") or "").strip(),
api_secret=str(
payload.get("apiSecret") or payload.get("api_secret") or ""
).strip(),
)
if not credential.app_id or not credential.api_key or not credential.api_secret:
raise ValueError("Xfyun API Key JSON requires appId, apiKey, and apiSecret")
return credential
def websocket_url(value: str, default: str) -> str:
url = (value or default).strip()
if url.startswith("https://"):
@@ -49,12 +10,6 @@ def websocket_url(value: str, default: str) -> str:
return f"ws://{url.removeprefix('http://')}"
return url
def is_super_tts(model_id: str, api_url: str) -> bool:
model = model_id.lower().replace("-", "_")
return "super" in model or "/private/" in api_url.lower()
def xfyun_language(value: str) -> str:
normalized = (value or "zh_cn").lower().replace("-", "_")
return {"zh": "zh_cn", "en": "en_us"}.get(normalized, normalized)