feat: Enhance OpenAI compatibility and introduce unified LLM service data models

- Refactored model fetching logic to support various authentication methods for OpenAI-compatible APIs.
- Introduced new data models for LLM service requests and responses to standardize interactions across layers.
- Added an adapter base class for unified request execution across different providers.
- Implemented utility functions for building OpenAI-compatible client configurations and request overrides.
This commit is contained in:
DrSmoothl
2026-03-26 16:15:42 +08:00
parent 6e7daae55d
commit 777d4cb0d2
48 changed files with 5443 additions and 2945 deletions

View File

@@ -1,191 +1,492 @@
"""LLM 服务模块
"""LLM 服务层。
提供与 LLM 模型交互的核心功能。
该模块负责在宿主侧收口统一的 LLM 服务请求模型,并将其转发到
`src.llm_models` 中的底层请求调度器。
"""
from typing import Any, Callable, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Tuple
import json
from src.common.data_models.llm_service_data_models import (
LLMAudioTranscriptionResult,
LLMEmbeddingResult,
LLMGenerationOptions,
LLMImageOptions,
LLMResponseResult,
LLMServiceRequest,
LLMServiceResult,
MessageFactory,
PromptInput,
PromptMessage,
)
from src.common.logger import get_logger
from src.config.config import config_manager
from src.config.model_configs import TaskConfig
from src.llm_models.model_client.base_client import BaseClient
from src.llm_models.payload_content.message import Message
from src.llm_models.payload_content.message import Message, MessageBuilder, RoleType
from src.llm_models.payload_content.tool_option import ToolCall
from src.llm_models.utils_model import LLMRequest
from src.llm_models.utils_model import LLMOrchestrator
logger = get_logger("llm_service")
class LLMServiceClient:
"""面向上层模块的 LLM 服务对象式门面。
async def _generate_response(
model_config: TaskConfig,
request_type: str,
prompt: Optional[str] = None,
message_factory: Optional[Callable[[BaseClient], List[Message]]] = None,
tool_options: Optional[List[Dict[str, Any]]] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
) -> Tuple[str, str, str, List[ToolCall] | None]:
llm_request = LLMRequest(model_set=model_config, request_type=request_type)
当前推荐优先使用以下正式接口:
- `generate_response`
- `generate_response_with_messages`
- `generate_response_for_image`
- `transcribe_audio`
- `embed_text`
"""
if message_factory is not None:
response, (reasoning_content, model_name, tool_call) = await llm_request.generate_response_with_message_async(
message_factory=message_factory,
tools=tool_options,
temperature=temperature,
max_tokens=max_tokens,
def __init__(self, task_name: str, request_type: str = "") -> None:
"""初始化 LLM 服务门面。
Args:
task_name: 任务配置名称,对应 `model_task_config` 下的字段名。
request_type: 当前请求的业务类型标识。
"""
self.task_name = resolve_task_name(task_name)
self.request_type = request_type
self._orchestrator = LLMOrchestrator(task_name=self.task_name, request_type=request_type)
@staticmethod
def _normalize_generation_options(options: LLMGenerationOptions | None = None) -> LLMGenerationOptions:
"""规范化文本生成选项。
Args:
options: 原始生成选项。
Returns:
LLMGenerationOptions: 可直接用于执行请求的完整选项对象。
"""
if options is None:
return LLMGenerationOptions()
return options
@staticmethod
def _normalize_image_options(options: LLMImageOptions | None = None) -> LLMImageOptions:
"""规范化图像理解选项。
Args:
options: 原始图像理解选项。
Returns:
LLMImageOptions: 可直接用于执行请求的完整选项对象。
"""
if options is None:
return LLMImageOptions()
return options
async def generate_response(
self,
prompt: str,
options: LLMGenerationOptions | None = None,
) -> LLMResponseResult:
"""生成单轮文本响应。
Args:
prompt: 文本提示词。
options: 文本生成选项。
Returns:
LLMResponseResult: 统一文本生成结果。
"""
active_options = self._normalize_generation_options(options)
return await self._orchestrator.generate_response_async(
prompt=prompt,
temperature=active_options.temperature,
max_tokens=active_options.max_tokens,
tools=active_options.tool_options,
response_format=active_options.response_format,
raise_when_empty=active_options.raise_when_empty,
interrupt_flag=active_options.interrupt_flag,
)
return response, reasoning_content, model_name, tool_call
if prompt is None:
raise ValueError("prompt 与 message_factory 不能同时为空")
async def generate_response_with_messages(
self,
message_factory: MessageFactory,
options: LLMGenerationOptions | None = None,
) -> LLMResponseResult:
"""基于消息工厂生成响应。
response, (reasoning_content, model_name, tool_call) = await llm_request.generate_response_async(
prompt,
tools=tool_options,
temperature=temperature,
max_tokens=max_tokens,
)
return response, reasoning_content, model_name, tool_call
Args:
message_factory: 消息工厂,会根据客户端能力构建消息列表。
options: 文本生成选项。
Returns:
LLMResponseResult: 统一文本生成结果。
"""
active_options = self._normalize_generation_options(options)
return await self._orchestrator.generate_response_with_message_async(
message_factory=message_factory,
temperature=active_options.temperature,
max_tokens=active_options.max_tokens,
tools=active_options.tool_options,
response_format=active_options.response_format,
raise_when_empty=active_options.raise_when_empty,
interrupt_flag=active_options.interrupt_flag,
)
async def generate_response_for_image(
self,
prompt: str,
image_base64: str,
image_format: str,
options: LLMImageOptions | None = None,
) -> LLMResponseResult:
"""为图像内容生成响应。
Args:
prompt: 文本提示词。
image_base64: 图像的 Base64 编码字符串。
image_format: 图像格式,例如 ``png``、``jpeg``。
options: 图像理解选项。
Returns:
LLMResponseResult: 统一文本生成结果。
"""
active_options = self._normalize_image_options(options)
return await self._orchestrator.generate_response_for_image(
prompt=prompt,
image_base64=image_base64,
image_format=image_format,
temperature=active_options.temperature,
max_tokens=active_options.max_tokens,
interrupt_flag=active_options.interrupt_flag,
)
async def transcribe_audio(self, voice_base64: str) -> LLMAudioTranscriptionResult:
"""执行音频转写请求。
Args:
voice_base64: 音频的 Base64 编码字符串。
Returns:
LLMAudioTranscriptionResult: 音频转写结果对象。
"""
return await self._orchestrator.generate_response_for_voice(voice_base64)
async def embed_text(self, embedding_input: str) -> LLMEmbeddingResult:
"""生成文本嵌入向量。
Args:
embedding_input: 待编码的文本。
Returns:
LLMEmbeddingResult: 向量生成结果对象。
"""
return await self._orchestrator.get_embedding(embedding_input)
def get_available_models() -> Dict[str, TaskConfig]:
"""获取所有可用模型配置
"""获取所有可用模型配置
Returns:
Dict[str, Any]: 模型配置字典key为模型名称value为模型配置
Dict[str, TaskConfig]: 模型任务名为键的配置映射。
"""
try:
models = config_manager.get_model_config().model_task_config
attrs = dir(models)
rets: Dict[str, TaskConfig] = {}
for attr in attrs:
if not attr.startswith("__"):
try:
value = getattr(models, attr)
if not callable(value) and isinstance(value, TaskConfig):
rets[attr] = value
except Exception as e:
logger.debug(f"[LLMService] 获取属性 {attr} 失败: {e}")
continue
return rets
except Exception as e:
logger.error(f"[LLMService] 获取可用模型失败: {e}")
available_models: Dict[str, TaskConfig] = {}
for attr_name in dir(models):
if attr_name.startswith("__"):
continue
try:
attr_value = getattr(models, attr_name)
except Exception as exc:
logger.debug(f"[LLMService] 获取属性 {attr_name} 失败: {exc}")
continue
if not callable(attr_value) and isinstance(attr_value, TaskConfig):
available_models[attr_name] = attr_value
return available_models
except Exception as exc:
logger.error(f"[LLMService] 获取可用模型失败: {exc}")
return {}
async def generate_with_model(
prompt: str,
model_config: TaskConfig,
request_type: str = "plugin.generate",
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
) -> Tuple[bool, str, str, str]:
"""使用指定模型生成内容
def resolve_task_name(task_name: str = "") -> str:
"""根据名称解析任务配置名。
Args:
prompt: 提示词
model_config: 模型配置(从 get_available_models 获取的模型配置)
request_type: 请求类型标识
task_name: 目标任务配置名;为空时返回首个可用任务名。
Returns:
Tuple[bool, str, str, str]: (是否成功, 生成的内容, 推理过程, 模型名称)
str: 解析得到的任务配置名。
Raises:
RuntimeError: 当前没有任何可用模型配置。
ValueError: 指定名称不存在时抛出。
"""
try:
logger.debug(f"[LLMService] 完整提示词: {prompt}")
response, reasoning_content, model_name, _ = await _generate_response(
model_config=model_config,
request_type=request_type,
prompt=prompt,
temperature=temperature,
max_tokens=max_tokens,
)
return True, response, reasoning_content, model_name
except Exception as e:
error_msg = f"生成内容时出错: {str(e)}"
logger.error(f"[LLMService] {error_msg}")
return False, error_msg, "", ""
models = get_available_models()
if not models:
raise RuntimeError("没有可用的模型配置")
normalized_task_name = task_name.strip()
if not normalized_task_name:
return next(iter(models.keys()))
if normalized_task_name not in models:
raise ValueError(f"未找到名为 `{normalized_task_name}` 的模型配置")
return normalized_task_name
async def generate_with_model_with_tools(
prompt: str,
model_config: TaskConfig,
tool_options: List[Dict[str, Any]] | None = None,
request_type: str = "plugin.generate",
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
) -> Tuple[bool, str, str, str, List[ToolCall] | None]:
"""使用指定模型和工具生成内容
def _normalize_role(role_name: str) -> RoleType:
"""将原始角色字符串转换为内部角色枚举。
Args:
prompt: 提示词
model_config: 模型配置(从 get_available_models 获取的模型配置)
tool_options: 工具选项列表
request_type: 请求类型标识
temperature: 温度参数
max_tokens: 最大token数
role_name: 原始角色名称。
Returns:
Tuple[bool, str, str, str, List[ToolCall] | None]: (是否成功, 生成的内容, 推理过程, 模型名称, 工具调用列表)
RoleType: 规范化后的角色枚举。
Raises:
ValueError: 角色类型不受支持时抛出。
"""
normalized_role_name = role_name.strip().lower()
try:
model_name_list = model_config.model_list
logger.info(f"使用模型{model_name_list}生成内容")
logger.debug(f"完整提示词: {prompt}")
response, reasoning_content, model_name, tool_call = await _generate_response(
model_config=model_config,
request_type=request_type,
prompt=prompt,
tool_options=tool_options,
temperature=temperature,
max_tokens=max_tokens,
)
return True, response, reasoning_content, model_name, tool_call
except Exception as e:
error_msg = f"生成内容时出错: {str(e)}"
logger.error(f"[LLMService] {error_msg}")
return False, error_msg, "", "", None
return RoleType(normalized_role_name)
except ValueError as exc:
raise ValueError(f"不支持的消息角色: {role_name}") from exc
async def generate_with_model_with_tools_by_message_factory(
message_factory: Callable[[BaseClient], List[Message]],
model_config: TaskConfig,
tool_options: List[Dict[str, Any]] | None = None,
request_type: str = "plugin.generate",
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
) -> Tuple[bool, str, str, str, List[ToolCall] | None]:
"""使用指定模型和工具生成内容(通过消息工厂构建消息列表)
def _parse_data_url_image(image_url: str) -> Tuple[str, str]:
"""解析 Data URL 形式的图片内容。
Args:
message_factory: 消息工厂函数
model_config: 模型配置
tool_options: 工具选项列表
request_type: 请求类型标识
temperature: 温度参数
max_tokens: 最大token数
image_url: 图片 URL。
Returns:
Tuple[bool, str, str, str, List[ToolCall] | None]: (是否成功, 生成的内容, 推理过程, 模型名称, 工具调用列表)
Tuple[str, str]: `(图片格式, Base64 数据)`。
Raises:
ValueError: 输入不是受支持的 Data URL 时抛出。
"""
try:
model_name_list = model_config.model_list
logger.info(f"使用模型 {model_name_list} 生成内容")
if not image_url.startswith("data:image/") or ";base64," not in image_url:
raise ValueError("仅支持 Data URL 形式的图片输入")
prefix, image_base64 = image_url.split(";base64,", maxsplit=1)
image_format = prefix.removeprefix("data:image/")
if not image_format or not image_base64:
raise ValueError("图片 Data URL 不完整")
return image_format, image_base64
response, reasoning_content, model_name, tool_call = await _generate_response(
model_config=model_config,
request_type=request_type,
message_factory=message_factory,
tool_options=tool_options,
temperature=temperature,
max_tokens=max_tokens,
def _append_content_parts(message_builder: MessageBuilder, content: Any) -> None:
"""将原始消息内容追加到内部消息构建器。
Args:
message_builder: 目标消息构建器。
content: 原始消息内容。
Raises:
ValueError: 消息内容结构不受支持时抛出。
"""
if isinstance(content, str):
message_builder.add_text_content(content)
return
content_items: List[Any]
if isinstance(content, list):
content_items = content
elif isinstance(content, dict):
content_items = [content]
else:
raise ValueError("消息内容必须为字符串、字典或列表")
for content_item in content_items:
if isinstance(content_item, str):
message_builder.add_text_content(content_item)
continue
if not isinstance(content_item, dict):
raise ValueError("消息内容列表中仅支持字符串或字典片段")
part_type = str(content_item.get("type", "text")).strip().lower()
if part_type == "text":
text_content = content_item.get("text")
if not isinstance(text_content, str):
raise ValueError("文本片段缺少 `text` 字段")
message_builder.add_text_content(text_content)
continue
if part_type in {"image", "image_url", "input_image"}:
image_url = content_item.get("image_url")
if isinstance(image_url, dict):
image_url = image_url.get("url")
if isinstance(image_url, str):
image_format, image_base64 = _parse_data_url_image(image_url)
message_builder.add_image_content(image_format=image_format, image_base64=image_base64)
continue
image_format = content_item.get("image_format")
image_base64 = content_item.get("image_base64")
if isinstance(image_format, str) and isinstance(image_base64, str):
message_builder.add_image_content(image_format=image_format, image_base64=image_base64)
continue
raise ValueError("图片片段缺少可识别的图片数据")
raise ValueError(f"不支持的消息片段类型: {part_type}")
def _normalize_tool_arguments(arguments: Any) -> Dict[str, Any] | None:
"""将原始工具参数规范化为字典。
Args:
arguments: 原始工具参数。
Returns:
Dict[str, Any] | None: 规范化后的参数字典。
"""
if arguments is None:
return None
if isinstance(arguments, dict):
return arguments
if isinstance(arguments, str):
stripped_arguments = arguments.strip()
if not stripped_arguments:
return {}
try:
parsed_arguments = json.loads(stripped_arguments)
except json.JSONDecodeError:
return {"raw_arguments": arguments}
if isinstance(parsed_arguments, dict):
return parsed_arguments
return {"value": parsed_arguments}
return {"value": arguments}
def _build_tool_calls(raw_tool_calls: Any) -> List[ToolCall] | None:
"""从原始消息中提取工具调用列表。
Args:
raw_tool_calls: 原始工具调用结构。
Returns:
List[ToolCall] | None: 规范化后的工具调用列表。
Raises:
ValueError: 工具调用结构缺失必要字段时抛出。
"""
if raw_tool_calls is None:
return None
if not isinstance(raw_tool_calls, list):
raise ValueError("`tool_calls` 必须为列表")
tool_calls: List[ToolCall] = []
for raw_tool_call in raw_tool_calls:
if not isinstance(raw_tool_call, dict):
raise ValueError("工具调用项必须为字典")
function_info = raw_tool_call.get("function")
if isinstance(function_info, dict):
func_name = function_info.get("name")
arguments = function_info.get("arguments")
else:
func_name = raw_tool_call.get("name") or raw_tool_call.get("func_name")
arguments = raw_tool_call.get("arguments") or raw_tool_call.get("args")
call_id = raw_tool_call.get("id") or raw_tool_call.get("call_id")
if not isinstance(call_id, str) or not isinstance(func_name, str):
raise ValueError("工具调用缺少 `id` 或函数名称")
tool_calls.append(
ToolCall(
call_id=call_id,
func_name=func_name,
args=_normalize_tool_arguments(arguments),
)
)
return True, response, reasoning_content, model_name, tool_call
except Exception as e:
error_msg = f"生成内容时出错: {str(e)}"
logger.error(f"[LLMService] {error_msg}")
return False, error_msg, "", "", None
return tool_calls or None
def _build_message_from_dict(raw_message: PromptMessage) -> Message:
"""将原始消息字典转换为内部消息对象。
Args:
raw_message: 原始消息字典。
Returns:
Message: 规范化后的消息对象。
Raises:
ValueError: 原始消息结构不合法时抛出。
"""
raw_role = raw_message.get("role")
if not isinstance(raw_role, str):
raise ValueError("消息缺少字符串类型的 `role` 字段")
role = _normalize_role(raw_role)
message_builder = MessageBuilder().set_role(role)
tool_calls = _build_tool_calls(raw_message.get("tool_calls"))
if tool_calls is not None:
message_builder.set_tool_calls(tool_calls)
tool_call_id = raw_message.get("tool_call_id")
if isinstance(tool_call_id, str) and role == RoleType.Tool:
message_builder.set_tool_call_id(tool_call_id)
if "content" in raw_message and raw_message["content"] not in (None, "", []):
_append_content_parts(message_builder, raw_message["content"])
return message_builder.build()
def _build_prompt_message_factory(prompt: PromptInput) -> MessageFactory:
"""将统一提示输入转换为消息工厂。
Args:
prompt: 原始提示输入。
Returns:
MessageFactory: 惰性构建消息列表的工厂函数。
"""
if isinstance(prompt, str):
def build_messages(_: BaseClient) -> List[Message]:
"""构建单条用户消息。"""
message_builder = MessageBuilder()
message_builder.add_text_content(prompt)
return [message_builder.build()]
return build_messages
def build_messages(_: BaseClient) -> List[Message]:
"""构建多消息对话输入。"""
return [_build_message_from_dict(raw_message) for raw_message in prompt]
return build_messages
async def generate(request: LLMServiceRequest) -> LLMServiceResult:
"""执行统一的 LLM 服务请求。
Args:
request: 服务层统一请求对象。
Returns:
LLMServiceResult: 统一响应对象;失败时 `success=False`。
"""
llm_client = LLMServiceClient(task_name=request.task_name, request_type=request.request_type)
if request.message_factory is not None:
active_message_factory = request.message_factory
else:
prompt = request.prompt
if prompt is None:
raise ValueError("`prompt` 与 `message_factory` 必须且只能提供一个")
active_message_factory = _build_prompt_message_factory(prompt)
try:
generation_result = await llm_client.generate_response_with_messages(
message_factory=active_message_factory,
options=LLMGenerationOptions(
temperature=request.temperature,
max_tokens=request.max_tokens,
tool_options=request.tool_options,
response_format=request.response_format,
interrupt_flag=request.interrupt_flag,
),
)
return LLMServiceResult.from_response_result(generation_result)
except Exception as exc:
error_message = f"生成内容时出错: {exc}"
logger.error(f"[LLMService] {error_message}")
return LLMServiceResult.from_error(error_message, str(exc))