nex_docus/backend/app/services/llm_provider_service.py

925 lines
32 KiB
Python
Raw Normal View History

2026-04-08 17:03:57 +00:00
"""
LLM 提供方测试服务
"""
import asyncio
2026-07-24 07:37:22 +00:00
import os
import queue
import ssl
2026-04-08 17:03:57 +00:00
import json
import socket
2026-07-24 07:37:22 +00:00
import threading
2026-04-08 17:03:57 +00:00
import time
import urllib.error
import urllib.parse
import urllib.request
2026-07-24 07:37:22 +00:00
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional
2026-04-08 17:03:57 +00:00
PROVIDER_CATALOG: List[Dict[str, str]] = [
{
"value": "openai",
"label": "OpenAI",
"default_endpoint_url": "https://api.openai.com/v1",
"protocol": "openai_compatible",
},
{
"value": "deepseek",
"label": "DeepSeek",
"default_endpoint_url": "https://api.deepseek.com/v1",
"protocol": "openai_compatible",
},
{
"value": "anthropic",
"label": "Anthropic",
"default_endpoint_url": "https://api.anthropic.com",
"protocol": "anthropic",
},
{
"value": "gemini",
"label": "Google Gemini",
"default_endpoint_url": "https://generativelanguage.googleapis.com/v1beta",
"protocol": "gemini",
},
{
"value": "dashscope",
"label": "阿里百炼",
"default_endpoint_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"protocol": "openai_compatible",
},
{
"value": "zhipu",
"label": "智谱 AI",
"default_endpoint_url": "https://open.bigmodel.cn/api/paas/v4",
"protocol": "openai_compatible",
},
{
"value": "moonshot",
"label": "Moonshot AI",
"default_endpoint_url": "https://api.moonshot.cn/v1",
"protocol": "openai_compatible",
},
{
"value": "groq",
"label": "Groq",
"default_endpoint_url": "https://api.groq.com/openai/v1",
"protocol": "openai_compatible",
},
{
"value": "openrouter",
"label": "OpenRouter",
"default_endpoint_url": "https://openrouter.ai/api/v1",
"protocol": "openai_compatible",
},
{
"value": "siliconflow",
"label": "SiliconFlow",
"default_endpoint_url": "https://api.siliconflow.cn/v1",
"protocol": "openai_compatible",
},
{
"value": "ollama",
"label": "Ollama",
"default_endpoint_url": "http://localhost:11434/v1",
"protocol": "openai_compatible",
},
{
"value": "ark",
"label": "火山方舟",
"default_endpoint_url": "https://ark.cn-beijing.volces.com/api/v3",
"protocol": "openai_compatible",
},
{
"value": "custom",
"label": "自定义兼容接口",
"default_endpoint_url": "",
"protocol": "openai_compatible",
},
]
PROVIDER_MAP = {item["value"]: item for item in PROVIDER_CATALOG}
class LLMProviderService:
"""LLM 提供方测试服务"""
@staticmethod
def get_provider_catalog() -> List[Dict[str, str]]:
return PROVIDER_CATALOG
@staticmethod
def get_provider_label(provider: Optional[str]) -> str:
if not provider:
return "自定义模型"
return PROVIDER_MAP.get(provider, {}).get("label", provider)
@staticmethod
def get_default_endpoint_url(provider: Optional[str]) -> str:
if not provider:
return ""
return PROVIDER_MAP.get(provider, {}).get("default_endpoint_url", "")
@classmethod
def build_model_name(cls, provider: Optional[str], llm_model_name: str) -> str:
model_name = (llm_model_name or "").strip()
label = cls.get_provider_label(provider)
if not model_name:
return label
return f"{label} {model_name}"
@staticmethod
def build_model_code(provider: Optional[str], llm_model_name: str) -> str:
provider_part = (provider or "custom").strip().lower()
model_part = (llm_model_name or "").strip().lower()
sanitized = []
previous_is_separator = False
for char in model_part:
if char.isalnum():
sanitized.append(char)
previous_is_separator = False
else:
if not previous_is_separator:
sanitized.append("_")
previous_is_separator = True
model_slug = "".join(sanitized).strip("_") or "model"
return f"llm_{provider_part}_{model_slug}"
@classmethod
async def test_model_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
provider = payload.get("provider")
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (payload.get("llm_model_name") or "").strip()
api_key = (payload.get("api_key") or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 llm_model_name")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再测试")
timeout = int(payload.get("llm_timeout") or 120)
started_at = time.perf_counter()
preview = await asyncio.to_thread(
cls._send_test_request,
provider_meta.get("protocol", "openai_compatible"),
provider,
endpoint_url,
api_key,
llm_model_name,
timeout,
)
latency_ms = int((time.perf_counter() - started_at) * 1000)
return {
"provider": provider,
"endpoint_url": endpoint_url,
"llm_model_name": llm_model_name,
"latency_ms": latency_ms,
"preview": preview[:200],
}
@classmethod
def _send_test_request(
cls,
protocol: str,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
) -> str:
if protocol == "anthropic":
return cls._test_anthropic(
endpoint_url,
api_key,
llm_model_name,
timeout,
)
if protocol == "gemini":
return cls._test_gemini(
endpoint_url,
api_key,
llm_model_name,
timeout,
)
return cls._test_openai_compatible(
provider,
endpoint_url,
api_key,
llm_model_name,
timeout,
)
@classmethod
def _test_openai_compatible(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
) -> str:
url = cls._join_endpoint(endpoint_url, "/chat/completions")
headers = {
"Content-Type": "application/json",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"model": llm_model_name,
2026-07-24 07:37:22 +00:00
"messages": cls._build_openai_test_messages(),
2026-04-08 17:03:57 +00:00
}
response = cls._request_json(url, headers, payload, timeout)
choices = response.get("choices") or []
if not choices:
raise ValueError("测试请求已发送,但未收到模型返回内容")
content = choices[0].get("message", {}).get("content", "")
2026-07-24 07:37:22 +00:00
if not content:
content = choices[0].get("text", "")
2026-04-08 17:03:57 +00:00
if isinstance(content, list):
content = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in content
)
content = str(content).strip()
if not content:
raise ValueError("模型返回成功,但内容为空")
return content
@classmethod
def _test_anthropic(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
) -> str:
url = cls._join_endpoint(endpoint_url, "/v1/messages")
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
payload = {
"model": llm_model_name,
2026-07-24 07:37:22 +00:00
"max_tokens": 32,
2026-04-08 17:03:57 +00:00
"messages": [
{
"role": "user",
"content": "请只回复“连接测试成功”。",
}
],
}
response = cls._request_json(url, headers, payload, timeout)
content = response.get("content") or []
texts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
texts.append(item.get("text", ""))
preview = "".join(texts).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
@classmethod
def _test_gemini(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
) -> str:
model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}"
encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/"))
url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}"
headers = {
"Content-Type": "application/json",
}
payload = {
"contents": [
{
"role": "user",
"parts": [{"text": "请只回复“连接测试成功”。"}],
}
2026-07-24 07:37:22 +00:00
]
2026-04-08 17:03:57 +00:00
}
response = cls._request_json(url, headers, payload, timeout)
candidates = response.get("candidates") or []
if not candidates:
raise ValueError("测试请求已发送,但未收到模型返回内容")
parts = candidates[0].get("content", {}).get("parts", [])
preview = "".join(
part.get("text", "") for part in parts if isinstance(part, dict)
).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
@staticmethod
2026-07-24 07:37:22 +00:00
def _build_openai_test_messages() -> List[Dict[str, str]]:
return [{"role": "user", "content": "请只回复“连接测试成功”。"}]
2026-04-08 17:03:57 +00:00
@staticmethod
def _join_endpoint(base_url: str, suffix: str) -> str:
normalized = base_url.rstrip("/")
if normalized.endswith(suffix):
return normalized
return f"{normalized}{suffix}"
2026-07-24 07:37:22 +00:00
@staticmethod
def _build_ssl_context() -> ssl.SSLContext:
"""构建 SSL 上下文。
默认用 certifi 提供的 CA 证书包解决部分系统 macOS
找不到本地根证书导致的 CERTIFICATE_VERIFY_FAILED
设置 DISABLE_SSL_VERIFY=1 可临时关闭校验仅用于自签名/调试
"""
if os.getenv("DISABLE_SSL_VERIFY", "").lower() in ("1", "true", "yes"):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
# 优先使用显式配置的 CA 文件,其次 certifi最后系统默认
ca_bundle = (
os.getenv("LLM_SSL_CA_FILE")
or os.getenv("SSL_CERT_FILE")
or os.getenv("REQUESTS_CA_BUNDLE")
)
if ca_bundle:
try:
return ssl.create_default_context(cafile=ca_bundle)
except (FileNotFoundError, OSError, ssl.SSLError):
pass
try:
import certifi
return ssl.create_default_context(cafile=certifi.where())
except ImportError:
return ssl.create_default_context()
2026-04-08 17:03:57 +00:00
@classmethod
def _request_json(
cls,
url: str,
headers: Dict[str, str],
payload: Dict[str, Any],
timeout: int,
) -> Dict[str, Any]:
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
2026-07-24 07:37:22 +00:00
ctx = cls._build_ssl_context()
2026-04-08 17:03:57 +00:00
try:
2026-07-24 07:37:22 +00:00
with urllib.request.urlopen(request, timeout=timeout, context=ctx) as response:
2026-04-08 17:03:57 +00:00
body = response.read().decode("utf-8")
if not body:
return {}
return json.loads(body)
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="ignore")
message = cls._extract_error_message(error_body) or error_body[:300] or str(exc)
raise ValueError(f"模型测试失败HTTP {exc.code}{message}") from exc
except urllib.error.URLError as exc:
reason = exc.reason
if isinstance(reason, socket.timeout):
raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc
raise ValueError(f"模型测试失败:{reason}") from exc
except socket.timeout as exc:
raise ValueError("模型测试超时,请检查网络或调大超时时间") from exc
except json.JSONDecodeError as exc:
raise ValueError("模型服务返回了无法解析的响应,请检查接口地址是否正确") from exc
@staticmethod
def _extract_error_message(error_body: str) -> Optional[str]:
if not error_body:
return None
try:
payload = json.loads(error_body)
except json.JSONDecodeError:
return error_body.strip()
if isinstance(payload, dict):
if isinstance(payload.get("error"), dict):
return payload["error"].get("message") or payload["error"].get("type")
return payload.get("message") or payload.get("detail")
return None
2026-07-24 07:37:22 +00:00
# ==================== 对话文本生成RAG 调用) ====================
@classmethod
async def generate_text(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, Any]],
) -> str:
"""根据多轮对话消息生成回复文本(供知识库 RAG 使用)"""
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (llm_model_name or "").strip()
api_key = (api_key or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 llm_model_name")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再调用")
normalized = cls._normalize_messages(messages)
if not normalized:
raise ValueError("缺少有效的对话消息")
protocol = provider_meta.get("protocol", "openai_compatible")
return await asyncio.to_thread(
cls._chat_completion,
protocol,
endpoint_url,
api_key,
llm_model_name,
int(timeout or 120),
float(temperature or 0.7),
float(top_p or 0.9),
int(max_tokens or 2048),
(system_prompt or "").strip(),
normalized,
)
@classmethod
async def generate_text_stream(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, Any]],
) -> AsyncIterator[str]:
"""流式生成回复文本。OpenAI-compatible 使用原生流,其它协议回退为分块输出。"""
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (llm_model_name or "").strip()
api_key = (api_key or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 llm_model_name")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再调用")
normalized = cls._normalize_messages(messages)
if not normalized:
raise ValueError("缺少有效的对话消息")
protocol = provider_meta.get("protocol", "openai_compatible")
if protocol != "openai_compatible":
text = await cls.generate_text(
provider,
endpoint_url,
api_key,
llm_model_name,
timeout,
temperature,
top_p,
max_tokens,
system_prompt,
normalized,
)
for index in range(0, len(text), 24):
yield text[index:index + 24]
return
result_queue: "queue.Queue[tuple[str, Any]]" = queue.Queue()
def worker():
try:
for chunk in cls._chat_openai_compatible_stream(
endpoint_url,
api_key,
llm_model_name,
int(timeout or 120),
float(temperature or 0.7),
float(top_p or 0.9),
int(max_tokens or 2048),
(system_prompt or "").strip(),
normalized,
):
result_queue.put(("chunk", chunk))
except Exception as exc: # pragma: no cover - surfaced through stream
result_queue.put(("error", exc))
finally:
result_queue.put(("done", None))
threading.Thread(target=worker, daemon=True).start()
while True:
kind, payload = await asyncio.to_thread(result_queue.get)
if kind == "chunk":
yield payload
elif kind == "error":
raise payload
else:
break
@staticmethod
def _normalize_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, str]]:
normalized: List[Dict[str, str]] = []
for item in messages or []:
if not isinstance(item, dict):
continue
role = str(item.get("role") or "").strip().lower()
if role not in {"system", "user", "assistant"}:
continue
content = item.get("content", "")
if isinstance(content, list):
content = "".join(
part.get("text", "") if isinstance(part, dict) else str(part)
for part in content
)
content = str(content).strip()
if not content:
continue
normalized.append({"role": role, "content": content})
return normalized
@classmethod
def _chat_completion(
cls,
protocol: str,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
if protocol == "anthropic":
return cls._chat_anthropic(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
if protocol == "gemini":
return cls._chat_gemini(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
return cls._chat_openai_compatible(
endpoint_url, api_key, llm_model_name, timeout,
temperature, top_p, max_tokens, system_prompt, messages,
)
@classmethod
def _chat_openai_compatible(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
url = cls._join_endpoint(endpoint_url, "/chat/completions")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload_messages: List[Dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(messages)
payload = {
"model": llm_model_name,
"messages": payload_messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": False,
}
response = cls._request_json(url, headers, payload, timeout)
choices = response.get("choices") or []
if not choices:
raise ValueError("请求已发送,但未收到模型返回内容")
content = choices[0].get("message", {}).get("content", "")
if isinstance(content, list):
content = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in content
)
content = str(content).strip()
if not content:
raise ValueError("模型返回成功,但内容为空")
return content
@classmethod
def _chat_openai_compatible_stream(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> Iterator[str]:
url = cls._join_endpoint(endpoint_url, "/chat/completions")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload_messages: List[Dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(messages)
payload = {
"model": llm_model_name,
"messages": payload_messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": True,
}
request = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers=headers,
method="POST",
)
ctx = cls._build_ssl_context()
try:
with urllib.request.urlopen(request, timeout=timeout, context=ctx) as response:
for raw_line in response:
line = raw_line.decode("utf-8", errors="ignore").strip()
if not line or not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
break
try:
payload = json.loads(data)
except json.JSONDecodeError:
continue
for choice in payload.get("choices") or []:
delta = choice.get("delta") or {}
content = delta.get("content")
if isinstance(content, list):
content = "".join(
item.get("text", "") if isinstance(item, dict) else str(item)
for item in content
)
if content:
yield str(content)
except urllib.error.HTTPError as exc:
error_body = exc.read().decode("utf-8", errors="ignore")
message = cls._extract_error_message(error_body) or error_body[:300] or str(exc)
raise ValueError(f"模型调用失败HTTP {exc.code}{message}") from exc
except urllib.error.URLError as exc:
reason = exc.reason
if isinstance(reason, socket.timeout):
raise ValueError("模型调用超时,请检查网络或调大超时时间") from exc
raise ValueError(f"模型调用失败:{reason}") from exc
except socket.timeout as exc:
raise ValueError("模型调用超时,请检查网络或调大超时时间") from exc
@classmethod
def _chat_anthropic(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
url = cls._join_endpoint(endpoint_url, "/v1/messages")
headers = {
"Content-Type": "application/json",
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
payload = {
"model": llm_model_name,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"messages": [
{"role": m["role"], "content": [{"type": "text", "text": m["content"]}]}
for m in messages
if m["role"] in {"user", "assistant"}
],
}
if system_prompt:
payload["system"] = system_prompt
response = cls._request_json(url, headers, payload, timeout)
content = response.get("content") or []
texts = [
item.get("text", "")
for item in content
if isinstance(item, dict) and item.get("type") == "text"
]
preview = "".join(texts).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
@classmethod
def _chat_gemini(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
timeout: int,
temperature: float,
top_p: float,
max_tokens: int,
system_prompt: str,
messages: List[Dict[str, str]],
) -> str:
model_path = llm_model_name if llm_model_name.startswith("models/") else f"models/{llm_model_name}"
encoded_model_path = "/".join(urllib.parse.quote(part) for part in model_path.split("/"))
url = f"{endpoint_url.rstrip('/')}/{encoded_model_path}:generateContent?key={urllib.parse.quote(api_key)}"
headers = {"Content-Type": "application/json"}
role_map = {"user": "user", "assistant": "model"}
contents = [
{"role": role_map[m["role"]], "parts": [{"text": m["content"]}]}
for m in messages
if m["role"] in role_map
]
payload = {
"contents": contents,
"generationConfig": {
"temperature": temperature,
"topP": top_p,
"maxOutputTokens": max_tokens,
},
}
if system_prompt:
payload["systemInstruction"] = {"parts": [{"text": system_prompt}]}
response = cls._request_json(url, headers, payload, timeout)
candidates = response.get("candidates") or []
if not candidates:
raise ValueError("请求已发送,但未收到模型返回内容")
parts = candidates[0].get("content", {}).get("parts", [])
preview = "".join(
part.get("text", "") for part in parts if isinstance(part, dict)
).strip()
if not preview:
raise ValueError("模型返回成功,但内容为空")
return preview
# ==================== Embedding 向量生成与测试 ====================
@classmethod
async def generate_embedding(
cls,
provider: Optional[str],
endpoint_url: str,
api_key: str,
llm_model_name: str,
text: str,
timeout: int = 60,
dimension: Optional[int] = None,
) -> List[float]:
"""调用 OpenAI 兼容的 /embeddings 接口生成单条文本向量"""
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (endpoint_url or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (llm_model_name or "").strip()
api_key = (api_key or "").strip()
text = (text or "").strip()
if not endpoint_url:
raise ValueError("缺少接口地址,请先选择提供方或手动填写 base_url")
if not llm_model_name:
raise ValueError("缺少模型名称,请填写 embedding 模型标识")
if provider not in {"ollama"} and not api_key:
raise ValueError("缺少 API Key请填写后再调用")
if not text:
raise ValueError("待向量化文本为空")
vectors = await asyncio.to_thread(
cls._embeddings_request,
endpoint_url,
api_key,
llm_model_name,
[text[:8000]],
int(timeout or 60),
dimension,
)
if not vectors:
raise ValueError("Embedding 接口返回为空")
return vectors[0]
@classmethod
def _embeddings_request(
cls,
endpoint_url: str,
api_key: str,
llm_model_name: str,
inputs: List[str],
timeout: int,
dimension: Optional[int],
) -> List[List[float]]:
url = cls._join_endpoint(endpoint_url, "/embeddings")
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload: Dict[str, Any] = {
"model": llm_model_name,
"input": inputs,
}
if dimension:
payload["dimensions"] = dimension
response = cls._request_json(url, headers, payload, timeout)
data = response.get("data") or []
if not data:
raise ValueError("Embedding 请求已发送,但未收到向量数据")
vectors: List[List[float]] = []
for item in data:
embedding = item.get("embedding") if isinstance(item, dict) else None
if embedding:
vectors.append([float(x) for x in embedding])
if not vectors:
raise ValueError("Embedding 返回结果中未解析到向量")
return vectors
@classmethod
async def test_embedding_connection(cls, payload: Dict[str, Any]) -> Dict[str, Any]:
"""测试 embedding 模型连通性,并返回实际向量维度"""
provider = payload.get("provider")
provider_meta = PROVIDER_MAP.get(provider, PROVIDER_MAP["custom"])
endpoint_url = (payload.get("endpoint_url") or provider_meta.get("default_endpoint_url") or "").strip()
llm_model_name = (payload.get("llm_model_name") or "").strip()
api_key = (payload.get("api_key") or "").strip()
timeout = int(payload.get("llm_timeout") or 60)
dimension = payload.get("embedding_dimension")
started_at = time.perf_counter()
vector = await cls.generate_embedding(
provider,
endpoint_url,
api_key,
llm_model_name,
"连接测试",
timeout=timeout,
dimension=int(dimension) if dimension else None,
)
latency_ms = int((time.perf_counter() - started_at) * 1000)
return {
"provider": provider,
"endpoint_url": endpoint_url,
"llm_model_name": llm_model_name,
"latency_ms": latency_ms,
"dimension": len(vector),
"preview": f"成功生成 {len(vector)} 维向量",
}