Enhance credential management and testing functionality

- Introduce new fields for voice, speed, and language in the AssistantConfig and ProviderCredential models to support TTS and ASR configurations.
- Update the database schema and seeding script to accommodate the new fields, ensuring backward compatibility.
- Implement credential testing endpoints and logic to validate OpenAI-compatible credentials, enhancing user experience and reliability.
- Modify frontend components to include new fields in the credential forms and improve connection testing feedback.
- Refactor related services and API interactions to support the new credential testing feature.
This commit is contained in:
Xin Wang
2026-06-09 14:42:25 +08:00
parent 3661dab81c
commit c64b7dcf99
12 changed files with 443 additions and 28 deletions

View File

@@ -9,7 +9,7 @@
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, func
from sqlalchemy import JSON, Boolean, DateTime, Float, ForeignKey, String, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
@@ -29,6 +29,9 @@ class ProviderCredential(Base):
interface_type: Mapped[str] = mapped_column(String(32), default="openai") # openai|xfyun|dashscope|gemini
api_url: Mapped[str] = mapped_column(String(512), default="")
api_key: Mapped[str] = mapped_column(String(512), default="") # 明文
voice: Mapped[str] = mapped_column(String(128), default="") # TTS 音色
speed: Mapped[float] = mapped_column(Float, default=1.0) # TTS 语速
language: Mapped[str] = mapped_column(String(32), default="") # ASR 语言
# 同一 type 下的默认凭证(后端解析用;前端 ModelResource 无此字段,留作可选)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -4,23 +4,35 @@
-- docker compose exec -T postgres psql -U postgres -d postgres < backend/db/seed_credentials.sql
--
-- 说明:
-- * id 固定为 model_001..012,配合 ON CONFLICT 做幂等,可重复执行不重复插入
-- * id 固定为 model_001..012,配合 ON CONFLICT 做幂等更新
-- * api_key 在库里是明文(读取走 API 时才打码),这里填的是占位示例 key。
-- * 每种 type 选第一条置为默认(is_default),供后端 config_resolver 解析使用。
-- * TTS 使用 voice/speed;ASR 使用 language;其他类型保持空值/default。
INSERT INTO provider_credentials
(id, name, model_id, type, interface_type, api_url, api_key, is_default)
(id, name, model_id, type, interface_type, api_url, api_key, voice, speed, language, is_default)
VALUES
('model_001', 'DeepSeek-Chat', 'deepseek-chat', 'LLM', 'openai', 'https://api.deepseek.com/v1', 'sk-230701ff1b6143ecbf322b3170606016', TRUE),
('model_002', 'SiliconFlow-TeleSpeechASR', 'TeleAI/TeleSpeechASR', 'ASR', 'openai', 'https://api.siliconflow.cn/v1', 'sk-uudpgflahqqjbofhgcbwjjefgwhvwwmxgeyehcueqlemwavq', FALSE),
('model_003', 'SiliconFlow-Qwen3-Embedding-4B', 'Qwen/Qwen3-Embedding-4B', 'Embedding', 'openai', 'https://api.siliconflow.cn/v1', 'sk-uudpgflahqqjbofhgcbwjjefgwhvwwmxgeyehcueqlemwavq', TRUE)
('model_004', 'SiliconFlow-CosyVoice2-0.5B', 'FunAudioLLM/CosyVoice2-0.5B', 'TTS', 'openai', 'https://api.siliconflow.cn/v1', 'sk-uudpgflahqqjbofhgcbwjjefgwhvwwmxgeyehcueqlemwavq', FALSE),
('model_005', 'Qwen-Max', 'qwen-max', 'LLM', 'openai', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'sk-qwen-4d8e2a6f0c', FALSE),
('model_006', '讯飞语音识别', 'iat', 'ASR', 'xfyun', 'https://iat-api.xfyun.cn/v2/iat', 'xf-asr-9b1c3d5e7a', TRUE),
('model_007', 'Paraformer 识别', 'paraformer-realtime-v2', 'ASR', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/asr', 'sk-paraformer-2e4f6a', FALSE),
('model_008', '讯飞语音合成', 'tts', 'TTS', 'xfyun', 'https://tts-api.xfyun.cn/v2/tts', 'xf-tts-6c8a0b2d4f', TRUE),
('model_009', 'CosyVoice 合成', 'cosyvoice-v1', 'TTS', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts', 'sk-cosyvoice-1a3c5e', FALSE),
('model_010', 'GPT Realtime', 'gpt-4o-realtime-preview', 'Realtime', 'openai', 'https://api.openai.com/v1/realtime', 'sk-realtime-3b5d7f9a1c', TRUE),
('model_011', 'Gemini Live', 'gemini-2.0-flash-live', 'Realtime', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', 'gm-live-5e7a9c1b3d', FALSE),
('model_012', 'text-embedding-3', 'text-embedding-3-small', 'Embedding', 'openai', 'https://api.openai.com/v1/embeddings', 'sk-embed-0c2e4a6f8b', FALSE),
ON CONFLICT (id) DO NOTHING;
('model_001', 'DeepSeek-Chat', 'deepseek-chat', 'LLM', 'openai', 'https://api.deepseek.com/v1', 'sk-230701ff1b6143ecbf322b3170606016', '', 1.0, '', TRUE),
('model_002', 'SiliconFlow-TeleSpeechASR', 'TeleAI/TeleSpeechASR', 'ASR', 'openai', 'https://api.siliconflow.cn/v1', 'sk-uudpgflahqqjbofhgcbwjjefgwhvwwmxgeyehcueqlemwavq', '', 1.0, 'zh', FALSE),
('model_003', 'SiliconFlow-Qwen3-Embedding-4B', 'Qwen/Qwen3-Embedding-4B', 'Embedding', 'openai', 'https://api.siliconflow.cn/v1', 'sk-uudpgflahqqjbofhgcbwjjefgwhvwwmxgeyehcueqlemwavq', '', 1.0, '', TRUE),
('model_004', 'SiliconFlow-CosyVoice2-0.5B', 'FunAudioLLM/CosyVoice2-0.5B', 'TTS', 'openai', 'https://api.siliconflow.cn/v1', 'sk-uudpgflahqqjbofhgcbwjjefgwhvwwmxgeyehcueqlemwavq', 'FunAudioLLM/CosyVoice2-0.5B:anna', 1.0, '', FALSE),
('model_005', 'Qwen-Max', 'qwen-max', 'LLM', 'openai', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'sk-qwen-4d8e2a6f0c', '', 1.0, '', FALSE),
('model_006', '讯飞语音识别', 'iat', 'ASR', 'xfyun', 'https://iat-api.xfyun.cn/v2/iat', 'xf-asr-9b1c3d5e7a', '', 1.0, 'zh', TRUE),
('model_007', 'Paraformer 识别', 'paraformer-realtime-v2', 'ASR', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/asr', 'sk-paraformer-2e4f6a', '', 1.0, 'zh', FALSE),
('model_008', '讯飞语音合成', 'tts', 'TTS', 'xfyun', 'https://tts-api.xfyun.cn/v2/tts', 'xf-tts-6c8a0b2d4f', 'xiaoyan', 1.0, '', TRUE),
('model_009', 'CosyVoice 合成', 'cosyvoice-v1', 'TTS', 'dashscope', 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts', 'sk-cosyvoice-1a3c5e', 'longxiaochun', 1.0, '', FALSE),
('model_010', 'GPT Realtime', 'gpt-4o-realtime-preview', 'Realtime', 'openai', 'https://api.openai.com/v1/realtime', 'sk-realtime-3b5d7f9a1c', '', 1.0, '', TRUE),
('model_011', 'Gemini Live', 'gemini-2.0-flash-live', 'Realtime', 'gemini', 'https://generativelanguage.googleapis.com/v1beta', 'gm-live-5e7a9c1b3d', '', 1.0, '', FALSE),
('model_012', 'text-embedding-3', 'text-embedding-3-small', 'Embedding', 'openai', 'https://api.openai.com/v1/embeddings', 'sk-embed-0c2e4a6f8b', '', 1.0, '', FALSE)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
model_id = EXCLUDED.model_id,
type = EXCLUDED.type,
interface_type = EXCLUDED.interface_type,
api_url = EXCLUDED.api_url,
api_key = EXCLUDED.api_key,
voice = EXCLUDED.voice,
speed = EXCLUDED.speed,
language = EXCLUDED.language,
is_default = EXCLUDED.is_default,
updated_at = now();

View File

@@ -9,6 +9,7 @@ from collections.abc import AsyncGenerator
import config
from db.models import Base
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
@@ -27,3 +28,22 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
async def init_db() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# MVP 兼容迁移:create_all 不会给已存在的表补列。
await conn.execute(
text(
"ALTER TABLE provider_credentials "
"ADD COLUMN IF NOT EXISTS voice VARCHAR(128) NOT NULL DEFAULT ''"
)
)
await conn.execute(
text(
"ALTER TABLE provider_credentials "
"ADD COLUMN IF NOT EXISTS speed DOUBLE PRECISION NOT NULL DEFAULT 1.0"
)
)
await conn.execute(
text(
"ALTER TABLE provider_credentials "
"ADD COLUMN IF NOT EXISTS language VARCHAR(32) NOT NULL DEFAULT ''"
)
)

View File

@@ -27,6 +27,8 @@ class AssistantConfig(BaseModel):
model: str = "" # LLM
asr: str = "" # STT
voice: str = "" # TTS 音色
stt_language: str = ""
tts_speed: float = 1.0
realtimeModel: str = ""
enableInterrupt: bool = True

View File

@@ -5,6 +5,7 @@
pipecat-ai[webrtc,silero,openai]~=0.0.60
fastapi
httpx
uvicorn[standard]
python-dotenv
pydantic

View File

@@ -9,7 +9,13 @@ import uuid
from db.models import ProviderCredential
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from schemas import CredentialOut, CredentialUpsert
from schemas import (
CredentialOut,
CredentialTestRequest,
CredentialTestResult,
CredentialUpsert,
)
from services.credential_tester import test_openai_credential
from services.masking import mask, resolve_incoming_key
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,6 +32,9 @@ def _to_out(c: ProviderCredential) -> CredentialOut:
interface_type=c.interface_type,
api_url=c.api_url,
api_key=mask(c.api_key), # 永远打码
voice=c.voice,
speed=c.speed,
language=c.language,
is_default=c.is_default,
)
@@ -60,6 +69,9 @@ async def create_credential(
interface_type=body.interface_type,
api_url=body.api_url,
api_key=resolve_incoming_key(body.api_key, ""),
voice=body.voice,
speed=body.speed,
language=body.language,
is_default=body.is_default,
)
session.add(c)
@@ -70,6 +82,44 @@ async def create_credential(
return _to_out(c)
@router.post("/test", response_model=CredentialTestResult)
async def test_new_credential(body: CredentialTestRequest):
if body.interface_type != "openai":
return CredentialTestResult(
ok=False,
message="暂不支持该接口类型",
detail="当前仅支持 OpenAI 兼容接口测试",
)
if not body.api_key:
return CredentialTestResult(
ok=False,
message="缺少 API Key",
detail="测试新配置时需要输入 API Key",
)
return await test_openai_credential(body)
@router.post("/{cred_id}/test", response_model=CredentialTestResult)
async def test_saved_credential(
cred_id: str,
body: CredentialTestRequest,
session: AsyncSession = Depends(get_session),
):
c = await session.get(ProviderCredential, cred_id)
if not c:
raise HTTPException(404, "凭证不存在")
config = body.model_copy(
update={"api_key": resolve_incoming_key(body.api_key, c.api_key)}
)
if config.interface_type != "openai":
return CredentialTestResult(
ok=False,
message="暂不支持该接口类型",
detail="当前仅支持 OpenAI 兼容接口测试",
)
return await test_openai_credential(config)
@router.post("/{cred_id}/duplicate", response_model=CredentialOut)
async def duplicate_credential(
cred_id: str, session: AsyncSession = Depends(get_session)
@@ -86,6 +136,9 @@ async def duplicate_credential(
interface_type=src.interface_type,
api_url=src.api_url,
api_key=src.api_key, # 真 key,DB→DB
voice=src.voice,
speed=src.speed,
language=src.language,
is_default=False, # 副本不继承默认,避免抢走源的默认标记
)
session.add(c)
@@ -108,6 +161,9 @@ async def update_credential(
c.type = body.type
c.interface_type = body.interface_type
c.api_url = body.api_url
c.voice = body.voice
c.speed = body.speed
c.language = body.language
c.is_default = body.is_default
# 写时哨兵:打码占位符 → 保留旧 key
c.api_key = resolve_incoming_key(body.api_key, c.api_key)

View File

@@ -101,8 +101,20 @@ class CredentialUpsert(CamelModel):
interface_type: InterfaceType = "openai" # openai/xfyun/dashscope/gemini
api_url: str = ""
api_key: str = "" # 写时:占位符/空表示不改
voice: str = "" # TTS
speed: float = 1.0 # TTS
language: str = "" # ASR
is_default: bool = False
@model_validator(mode="after")
def _strip_irrelevant_options(self):
if self.type != "TTS":
self.voice = ""
self.speed = 1.0
if self.type != "ASR":
self.language = ""
return self
class CredentialOut(CamelModel):
id: str
@@ -112,4 +124,25 @@ class CredentialOut(CamelModel):
interface_type: str
api_url: str
api_key: str # 读时:打码后的值
voice: str
speed: float
language: str
is_default: bool
class CredentialTestRequest(CamelModel):
model_id: str
type: ModelType
interface_type: InterfaceType = "openai"
api_url: str
api_key: str = ""
voice: str = ""
speed: float = 1.0
language: str = ""
class CredentialTestResult(CamelModel):
ok: bool
latency_ms: int | None = None
message: str
detail: str = ""

View File

@@ -58,7 +58,9 @@ async def resolve_runtime_config(
# 模型/音色:凭证的模型ID优先
model=(llm.model_id if llm else ""),
asr=(stt.model_id if stt else ""),
voice="", # 音色无独立列,留空 → service_factory 回退 .env TTS_VOICE
voice=(tts.voice if tts else ""),
stt_language=(stt.language if stt else ""),
tts_speed=(tts.speed if tts else 1.0),
realtimeModel=(realtime.model_id if realtime else ""),
# 运行时连接信息(真 key + url):凭证优先,否则 .env 兜底
llm_api_key=(llm.api_key if llm else config.LLM_API_KEY),

View File

@@ -0,0 +1,124 @@
"""OpenAI 兼容模型凭证的最小连通测试。"""
from __future__ import annotations
import io
import time
import wave
import httpx
from schemas import CredentialTestRequest, CredentialTestResult
TEST_TIMEOUT_SECONDS = 10.0
def _endpoint(base_url: str, path: str) -> str:
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
def _silent_wav() -> bytes:
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16_000)
wav.writeframes(b"\x00\x00" * 1_600)
return buffer.getvalue()
def _error_detail(response: httpx.Response, api_key: str) -> str:
try:
body = response.json()
detail = (
body.get("error", {}).get("message")
if isinstance(body, dict) and isinstance(body.get("error"), dict)
else body.get("detail") if isinstance(body, dict) else None
)
except ValueError:
detail = None
text = str(detail or response.text or response.reason_phrase).strip()
return text.replace(api_key, "***")[:300]
async def test_openai_credential(
config: CredentialTestRequest,
) -> CredentialTestResult:
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":
response = await client.post(
_endpoint(config.api_url, "chat/completions"),
headers=headers,
json={
"model": config.model_id,
"messages": [{"role": "user", "content": "Reply with OK."}],
"max_tokens": 1,
"stream": False,
},
)
elif config.type == "Embedding":
response = await client.post(
_endpoint(config.api_url, "embeddings"),
headers=headers,
json={"model": config.model_id, "input": "ping"},
)
elif config.type == "ASR":
response = await client.post(
_endpoint(config.api_url, "audio/transcriptions"),
headers=headers,
data={
"model": config.model_id,
**({"language": config.language} if config.language else {}),
},
files={"file": ("test.wav", _silent_wav(), "audio/wav")},
)
elif config.type == "TTS":
response = await client.post(
_endpoint(config.api_url, "audio/speech"),
headers=headers,
json={
"model": config.model_id,
"input": "测试",
"voice": config.voice,
"speed": config.speed,
},
)
else:
return CredentialTestResult(
ok=False,
message="暂不支持该资源类型的连通测试",
detail=f"当前仅支持 LLM、Embedding、ASR、TTS收到 {config.type}",
)
latency_ms = round((time.perf_counter() - started) * 1000)
if response.is_success:
return CredentialTestResult(
ok=True,
latency_ms=latency_ms,
message="连接成功",
detail=f"OpenAI 兼容接口响应正常HTTP {response.status_code}",
)
return CredentialTestResult(
ok=False,
latency_ms=latency_ms,
message=f"连接失败HTTP {response.status_code}",
detail=_error_detail(response, config.api_key),
)
except httpx.TimeoutException:
return CredentialTestResult(
ok=False,
latency_ms=round((time.perf_counter() - started) * 1000),
message="连接超时",
detail=f"服务未在 {TEST_TIMEOUT_SECONDS:g} 秒内响应",
)
except httpx.RequestError as exc:
return CredentialTestResult(
ok=False,
latency_ms=round((time.perf_counter() - started) * 1000),
message="无法连接到模型服务",
detail=str(exc)[:300],
)

View File

@@ -11,6 +11,17 @@ from models import AssistantConfig
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.services.openai.stt import OpenAISTTService
from pipecat.services.openai.tts import OpenAITTSService
from pipecat.transcriptions.language import Language
def _language(value: str) -> Language | None:
if not value:
return None
try:
return Language(value)
except ValueError:
logger.warning(f"忽略不支持的 ASR language: {value}")
return None
def create_stt(cfg: AssistantConfig):
@@ -22,6 +33,7 @@ def create_stt(cfg: AssistantConfig):
api_key=cfg.stt_api_key or config.STT_API_KEY,
base_url=cfg.stt_base_url or config.STT_BASE_URL,
model=cfg.asr or config.STT_MODEL,
language=_language(cfg.stt_language),
)
@@ -41,6 +53,7 @@ def create_tts(cfg: AssistantConfig):
base_url=cfg.tts_base_url or config.TTS_BASE_URL,
model=config.TTS_MODEL,
voice=cfg.voice or config.TTS_VOICE,
speed=cfg.tts_speed,
)