2026-06-14 19:36:12 +08:00
|
|
|
|
"""Connectivity checks for interface-definition driven model resources."""
|
2026-06-09 14:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-06-14 23:41:40 +08:00
|
|
|
|
import asyncio
|
2026-06-09 14:42:25 +08:00
|
|
|
|
import io
|
2026-06-14 23:41:40 +08:00
|
|
|
|
import json
|
2026-06-09 14:42:25 +08:00
|
|
|
|
import time
|
|
|
|
|
|
import wave
|
2026-06-14 23:41:40 +08:00
|
|
|
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
2026-06-09 14:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
import httpx
|
2026-06-14 23:41:40 +08:00
|
|
|
|
from websockets.asyncio.client import connect as websocket_connect
|
2026-06-09 14:42:25 +08:00
|
|
|
|
|
2026-06-14 19:36:12 +08:00
|
|
|
|
from schemas import ModelResourceTestResult
|
2026-06-09 14:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
TEST_TIMEOUT_SECONDS = 10.0
|
2026-07-09 21:40:29 +08:00
|
|
|
|
DEFAULT_TEST_TTS_VOICE = "alloy"
|
2026-06-09 14:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 19:36:12 +08:00
|
|
|
|
def _error_detail(response: httpx.Response, secrets: dict) -> str:
|
2026-06-09 14:42:25 +08:00
|
|
|
|
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()
|
2026-06-14 19:36:12 +08:00
|
|
|
|
for secret in secrets.values():
|
|
|
|
|
|
if secret:
|
|
|
|
|
|
text = text.replace(str(secret), "***")
|
|
|
|
|
|
return text[:300]
|
2026-06-09 14:42:25 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-14 19:36:12 +08:00
|
|
|
|
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="鉴权字段和连接参数完整,请在语音测试页验证签名及音频链路",
|
|
|
|
|
|
)
|
2026-06-14 23:41:40 +08:00
|
|
|
|
if interface_type == "stepfun-realtime":
|
2026-07-21 15:57:43 +08:00
|
|
|
|
return await _test_realtime_websocket(
|
|
|
|
|
|
values,
|
|
|
|
|
|
secrets,
|
|
|
|
|
|
provider="StepFun",
|
|
|
|
|
|
)
|
|
|
|
|
|
if interface_type == "qwen-audio-realtime":
|
|
|
|
|
|
return await _test_realtime_websocket(
|
|
|
|
|
|
values,
|
|
|
|
|
|
secrets,
|
|
|
|
|
|
provider="Qwen-Audio",
|
|
|
|
|
|
extra_headers={"x-dashscope-dataInspection": "disable"},
|
|
|
|
|
|
)
|
2026-06-14 19:36:12 +08:00
|
|
|
|
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}"}
|
2026-06-09 14:42:25 +08:00
|
|
|
|
started = time.perf_counter()
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=TEST_TIMEOUT_SECONDS) as client:
|
2026-06-14 19:36:12 +08:00
|
|
|
|
if capability == "LLM":
|
2026-06-09 14:42:25 +08:00
|
|
|
|
response = await client.post(
|
2026-06-14 19:36:12 +08:00
|
|
|
|
_endpoint(api_url, "chat/completions"),
|
2026-06-09 14:42:25 +08:00
|
|
|
|
headers=headers,
|
|
|
|
|
|
json={
|
2026-06-14 19:36:12 +08:00
|
|
|
|
"model": model_id,
|
2026-06-09 14:42:25 +08:00
|
|
|
|
"messages": [{"role": "user", "content": "Reply with OK."}],
|
|
|
|
|
|
"max_tokens": 1,
|
|
|
|
|
|
"stream": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
2026-06-14 19:36:12 +08:00
|
|
|
|
elif capability == "Embedding":
|
2026-06-09 14:42:25 +08:00
|
|
|
|
response = await client.post(
|
2026-06-14 19:36:12 +08:00
|
|
|
|
_endpoint(api_url, "embeddings"),
|
2026-06-09 14:42:25 +08:00
|
|
|
|
headers=headers,
|
2026-06-14 19:36:12 +08:00
|
|
|
|
json={"model": model_id, "input": "ping"},
|
2026-06-09 14:42:25 +08:00
|
|
|
|
)
|
2026-06-14 19:36:12 +08:00
|
|
|
|
elif capability == "ASR":
|
2026-06-09 14:42:25 +08:00
|
|
|
|
response = await client.post(
|
2026-06-14 19:36:12 +08:00
|
|
|
|
_endpoint(api_url, "audio/transcriptions"),
|
2026-06-09 14:42:25 +08:00
|
|
|
|
headers=headers,
|
|
|
|
|
|
data={
|
2026-06-14 19:36:12 +08:00
|
|
|
|
"model": model_id,
|
|
|
|
|
|
**(
|
|
|
|
|
|
{"language": str(values["language"])}
|
|
|
|
|
|
if values.get("language")
|
|
|
|
|
|
else {}
|
|
|
|
|
|
),
|
2026-06-09 14:42:25 +08:00
|
|
|
|
},
|
|
|
|
|
|
files={"file": ("test.wav", _silent_wav(), "audio/wav")},
|
|
|
|
|
|
)
|
2026-06-14 19:36:12 +08:00
|
|
|
|
elif capability == "TTS":
|
2026-06-09 14:42:25 +08:00
|
|
|
|
response = await client.post(
|
2026-06-14 19:36:12 +08:00
|
|
|
|
_endpoint(api_url, "audio/speech"),
|
2026-06-09 14:42:25 +08:00
|
|
|
|
headers=headers,
|
|
|
|
|
|
json={
|
2026-06-14 19:36:12 +08:00
|
|
|
|
"model": model_id,
|
2026-06-09 14:42:25 +08:00
|
|
|
|
"input": "测试",
|
2026-07-09 21:40:29 +08:00
|
|
|
|
"voice": str(values.get("voice") or DEFAULT_TEST_TTS_VOICE),
|
2026-06-10 12:32:55 +08:00
|
|
|
|
"response_format": "pcm",
|
2026-06-14 19:36:12 +08:00
|
|
|
|
"speed": float(values.get("speed") or 1),
|
2026-06-09 14:42:25 +08:00
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
2026-06-14 19:36:12 +08:00
|
|
|
|
return ModelResourceTestResult(
|
2026-06-09 14:42:25 +08:00
|
|
|
|
ok=False,
|
2026-06-14 19:36:12 +08:00
|
|
|
|
message="暂不支持该能力的连接测试",
|
|
|
|
|
|
detail=f"收到能力类型 {capability}",
|
2026-06-09 14:42:25 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
latency_ms = round((time.perf_counter() - started) * 1000)
|
|
|
|
|
|
if response.is_success:
|
2026-06-14 19:36:12 +08:00
|
|
|
|
return ModelResourceTestResult(
|
2026-06-09 14:42:25 +08:00
|
|
|
|
ok=True,
|
|
|
|
|
|
latency_ms=latency_ms,
|
|
|
|
|
|
message="连接成功",
|
2026-06-14 19:36:12 +08:00
|
|
|
|
detail=f"接口响应正常(HTTP {response.status_code})",
|
2026-06-09 14:42:25 +08:00
|
|
|
|
)
|
2026-06-14 19:36:12 +08:00
|
|
|
|
return ModelResourceTestResult(
|
2026-06-09 14:42:25 +08:00
|
|
|
|
ok=False,
|
|
|
|
|
|
latency_ms=latency_ms,
|
|
|
|
|
|
message=f"连接失败(HTTP {response.status_code})",
|
2026-06-14 19:36:12 +08:00
|
|
|
|
detail=_error_detail(response, secrets),
|
2026-06-09 14:42:25 +08:00
|
|
|
|
)
|
|
|
|
|
|
except httpx.TimeoutException:
|
2026-06-14 19:36:12 +08:00
|
|
|
|
return ModelResourceTestResult(
|
2026-06-09 14:42:25 +08:00
|
|
|
|
ok=False,
|
|
|
|
|
|
latency_ms=round((time.perf_counter() - started) * 1000),
|
|
|
|
|
|
message="连接超时",
|
|
|
|
|
|
detail=f"服务未在 {TEST_TIMEOUT_SECONDS:g} 秒内响应",
|
|
|
|
|
|
)
|
|
|
|
|
|
except httpx.RequestError as exc:
|
2026-06-14 19:36:12 +08:00
|
|
|
|
return ModelResourceTestResult(
|
2026-06-09 14:42:25 +08:00
|
|
|
|
ok=False,
|
|
|
|
|
|
latency_ms=round((time.perf_counter() - started) * 1000),
|
|
|
|
|
|
message="无法连接到模型服务",
|
|
|
|
|
|
detail=str(exc)[:300],
|
|
|
|
|
|
)
|
2026-06-14 23:41:40 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-21 15:57:43 +08:00
|
|
|
|
async def _test_realtime_websocket(
|
|
|
|
|
|
values: dict,
|
|
|
|
|
|
secrets: dict,
|
|
|
|
|
|
*,
|
|
|
|
|
|
provider: str,
|
|
|
|
|
|
extra_headers: dict[str, str] | None = None,
|
2026-06-14 23:41:40 +08:00
|
|
|
|
) -> ModelResourceTestResult:
|
|
|
|
|
|
api_url = str(values.get("apiUrl") or "")
|
|
|
|
|
|
model_id = str(values.get("modelId") or "")
|
|
|
|
|
|
api_key = str(secrets.get("apiKey") or "")
|
|
|
|
|
|
parts = urlsplit(api_url)
|
|
|
|
|
|
query = dict(parse_qsl(parts.query))
|
|
|
|
|
|
query["model"] = model_id
|
|
|
|
|
|
url = urlunsplit(
|
|
|
|
|
|
(parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)
|
|
|
|
|
|
)
|
|
|
|
|
|
started = time.perf_counter()
|
2026-07-21 15:57:43 +08:00
|
|
|
|
headers = {"Authorization": f"Bearer {api_key}"}
|
|
|
|
|
|
headers.update(extra_headers or {})
|
2026-06-14 23:41:40 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
async with websocket_connect(
|
|
|
|
|
|
url,
|
2026-07-21 15:57:43 +08:00
|
|
|
|
additional_headers=headers,
|
2026-06-14 23:41:40 +08:00
|
|
|
|
open_timeout=TEST_TIMEOUT_SECONDS,
|
|
|
|
|
|
close_timeout=2,
|
|
|
|
|
|
) as websocket:
|
|
|
|
|
|
raw_message = await asyncio.wait_for(
|
|
|
|
|
|
websocket.recv(), timeout=TEST_TIMEOUT_SECONDS
|
|
|
|
|
|
)
|
|
|
|
|
|
event = json.loads(raw_message)
|
|
|
|
|
|
if event.get("type") != "session.created":
|
|
|
|
|
|
return ModelResourceTestResult(
|
|
|
|
|
|
ok=False,
|
|
|
|
|
|
latency_ms=round((time.perf_counter() - started) * 1000),
|
|
|
|
|
|
message="Realtime 连接返回了意外事件",
|
|
|
|
|
|
detail=str(event.get("type") or event)[:300],
|
|
|
|
|
|
)
|
|
|
|
|
|
return ModelResourceTestResult(
|
|
|
|
|
|
ok=True,
|
|
|
|
|
|
latency_ms=round((time.perf_counter() - started) * 1000),
|
|
|
|
|
|
message="Realtime 连接成功",
|
2026-07-21 15:57:43 +08:00
|
|
|
|
detail=f"{provider} 返回 session.created",
|
2026-06-14 23:41:40 +08:00
|
|
|
|
)
|
|
|
|
|
|
except TimeoutError:
|
|
|
|
|
|
return ModelResourceTestResult(
|
|
|
|
|
|
ok=False,
|
|
|
|
|
|
latency_ms=round((time.perf_counter() - started) * 1000),
|
|
|
|
|
|
message="Realtime 连接超时",
|
|
|
|
|
|
detail=f"服务未在 {TEST_TIMEOUT_SECONDS:g} 秒内创建 session",
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
detail = str(exc)
|
|
|
|
|
|
for secret in secrets.values():
|
|
|
|
|
|
if secret:
|
|
|
|
|
|
detail = detail.replace(str(secret), "***")
|
|
|
|
|
|
return ModelResourceTestResult(
|
|
|
|
|
|
ok=False,
|
|
|
|
|
|
latency_ms=round((time.perf_counter() - started) * 1000),
|
2026-07-21 15:57:43 +08:00
|
|
|
|
message=f"无法连接到 {provider} Realtime",
|
2026-06-14 23:41:40 +08:00
|
|
|
|
detail=detail[:300],
|
|
|
|
|
|
)
|