Files
MoviePilot/tests/test_agent_llm_capability.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

433 lines
17 KiB
Python

"""
验证语音输入输出提供方选择、载荷结构及兼容回退。
"""
from base64 import b64encode
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import Mock, patch
from app.agent.llm import capability as capability_module
from app.agent.llm.capability import (
AgentCapabilityManager,
MiMoAudioProvider,
MiniMaxAudioProvider,
OpenAIAudioProvider,
OpenAIChatAudioProvider,
)
from app.runtime.config import settings
from app.schemas.notification import ChannelCapability, ChannelCapabilityManager
from app.schemas.types import NotificationChannel
def test_registered_audio_providers_contains_builtin_providers() -> None:
"""
音频能力注册表包含全部内置提供方。
"""
assert "openai" in AgentCapabilityManager.get_registered_audio_providers()
assert "openai_chat_audio" in AgentCapabilityManager.get_registered_audio_providers()
assert "mimo" in AgentCapabilityManager.get_registered_audio_providers()
assert "minimax" in AgentCapabilityManager.get_registered_audio_providers()
def test_get_audio_provider_uses_separate_input_and_output_settings() -> None:
"""
语音输入与输出分别读取各自的提供方配置。
"""
with (
patch.object(settings, "AUDIO_INPUT_PROVIDER", "openai"),
patch.object(settings, "AUDIO_OUTPUT_PROVIDER", "mimo"),
):
assert isinstance(AgentCapabilityManager.get_audio_provider("input"), OpenAIAudioProvider)
assert isinstance(AgentCapabilityManager.get_audio_provider("output"), MiMoAudioProvider)
def test_chat_audio_provider_keeps_arbitrary_compatible_models() -> None:
"""
兼容音频聊天接口保留用户配置的自定义模型名。
"""
provider = OpenAIChatAudioProvider()
with (
patch.object(settings, "AUDIO_INPUT_MODEL", "vendor-omni-audio"),
patch.object(settings, "AUDIO_OUTPUT_MODEL", "vendor-tts-audio"),
):
assert provider._normalize_stt_model() == "vendor-omni-audio"
assert provider._normalize_tts_model() == "vendor-tts-audio"
def test_chat_audio_provider_uses_openai_audio_payload_shape() -> None:
"""
转写请求使用 OpenAI 兼容的输入音频结构。
"""
provider = OpenAIChatAudioProvider()
fake_client = Mock()
fake_client.chat.completions.create.return_value = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="你好"))]
)
with (
patch.object(provider, "_build_client", return_value=fake_client),
patch.object(settings, "AUDIO_INPUT_MODEL", "gpt-4o-audio-preview"),
patch.object(settings, "AUDIO_INPUT_LANGUAGE", "zh"),
patch.object(settings, "AUDIO_INPUT_API_KEY", "sk-test"),
patch.object(settings, "AUDIO_INPUT_BASE_URL", "https://example.com/v1"),
):
result = provider.transcribe_audio(b"audio-bytes", filename="input.wav")
assert result == "你好"
request = fake_client.chat.completions.create.call_args.kwargs
content = request["messages"][0]["content"]
assert content[0]["input_audio"] == {"data": b64encode(b"audio-bytes").decode("utf-8"), "format": "wav"}
def test_chat_audio_provider_requests_audio_modality_for_tts() -> None:
"""
语音合成请求显式声明音频模态及输出格式。
"""
provider = OpenAIChatAudioProvider()
fake_client = Mock()
audio_data = b64encode(b"wav-bytes").decode("utf-8")
fake_client.chat.completions.create.return_value = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(audio={"data": audio_data}))]
)
with (
TemporaryDirectory() as temp_dir,
patch.object(provider, "_build_client", return_value=fake_client),
patch.object(
capability_module,
"get_runtime_setting",
side_effect=lambda key, default=None: getattr(
SimpleNamespace(
TEMP_PATH=Path(temp_dir),
AUDIO_OUTPUT_MODEL="gpt-4o-audio-preview",
AUDIO_OUTPUT_VOICE="alloy",
AUDIO_OUTPUT_API_KEY="sk-test",
AUDIO_OUTPUT_BASE_URL="https://example.com/v1",
),
key,
default,
),
),
patch.object(provider, "_convert_wav_to_opus", return_value=None),
):
output_path = provider.synthesize_speech("你好")
assert output_path is not None
request = fake_client.chat.completions.create.call_args.kwargs
assert request["messages"][0]["role"] == "user"
assert request["modalities"] == ["text", "audio"]
assert request["audio"] == {"format": "wav", "voice": "alloy"}
def test_audio_input_and_output_switches_are_independent() -> None:
"""
语音输入与输出开关互不影响。
"""
provider = Mock()
provider.is_available_for_audio_input.return_value = True
provider.is_available_for_audio_output.return_value = True
with (
patch.object(settings, "LLM_SUPPORT_AUDIO_INPUT", True),
patch.object(settings, "LLM_SUPPORT_AUDIO_OUTPUT", False),
patch.object(AgentCapabilityManager, "get_audio_provider", return_value=provider),
):
assert AgentCapabilityManager.is_audio_input_available()
assert not AgentCapabilityManager.is_audio_output_available()
with (
patch.object(settings, "LLM_SUPPORT_AUDIO_INPUT", False),
patch.object(settings, "LLM_SUPPORT_AUDIO_OUTPUT", True),
patch.object(AgentCapabilityManager, "get_audio_provider", return_value=provider),
):
assert not AgentCapabilityManager.is_audio_input_available()
assert AgentCapabilityManager.is_audio_output_available()
def test_transcribe_audio_routes_to_input_provider() -> None:
"""
音频转写只路由到配置的输入提供方。
"""
provider = Mock()
provider.name = "mock_audio"
provider.is_available_for_audio_input.return_value = True
provider.transcribe_audio.return_value = "你好"
with (
patch.object(settings, "LLM_SUPPORT_AUDIO_INPUT", True),
patch.object(AgentCapabilityManager, "get_audio_provider", return_value=provider),
patch.object(capability_module.logger, "info") as log_info,
):
result = AgentCapabilityManager.transcribe_audio(b"audio")
assert result == "你好"
provider.transcribe_audio.assert_called_once()
assert any(("语音转文字开始" in call.args[0] for call in log_info.call_args_list))
assert any(("语音转文字完成" in call.args[0] for call in log_info.call_args_list))
def test_synthesize_speech_routes_to_output_provider() -> None:
"""
语音合成只路由到配置的输出提供方。
"""
provider = Mock()
provider.name = "mock_audio"
provider.is_available_for_audio_output.return_value = True
provider.synthesize_speech.return_value = Path("/tmp/reply.opus")
with (
patch.object(settings, "LLM_SUPPORT_AUDIO_OUTPUT", True),
patch.object(AgentCapabilityManager, "get_audio_provider", return_value=provider),
patch.object(capability_module.logger, "info") as log_info,
):
result = AgentCapabilityManager.synthesize_speech("你好")
assert result == Path("/tmp/reply.opus")
provider.synthesize_speech.assert_called_once_with(text="你好")
assert any(("文字转语音开始" in call.args[0] for call in log_info.call_args_list))
assert any(("文字转语音完成" in call.args[0] for call in log_info.call_args_list))
def test_native_voice_reply_supports_channels_with_audio_output() -> None:
"""
校验 Agent 语音回复渠道支持判断覆盖常见渠道写法。
"""
assert AgentCapabilityManager.supports_native_voice_reply("telegram", None)
assert AgentCapabilityManager.supports_native_voice_reply(NotificationChannel.Telegram.value, None)
assert AgentCapabilityManager.supports_native_voice_reply(NotificationChannel.Feishu.value, None)
assert AgentCapabilityManager.supports_native_voice_reply("Feishu", None)
assert AgentCapabilityManager.supports_native_voice_reply(NotificationChannel.WebAgent.value, None)
assert not AgentCapabilityManager.supports_native_voice_reply("Slack", None)
def test_native_voice_reply_respects_wechat_mode() -> None:
"""
校验企业微信只有自建应用模式允许 Agent 语音回复。
"""
configs = [
SimpleNamespace(name="wechat-app", config={"WECHAT_MODE": "app"}),
SimpleNamespace(name="wechat-bot", config={"WECHAT_MODE": "bot"}),
]
with patch(
"app.agent.llm.capability.get_notification_configs",
return_value=configs,
):
assert AgentCapabilityManager.supports_native_voice_reply(NotificationChannel.Wechat.value, "wechat-app")
assert not AgentCapabilityManager.supports_native_voice_reply(NotificationChannel.Wechat.value, "wechat-bot")
assert not AgentCapabilityManager.supports_native_voice_reply(NotificationChannel.Wechat.value, "missing")
def test_channel_capability_marks_voice_output_channels() -> None:
"""
校验消息渠道能力显式声明原生语音输出支持。
"""
for channel in (
NotificationChannel.Telegram,
NotificationChannel.Feishu,
NotificationChannel.Wechat,
NotificationChannel.WebAgent,
):
assert ChannelCapabilityManager.supports_capability(channel, ChannelCapability.AUDIO_OUTPUT)
assert not ChannelCapabilityManager.supports_capability(NotificationChannel.Slack, ChannelCapability.AUDIO_OUTPUT)
def test_mimo_tts_uses_chat_completions_audio_payload() -> None:
"""
MiMo 语音合成使用聊天补全接口的音频载荷。
"""
provider = MiMoAudioProvider()
fake_client = Mock()
audio_data = b64encode(b"wav-bytes").decode("utf-8")
fake_client.chat.completions.create.return_value = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(audio={"data": audio_data}))]
)
with (
TemporaryDirectory() as temp_dir,
patch.object(provider, "_build_client", return_value=fake_client),
patch.object(
capability_module,
"get_runtime_setting",
side_effect=lambda key, default=None: getattr(
SimpleNamespace(
TEMP_PATH=Path(temp_dir),
AUDIO_OUTPUT_MODEL="mimo-v2.5-tts",
AUDIO_OUTPUT_VOICE="冰糖",
AUDIO_OUTPUT_API_KEY="sk-test",
AUDIO_OUTPUT_BASE_URL="https://api.xiaomimimo.com/v1",
),
key,
default,
),
),
patch.object(provider, "_convert_wav_to_opus", return_value=None),
):
output_path = provider.synthesize_speech("你好")
output_bytes = output_path.read_bytes() if output_path else None
assert output_path is not None
assert output_bytes == b"wav-bytes"
fake_client.chat.completions.create.assert_called_once()
request = fake_client.chat.completions.create.call_args.kwargs
assert request["model"] == "mimo-v2.5-tts"
assert request["messages"][0]["role"] == "assistant"
assert request["messages"][0]["content"] == "你好"
assert request["audio"] == {"format": "wav", "voice": "冰糖"}
def test_mimo_tts_rejects_voice_design_and_clone_models() -> None:
"""
MiMo 语音合成拒绝声音设计和克隆专用模型。
"""
provider = MiMoAudioProvider()
with (
patch.object(settings, "AUDIO_OUTPUT_MODEL", "mimo-v2.5-tts-voiceclone"),
patch.object(provider, "_build_client") as build_client,
):
result = provider.synthesize_speech("你好")
assert result is None
build_client.assert_not_called()
def test_mimo_stt_rejects_non_audio_mimo_models_by_falling_back() -> None:
"""
MiMo 转写配置为非音频模型时回退到受支持模型。
"""
provider = MiMoAudioProvider()
with patch.object(settings, "AUDIO_INPUT_MODEL", "mimo-v2.5-pro"):
assert provider._normalize_stt_model() == "mimo-v2.5"
def test_mimo_stt_uses_base64_audio_input() -> None:
"""
MiMo 转写以 Base64 内容提交输入音频。
"""
provider = MiMoAudioProvider()
fake_client = Mock()
fake_client.chat.completions.create.return_value = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="你好"))]
)
with (
patch.object(provider, "_build_client", return_value=fake_client),
patch.object(settings, "AUDIO_INPUT_MODEL", "mimo-v2.5"),
patch.object(settings, "AUDIO_INPUT_LANGUAGE", "zh"),
patch.object(settings, "AUDIO_INPUT_API_KEY", "sk-test"),
patch.object(settings, "AUDIO_INPUT_BASE_URL", "https://api.xiaomimimo.com/v1"),
):
result = provider.transcribe_audio(b"audio-bytes", filename="input.wav")
assert result == "你好"
request = fake_client.chat.completions.create.call_args.kwargs
content = request["messages"][0]["content"]
assert request["model"] == "mimo-v2.5"
assert content[0]["input_audio"]["data"].startswith("data:audio/wav;base64,")
assert "只输出转写结果" in content[1]["text"]
def test_mimo_stt_transcodes_amr_before_payload() -> None:
"""
校验 MiMo 音频输入会先将企业微信 AMR 转为受支持的 WAV。
"""
provider = MiMoAudioProvider()
fake_client = Mock()
fake_client.chat.completions.create.return_value = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="你好"))]
)
with (
patch.object(provider, "_build_client", return_value=fake_client),
patch.object(
provider,
"_convert_audio_for_transcription",
return_value=(b"wav-bytes", "input.wav"),
) as convert_audio,
patch.object(settings, "AUDIO_INPUT_MODEL", "mimo-v2.5"),
patch.object(settings, "AUDIO_INPUT_LANGUAGE", "zh"),
patch.object(settings, "AUDIO_INPUT_API_KEY", "sk-test"),
patch.object(settings, "AUDIO_INPUT_BASE_URL", "https://api.xiaomimimo.com/v1"),
):
result = provider.transcribe_audio(b"amr-bytes", filename="input.amr")
assert result == "你好"
convert_audio.assert_called_once_with(content=b"amr-bytes", filename="input.amr")
request = fake_client.chat.completions.create.call_args.kwargs
content = request["messages"][0]["content"]
assert content[0]["input_audio"]["data"] == f"data:audio/wav;base64,{b64encode(b'wav-bytes').decode('utf-8')}"
def test_minimax_stt_normalizes_openai_default_model() -> None:
"""
校验 MiniMax 音频输入会把 OpenAI 默认模型兜底为 MiniMax 模型。
"""
provider = MiniMaxAudioProvider()
with patch.object(settings, "AUDIO_INPUT_MODEL", "gpt-4o-mini-transcribe"):
assert provider._normalize_stt_model() == "MiniMax-M2.7"
def test_minimax_tts_uses_t2a_http_payload() -> None:
"""
校验 MiniMax 音频输出会调用官方 T2A HTTP 接口并写入音频文件。
"""
provider = MiniMaxAudioProvider()
fake_response = SimpleNamespace(
status_code=200,
json=Mock(
return_value={
"data": {"audio": b"opus-bytes".hex(), "status": 2},
"base_resp": {"status_code": 0, "status_msg": "success"},
}
),
)
request_utils = Mock()
request_utils.post_res.return_value = fake_response
with (
TemporaryDirectory() as temp_dir,
patch.object(capability_module, "RequestUtils", return_value=request_utils) as request_utils_cls,
patch.object(
capability_module,
"get_runtime_setting",
side_effect=lambda key, default=None: getattr(
SimpleNamespace(
TEMP_PATH=Path(temp_dir),
PROXY={},
AUDIO_OUTPUT_MODEL="gpt-4o-mini-tts",
AUDIO_OUTPUT_VOICE="alloy",
AUDIO_OUTPUT_API_KEY="sk-test",
AUDIO_OUTPUT_BASE_URL="https://api.minimaxi.com/anthropic/v1",
),
key,
default,
),
),
):
output_path = provider.synthesize_speech("你好")
output_bytes = output_path.read_bytes() if output_path else None
assert output_path is not None
assert output_bytes == b"opus-bytes"
request_utils_cls.assert_called_once()
request = request_utils.post_res.call_args.kwargs
assert request["url"] == "https://api.minimaxi.com/v1/t2a_v2"
assert request["json"]["model"] == "speech-2.8-turbo"
assert request["json"]["voice_setting"]["voice_id"] == "Chinese (Mandarin)_Lyrical_Voice"
assert request["json"]["audio_setting"]["format"] == "opus"
def test_minimax_tts_accepts_base64_audio_payload() -> None:
"""
校验 MiniMax 音频解析兼容部分代理返回的 base64 音频数据。
"""
provider = MiniMaxAudioProvider()
assert provider._decode_audio_payload(b64encode(b"opus-bytes").decode("utf-8")) == b"opus-bytes"