Compare commits
4 Commits
fix/3826-a
...
perf/provi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26467fbe22 | ||
|
|
38281ba2cf | ||
|
|
21aa3174f4 | ||
|
|
dcda871fc0 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -48,3 +48,5 @@ astrbot.lock
|
||||
chroma
|
||||
venv/*
|
||||
pytest.ini
|
||||
AGENTS.md
|
||||
IFLOW.md
|
||||
|
||||
@@ -73,6 +73,7 @@ DEFAULT_CONFIG = {
|
||||
"coze_agent_runner_provider_id": "",
|
||||
"dashscope_agent_runner_provider_id": "",
|
||||
"unsupported_streaming_strategy": "realtime_segmenting",
|
||||
"reachability_check": False,
|
||||
"max_agent_step": 30,
|
||||
"tool_call_timeout": 60,
|
||||
},
|
||||
@@ -2496,6 +2497,11 @@ CONFIG_METADATA_3 = {
|
||||
"description": "开启 TTS 时同时输出语音和文字内容",
|
||||
"type": "bool",
|
||||
},
|
||||
"provider_settings.reachability_check": {
|
||||
"description": "提供商可达性检测",
|
||||
"type": "bool",
|
||||
"hint": "/provider 命令列出模型时是否并发检测连通性。开启后会主动调用模型测试连通性,可能产生额外 token 消耗。",
|
||||
},
|
||||
},
|
||||
"condition": {
|
||||
"provider_settings.enable": True,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import abc
|
||||
import asyncio
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from astrbot.core.agent.message import Message
|
||||
@@ -11,6 +12,7 @@ from astrbot.core.provider.entities import (
|
||||
ToolCallsResult,
|
||||
)
|
||||
from astrbot.core.provider.register import provider_cls_map
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_path
|
||||
|
||||
|
||||
class AbstractProvider(abc.ABC):
|
||||
@@ -43,6 +45,14 @@ class AbstractProvider(abc.ABC):
|
||||
)
|
||||
return meta
|
||||
|
||||
async def test(self) -> bool:
|
||||
"""test the provider is a
|
||||
|
||||
Returns:
|
||||
bool: the provider is available
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
class Provider(AbstractProvider):
|
||||
"""Chat Provider"""
|
||||
@@ -165,6 +175,16 @@ class Provider(AbstractProvider):
|
||||
|
||||
return dicts
|
||||
|
||||
async def test(self, timeout: float = 45.0) -> bool:
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
self.text_chat(prompt="REPLY `PONG` ONLY"),
|
||||
timeout=timeout,
|
||||
)
|
||||
return response is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class STTProvider(AbstractProvider):
|
||||
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
||||
@@ -177,6 +197,20 @@ class STTProvider(AbstractProvider):
|
||||
"""获取音频的文本"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def test(self) -> bool:
|
||||
try:
|
||||
sample_audio_path = os.path.join(
|
||||
get_astrbot_path(),
|
||||
"samples",
|
||||
"stt_health_check.wav",
|
||||
)
|
||||
if not os.path.exists(sample_audio_path):
|
||||
return False
|
||||
text_result = await self.get_text(sample_audio_path)
|
||||
return isinstance(text_result, str) and bool(text_result)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class TTSProvider(AbstractProvider):
|
||||
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
||||
@@ -189,6 +223,13 @@ class TTSProvider(AbstractProvider):
|
||||
"""获取文本的音频,返回音频文件路径"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def test(self) -> bool:
|
||||
try:
|
||||
audio_result = await self.get_audio("hi")
|
||||
return isinstance(audio_result, str) and bool(audio_result)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class EmbeddingProvider(AbstractProvider):
|
||||
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
|
||||
@@ -211,6 +252,15 @@ class EmbeddingProvider(AbstractProvider):
|
||||
"""获取向量的维度"""
|
||||
...
|
||||
|
||||
async def test(self) -> bool:
|
||||
try:
|
||||
embedding_result = await self.get_embedding("health_check")
|
||||
return isinstance(embedding_result, list) and (
|
||||
not embedding_result or isinstance(embedding_result[0], float)
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_embeddings_batch(
|
||||
self,
|
||||
texts: list[str],
|
||||
@@ -294,3 +344,10 @@ class RerankProvider(AbstractProvider):
|
||||
) -> list[RerankResult]:
|
||||
"""获取查询和文档的重排序分数"""
|
||||
...
|
||||
|
||||
async def test(self) -> bool:
|
||||
try:
|
||||
await self.rerank("Apple", documents=["apple", "banana"])
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -18,11 +18,8 @@ from astrbot.core.config.i18n_utils import ConfigMetadataI18n
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.platform.register import platform_cls_map, platform_registry
|
||||
from astrbot.core.provider import Provider
|
||||
from astrbot.core.provider.entities import ProviderType
|
||||
from astrbot.core.provider.provider import RerankProvider
|
||||
from astrbot.core.provider.register import provider_registry
|
||||
from astrbot.core.star.star import star_registry
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_path
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
@@ -356,169 +353,26 @@ class ConfigRoute(Route):
|
||||
f"Attempting to check provider: {status_info['name']} (ID: {status_info['id']}, Type: {status_info['type']}, Model: {status_info['model']})",
|
||||
)
|
||||
|
||||
if provider_capability_type == ProviderType.CHAT_COMPLETION:
|
||||
try:
|
||||
logger.debug(f"Sending 'Ping' to provider: {status_info['name']}")
|
||||
response = await asyncio.wait_for(
|
||||
provider.text_chat(prompt="REPLY `PONG` ONLY"),
|
||||
timeout=45.0,
|
||||
)
|
||||
logger.debug(
|
||||
f"Received response from {status_info['name']}: {response}",
|
||||
)
|
||||
if response is not None:
|
||||
status_info["status"] = "available"
|
||||
response_text_snippet = ""
|
||||
if (
|
||||
hasattr(response, "completion_text")
|
||||
and response.completion_text
|
||||
):
|
||||
response_text_snippet = (
|
||||
response.completion_text[:70] + "..."
|
||||
if len(response.completion_text) > 70
|
||||
else response.completion_text
|
||||
)
|
||||
elif hasattr(response, "result_chain") and response.result_chain:
|
||||
try:
|
||||
response_text_snippet = (
|
||||
response.result_chain.get_plain_text()[:70] + "..."
|
||||
if len(response.result_chain.get_plain_text()) > 70
|
||||
else response.result_chain.get_plain_text()
|
||||
)
|
||||
except Exception as _:
|
||||
pass
|
||||
logger.info(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{response_text_snippet}'",
|
||||
)
|
||||
else:
|
||||
status_info["error"] = (
|
||||
"Test call returned None, but expected an LLMResponse object."
|
||||
)
|
||||
logger.warning(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None.",
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
status_info["error"] = (
|
||||
"Connection timed out after 45 seconds during test call."
|
||||
)
|
||||
logger.warning(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) timed out.",
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
status_info["error"] = error_message
|
||||
logger.warning(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}",
|
||||
)
|
||||
logger.debug(
|
||||
f"Traceback for {status_info['name']}:\n{traceback.format_exc()}",
|
||||
)
|
||||
|
||||
elif provider_capability_type == ProviderType.EMBEDDING:
|
||||
try:
|
||||
# For embedding, we can call the get_embedding method with a short prompt.
|
||||
embedding_result = await provider.get_embedding("health_check")
|
||||
if isinstance(embedding_result, list) and (
|
||||
not embedding_result or isinstance(embedding_result[0], float)
|
||||
):
|
||||
status_info["status"] = "available"
|
||||
else:
|
||||
status_info["status"] = "unavailable"
|
||||
status_info["error"] = (
|
||||
f"Embedding test failed: unexpected result type {type(embedding_result)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error testing embedding provider {provider_name}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
status_info["status"] = "unavailable"
|
||||
status_info["error"] = f"Embedding test failed: {e!s}"
|
||||
|
||||
elif provider_capability_type == ProviderType.TEXT_TO_SPEECH:
|
||||
try:
|
||||
# For TTS, we can call the get_audio method with a short prompt.
|
||||
audio_result = await provider.get_audio("你好")
|
||||
if isinstance(audio_result, str) and audio_result:
|
||||
status_info["status"] = "available"
|
||||
else:
|
||||
status_info["status"] = "unavailable"
|
||||
status_info["error"] = (
|
||||
f"TTS test failed: unexpected result type {type(audio_result)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error testing TTS provider {provider_name}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
status_info["status"] = "unavailable"
|
||||
status_info["error"] = f"TTS test failed: {e!s}"
|
||||
elif provider_capability_type == ProviderType.SPEECH_TO_TEXT:
|
||||
try:
|
||||
logger.debug(
|
||||
f"Sending health check audio to provider: {status_info['name']}",
|
||||
)
|
||||
sample_audio_path = os.path.join(
|
||||
get_astrbot_path(),
|
||||
"samples",
|
||||
"stt_health_check.wav",
|
||||
)
|
||||
if not os.path.exists(sample_audio_path):
|
||||
status_info["status"] = "unavailable"
|
||||
status_info["error"] = (
|
||||
"STT test failed: sample audio file not found."
|
||||
)
|
||||
logger.warning(
|
||||
f"STT test for {status_info['name']} failed: sample audio file not found at {sample_audio_path}",
|
||||
)
|
||||
else:
|
||||
text_result = await provider.get_text(sample_audio_path)
|
||||
if isinstance(text_result, str) and text_result:
|
||||
status_info["status"] = "available"
|
||||
snippet = (
|
||||
text_result[:70] + "..."
|
||||
if len(text_result) > 70
|
||||
else text_result
|
||||
)
|
||||
logger.info(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{snippet}'",
|
||||
)
|
||||
else:
|
||||
status_info["status"] = "unavailable"
|
||||
status_info["error"] = (
|
||||
f"STT test failed: unexpected result type {type(text_result)}"
|
||||
)
|
||||
logger.warning(
|
||||
f"STT test for {status_info['name']} failed: unexpected result type {type(text_result)}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error testing STT provider {provider_name}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
status_info["status"] = "unavailable"
|
||||
status_info["error"] = f"STT test failed: {e!s}"
|
||||
elif provider_capability_type == ProviderType.RERANK:
|
||||
try:
|
||||
assert isinstance(provider, RerankProvider)
|
||||
await provider.rerank("Apple", documents=["apple", "banana"])
|
||||
try:
|
||||
result = await provider.test()
|
||||
if result:
|
||||
status_info["status"] = "available"
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error testing rerank provider {provider_name}: {e}",
|
||||
exc_info=True,
|
||||
logger.info(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) is available.",
|
||||
)
|
||||
status_info["status"] = "unavailable"
|
||||
status_info["error"] = f"Rerank test failed: {e!s}"
|
||||
|
||||
else:
|
||||
logger.debug(
|
||||
f"Provider {provider_name} is not a Chat Completion or Embedding provider. Marking as available without test. Meta: {meta}",
|
||||
else:
|
||||
status_info["error"] = "Provider test returned False."
|
||||
logger.warning(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) test returned False.",
|
||||
)
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
status_info["error"] = error_message
|
||||
logger.warning(
|
||||
f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}",
|
||||
)
|
||||
status_info["status"] = "available"
|
||||
status_info["error"] = (
|
||||
"This provider type is not tested and is assumed to be available."
|
||||
logger.debug(
|
||||
f"Traceback for {status_info['name']}:\n{traceback.format_exc()}",
|
||||
)
|
||||
|
||||
return status_info
|
||||
|
||||
@@ -159,6 +159,10 @@
|
||||
"prompt_prefix": {
|
||||
"description": "User Prompt",
|
||||
"hint": "You can use {{prompt}} as a placeholder for user input. If no placeholder is provided, it will be added before the user input."
|
||||
},
|
||||
"reachability_check": {
|
||||
"description": "Provider Reachability Check",
|
||||
"hint": "When running the /provider command, test provider connectivity in parallel. This actively pings models and may consume extra tokens."
|
||||
}
|
||||
},
|
||||
"provider_tts_settings": {
|
||||
@@ -453,4 +457,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,10 @@
|
||||
"prompt_prefix": {
|
||||
"description": "用户提示词",
|
||||
"hint": "可使用 {{prompt}} 作为用户输入的占位符。如果不输入占位符则代表添加在用户输入的前面。"
|
||||
},
|
||||
"reachability_check": {
|
||||
"description": "提供商可达性检测",
|
||||
"hint": "/provider 命令列出模型时并发检测连通性。开启后会主动调用模型测试连通性,可能产生额外 token 消耗。"
|
||||
}
|
||||
},
|
||||
"provider_tts_settings": {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api import star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core.provider.entities import ProviderType
|
||||
@@ -9,6 +11,53 @@ class ProviderCommands:
|
||||
def __init__(self, context: star.Context):
|
||||
self.context = context
|
||||
|
||||
def _log_reachability_failure(
|
||||
self,
|
||||
provider,
|
||||
provider_capability_type: ProviderType | None,
|
||||
err_code: str,
|
||||
err_reason: str,
|
||||
):
|
||||
"""记录不可达原因到日志。"""
|
||||
meta = provider.meta()
|
||||
logger.warning(
|
||||
"Provider reachability check failed: id=%s type=%s code=%s reason=%s",
|
||||
meta.id,
|
||||
provider_capability_type.name if provider_capability_type else "unknown",
|
||||
err_code,
|
||||
err_reason,
|
||||
)
|
||||
|
||||
async def _test_provider_capability(self, provider):
|
||||
"""测试单个 provider 的可用性"""
|
||||
meta = provider.meta()
|
||||
provider_capability_type = meta.provider_type
|
||||
|
||||
try:
|
||||
result = await provider.test()
|
||||
if result:
|
||||
return True, None, None
|
||||
err_code = "TEST_FAILED"
|
||||
err_reason = "Provider test returned False"
|
||||
self._log_reachability_failure(
|
||||
provider, provider_capability_type, err_code, err_reason
|
||||
)
|
||||
return False, err_code, err_reason
|
||||
except Exception as exc:
|
||||
err_code = (
|
||||
getattr(exc, "status_code", None)
|
||||
or getattr(exc, "code", None)
|
||||
or getattr(exc, "error_code", None)
|
||||
)
|
||||
err_reason = str(exc)
|
||||
if not err_code:
|
||||
err_code = exc.__class__.__name__
|
||||
|
||||
self._log_reachability_failure(
|
||||
provider, provider_capability_type, err_code, err_reason
|
||||
)
|
||||
return False, err_code, err_reason
|
||||
|
||||
async def provider(
|
||||
self,
|
||||
event: AstrMessageEvent,
|
||||
@@ -17,46 +66,131 @@ class ProviderCommands:
|
||||
):
|
||||
"""查看或者切换 LLM Provider"""
|
||||
umo = event.unified_msg_origin
|
||||
cfg = self.context.get_config(umo).get("provider_settings", {})
|
||||
reachability_check_enabled = cfg.get("reachability_check", True)
|
||||
|
||||
if idx is None:
|
||||
parts = ["## 载入的 LLM 提供商\n"]
|
||||
for idx, llm in enumerate(self.context.get_all_providers()):
|
||||
id_ = llm.meta().id
|
||||
line = f"{idx + 1}. {id_} ({llm.meta().model})"
|
||||
|
||||
# 获取所有类型的提供商
|
||||
llms = list(self.context.get_all_providers())
|
||||
ttss = self.context.get_all_tts_providers()
|
||||
stts = self.context.get_all_stt_providers()
|
||||
|
||||
# 构造待检测列表: [(provider, type_label), ...]
|
||||
all_providers = []
|
||||
all_providers.extend([(p, "llm") for p in llms])
|
||||
all_providers.extend([(p, "tts") for p in ttss])
|
||||
all_providers.extend([(p, "stt") for p in stts])
|
||||
|
||||
# 并发测试连通性
|
||||
if reachability_check_enabled:
|
||||
if all_providers:
|
||||
await event.send(
|
||||
MessageEventResult().message(
|
||||
"正在进行提供商可达性测试,请稍候..."
|
||||
)
|
||||
)
|
||||
check_results = await asyncio.gather(
|
||||
*[self._test_provider_capability(p) for p, _ in all_providers],
|
||||
return_exceptions=True,
|
||||
)
|
||||
else:
|
||||
# 用 None 表示未检测
|
||||
check_results = [None for _ in all_providers]
|
||||
|
||||
# 整合结果
|
||||
display_data = []
|
||||
for (p, p_type), reachable in zip(all_providers, check_results):
|
||||
meta = p.meta()
|
||||
id_ = meta.id
|
||||
error_code = None
|
||||
|
||||
if isinstance(reachable, Exception):
|
||||
# 异常情况下兜底处理,避免单个 provider 导致列表失败
|
||||
self._log_reachability_failure(
|
||||
p,
|
||||
None,
|
||||
reachable.__class__.__name__,
|
||||
str(reachable),
|
||||
)
|
||||
reachable_flag = False
|
||||
error_code = reachable.__class__.__name__
|
||||
elif isinstance(reachable, tuple):
|
||||
reachable_flag, error_code, _ = reachable
|
||||
else:
|
||||
reachable_flag = reachable
|
||||
|
||||
# 根据类型构建显示名称
|
||||
if p_type == "llm":
|
||||
info = f"{id_} ({meta.model})"
|
||||
else:
|
||||
info = f"{id_}"
|
||||
|
||||
# 确定状态标记
|
||||
if reachable_flag is True:
|
||||
mark = " ✅"
|
||||
elif reachable_flag is False:
|
||||
if error_code:
|
||||
mark = f" ❌(错误码: {error_code})"
|
||||
else:
|
||||
mark = " ❌"
|
||||
else:
|
||||
mark = "" # 不支持检测时不显示标记
|
||||
|
||||
display_data.append(
|
||||
{
|
||||
"type": p_type,
|
||||
"info": info,
|
||||
"mark": mark,
|
||||
"provider": p,
|
||||
}
|
||||
)
|
||||
|
||||
# 分组输出
|
||||
# 1. LLM
|
||||
llm_data = [d for d in display_data if d["type"] == "llm"]
|
||||
for i, d in enumerate(llm_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
provider_using = self.context.get_using_provider(umo=umo)
|
||||
if provider_using and provider_using.meta().id == id_:
|
||||
if (
|
||||
provider_using
|
||||
and provider_using.meta().id == d["provider"].meta().id
|
||||
):
|
||||
line += " (当前使用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
tts_providers = self.context.get_all_tts_providers()
|
||||
if tts_providers:
|
||||
# 2. TTS
|
||||
tts_data = [d for d in display_data if d["type"] == "tts"]
|
||||
if tts_data:
|
||||
parts.append("\n## 载入的 TTS 提供商\n")
|
||||
for idx, tts in enumerate(tts_providers):
|
||||
id_ = tts.meta().id
|
||||
line = f"{idx + 1}. {id_}"
|
||||
for i, d in enumerate(tts_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
tts_using = self.context.get_using_tts_provider(umo=umo)
|
||||
if tts_using and tts_using.meta().id == id_:
|
||||
if tts_using and tts_using.meta().id == d["provider"].meta().id:
|
||||
line += " (当前使用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
stt_providers = self.context.get_all_stt_providers()
|
||||
if stt_providers:
|
||||
# 3. STT
|
||||
stt_data = [d for d in display_data if d["type"] == "stt"]
|
||||
if stt_data:
|
||||
parts.append("\n## 载入的 STT 提供商\n")
|
||||
for idx, stt in enumerate(stt_providers):
|
||||
id_ = stt.meta().id
|
||||
line = f"{idx + 1}. {id_}"
|
||||
for i, d in enumerate(stt_data):
|
||||
line = f"{i + 1}. {d['info']}{d['mark']}"
|
||||
stt_using = self.context.get_using_stt_provider(umo=umo)
|
||||
if stt_using and stt_using.meta().id == id_:
|
||||
if stt_using and stt_using.meta().id == d["provider"].meta().id:
|
||||
line += " (当前使用)"
|
||||
parts.append(line + "\n")
|
||||
|
||||
parts.append("\n使用 /provider <序号> 切换 LLM 提供商。")
|
||||
ret = "".join(parts)
|
||||
|
||||
if tts_providers:
|
||||
if ttss:
|
||||
ret += "\n使用 /provider tts <序号> 切换 TTS 提供商。"
|
||||
if stt_providers:
|
||||
ret += "\n使用 /provider stt <切换> STT 提供商。"
|
||||
if stts:
|
||||
ret += "\n使用 /provider stt <序号> 切换 STT 提供商。"
|
||||
if not reachability_check_enabled:
|
||||
ret += "\n已跳过提供商可达性检测,如需检测请在配置文件中开启。"
|
||||
|
||||
event.set_result(MessageEventResult().message(ret))
|
||||
elif idx == "tts":
|
||||
|
||||
Reference in New Issue
Block a user