mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2025-12-14 21:52:56 +08:00
- 【重构】LLM 服务核心架构:
- 引入中间件管道,统一处理请求生命周期(重试、密钥选择、日志、网络请求)。
- 适配器重构为组件化设计,分离配置映射、消息转换、响应解析和工具序列化逻辑。
- 移除 `with_smart_retry` 装饰器,其功能由中间件接管。
- 移除 `LLMToolExecutor`,工具执行逻辑集成到 `ToolInvoker`。
- 【功能】增强配置系统:
- `LLMGenerationConfig` 采用组件化结构(Core, Reasoning, Visual, Output, Safety, ToolConfig)。
- 新增 `GenConfigBuilder` 提供语义化配置构建方式。
- 新增 `LLMEmbeddingConfig` 用于嵌入专用配置。
- `CommonOverrides` 迁移并更新至新配置结构。
- 【功能】强化工具系统:
- 引入 `ToolInvoker` 实现更灵活的工具执行,支持回调与结构化错误。
- `function_tool` 装饰器支持动态 Pydantic 模型创建和依赖注入 (`ToolParam`, `RunContext`)。
- 平台原生工具支持 (`GeminiCodeExecution`, `GeminiGoogleSearch`, `GeminiUrlContext`)。
- 【功能】高级生成与嵌入:
- `generate_structured` 方法支持 In-Context Validation and Repair (IVR) 循环和 AutoCoT (思维链) 包装。
- 新增 `embed_query` 和 `embed_documents` 便捷嵌入 API。
- `OpenAIImageAdapter` 支持 OpenAI 兼容的图像生成。
- `SmartAdapter` 实现模型名称智能路由。
- 【重构】消息与类型系统:
- `LLMContentPart` 扩展支持更多模态和代码执行相关内容。
- `LLMMessage` 和 `LLMResponse` 结构更新,支持 `content_parts` 和思维链签名。
- 统一 `LLMErrorCode` 和用户友好错误消息,提供更详细的网络/代理错误提示。
- `pyproject.toml` 移除 `bilireq`,新增 `json_repair`。
- 【优化】日志与调试:
- 引入 `DebugLogOptions`,提供细粒度日志脱敏控制。
- 增强日志净化器,处理更多敏感数据和长字符串。
- 【清理】删除废弃模块:
- `zhenxun/services/llm/memory.py`
- `zhenxun/services/llm/executor.py`
- `zhenxun/services/llm/config/presets.py`
- `zhenxun/services/llm/types/content.py`
- `zhenxun/services/llm/types/enums.py`
- `zhenxun/services/llm/tools/__init__.py`
- `zhenxun/services/llm/tools/manager.py`
348 lines
14 KiB
Python
348 lines
14 KiB
Python
import base64
|
||
import binascii
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from zhenxun.services.llm.adapters.base import ResponseData, process_image_data
|
||
from zhenxun.services.llm.adapters.components.interfaces import (
|
||
ConfigMapper,
|
||
MessageConverter,
|
||
ResponseParser,
|
||
ToolSerializer,
|
||
)
|
||
from zhenxun.services.llm.config.generation import (
|
||
ImageAspectRatio,
|
||
LLMGenerationConfig,
|
||
ResponseFormat,
|
||
StructuredOutputStrategy,
|
||
)
|
||
from zhenxun.services.llm.types import LLMMessage
|
||
from zhenxun.services.llm.types.capabilities import ModelCapabilities
|
||
from zhenxun.services.llm.types.exceptions import LLMErrorCode, LLMException
|
||
from zhenxun.services.llm.types.models import (
|
||
LLMToolCall,
|
||
LLMToolFunction,
|
||
ModelDetail,
|
||
ToolDefinition,
|
||
)
|
||
from zhenxun.services.llm.utils import sanitize_schema_for_llm
|
||
from zhenxun.services.log import logger
|
||
from zhenxun.utils.pydantic_compat import model_dump
|
||
|
||
|
||
class OpenAIConfigMapper(ConfigMapper):
|
||
def __init__(self, api_type: str = "openai"):
|
||
self.api_type = api_type
|
||
|
||
def map_config(
|
||
self,
|
||
config: LLMGenerationConfig,
|
||
model_detail: ModelDetail | None = None,
|
||
capabilities: ModelCapabilities | None = None,
|
||
) -> dict[str, Any]:
|
||
params: dict[str, Any] = {}
|
||
strategy = config.output.structured_output_strategy if config.output else None
|
||
if strategy is None:
|
||
strategy = (
|
||
StructuredOutputStrategy.TOOL_CALL
|
||
if self.api_type == "deepseek"
|
||
else StructuredOutputStrategy.NATIVE
|
||
)
|
||
|
||
if config.core:
|
||
if config.core.temperature is not None:
|
||
params["temperature"] = config.core.temperature
|
||
if config.core.max_tokens is not None:
|
||
params["max_tokens"] = config.core.max_tokens
|
||
if config.core.top_k is not None:
|
||
params["top_k"] = config.core.top_k
|
||
if config.core.top_p is not None:
|
||
params["top_p"] = config.core.top_p
|
||
if config.core.frequency_penalty is not None:
|
||
params["frequency_penalty"] = config.core.frequency_penalty
|
||
if config.core.presence_penalty is not None:
|
||
params["presence_penalty"] = config.core.presence_penalty
|
||
if config.core.stop is not None:
|
||
params["stop"] = config.core.stop
|
||
|
||
if config.core.repetition_penalty is not None:
|
||
if self.api_type == "openai":
|
||
logger.warning("OpenAI官方API不支持repetition_penalty参数,已忽略")
|
||
else:
|
||
params["repetition_penalty"] = config.core.repetition_penalty
|
||
|
||
if config.reasoning and config.reasoning.effort:
|
||
params["reasoning_effort"] = config.reasoning.effort.value.lower()
|
||
|
||
if config.output:
|
||
if isinstance(config.output.response_format, dict):
|
||
params["response_format"] = config.output.response_format
|
||
elif (
|
||
config.output.response_format == ResponseFormat.JSON
|
||
and strategy == StructuredOutputStrategy.NATIVE
|
||
):
|
||
if config.output.response_schema:
|
||
sanitized = sanitize_schema_for_llm(
|
||
config.output.response_schema, api_type="openai"
|
||
)
|
||
params["response_format"] = {
|
||
"type": "json_schema",
|
||
"json_schema": {
|
||
"name": "structured_response",
|
||
"schema": sanitized,
|
||
"strict": True,
|
||
},
|
||
}
|
||
else:
|
||
params["response_format"] = {"type": "json_object"}
|
||
|
||
if config.tool_config:
|
||
mode = config.tool_config.mode
|
||
if mode == "NONE":
|
||
params["tool_choice"] = "none"
|
||
elif mode == "AUTO":
|
||
params["tool_choice"] = "auto"
|
||
elif mode == "ANY":
|
||
params["tool_choice"] = "required"
|
||
|
||
if config.visual and config.visual.aspect_ratio:
|
||
size_map = {
|
||
ImageAspectRatio.SQUARE: "1024x1024",
|
||
ImageAspectRatio.LANDSCAPE_16_9: "1792x1024",
|
||
ImageAspectRatio.PORTRAIT_9_16: "1024x1792",
|
||
}
|
||
ar = config.visual.aspect_ratio
|
||
if isinstance(ar, ImageAspectRatio):
|
||
mapped_size = size_map.get(ar)
|
||
if mapped_size:
|
||
params["size"] = mapped_size
|
||
elif isinstance(ar, str):
|
||
params["size"] = ar
|
||
|
||
if config.custom_params:
|
||
mapped_custom = config.custom_params.copy()
|
||
if "repetition_penalty" in mapped_custom and self.api_type == "openai":
|
||
mapped_custom.pop("repetition_penalty")
|
||
|
||
if "stop" in mapped_custom:
|
||
stop_value = mapped_custom["stop"]
|
||
if isinstance(stop_value, str):
|
||
mapped_custom["stop"] = [stop_value]
|
||
|
||
params.update(mapped_custom)
|
||
|
||
return params
|
||
|
||
|
||
class OpenAIMessageConverter(MessageConverter):
|
||
def convert_messages(self, messages: list[LLMMessage]) -> list[dict[str, Any]]:
|
||
openai_messages: list[dict[str, Any]] = []
|
||
for msg in messages:
|
||
openai_msg: dict[str, Any] = {"role": msg.role}
|
||
|
||
if msg.role == "tool":
|
||
openai_msg["tool_call_id"] = msg.tool_call_id
|
||
openai_msg["name"] = msg.name
|
||
openai_msg["content"] = msg.content
|
||
else:
|
||
if isinstance(msg.content, str):
|
||
openai_msg["content"] = msg.content
|
||
else:
|
||
content_parts = []
|
||
for part in msg.content:
|
||
if part.type == "text":
|
||
content_parts.append({"type": "text", "text": part.text})
|
||
elif part.type == "image":
|
||
content_parts.append(
|
||
{
|
||
"type": "image_url",
|
||
"image_url": {"url": part.image_source},
|
||
}
|
||
)
|
||
openai_msg["content"] = content_parts
|
||
|
||
if msg.role == "assistant" and msg.tool_calls:
|
||
assistant_tool_calls = []
|
||
for call in msg.tool_calls:
|
||
assistant_tool_calls.append(
|
||
{
|
||
"id": call.id,
|
||
"type": "function",
|
||
"function": {
|
||
"name": call.function.name,
|
||
"arguments": call.function.arguments,
|
||
},
|
||
}
|
||
)
|
||
openai_msg["tool_calls"] = assistant_tool_calls
|
||
|
||
if msg.name and msg.role != "tool":
|
||
openai_msg["name"] = msg.name
|
||
|
||
openai_messages.append(openai_msg)
|
||
return openai_messages
|
||
|
||
|
||
class OpenAIToolSerializer(ToolSerializer):
|
||
def serialize_tools(
|
||
self, tools: list[ToolDefinition]
|
||
) -> list[dict[str, Any]] | None:
|
||
if not tools:
|
||
return None
|
||
|
||
openai_tools = []
|
||
for tool in tools:
|
||
tool_dict = model_dump(tool)
|
||
parameters = tool_dict.get("parameters")
|
||
if parameters:
|
||
tool_dict["parameters"] = sanitize_schema_for_llm(
|
||
parameters, api_type="openai"
|
||
)
|
||
tool_dict["strict"] = True
|
||
openai_tools.append({"type": "function", "function": tool_dict})
|
||
return openai_tools
|
||
|
||
|
||
class OpenAIResponseParser(ResponseParser):
|
||
def validate_response(self, response_json: dict[str, Any]) -> None:
|
||
if response_json.get("error"):
|
||
error_info = response_json["error"]
|
||
if isinstance(error_info, dict):
|
||
error_message = error_info.get("message", "未知错误")
|
||
error_code = error_info.get("code", "unknown")
|
||
|
||
error_code_mapping = {
|
||
"invalid_api_key": LLMErrorCode.API_KEY_INVALID,
|
||
"authentication_failed": LLMErrorCode.API_KEY_INVALID,
|
||
"insufficient_quota": LLMErrorCode.API_QUOTA_EXCEEDED,
|
||
"rate_limit_exceeded": LLMErrorCode.API_RATE_LIMITED,
|
||
"quota_exceeded": LLMErrorCode.API_RATE_LIMITED,
|
||
"model_not_found": LLMErrorCode.MODEL_NOT_FOUND,
|
||
"invalid_model": LLMErrorCode.MODEL_NOT_FOUND,
|
||
"context_length_exceeded": LLMErrorCode.CONTEXT_LENGTH_EXCEEDED,
|
||
"max_tokens_exceeded": LLMErrorCode.CONTEXT_LENGTH_EXCEEDED,
|
||
"invalid_request_error": LLMErrorCode.INVALID_PARAMETER,
|
||
"invalid_parameter": LLMErrorCode.INVALID_PARAMETER,
|
||
}
|
||
|
||
llm_error_code = error_code_mapping.get(
|
||
error_code, LLMErrorCode.API_RESPONSE_INVALID
|
||
)
|
||
else:
|
||
error_message = str(error_info)
|
||
error_code = "unknown"
|
||
llm_error_code = LLMErrorCode.API_RESPONSE_INVALID
|
||
|
||
raise LLMException(
|
||
f"API请求失败: {error_message}",
|
||
code=llm_error_code,
|
||
details={"api_error": error_info, "error_code": error_code},
|
||
)
|
||
|
||
def parse(self, response_json: dict[str, Any]) -> ResponseData:
|
||
self.validate_response(response_json)
|
||
|
||
choices = response_json.get("choices", [])
|
||
if not choices:
|
||
return ResponseData(text="", raw_response=response_json)
|
||
|
||
choice = choices[0]
|
||
message = choice.get("message", {})
|
||
content = message.get("content", "")
|
||
reasoning_content = message.get("reasoning_content", None)
|
||
refusal = message.get("refusal")
|
||
|
||
if refusal:
|
||
raise LLMException(
|
||
f"模型拒绝生成请求: {refusal}",
|
||
code=LLMErrorCode.CONTENT_FILTERED,
|
||
details={"refusal": refusal},
|
||
recoverable=False,
|
||
)
|
||
|
||
if content:
|
||
content = content.strip()
|
||
|
||
images_payload: list[bytes | Path] = []
|
||
if content and content.startswith("{") and content.endswith("}"):
|
||
try:
|
||
content_json = json.loads(content)
|
||
if "b64_json" in content_json:
|
||
b64_str = content_json["b64_json"]
|
||
if isinstance(b64_str, str) and b64_str.startswith("data:"):
|
||
b64_str = b64_str.split(",", 1)[1]
|
||
decoded = base64.b64decode(b64_str)
|
||
images_payload.append(process_image_data(decoded))
|
||
content = "[图片已生成]"
|
||
elif "data" in content_json and isinstance(content_json["data"], str):
|
||
b64_str = content_json["data"]
|
||
if b64_str.startswith("data:"):
|
||
b64_str = b64_str.split(",", 1)[1]
|
||
decoded = base64.b64decode(b64_str)
|
||
images_payload.append(process_image_data(decoded))
|
||
content = "[图片已生成]"
|
||
|
||
except (json.JSONDecodeError, KeyError, binascii.Error):
|
||
pass
|
||
elif (
|
||
"images" in message
|
||
and isinstance(message["images"], list)
|
||
and message["images"]
|
||
):
|
||
for image_info in message["images"]:
|
||
if image_info.get("type") == "image_url":
|
||
image_url_obj = image_info.get("image_url", {})
|
||
url_str = image_url_obj.get("url", "")
|
||
if url_str.startswith("data:image"):
|
||
try:
|
||
b64_data = url_str.split(",", 1)[1]
|
||
decoded = base64.b64decode(b64_data)
|
||
images_payload.append(process_image_data(decoded))
|
||
except (IndexError, binascii.Error) as e:
|
||
logger.warning(f"解析OpenRouter Base64图片数据失败: {e}")
|
||
|
||
if images_payload:
|
||
content = content if content else "[图片已生成]"
|
||
|
||
parsed_tool_calls: list[LLMToolCall] | None = None
|
||
if message_tool_calls := message.get("tool_calls"):
|
||
parsed_tool_calls = []
|
||
for tc_data in message_tool_calls:
|
||
try:
|
||
if tc_data.get("type") == "function":
|
||
parsed_tool_calls.append(
|
||
LLMToolCall(
|
||
id=tc_data["id"],
|
||
function=LLMToolFunction(
|
||
name=tc_data["function"]["name"],
|
||
arguments=tc_data["function"]["arguments"],
|
||
),
|
||
)
|
||
)
|
||
except KeyError as e:
|
||
logger.warning(
|
||
f"解析OpenAI工具调用数据时缺少键: {tc_data}, 错误: {e}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"解析OpenAI工具调用数据时出错: {tc_data}, 错误: {e}"
|
||
)
|
||
if not parsed_tool_calls:
|
||
parsed_tool_calls = None
|
||
|
||
final_text = content if content is not None else ""
|
||
if not final_text and parsed_tool_calls:
|
||
final_text = f"请求调用 {len(parsed_tool_calls)} 个工具。"
|
||
|
||
usage_info = response_json.get("usage")
|
||
|
||
return ResponseData(
|
||
text=final_text,
|
||
tool_calls=parsed_tool_calls,
|
||
usage_info=usage_info,
|
||
images=images_payload if images_payload else None,
|
||
raw_response=response_json,
|
||
thought_text=reasoning_content,
|
||
)
|