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`
600 lines
21 KiB
Python
600 lines
21 KiB
Python
"""
|
||
OpenAI API 适配器
|
||
|
||
支持 OpenAI、智谱AI 等 OpenAI 兼容的 API 服务。
|
||
"""
|
||
|
||
from abc import ABC, abstractmethod
|
||
import base64
|
||
from pathlib import Path
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
import json_repair
|
||
|
||
from zhenxun.services.llm.config.generation import ImageAspectRatio
|
||
from zhenxun.services.llm.types.exceptions import LLMErrorCode, LLMException
|
||
from zhenxun.services.log import logger
|
||
from zhenxun.utils.http_utils import AsyncHttpx
|
||
|
||
from ..types import StructuredOutputStrategy
|
||
from ..types.models import ToolChoice
|
||
from ..utils import sanitize_schema_for_llm
|
||
from .base import (
|
||
BaseAdapter,
|
||
OpenAICompatAdapter,
|
||
RequestData,
|
||
ResponseData,
|
||
process_image_data,
|
||
)
|
||
from .components.openai_components import (
|
||
OpenAIConfigMapper,
|
||
OpenAIMessageConverter,
|
||
OpenAIResponseParser,
|
||
OpenAIToolSerializer,
|
||
)
|
||
|
||
if TYPE_CHECKING:
|
||
from ..config.generation import LLMEmbeddingConfig, LLMGenerationConfig
|
||
from ..service import LLMModel
|
||
from ..types import LLMMessage
|
||
|
||
|
||
class APIProtocol(ABC):
|
||
"""API 协议策略基类"""
|
||
|
||
@abstractmethod
|
||
def build_request_body(
|
||
self,
|
||
model: "LLMModel",
|
||
messages: list["LLMMessage"],
|
||
tools: list[dict[str, Any]] | None,
|
||
tool_choice: Any,
|
||
) -> dict[str, Any]:
|
||
"""构建不同协议下的请求体"""
|
||
pass
|
||
|
||
@abstractmethod
|
||
def parse_response(self, response_json: dict[str, Any]) -> ResponseData:
|
||
"""解析不同协议下的响应"""
|
||
pass
|
||
|
||
|
||
class StandardProtocol(APIProtocol):
|
||
"""标准 OpenAI 协议策略"""
|
||
|
||
def __init__(self, adapter: "OpenAICompatAdapter"):
|
||
self.adapter = adapter
|
||
|
||
def build_request_body(
|
||
self,
|
||
model: "LLMModel",
|
||
messages: list["LLMMessage"],
|
||
tools: list[dict[str, Any]] | None,
|
||
tool_choice: Any,
|
||
) -> dict[str, Any]:
|
||
converter = OpenAIMessageConverter()
|
||
openai_messages = converter.convert_messages(messages)
|
||
body: dict[str, Any] = {
|
||
"model": model.model_name,
|
||
"messages": openai_messages,
|
||
}
|
||
if tools:
|
||
body["tools"] = tools
|
||
if tool_choice:
|
||
body["tool_choice"] = tool_choice
|
||
return body
|
||
|
||
def parse_response(self, response_json: dict[str, Any]) -> ResponseData:
|
||
parser = OpenAIResponseParser()
|
||
return parser.parse(response_json)
|
||
|
||
|
||
class ResponsesProtocol(APIProtocol):
|
||
"""/v1/responses 新版协议策略"""
|
||
|
||
def __init__(self, adapter: "OpenAICompatAdapter"):
|
||
self.adapter = adapter
|
||
|
||
def build_request_body(
|
||
self,
|
||
model: "LLMModel",
|
||
messages: list["LLMMessage"],
|
||
tools: list[dict[str, Any]] | None,
|
||
tool_choice: Any,
|
||
) -> dict[str, Any]:
|
||
input_items: list[dict[str, Any]] = []
|
||
|
||
for msg in messages:
|
||
role = msg.role
|
||
content_list: list[dict[str, Any]] = []
|
||
raw_contents = (
|
||
msg.content if isinstance(msg.content, list) else [msg.content]
|
||
)
|
||
|
||
for part in raw_contents:
|
||
if part is None:
|
||
continue
|
||
if isinstance(part, str):
|
||
content_list.append({"type": "input_text", "text": part})
|
||
continue
|
||
|
||
if hasattr(part, "type"):
|
||
part_type = getattr(part, "type", None)
|
||
if part_type == "text":
|
||
content_list.append(
|
||
{"type": "input_text", "text": getattr(part, "text", "")}
|
||
)
|
||
elif part_type == "image":
|
||
content_list.append(
|
||
{
|
||
"type": "input_image",
|
||
"image_url": getattr(part, "image_source", ""),
|
||
}
|
||
)
|
||
continue
|
||
|
||
if isinstance(part, dict):
|
||
part_type = part.get("type")
|
||
if part_type == "text":
|
||
content_list.append(
|
||
{"type": "input_text", "text": part.get("text", "")}
|
||
)
|
||
elif part_type in {"image", "image_url"}:
|
||
image_src = part.get("image_url") or part.get(
|
||
"image_source", ""
|
||
)
|
||
content_list.append(
|
||
{
|
||
"type": "input_image",
|
||
"image_url": image_src,
|
||
}
|
||
)
|
||
|
||
input_items.append({"role": role, "content": content_list})
|
||
|
||
body: dict[str, Any] = {
|
||
"model": model.model_name,
|
||
"input": input_items,
|
||
}
|
||
if tools:
|
||
body["tools"] = tools
|
||
if tool_choice:
|
||
body["tool_choice"] = tool_choice
|
||
return body
|
||
|
||
def parse_response(self, response_json: dict[str, Any]) -> ResponseData:
|
||
self.adapter.validate_response(response_json)
|
||
text_content = ""
|
||
for item in response_json.get("output", []):
|
||
if item.get("type") == "message" and item.get("role") == "assistant":
|
||
for content_item in item.get("content", []):
|
||
if content_item.get("type") == "output_text":
|
||
text_content += content_item.get("text", "")
|
||
|
||
return ResponseData(
|
||
text=text_content,
|
||
usage_info=response_json.get("usage"),
|
||
raw_response=response_json,
|
||
)
|
||
|
||
|
||
class OpenAIAdapter(OpenAICompatAdapter):
|
||
"""OpenAI兼容API适配器"""
|
||
|
||
@property
|
||
def api_type(self) -> str:
|
||
return "openai"
|
||
|
||
@property
|
||
def supported_api_types(self) -> list[str]:
|
||
return [
|
||
"openai",
|
||
"zhipu",
|
||
"ark",
|
||
"openrouter",
|
||
"openai_responses",
|
||
]
|
||
|
||
def get_chat_endpoint(self, model: "LLMModel") -> str:
|
||
"""返回聊天完成端点"""
|
||
if model.model_detail.endpoint:
|
||
return model.model_detail.endpoint
|
||
|
||
current_api_type = model.model_detail.api_type or model.api_type
|
||
|
||
if current_api_type == "openai_responses":
|
||
return "/v1/responses"
|
||
if current_api_type == "ark":
|
||
return "/api/v3/chat/completions"
|
||
if current_api_type == "zhipu":
|
||
return "/api/paas/v4/chat/completions"
|
||
return "/v1/chat/completions"
|
||
|
||
def _get_protocol_strategy(self, model: "LLMModel") -> APIProtocol:
|
||
"""根据 API 类型获取对应的处理策略"""
|
||
current_api_type = model.model_detail.api_type or model.api_type
|
||
if current_api_type == "openai_responses":
|
||
return ResponsesProtocol(self)
|
||
return StandardProtocol(self)
|
||
|
||
def get_embedding_endpoint(self, model: "LLMModel") -> str:
|
||
"""根据API类型返回嵌入端点"""
|
||
if model.api_type == "zhipu":
|
||
return "/v4/embeddings"
|
||
return "/v1/embeddings"
|
||
|
||
def convert_generation_config(
|
||
self, config: "LLMGenerationConfig", model: "LLMModel"
|
||
) -> dict[str, Any]:
|
||
mapper = OpenAIConfigMapper(api_type=self.api_type)
|
||
return mapper.map_config(config, model.model_detail, model.capabilities)
|
||
|
||
async def prepare_advanced_request(
|
||
self,
|
||
model: "LLMModel",
|
||
api_key: str,
|
||
messages: list["LLMMessage"],
|
||
config: "LLMGenerationConfig | None" = None,
|
||
tools: list[Any] | None = None,
|
||
tool_choice: str | dict[str, Any] | ToolChoice | None = None,
|
||
) -> "RequestData":
|
||
"""根据不同协议策略构建高级请求"""
|
||
url = self.get_api_url(model, self.get_chat_endpoint(model))
|
||
headers = self.get_base_headers(api_key)
|
||
if model.api_type == "openrouter":
|
||
headers.update(
|
||
{
|
||
"HTTP-Referer": "https://github.com/zhenxun-org/zhenxun_bot",
|
||
"X-Title": "Zhenxun Bot",
|
||
}
|
||
)
|
||
|
||
default_config = getattr(model, "_generation_config", None)
|
||
effective_config = config if config is not None else default_config
|
||
structured_strategy = (
|
||
effective_config.output.structured_output_strategy
|
||
if effective_config and effective_config.output
|
||
else None
|
||
)
|
||
if structured_strategy is None:
|
||
structured_strategy = StructuredOutputStrategy.NATIVE
|
||
|
||
openai_tools: list[dict[str, Any]] | None = None
|
||
executables: list[Any] = []
|
||
if tools:
|
||
if isinstance(tools, dict):
|
||
executables = list(tools.values())
|
||
else:
|
||
for tool in tools:
|
||
if hasattr(tool, "get_definition"):
|
||
executables.append(tool)
|
||
|
||
definition_tasks = [executable.get_definition() for executable in executables]
|
||
tool_defs: list[Any] = []
|
||
if definition_tasks:
|
||
import asyncio
|
||
|
||
tool_defs = await asyncio.gather(*definition_tasks)
|
||
|
||
if tool_defs:
|
||
serializer = OpenAIToolSerializer()
|
||
openai_tools = serializer.serialize_tools(tool_defs)
|
||
|
||
final_tool_choice = tool_choice
|
||
if final_tool_choice is None:
|
||
if (
|
||
effective_config
|
||
and effective_config.tool_config
|
||
and effective_config.tool_config.mode == "ANY"
|
||
):
|
||
allowed = effective_config.tool_config.allowed_function_names
|
||
if allowed:
|
||
if len(allowed) == 1:
|
||
final_tool_choice = {
|
||
"type": "function",
|
||
"function": {"name": allowed[0]},
|
||
}
|
||
else:
|
||
logger.warning(
|
||
"OpenAI API 不支持多个 allowed_function_names,降级为"
|
||
" required。"
|
||
)
|
||
final_tool_choice = "required"
|
||
else:
|
||
final_tool_choice = "required"
|
||
|
||
if (
|
||
structured_strategy == StructuredOutputStrategy.TOOL_CALL
|
||
and effective_config
|
||
and effective_config.output
|
||
and effective_config.output.response_schema
|
||
):
|
||
sanitized_schema = sanitize_schema_for_llm(
|
||
effective_config.output.response_schema, api_type="openai"
|
||
)
|
||
structured_tool = {
|
||
"type": "function",
|
||
"function": {
|
||
"name": "return_structured_response",
|
||
"description": "Return the final structured response.",
|
||
"parameters": sanitized_schema,
|
||
"strict": True if model.api_type != "deepseek" else False,
|
||
},
|
||
}
|
||
if openai_tools is None:
|
||
openai_tools = []
|
||
openai_tools.append(structured_tool)
|
||
final_tool_choice = {
|
||
"type": "function",
|
||
"function": {"name": "return_structured_response"},
|
||
}
|
||
|
||
protocol_strategy = self._get_protocol_strategy(model)
|
||
body = protocol_strategy.build_request_body(
|
||
model=model,
|
||
messages=messages,
|
||
tools=openai_tools,
|
||
tool_choice=final_tool_choice,
|
||
)
|
||
|
||
body = self.apply_config_override(model, body, config)
|
||
|
||
if final_tool_choice is not None:
|
||
body["tool_choice"] = final_tool_choice
|
||
|
||
response_format = body.get("response_format", {})
|
||
inject_prompt = (
|
||
structured_strategy == StructuredOutputStrategy.NATIVE
|
||
and isinstance(response_format, dict)
|
||
and response_format.get("type") == "json_object"
|
||
)
|
||
|
||
if inject_prompt:
|
||
messages_list = body.get("messages", [])
|
||
has_json_keyword = False
|
||
for msg in messages_list:
|
||
content = msg.get("content")
|
||
if isinstance(content, str) and "json" in content.lower():
|
||
has_json_keyword = True
|
||
break
|
||
if isinstance(content, list):
|
||
for part in content:
|
||
if (
|
||
isinstance(part, dict)
|
||
and part.get("type") == "text"
|
||
and "json" in part.get("text", "").lower()
|
||
):
|
||
has_json_keyword = True
|
||
break
|
||
if has_json_keyword:
|
||
break
|
||
|
||
if not has_json_keyword:
|
||
injection_text = (
|
||
"请务必输出合法的 JSON 格式,避免额外的文本、Markdown 或解释。"
|
||
)
|
||
system_msg = next(
|
||
(m for m in messages_list if m.get("role") == "system"), None
|
||
)
|
||
if system_msg:
|
||
if isinstance(system_msg.get("content"), str):
|
||
system_msg["content"] += " " + injection_text
|
||
elif isinstance(system_msg.get("content"), list):
|
||
system_msg["content"].append(
|
||
{"type": "text", "text": injection_text}
|
||
)
|
||
else:
|
||
messages_list.insert(
|
||
0, {"role": "system", "content": injection_text}
|
||
)
|
||
body["messages"] = messages_list
|
||
|
||
return RequestData(url=url, headers=headers, body=body)
|
||
|
||
def parse_response(
|
||
self,
|
||
model: "LLMModel",
|
||
response_json: dict[str, Any],
|
||
is_advanced: bool = False,
|
||
) -> ResponseData:
|
||
"""解析响应 - 使用策略模式委托处理"""
|
||
_ = is_advanced
|
||
protocol_strategy = self._get_protocol_strategy(model)
|
||
response_data = protocol_strategy.parse_response(response_json)
|
||
|
||
if response_data.tool_calls:
|
||
target_tool = next(
|
||
(
|
||
tc
|
||
for tc in response_data.tool_calls
|
||
if tc.function.name == "return_structured_response"
|
||
),
|
||
None,
|
||
)
|
||
if target_tool:
|
||
response_data.text = json_repair.repair_json(
|
||
target_tool.function.arguments
|
||
)
|
||
remaining = [
|
||
tc
|
||
for tc in response_data.tool_calls
|
||
if tc.function.name != "return_structured_response"
|
||
]
|
||
response_data.tool_calls = remaining or None
|
||
|
||
return response_data
|
||
|
||
|
||
class DeepSeekAdapter(OpenAIAdapter):
|
||
"""DeepSeek 专用适配器 (基于 OpenAI 协议)"""
|
||
|
||
@property
|
||
def api_type(self) -> str:
|
||
return "deepseek"
|
||
|
||
@property
|
||
def supported_api_types(self) -> list[str]:
|
||
return ["deepseek"]
|
||
|
||
|
||
class OpenAIImageAdapter(BaseAdapter):
|
||
"""OpenAI 图像生成/编辑适配器"""
|
||
|
||
@property
|
||
def api_type(self) -> str:
|
||
return "openai_image"
|
||
|
||
@property
|
||
def log_sanitization_context(self) -> str:
|
||
return "openai_request"
|
||
|
||
@property
|
||
def supported_api_types(self) -> list[str]:
|
||
return ["openai_image", "nano_banana"]
|
||
|
||
async def prepare_advanced_request(
|
||
self,
|
||
model: "LLMModel",
|
||
api_key: str,
|
||
messages: list["LLMMessage"],
|
||
config: "LLMGenerationConfig | None" = None,
|
||
tools: list[Any] | None = None,
|
||
tool_choice: "str | dict[str, Any] | ToolChoice | None" = None,
|
||
) -> RequestData:
|
||
_ = tools, tool_choice
|
||
effective_config = config if config is not None else model._generation_config
|
||
headers = self.get_base_headers(api_key)
|
||
|
||
prompt = ""
|
||
images_bytes_list: list[bytes] = []
|
||
|
||
for msg in reversed(messages):
|
||
if msg.role != "user":
|
||
continue
|
||
if isinstance(msg.content, str):
|
||
prompt = msg.content
|
||
elif isinstance(msg.content, list):
|
||
for part in msg.content:
|
||
if part.type == "text" and not prompt:
|
||
prompt = part.text
|
||
elif part.type == "image":
|
||
if part.is_image_base64():
|
||
if b64_data := part.get_base64_data():
|
||
_, b64_str = b64_data
|
||
images_bytes_list.append(base64.b64decode(b64_str))
|
||
elif part.is_image_url() and part.image_source:
|
||
images_bytes_list.append(
|
||
await AsyncHttpx.get_content(part.image_source)
|
||
)
|
||
if prompt:
|
||
break
|
||
|
||
if not prompt and not images_bytes_list:
|
||
raise LLMException(
|
||
"图像生成需要提供 Prompt",
|
||
code=LLMErrorCode.CONFIGURATION_ERROR,
|
||
)
|
||
|
||
body: dict[str, Any] = {
|
||
"model": model.model_name,
|
||
"prompt": prompt,
|
||
"response_format": "b64_json",
|
||
}
|
||
|
||
if effective_config:
|
||
if effective_config.visual:
|
||
if effective_config.visual.aspect_ratio:
|
||
ar = effective_config.visual.aspect_ratio
|
||
size_map = {
|
||
ImageAspectRatio.SQUARE: "1024x1024",
|
||
ImageAspectRatio.LANDSCAPE_16_9: "1792x1024",
|
||
ImageAspectRatio.PORTRAIT_9_16: "1024x1792",
|
||
}
|
||
if isinstance(ar, ImageAspectRatio) and ar in size_map:
|
||
body["size"] = size_map[ar]
|
||
body["aspect_ratio"] = ar.value
|
||
elif isinstance(ar, str):
|
||
if "x" in ar:
|
||
body["size"] = ar
|
||
else:
|
||
body["aspect_ratio"] = ar
|
||
|
||
if effective_config.visual.resolution:
|
||
res_val = effective_config.visual.resolution
|
||
if not isinstance(res_val, str):
|
||
res_val = getattr(res_val, "value", res_val)
|
||
body["image_size"] = res_val
|
||
|
||
if effective_config.custom_params:
|
||
body.update(effective_config.custom_params)
|
||
|
||
if images_bytes_list:
|
||
b64_images = []
|
||
for img_bytes in images_bytes_list:
|
||
b64_str = base64.b64encode(img_bytes).decode("utf-8")
|
||
b64_images.append(b64_str)
|
||
body["image"] = b64_images
|
||
|
||
endpoint = "/v1/images/generations"
|
||
url = self.get_api_url(model, endpoint)
|
||
return RequestData(url=url, headers=headers, body=body)
|
||
|
||
def parse_response(
|
||
self,
|
||
model: "LLMModel",
|
||
response_json: dict[str, Any],
|
||
is_advanced: bool = False,
|
||
) -> ResponseData:
|
||
_ = model, is_advanced
|
||
self.validate_response(response_json)
|
||
|
||
images_data: list[bytes | Path] = []
|
||
data_list = response_json.get("data", [])
|
||
|
||
for item in data_list:
|
||
if "b64_json" in item:
|
||
try:
|
||
b64_str = item["b64_json"]
|
||
if b64_str.startswith("data:"):
|
||
b64_str = b64_str.split(",", 1)[1]
|
||
img = base64.b64decode(b64_str)
|
||
images_data.append(process_image_data(img))
|
||
except Exception as exc:
|
||
logger.error(f"Base64 解码失败: {exc}")
|
||
elif "url" in item:
|
||
logger.warning(
|
||
f"API 返回了 URL 而不是 Base64: {item.get('url', 'unknown')}"
|
||
)
|
||
|
||
text_summary = (
|
||
f"已生成 {len(images_data)} 张图片。"
|
||
if images_data
|
||
else "图像生成接口调用成功,但未解析到图片数据。"
|
||
)
|
||
|
||
return ResponseData(
|
||
text=text_summary,
|
||
images=images_data if images_data else None,
|
||
raw_response=response_json,
|
||
)
|
||
|
||
def prepare_embedding_request(
|
||
self,
|
||
model: "LLMModel",
|
||
api_key: str,
|
||
texts: list[str],
|
||
config: "LLMEmbeddingConfig",
|
||
) -> RequestData:
|
||
raise NotImplementedError("OpenAIImageAdapter 不支持 Embedding")
|
||
|
||
def parse_embedding_response(
|
||
self, response_json: dict[str, Any]
|
||
) -> list[list[float]]:
|
||
raise NotImplementedError("OpenAIImageAdapter 不支持 Embedding")
|
||
|
||
def convert_generation_config(
|
||
self, config: "LLMGenerationConfig", model: "LLMModel"
|
||
) -> dict[str, Any]:
|
||
_ = config, model
|
||
return {}
|