mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-18 02:30:08 +08:00
✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 (#2146)
* ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 - 【重构】将原 services/llm 重构并迁移至全新的 services/ai 架构,提供向下兼容垫片 - 【新增】引入 Agent、Team、Workflow 三大智能体与工作流编排范式 - 【新增】引入基于 RAG 的长期向量记忆与中期槽位记忆系统 - 【新增】引入基于 Docker 的安全代码执行沙箱环境 - 【新增】支持 MCP 协议,允许动态管理和调用 MCP 服务 - 【新增】引入输入输出安全合规护栏与自愈反思机制 - 【优化】重构并优化多厂商 API 适配器 (Gemini, OpenAI, DeepSeek, GLM 等) - 【优化】优化日志脱敏与 Token 预估机制 - 【移除】移除旧版 llm default 和 llm reset-key 命令,新增 llm mcp 管理命令 * 🔧 chore(deps): 更新项目依赖与配置 - 添加 mcp、jieba 和 aiodocker 依赖到配置文件及 requirements.txt - 在 pyright 配置中设置 reportMissingImports 为 none - 调整 .gitignore 中 resources 目录的忽略规则 * ♻️ refactor(tools): 重构工具终止机制并清理知识库日志输出 - 统一使用 `context.state["__end_run__"]` 替代 `EndRunResult` 控制任务结束 - 移除文件系统和向量知识库检索工具中 `ToolResult` 的 `.with_log` 调用 - 调整指令处理器(Directive)的返回值为 `tool_res.output` - 修复部分类型检查警告并优化联合类型判断语法 * ♻️ refactor(tools): 重构工具副作用指令与控制流熔断机制 - 引入 `DirectivePayload` 及 `ToolResult` 的子类以结构化表达工具副作用 - 移除通过 `context.state` 传递魔术变量的隐式控制流设计 - 重构 `DirectiveManager` 处理器接口,直接在处理器中修改 `AgentState` 并构建 `AgentRunResult` - 在 `StandardAgentExecutor` 中统一通过 `directive_manager` 调度工具返回的副作用指令 - 补全 `MessageBuilder` 中部分核心方法的文档注释 * 🐛 fix(sandbox): 修复 Docker 沙箱容器状态检测与会话清理逻辑 -【修复】修正 `is_alive` 中直接读取私有属性的问题,改用 `show()` 返回值 -【修复】解决 `execute_code` 中缓存的执行器与当前会话不一致的问题 -【优化】在清理工作区前增加容器存活检测,避免向已死容器发送请求 -【优化】创建容器时增加运行状态校验,若已停止则自动从缓存中移除并重建 -【优化】优化容器销毁和清理逻辑,静默处理容器不存在 (404) 的异常 * 📝 docs(core): 补充核心模块初始化方法的文档注释 * 🚨 auto fix by pre-commit hooks --------- Co-authored-by: webjoin111 <455457521@qq.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
bdc1374848
commit
80fc5b86a7
@@ -0,0 +1,25 @@
|
||||
from .exceptions import LLMException
|
||||
from .messages import (
|
||||
AgentEvent,
|
||||
AgentMessage,
|
||||
HandoffEvent,
|
||||
LLMMessage,
|
||||
TaskLifecycleEvent,
|
||||
)
|
||||
from .options import (
|
||||
GenerationConfig,
|
||||
)
|
||||
from .templates import (
|
||||
PromptTemplate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentEvent",
|
||||
"AgentMessage",
|
||||
"GenerationConfig",
|
||||
"HandoffEvent",
|
||||
"LLMException",
|
||||
"LLMMessage",
|
||||
"PromptTemplate",
|
||||
"TaskLifecycleEvent",
|
||||
]
|
||||
@@ -0,0 +1,178 @@
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from zhenxun.utils.pydantic_compat import model_dump
|
||||
|
||||
|
||||
@dataclass
|
||||
class StablePrefixSnapshot:
|
||||
"""系统提示词与工具的稳定前缀快照"""
|
||||
|
||||
system_prompt: list[str]
|
||||
tools: list[Any]
|
||||
fingerprint: str
|
||||
|
||||
|
||||
class StablePrefix:
|
||||
"""
|
||||
一个冻结 of 系统前缀(系统提示词 + 工具)。
|
||||
通过比对特征指纹,在内容未改变时避免重新构建。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._snapshot: StablePrefixSnapshot | None = None
|
||||
self._version = 0
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self._snapshot.fingerprint if self._snapshot else "<unbuilt>"
|
||||
|
||||
@property
|
||||
def version(self) -> int:
|
||||
return self._version
|
||||
|
||||
@property
|
||||
def built(self) -> bool:
|
||||
return self._snapshot is not None
|
||||
|
||||
def build(self, system_prompt: list[str], tools: list[Any]) -> bool:
|
||||
"""
|
||||
构建或重新构建前缀。
|
||||
返回 True 表示内容发生实质变化(缓存可能失效),False 表示使用旧快照。
|
||||
"""
|
||||
snapshot = self._take_snapshot(system_prompt, tools)
|
||||
if self._snapshot and self._snapshot.fingerprint == snapshot.fingerprint:
|
||||
return False
|
||||
self._snapshot = snapshot
|
||||
self._version += 1
|
||||
return True
|
||||
|
||||
def invalidate(self):
|
||||
self._snapshot = None
|
||||
|
||||
def to_context(self) -> tuple[list[str], list[Any]]:
|
||||
if not self._snapshot:
|
||||
raise RuntimeError("StablePrefix.to_context() called before build()")
|
||||
return self._snapshot.system_prompt, self._snapshot.tools
|
||||
|
||||
def _take_snapshot(
|
||||
self, system_prompt: list[str], tools: list[Any]
|
||||
) -> StablePrefixSnapshot:
|
||||
parsed_tools = []
|
||||
for t in tools:
|
||||
if hasattr(t, "name"):
|
||||
parsed_tools.append((t.name, getattr(t, "description", "")))
|
||||
elif isinstance(t, dict):
|
||||
parsed_tools.append((t.get("name", ""), t.get("description", "")))
|
||||
else:
|
||||
parsed_tools.append(str(t))
|
||||
|
||||
payload = {"s": system_prompt, "t": parsed_tools}
|
||||
json_str = json.dumps(payload, default=str, sort_keys=True)
|
||||
fingerprint = hashlib.md5(json_str.encode("utf-8")).hexdigest()[:8]
|
||||
return StablePrefixSnapshot(
|
||||
system_prompt=list(system_prompt),
|
||||
tools=list(tools),
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
|
||||
|
||||
class AppendOnlyLog:
|
||||
"""追加写入模式 of 对话日志管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self._entries: list[Any] = []
|
||||
|
||||
@property
|
||||
def length(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
def append(self, message: Any):
|
||||
self._entries.append(message)
|
||||
|
||||
def extend(self, messages: list[Any]):
|
||||
self._entries.extend(messages)
|
||||
|
||||
def clear(self):
|
||||
self._entries.clear()
|
||||
|
||||
def to_messages(self) -> list[Any]:
|
||||
"""返回浅拷贝 of 消息列表,防止外部意外修改"""
|
||||
return list(self._entries)
|
||||
|
||||
|
||||
class AppendOnlyContextManager:
|
||||
"""
|
||||
为大模型 Prefix Cache 深度定制 of 上下文管理器。
|
||||
将上下文拆分为绝对稳定 of Prefix (系统提示/工具) 和只增不减 of Log (对话历史)。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.prefix = StablePrefix()
|
||||
self.log = AppendOnlyLog()
|
||||
self._last_sync_count = 0
|
||||
self._synced_digest = 0
|
||||
|
||||
def build(
|
||||
self, system_prompt: list[str], tools: list[Any]
|
||||
) -> tuple[list[str], list[Any], list[Any]]:
|
||||
"""装配并获取当前 of 完整上下文元组:(系统提示词, 历史消息, 工具)"""
|
||||
self.prefix.build(system_prompt, tools)
|
||||
sys_p, ts = self.prefix.to_context()
|
||||
return sys_p, self.log.to_messages(), ts
|
||||
|
||||
def sync_messages(self, normalized_messages: list[Any]):
|
||||
"""
|
||||
同步消息游标。
|
||||
通过滚动摘要算法(Rolling Digest)自动检测历史消息是否被就地篡改或截断。
|
||||
如果是,则自动重置基线;否则执行极速追加写入。
|
||||
"""
|
||||
if 0 < self._last_sync_count <= len(normalized_messages):
|
||||
synced_part = normalized_messages[: self._last_sync_count]
|
||||
if self._compute_digest(synced_part) != self._synced_digest:
|
||||
self.log.clear()
|
||||
self._last_sync_count = 0
|
||||
|
||||
if len(normalized_messages) < self._last_sync_count:
|
||||
self.log.clear()
|
||||
self._last_sync_count = 0
|
||||
|
||||
new_msgs = normalized_messages[self._last_sync_count :]
|
||||
for msg in new_msgs:
|
||||
self.log.append(msg)
|
||||
|
||||
self._last_sync_count = len(normalized_messages)
|
||||
self._synced_digest = self._compute_digest(normalized_messages)
|
||||
|
||||
def invalidate(self):
|
||||
"""使前缀快照失效(通常在模型发生变更时调用)"""
|
||||
self.prefix.invalidate()
|
||||
|
||||
def reset_sync_cursor(self):
|
||||
"""强制重置对话历史游标和日志"""
|
||||
self.log.clear()
|
||||
self._last_sync_count = 0
|
||||
self._synced_digest = 0
|
||||
|
||||
def _compute_digest(self, messages: list[Any]) -> int:
|
||||
"""核心:计算消息列表 of 指纹,用于识别内容篡改。包含 role 与 content。"""
|
||||
from zhenxun.services.ai.core.engine.context_renderer import ContextConverter
|
||||
|
||||
payloads = []
|
||||
flattened = ContextConverter.flatten_to_llm_messages(messages)
|
||||
for msg in flattened:
|
||||
try:
|
||||
d = model_dump(msg, include={"role", "content"})
|
||||
payloads.append(d)
|
||||
except Exception:
|
||||
payloads.append(str(msg))
|
||||
|
||||
def _default(obj):
|
||||
if isinstance(obj, bytes):
|
||||
return "<bytes>"
|
||||
return str(obj)
|
||||
|
||||
json_str = json.dumps(payloads, default=_default, sort_keys=True)
|
||||
return int(hashlib.md5(json_str.encode("utf-8")).hexdigest()[:8], 16)
|
||||
@@ -0,0 +1,52 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from zhenxun.services.ai.core.messages import AgentEvent, AgentMessage, LLMMessage
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
|
||||
class ContextConverter:
|
||||
"""
|
||||
上下文边界降维转换器。
|
||||
负责将内存中混合了 AgentEvent 与 LLMMessage 的业务事件流,
|
||||
安全拍平为底层大模型可读的原生 API 载体。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def flatten_to_llm_messages(
|
||||
messages: Sequence[AgentMessage], context: Any | None = None
|
||||
) -> list[LLMMessage]:
|
||||
flattened: list[LLMMessage] = []
|
||||
|
||||
for msg in messages:
|
||||
if isinstance(msg, LLMMessage):
|
||||
flattened.append(msg)
|
||||
elif isinstance(msg, AgentEvent):
|
||||
try:
|
||||
res = msg.to_llm_message(context)
|
||||
if res is None:
|
||||
continue
|
||||
|
||||
if isinstance(res, str):
|
||||
flattened.append(LLMMessage.system(res))
|
||||
elif isinstance(res, LLMMessage):
|
||||
flattened.append(res)
|
||||
elif isinstance(res, list):
|
||||
flattened.extend(res)
|
||||
else:
|
||||
logger.warning(
|
||||
f"事件 {msg.__class__.__name__} 的 to_llm_message "
|
||||
f"返回了不支持的类型: {type(res)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"业务事件 [{msg.__class__.__name__}] "
|
||||
f"在降维渲染为大模型 Prompt 时发生崩溃: {e}\n"
|
||||
f"防呆拦截:请检查该事件 to_llm_message 方法的实现。"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"ContextConverter 遇到未知类型的消息,已跳过: {type(msg)}"
|
||||
)
|
||||
|
||||
return flattened
|
||||
@@ -0,0 +1,214 @@
|
||||
import types
|
||||
from typing import Any, Generic, Union, cast, get_origin
|
||||
|
||||
import json_repair
|
||||
from nonebot.compat import type_validate_json
|
||||
from pydantic import BaseModel, Field, ValidationError, create_model
|
||||
|
||||
from zhenxun.services.ai.core.exceptions import (
|
||||
ControlFlowExit,
|
||||
ModelRetry,
|
||||
SchemaParseError,
|
||||
)
|
||||
from zhenxun.services.ai.core.models import ToolDefinition
|
||||
from zhenxun.services.ai.run.models import OutputDataT
|
||||
from zhenxun.services.ai.tools.core.tool import BaseTool
|
||||
from zhenxun.services.ai.tools.models import StructuredSubmissionResult, ToolResult
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.pydantic_compat import model_json_schema, model_validate
|
||||
|
||||
DEFAULT_IVR_TEMPLATE = (
|
||||
"### ❌ [输出内容或格式验证失败]\n"
|
||||
"你的上一次输出未能通过系统的校验与规则检查。请立即启动修正流程:\n\n"
|
||||
"**错误反馈报告:**\n"
|
||||
"> {error_msg}\n\n"
|
||||
"**修正要求:** 请结合反馈报告,"
|
||||
"仔细反思你的输出内容或格式,\n"
|
||||
"并重新生成正确的数据以满足所有的规则与规范。"
|
||||
)
|
||||
|
||||
|
||||
class BaseOutputProcessor(Generic[OutputDataT]):
|
||||
"""
|
||||
统一的结构化输出处理器。
|
||||
负责管理 Schema 生成、Prompt 约束注入以及最终的
|
||||
JSON 反序列化和业务校验。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
response_model: type[Any] | None = None,
|
||||
error_template: str | None = None,
|
||||
raw_schema: dict[str, Any] | None = None,
|
||||
):
|
||||
"""
|
||||
初始化结构化输出处理器。
|
||||
|
||||
参数:
|
||||
response_model: 期望的输出目标 Pydantic 模型类或 Union 类型,默认 None。
|
||||
error_template: 当 JSON 解析或模型验证失败时,反馈给大模型的 IVR 纠错提示词模板,默认 None。
|
||||
raw_schema: 显式传入的原始 JSON Schema 字典,
|
||||
如果不为 None 则跳过根据 response_model 生成,默认 None。
|
||||
""" # noqa: E501
|
||||
self.original_model = response_model
|
||||
self.error_template = error_template or DEFAULT_IVR_TEMPLATE
|
||||
self.raw_schema = raw_schema
|
||||
self.target_model = None
|
||||
self.is_union_wrapped = False
|
||||
|
||||
if response_model is not None:
|
||||
self.target_model, self.is_union_wrapped = self._create_union_wrapper(
|
||||
response_model
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_union_wrapper(union_type: Any) -> tuple[type[BaseModel], bool]:
|
||||
"""[私有方法] 如果是 Union 类型,
|
||||
动态构建带 kind 区分字段的模型"""
|
||||
origin = get_origin(union_type)
|
||||
union_types = [Union]
|
||||
if hasattr(types, "UnionType"):
|
||||
union_types.append(types.UnionType)
|
||||
|
||||
if origin not in union_types:
|
||||
return union_type, False
|
||||
|
||||
UnionWrapper = create_model(
|
||||
"UnionResponseWrapper",
|
||||
result=(
|
||||
union_type,
|
||||
Field(..., description="根据你的决策,输出对应的结构化数据"),
|
||||
),
|
||||
)
|
||||
return UnionWrapper, True
|
||||
|
||||
def get_json_schema(self) -> dict[str, Any]:
|
||||
"""提取目标模型的 JSON Schema"""
|
||||
if self.raw_schema is not None:
|
||||
return self.raw_schema
|
||||
if self.target_model is None:
|
||||
raise ValueError("未提供 response_model 或 raw_schema")
|
||||
try:
|
||||
return model_json_schema(self.target_model)
|
||||
except AttributeError:
|
||||
return self.target_model.schema()
|
||||
|
||||
def _parse_and_validate(self, text: str) -> Any:
|
||||
"""[私有方法] 执行带有容错修复的 JSON 解析与模型验证"""
|
||||
if self.raw_schema is not None:
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
try:
|
||||
return json_repair.loads(text, skip_json_loads=True)
|
||||
except Exception as repair_error:
|
||||
raise SchemaParseError(f"JSON格式损坏: {repair_error}")
|
||||
if self.target_model is None:
|
||||
raise SchemaParseError("未提供 response_model 或 raw_schema")
|
||||
try:
|
||||
return type_validate_json(self.target_model, text)
|
||||
except (ValidationError, ValueError) as e:
|
||||
try:
|
||||
logger.warning(f"标准JSON解析失败,尝试使用json_repair修复: {e}")
|
||||
repaired_obj = json_repair.loads(text, skip_json_loads=True)
|
||||
return model_validate(self.target_model, repaired_obj)
|
||||
except Exception as repair_error:
|
||||
logger.error(
|
||||
f"LLM结构化输出校验最终失败: {repair_error}",
|
||||
e=repair_error,
|
||||
)
|
||||
raise SchemaParseError(
|
||||
f"JSON格式损坏或字段不匹配,未能通过Schema验证: {repair_error}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"解析LLM结构化输出时发生未知错误: {e}", e=e)
|
||||
raise SchemaParseError(f"解析LLM的JSON输出时失败: {e}")
|
||||
|
||||
async def validate_and_parse(self, text: str, context: Any = None) -> OutputDataT:
|
||||
"""执行 JSON 解析与回调验证"""
|
||||
try:
|
||||
parsed_obj = self._parse_and_validate(text)
|
||||
|
||||
current_obj = parsed_obj
|
||||
|
||||
if getattr(self, "is_union_wrapped", False):
|
||||
current_obj = getattr(current_obj, "result")
|
||||
|
||||
final_obj = cast(OutputDataT, current_obj)
|
||||
|
||||
return final_obj
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
|
||||
class SubmitFinalResultExecutable(BaseTool):
|
||||
"""
|
||||
动态生成的提交最终结果工具。
|
||||
用于将大模型的结构化输出拦截并终止 AgentExecutor 的循环。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
output_processor: BaseOutputProcessor,
|
||||
guardrails: list[Any] | None = None,
|
||||
):
|
||||
"""
|
||||
初始化提交最终结果的动态执行工具。
|
||||
|
||||
参数:
|
||||
output_processor: 绑定的结构化输出处理器,用于验证提交的最终结果。
|
||||
guardrails: 用于在结果输出前进行安全合规拦截的护栏中间件列表,默认 None。
|
||||
"""
|
||||
super().__init__(
|
||||
name="submit_final_result",
|
||||
description=(
|
||||
"当你完成所有必要的调查 and 思考后,"
|
||||
"必须且只能调用此工具来提交最终的结构化结果。"
|
||||
"提交后任务将立刻结束。"
|
||||
),
|
||||
)
|
||||
self.output_processor = output_processor
|
||||
self.guardrails = guardrails or []
|
||||
|
||||
async def get_definition(self, context: Any | None = None) -> ToolDefinition | None:
|
||||
if getattr(self, "_dynamic_def", None) is not None:
|
||||
return self._dynamic_def
|
||||
schema = self.output_processor.get_json_schema()
|
||||
return ToolDefinition(
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
parameters=schema,
|
||||
)
|
||||
|
||||
async def execute(self, context: Any | None = None, **kwargs) -> ToolResult:
|
||||
parse_target = kwargs
|
||||
if isinstance(kwargs, dict):
|
||||
if "kwargs" in kwargs and len(kwargs) == 1:
|
||||
parse_target = kwargs["kwargs"]
|
||||
elif "result" in kwargs and len(kwargs) == 1:
|
||||
parse_target = kwargs["result"]
|
||||
|
||||
try:
|
||||
json_str = __import__("json").dumps(parse_target, ensure_ascii=False)
|
||||
final_obj = await self.output_processor.validate_and_parse(
|
||||
json_str, context=context
|
||||
)
|
||||
from zhenxun.services.ai.guardrails import GuardrailPipeline
|
||||
|
||||
pipeline = GuardrailPipeline(self.guardrails)
|
||||
json_str, final_obj = await pipeline.run_output_pipeline(
|
||||
json_str, final_obj, context
|
||||
)
|
||||
|
||||
return StructuredSubmissionResult(
|
||||
output="结构化数据已成功提交", parsed_obj=final_obj
|
||||
)
|
||||
except ControlFlowExit as e:
|
||||
raise e
|
||||
except ModelRetry as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
error_msg = f"系统捕获到解析异常:\n{e}"
|
||||
raise SchemaParseError(error_msg)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
LLM Token 动态预估与上下文管理模块
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from zhenxun.services.ai.core.messages import (
|
||||
AgentEvent,
|
||||
AgentMessage,
|
||||
AudioPart,
|
||||
FilePart,
|
||||
ImagePart,
|
||||
LLMMessage,
|
||||
TextPart,
|
||||
ThoughtPart,
|
||||
ToolCallPart,
|
||||
ToolMessage,
|
||||
ToolReturnPart,
|
||||
UsageInfo,
|
||||
VideoPart,
|
||||
)
|
||||
|
||||
|
||||
class TokenCounter:
|
||||
"""
|
||||
Token 计数器
|
||||
基于确定性规则,摆脱外部库依赖,提供绝对稳定的 Token 消耗预估基线。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _count_text(text: str) -> int:
|
||||
"""基于字符类型近似计算纯文本的 Token 消耗量。"""
|
||||
if not text:
|
||||
return 0
|
||||
cjk_chars = len(re.findall(r"[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]", text))
|
||||
ascii_chars = len(text) - cjk_chars
|
||||
return math.ceil(cjk_chars * 1.2 + ascii_chars * 0.3)
|
||||
|
||||
@staticmethod
|
||||
def _count_image(resolution_hint: str | None, model_name: str) -> int:
|
||||
"""根据分辨率策略和模型厂商计算单张图片的 Token 消耗量。"""
|
||||
if "gemini" in model_name.lower():
|
||||
res = (resolution_hint or "").upper()
|
||||
if "ULTRA_HIGH" in res:
|
||||
return 6192
|
||||
if "HIGH" in res:
|
||||
return 3096
|
||||
if "LOW" in res:
|
||||
return 258
|
||||
return 1032
|
||||
return 765
|
||||
|
||||
@classmethod
|
||||
def count_tools_schema(cls, obj: dict | list | str | Any) -> int:
|
||||
"""递归计算 JSON Schema 结构在被大模型作为工具时的 Token 开销。"""
|
||||
if isinstance(obj, dict):
|
||||
cost = len(obj.keys()) * 12
|
||||
for k, v in obj.items():
|
||||
if k == "description" and isinstance(v, str):
|
||||
cost += int(len(v) * 0.3)
|
||||
else:
|
||||
cost += cls.count_tools_schema(v)
|
||||
return cost
|
||||
elif isinstance(obj, list):
|
||||
return sum(cls.count_tools_schema(item) for item in obj)
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def count_message(cls, msg: LLMMessage, model_name: str) -> int:
|
||||
"""累加计算单条包含多模态片段和工具调用的消息 Token 总数。"""
|
||||
if msg.token_cost is not None:
|
||||
return msg.token_cost
|
||||
|
||||
total_tokens = 4
|
||||
|
||||
if isinstance(msg, ToolMessage):
|
||||
total_tokens += 40
|
||||
|
||||
if isinstance(msg.content, str):
|
||||
total_tokens += cls._count_text(msg.content)
|
||||
elif isinstance(msg.content, list):
|
||||
for part in msg.content:
|
||||
if isinstance(part, TextPart) and part.text:
|
||||
total_tokens += cls._count_text(part.text)
|
||||
elif isinstance(part, ImagePart):
|
||||
total_tokens += cls._count_image(
|
||||
getattr(part, "media_resolution", None), model_name
|
||||
)
|
||||
elif isinstance(part, VideoPart | AudioPart | FilePart):
|
||||
total_tokens += 1032
|
||||
elif isinstance(part, ThoughtPart) and part.thought_text:
|
||||
total_tokens += cls._count_text(part.thought_text)
|
||||
elif isinstance(part, ToolCallPart) and part.args:
|
||||
total_tokens += cls._count_text(str(part.args))
|
||||
elif isinstance(part, ToolReturnPart) and part.output:
|
||||
total_tokens += cls._count_text(str(part.output))
|
||||
|
||||
msg.token_cost = total_tokens
|
||||
return total_tokens
|
||||
|
||||
@classmethod
|
||||
def count_context(
|
||||
cls, messages: Sequence[AgentMessage], model_name: str, base_overhead: int = 0
|
||||
) -> int:
|
||||
"""计算整个对话历史上下文的 Token 总和。"""
|
||||
if not messages:
|
||||
return base_overhead
|
||||
|
||||
total = base_overhead
|
||||
for msg in messages:
|
||||
if isinstance(msg, AgentEvent):
|
||||
try:
|
||||
res = msg.to_llm_message(None)
|
||||
if res is None:
|
||||
continue
|
||||
if isinstance(res, str):
|
||||
total += cls.count_message(LLMMessage.system(res), model_name)
|
||||
elif isinstance(res, list):
|
||||
total += sum(cls.count_message(m, model_name) for m in res)
|
||||
elif isinstance(res, LLMMessage):
|
||||
total += cls.count_message(res, model_name)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
total += cls.count_message(msg, model_name)
|
||||
return total
|
||||
|
||||
|
||||
token_counter = TokenCounter
|
||||
|
||||
|
||||
def parse_usage_info(usage_info: dict | None) -> UsageInfo:
|
||||
"""
|
||||
全协议统一遥测解析器 (Universal Telemetry Parser)
|
||||
兼容 OpenAI Standard、OpenAI Responses (v1/responses) 以及 Gemini (usageMetadata)。
|
||||
"""
|
||||
if not usage_info or not isinstance(usage_info, dict):
|
||||
return UsageInfo()
|
||||
|
||||
prompt = 0
|
||||
completion = 0
|
||||
total = 0
|
||||
cache_hit = 0
|
||||
cache_miss = 0
|
||||
reasoning = 0
|
||||
|
||||
if "promptTokenCount" in usage_info or "candidatesTokenCount" in usage_info:
|
||||
prompt = usage_info.get("promptTokenCount", 0)
|
||||
completion = usage_info.get("candidatesTokenCount", 0)
|
||||
total = usage_info.get("totalTokenCount", 0)
|
||||
reasoning = usage_info.get("thoughtsTokenCount", 0)
|
||||
cache_hit = usage_info.get("cachedContentTokenCount", 0)
|
||||
|
||||
elif "input_tokens" in usage_info or "output_tokens" in usage_info:
|
||||
prompt = usage_info.get("input_tokens", 0)
|
||||
completion = usage_info.get("output_tokens", 0)
|
||||
total = usage_info.get("total_tokens", 0)
|
||||
cache_hit = (usage_info.get("input_tokens_details") or {}).get(
|
||||
"cached_tokens", 0
|
||||
)
|
||||
reasoning = (usage_info.get("output_tokens_details") or {}).get(
|
||||
"reasoning_tokens", 0
|
||||
)
|
||||
|
||||
else:
|
||||
prompt = usage_info.get("prompt_tokens", 0)
|
||||
completion = usage_info.get("completion_tokens", 0)
|
||||
total = usage_info.get("total_tokens", 0)
|
||||
|
||||
cache_hit = usage_info.get("prompt_cache_hit_tokens") or (
|
||||
usage_info.get("prompt_tokens_details") or {}
|
||||
).get("cached_tokens", 0)
|
||||
cache_miss = usage_info.get("prompt_cache_miss_tokens", 0)
|
||||
reasoning = (usage_info.get("completion_tokens_details") or {}).get(
|
||||
"reasoning_tokens", 0
|
||||
)
|
||||
|
||||
if cache_miss == 0 and prompt > 0:
|
||||
cache_miss = max(0, prompt - cache_hit)
|
||||
|
||||
return UsageInfo(
|
||||
prompt_tokens=prompt,
|
||||
completion_tokens=completion,
|
||||
total_tokens=total,
|
||||
prompt_cache_hit_tokens=cache_hit,
|
||||
prompt_cache_miss_tokens=cache_miss,
|
||||
reasoning_tokens=reasoning,
|
||||
)
|
||||
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
自定义异常与错误码定义
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ModelRetry(Exception):
|
||||
"""用于通知大模型修正并重试的异常"""
|
||||
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class SchemaParseError(ModelRetry):
|
||||
"""格式解析异常。当大模型返回的 JSON 损坏或不符合 Schema 时抛出。"""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class GuardrailViolationError(ModelRetry):
|
||||
"""护栏违规异常。当大模型返回的数据格式正确,但违反业务规则时抛出。"""
|
||||
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ControlFlowExit(BaseException):
|
||||
"""控制流退出基类,继承自BaseException以避免被常规Exception捕获,用于静默中断。"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ToolFatalError(ControlFlowExit):
|
||||
"""
|
||||
致命工具异常(不可恢复)。
|
||||
当工具执行遇到权限不足、严重系统故障等大模型无法通过重试解决的问题时抛出。
|
||||
这会直接熔断 Agent 推理流,并将 display_content 抛给用户。
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, display_content: str | None = None):
|
||||
self.message = message
|
||||
self.display_content = display_content or f"❌ 工具遇到致命错误: {message}"
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class GuardrailFatalException(ControlFlowExit):
|
||||
"""护栏致命拦截异常 (触发 ABORT/REJECT 时抛出)"""
|
||||
|
||||
def __init__(self, guard_name: str, reason: str, display: str | None = None):
|
||||
self.guard_name = guard_name
|
||||
self.reason = reason
|
||||
self.display = display or f"🛡️ 安全拦截: {reason}"
|
||||
super().__init__(f"Guardrail '{guard_name}' aborted execution: {reason}")
|
||||
|
||||
|
||||
class ToolRetryError(Exception):
|
||||
"""
|
||||
可恢复工具异常。
|
||||
当参数解析错误、业务逻辑校验失败、网络超时等问题发生时抛出。
|
||||
会被 ToolExecutor 捕获并转化为引导大模型自我反思 (Reflexion) 的 ToolResult。
|
||||
"""
|
||||
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ToolFinishException(ToolFatalError):
|
||||
"""
|
||||
工具执行中止异常。
|
||||
当工具开发者希望立刻停止大模型的思考循环,并直接将错误/提示信息返回给用户时抛出。
|
||||
此异常不会被大模型进行"影子自愈(Reflexion)",而是直接熔断 Agent 执行流。
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, display_content: str | None = None):
|
||||
super().__init__(message, display_content)
|
||||
|
||||
|
||||
class AbortException(ControlFlowExit):
|
||||
"""异常中止当前 Agent 思考流。"""
|
||||
|
||||
def __init__(self, reason: str, display: Any = None):
|
||||
self.reason = reason
|
||||
self.display = display
|
||||
super().__init__(f"Aborted: {reason}")
|
||||
|
||||
|
||||
class InterventionHandledException(ControlFlowExit):
|
||||
"""
|
||||
干预成功处理异常。
|
||||
当用户的消息被成功作为 STEER 或 FOLLOW_UP 注入到后台运行中的 Agent 队列时抛出,
|
||||
用于中断当前的新请求生命周期,避免重复启动。
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, display_content: str | None = None):
|
||||
self.message = message
|
||||
self.display_content = display_content
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ConcurrencyRejectException(ControlFlowExit):
|
||||
"""并发拒绝异常。当 Agent 设置为 REJECT 且正在忙碌时抛出。"""
|
||||
|
||||
def __init__(self, message: str, display: Any = None):
|
||||
self.message = message
|
||||
self.display = display or "⏳ 智能体正在处理您的上一个请求,请稍后再试~"
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ConcurrencyInterruptException(ControlFlowExit):
|
||||
"""并发打断异常。当 Agent 设置为 INTERRUPT 且被新请求打断时抛出。"""
|
||||
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class NeedsInputException(Exception):
|
||||
"""
|
||||
当工具配置了 interactive=True 且缺少必要参数(或参数验证失败)时抛出此异常,
|
||||
用于交由外部中间件捕获并进行 HITL (Human-in-the-loop) 参数补充。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, missing_field: str, missing_description: str, original_kwargs: dict
|
||||
):
|
||||
self.missing_field = missing_field
|
||||
self.missing_description = missing_description
|
||||
self.original_kwargs = original_kwargs
|
||||
super().__init__(
|
||||
f"Need input for parameter: {missing_field} - {missing_description}"
|
||||
)
|
||||
|
||||
|
||||
class NeedsAuthException(Exception):
|
||||
"""
|
||||
当工具在执行过程中发现授权失效或凭证过期时主动抛出。
|
||||
用于交由外部中间件捕获并重新发起授权 (HITL) 流程。
|
||||
"""
|
||||
|
||||
def __init__(self, provider: str, message: str):
|
||||
self.provider = provider
|
||||
self.message = message
|
||||
super().__init__(f"Needs auth for: {provider} - {message}")
|
||||
|
||||
|
||||
class SandboxPathEscapeError(Exception):
|
||||
"""当沙箱内的路径解析结果试图逃逸出允许的工作区根目录时抛出"""
|
||||
|
||||
def __init__(self, path: str, resolved_path: str | None = None, reason: str = ""):
|
||||
self.path = path
|
||||
self.resolved_path = resolved_path
|
||||
self.reason = reason
|
||||
msg = f"沙箱路径逃逸拦截: {path}"
|
||||
if resolved_path:
|
||||
msg += f" (解析至 {resolved_path})"
|
||||
if reason:
|
||||
msg += f" - {reason}"
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
class WorkspaceIOError(Exception):
|
||||
"""沙箱文件系统读写操作失败"""
|
||||
|
||||
def __init__(self, path: str, message: str, cause: Exception | None = None):
|
||||
self.path = path
|
||||
self.cause = cause
|
||||
super().__init__(f"沙箱 IO 异常 [{path}]: {message}")
|
||||
|
||||
|
||||
class SandboxFatalError(ToolFatalError):
|
||||
"""沙箱底层容器发生致命崩溃(如 OOM, 被宿主机强杀等)"""
|
||||
|
||||
def __init__(self, message: str, display_content: str | None = None):
|
||||
display = display_content or f"❌ 沙箱不可用: {message}"
|
||||
super().__init__(message, display_content=display)
|
||||
|
||||
|
||||
class LLMException(Exception):
|
||||
"""LLM 服务相关的基础异常类 (多态基类)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
cause: Exception | None = None,
|
||||
):
|
||||
self.message = message
|
||||
self.details = details or {}
|
||||
self.cause = cause
|
||||
super().__init__(message)
|
||||
|
||||
@property
|
||||
def is_retryable(self) -> bool:
|
||||
"""是否允许在当前节点进行退避重试(如偶发网络抖动)"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def should_failover(self) -> bool:
|
||||
"""是否允许触发节点故障转移(切换到下一个备用模型)"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def should_rotate_key(self) -> bool:
|
||||
"""是否应该标记当前 Key 失效并轮换 API Key"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def user_friendly_message(self) -> str:
|
||||
"""返回适合向用户展示的错误消息"""
|
||||
return "AI服务暂时不可用,请稍后再试。"
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.details:
|
||||
safe_details = {k: v for k, v in self.details.items() if k != "api_key"}
|
||||
if safe_details:
|
||||
return f"{self.message} (详情: {safe_details})"
|
||||
return self.message
|
||||
|
||||
|
||||
class InvalidRequestException(LLMException):
|
||||
@property
|
||||
def user_friendly_message(self) -> str:
|
||||
return "请求参数错误或API类型不支持,请检查输入内容。"
|
||||
|
||||
|
||||
class ContextLengthExceededException(LLMException):
|
||||
@property
|
||||
def user_friendly_message(self) -> str:
|
||||
return "输入内容过长,请缩短后重试。"
|
||||
|
||||
|
||||
class ContentFilteredException(LLMException):
|
||||
@property
|
||||
def user_friendly_message(self) -> str:
|
||||
return "内容被安全过滤,请修改后重试。"
|
||||
|
||||
|
||||
class ConfigurationException(LLMException):
|
||||
@property
|
||||
def user_friendly_message(self) -> str:
|
||||
return "AI模型配置错误或未找到,请联系管理员检查配置。"
|
||||
|
||||
|
||||
class AuthenticationException(LLMException):
|
||||
@property
|
||||
def should_rotate_key(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def user_friendly_message(self) -> str:
|
||||
return "API密钥无效或权限不足,请联系管理员更新配置。"
|
||||
|
||||
|
||||
class QuotaExceededException(LLMException):
|
||||
@property
|
||||
def should_rotate_key(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def user_friendly_message(self) -> str:
|
||||
return "API使用配额已用尽,请稍后再试或联系管理员。"
|
||||
|
||||
|
||||
class LocationNotSupportedException(LLMException):
|
||||
@property
|
||||
def should_failover(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def user_friendly_message(self) -> str:
|
||||
return (
|
||||
"当前网络环境不支持此 AI 模型。\n"
|
||||
"建议: 请尝试更换代理节点至支持的地区或切换备用模型。"
|
||||
)
|
||||
|
||||
|
||||
class RateLimitException(LLMException):
|
||||
@property
|
||||
def is_retryable(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def should_rotate_key(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def user_friendly_message(self) -> str:
|
||||
return "请求过于频繁,已被AI服务限流,请稍后再试。"
|
||||
|
||||
|
||||
class UpstreamServerException(LLMException):
|
||||
@property
|
||||
def is_retryable(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def should_failover(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def user_friendly_message(self) -> str:
|
||||
return "AI服务响应异常或端点宕机,请稍后再试。"
|
||||
|
||||
|
||||
class NetworkTimeoutException(LLMException):
|
||||
@property
|
||||
def is_retryable(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def should_failover(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def user_friendly_message(self) -> str:
|
||||
return "AI服务请求超时,请稍后再试。"
|
||||
|
||||
|
||||
class ResponseParseException(LLMException):
|
||||
@property
|
||||
def is_retryable(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def user_friendly_message(self) -> str:
|
||||
return "AI服务响应解析失败,请稍后再试。"
|
||||
|
||||
|
||||
def get_user_friendly_error_message(error: Exception) -> str:
|
||||
"""将任何异常转换为用户友好的错误消息"""
|
||||
if isinstance(error, LLMException):
|
||||
return error.user_friendly_message
|
||||
|
||||
error_str = str(error).lower()
|
||||
|
||||
if "timeout" in error_str or "timed out" in error_str:
|
||||
return "网络请求超时,请检查服务器网络或代理连接。"
|
||||
if "connect" in error_str and ("refused" in error_str or "error" in error_str):
|
||||
return "无法连接到 AI 服务商,请检查网络连接或代理设置。"
|
||||
if "proxy" in error_str:
|
||||
return "代理连接失败,请检查代理服务器是否正常运行。"
|
||||
if "ssl" in error_str or "certificate" in error_str:
|
||||
return "SSL 证书验证失败,请检查网络环境。"
|
||||
|
||||
return f"服务暂时不可用 ({type(error).__name__}),请稍后再试。"
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
消息与响应域类型定义 - 统一导出门面
|
||||
"""
|
||||
|
||||
from nonebot.compat import PYDANTIC_V2
|
||||
|
||||
from .context_events import (
|
||||
AgentEvent,
|
||||
HandoffEvent,
|
||||
TaskLifecycleEvent,
|
||||
)
|
||||
from .models import (
|
||||
AssistantMessage,
|
||||
LLMMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
UserMessage,
|
||||
)
|
||||
from .parts import (
|
||||
AudioPart,
|
||||
BaseContentPart,
|
||||
EmbedBatch,
|
||||
EmbedPayload,
|
||||
FilePart,
|
||||
ImagePart,
|
||||
LLMContentPart,
|
||||
TextDeltaPart,
|
||||
TextPart,
|
||||
ThoughtDeltaPart,
|
||||
ThoughtPart,
|
||||
ToolCallDeltaPart,
|
||||
ToolCallPart,
|
||||
ToolReturnPart,
|
||||
VideoPart,
|
||||
)
|
||||
from .requests import (
|
||||
BaseRequest,
|
||||
ChatRequest,
|
||||
EmbeddingRequest,
|
||||
ImageRequest,
|
||||
RerankRequest,
|
||||
SpeechRequest,
|
||||
)
|
||||
from .responses import (
|
||||
AudioResponse,
|
||||
ChatResponse,
|
||||
EmbeddingResponse,
|
||||
ImageResponse,
|
||||
RerankResponse,
|
||||
)
|
||||
from .shared import (
|
||||
LLMCodeExecution,
|
||||
LLMGroundingAttribution,
|
||||
LLMGroundingMetadata,
|
||||
RerankDocument,
|
||||
RerankResult,
|
||||
UsageInfo,
|
||||
)
|
||||
from .types import (
|
||||
AgentMessage,
|
||||
AnyLLMMessage,
|
||||
AssistantContentUnion,
|
||||
ContentT,
|
||||
PromptInput,
|
||||
RoleT,
|
||||
SystemContentUnion,
|
||||
ToolContentUnion,
|
||||
UserContentUnion,
|
||||
)
|
||||
|
||||
if PYDANTIC_V2:
|
||||
ChatResponse.model_rebuild()
|
||||
LLMMessage.model_rebuild()
|
||||
SystemMessage.model_rebuild()
|
||||
UserMessage.model_rebuild()
|
||||
AssistantMessage.model_rebuild()
|
||||
ToolMessage.model_rebuild()
|
||||
RerankResponse.model_rebuild()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentEvent",
|
||||
"AgentMessage",
|
||||
"AnyLLMMessage",
|
||||
"AssistantContentUnion",
|
||||
"AssistantMessage",
|
||||
"AudioPart",
|
||||
"AudioResponse",
|
||||
"BaseContentPart",
|
||||
"BaseRequest",
|
||||
"ChatRequest",
|
||||
"ChatResponse",
|
||||
"ContentT",
|
||||
"EmbedBatch",
|
||||
"EmbedPayload",
|
||||
"EmbeddingRequest",
|
||||
"EmbeddingResponse",
|
||||
"FilePart",
|
||||
"HandoffEvent",
|
||||
"ImagePart",
|
||||
"ImageRequest",
|
||||
"ImageResponse",
|
||||
"LLMCodeExecution",
|
||||
"LLMContentPart",
|
||||
"LLMGroundingAttribution",
|
||||
"LLMGroundingMetadata",
|
||||
"LLMMessage",
|
||||
"PromptInput",
|
||||
"RerankDocument",
|
||||
"RerankRequest",
|
||||
"RerankResponse",
|
||||
"RerankResult",
|
||||
"RoleT",
|
||||
"SpeechRequest",
|
||||
"SystemContentUnion",
|
||||
"SystemMessage",
|
||||
"TaskLifecycleEvent",
|
||||
"TextDeltaPart",
|
||||
"TextPart",
|
||||
"ThoughtDeltaPart",
|
||||
"ThoughtPart",
|
||||
"ToolCallDeltaPart",
|
||||
"ToolCallPart",
|
||||
"ToolContentUnion",
|
||||
"ToolMessage",
|
||||
"ToolReturnPart",
|
||||
"UsageInfo",
|
||||
"UserContentUnion",
|
||||
"UserMessage",
|
||||
"VideoPart",
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from .models import LLMMessage
|
||||
|
||||
|
||||
class AgentEvent(BaseModel):
|
||||
"""
|
||||
业务事件抽象基类/协议。
|
||||
支持作为一种特殊的消息,直接被混入到大模型的上下文(记忆)中。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") # type: ignore
|
||||
|
||||
def to_llm_message(
|
||||
self, context: Any | None = None
|
||||
) -> LLMMessage | list[LLMMessage] | str | None:
|
||||
"""
|
||||
将业务事件渲染为大模型能看懂的 API 原生消息。
|
||||
子类必须重写此方法。
|
||||
返回 None 代表此事件对大模型不可见(例如:纯后台打点或审计日志)。
|
||||
如果返回 str,系统将默认包装为 SystemMessage 发送给大模型。
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
class TaskLifecycleEvent(AgentEvent):
|
||||
"""内置业务事件:任务状态打点追踪"""
|
||||
|
||||
task_name: str
|
||||
"""任务名称"""
|
||||
action: Literal["start", "complete", "fail"]
|
||||
"""任务状态动作,支持 "start"(开始)、"complete"(完成)、"fail"(失败)"""
|
||||
error_msg: str | None = None
|
||||
"""任务执行失败时的具体错误描述,可选"""
|
||||
|
||||
def to_llm_message(self, context: Any | None = None) -> str | None:
|
||||
if self.action == "start":
|
||||
return f"[任务生命周期] 开始执行任务:{self.task_name}"
|
||||
elif self.action == "complete":
|
||||
return f"[任务生命周期] 任务已完美达成:{self.task_name}"
|
||||
elif self.action == "fail":
|
||||
return (
|
||||
f"[任务生命周期] 任务执行失败:{self.task_name},原因:{self.error_msg}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class HandoffEvent(AgentEvent):
|
||||
"""内置业务事件:控制权移交记录"""
|
||||
|
||||
target: str
|
||||
"""目标接收节点或 Agent 的名称"""
|
||||
reason: str
|
||||
"""移交控制权的具体原因说明"""
|
||||
context_data: Any = None
|
||||
"""移交时附带的上下文数据,默认为 None"""
|
||||
|
||||
def to_llm_message(self, context: Any | None = None) -> str | None:
|
||||
return (
|
||||
f"[控制权移交] 任务及会话控制权已被系统转移至节点 "
|
||||
f"'{self.target}'。移交原因:{self.reason}"
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentEvent",
|
||||
"HandoffEvent",
|
||||
"TaskLifecycleEvent",
|
||||
]
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
标准消息实体 - 依赖 parts.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Sequence
|
||||
import time
|
||||
from typing import Annotated, Any, Generic, Literal, cast
|
||||
from typing_extensions import Self, TypeVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from zhenxun.utils.pydantic_compat import model_copy, model_dump, model_validator
|
||||
|
||||
from .parts import (
|
||||
BaseContentPart,
|
||||
FilePart,
|
||||
ImagePart,
|
||||
LLMContentPart,
|
||||
TextPart,
|
||||
ToolCallPart,
|
||||
ToolReturnPart,
|
||||
)
|
||||
|
||||
RoleT = TypeVar("RoleT", default=str, covariant=True)
|
||||
"""泛型:消息参与者角色类型变量"""
|
||||
|
||||
ContentT = TypeVar("ContentT", default=LLMContentPart, covariant=True)
|
||||
"""泛型:多模态片段数组的元素内容类型变量"""
|
||||
|
||||
|
||||
class LLMMessage(BaseModel, Generic[RoleT, ContentT]):
|
||||
"""
|
||||
LLM 消息基类与门面工厂。
|
||||
提供统一的元数据访问、魔法加法重载以及极简实例化方法。
|
||||
"""
|
||||
|
||||
role: RoleT
|
||||
"""消息参与者角色 (如 user, assistant, system, tool)"""
|
||||
content: list[ContentT] = Field(default_factory=list)
|
||||
"""容纳实际数据的多模态片段数组"""
|
||||
created_at: float = Field(default_factory=time.time)
|
||||
"""消息最初被构建的 Unix 时间戳"""
|
||||
metadata: dict[str, Any] | None = Field(default=None)
|
||||
"""自由存取字典,供系统内穿透传递额外状态数据"""
|
||||
|
||||
@property
|
||||
def tool_calls(self) -> list[ToolCallPart]:
|
||||
"""获取当前消息中包含的所有工具调用请求片段"""
|
||||
return [p for p in self.content if isinstance(p, ToolCallPart)]
|
||||
|
||||
@property
|
||||
def tool_returns(self) -> list[ToolReturnPart]:
|
||||
"""获取当前消息中包含的所有工具执行结果片段"""
|
||||
return [p for p in self.content if isinstance(p, ToolReturnPart)]
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _normalize_content(cls, data: Any) -> Any:
|
||||
"""核心拦截:外部传入 str 时自动转为 Part,保持内部类型绝对纯净"""
|
||||
if isinstance(data, dict):
|
||||
content = data.get("content")
|
||||
if isinstance(content, str):
|
||||
data["content"] = (
|
||||
[{"type": "text", "text": content}] if content.strip() else []
|
||||
)
|
||||
elif content is None:
|
||||
data["content"] = []
|
||||
elif isinstance(content, list):
|
||||
new_content = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
new_content.append({"type": "text", "text": item})
|
||||
else:
|
||||
new_content.append(item)
|
||||
data["content"] = new_content
|
||||
elif not isinstance(content, list):
|
||||
import json
|
||||
|
||||
try:
|
||||
text_val = json.dumps(content, ensure_ascii=False)
|
||||
except Exception:
|
||||
text_val = str(content)
|
||||
data["content"] = [{"type": "text", "text": text_val}]
|
||||
return data
|
||||
|
||||
def __add__(self, other: str | LLMContentPart | "LLMMessage") -> Self:
|
||||
"""极简语法糖:支持通过加号拼接文本或多模态片段。
|
||||
示例: msg = LLMMessage.user("查看图片:") + ImagePart(url="...")
|
||||
"""
|
||||
new_msg = cast(Self, model_copy(self, deep=True))
|
||||
if isinstance(other, str):
|
||||
new_msg.content.append(cast(Any, TextPart(text=other)))
|
||||
elif isinstance(other, BaseContentPart):
|
||||
new_msg.content.append(cast(Any, other))
|
||||
elif isinstance(other, LLMMessage):
|
||||
new_msg.content.extend(cast(Any, other.content))
|
||||
return new_msg
|
||||
|
||||
@property
|
||||
def extract_text(self) -> str:
|
||||
"""便捷属性:提取当前消息中所有的纯文本"""
|
||||
return "".join(p.text for p in self.content if isinstance(p, TextPart))
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
"""获取消息的来源标识 (存取于 metadata 中)"""
|
||||
return self.metadata.get("source") if self.metadata else None
|
||||
|
||||
@source.setter
|
||||
def source(self, value: str | None):
|
||||
"""设置消息的来源标识"""
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
self.metadata["source"] = value
|
||||
|
||||
@property
|
||||
def source_name(self) -> str | None:
|
||||
"""获取消息来源的具体名称 (例如真实用户的昵称)"""
|
||||
return self.metadata.get("source_name") if self.metadata else None
|
||||
|
||||
@source_name.setter
|
||||
def source_name(self, value: str | None):
|
||||
"""设置消息来源的具体名称"""
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
self.metadata["source_name"] = value
|
||||
|
||||
@property
|
||||
def scope(self) -> str | None:
|
||||
"""获取该消息所绑定的作用域或会话ID"""
|
||||
return self.metadata.get("scope") if self.metadata else None
|
||||
|
||||
@scope.setter
|
||||
def scope(self, value: str | None):
|
||||
"""设置该消息的作用域"""
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
self.metadata["scope"] = value
|
||||
|
||||
@property
|
||||
def thought_signature(self) -> str | None:
|
||||
"""获取连续对话中用于保持思考一致性的加密签名 (Gemini 专属)"""
|
||||
return self.metadata.get("thought_signature") if self.metadata else None
|
||||
|
||||
@thought_signature.setter
|
||||
def thought_signature(self, value: str | None):
|
||||
"""设置思考加密签名"""
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
self.metadata["thought_signature"] = value
|
||||
|
||||
@property
|
||||
def token_cost(self) -> int | None:
|
||||
"""获取该消息自身消耗的预估或真实 Token 数"""
|
||||
return self.metadata.get("token_cost") if self.metadata else None
|
||||
|
||||
@token_cost.setter
|
||||
def token_cost(self, value: int | None):
|
||||
"""设置 Token 消耗数"""
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
self.metadata["token_cost"] = value
|
||||
|
||||
@classmethod
|
||||
def user(
|
||||
cls,
|
||||
content: str | Sequence[Any],
|
||||
source: str | None = None,
|
||||
source_name: str | None = None,
|
||||
scope: str | None = None,
|
||||
) -> "UserMessage":
|
||||
"""
|
||||
工厂方法:创建一条 User (用户) 角色的消息。
|
||||
|
||||
参数:
|
||||
content: 消息内容,支持纯字符串或多模态片段数组。
|
||||
source: 可选,消息来源标识。
|
||||
source_name: 可选,消息来源的可读名称。
|
||||
scope: 可选,消息关联的会话作用域。
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
content = [TextPart(text=content)] if content.strip() else []
|
||||
elif not isinstance(content, list):
|
||||
content = list(content)
|
||||
msg = UserMessage(content=cast(Any, content))
|
||||
msg.source = source
|
||||
msg.source_name = source_name
|
||||
msg.scope = scope
|
||||
return msg
|
||||
|
||||
@classmethod
|
||||
def assistant_tool_calls(
|
||||
cls,
|
||||
tool_calls: list[ToolCallPart],
|
||||
content: str | Sequence[Any] = "",
|
||||
scope: str | None = None,
|
||||
) -> "AssistantMessage":
|
||||
"""
|
||||
工厂方法:创建一条包含工具调用请求的 Assistant (助手) 角色消息。
|
||||
|
||||
参数:
|
||||
tool_calls: 大模型发出的工具调用片段列表。
|
||||
content: 伴随工具调用的其他文本或思维过程。
|
||||
scope: 可选,消息关联的会话作用域。
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
_content: list[Any] = [TextPart(text=content)] if content else []
|
||||
else:
|
||||
_content = list(content)
|
||||
_content.extend(tool_calls)
|
||||
msg = AssistantMessage(content=cast(Any, _content))
|
||||
msg.scope = scope
|
||||
return msg
|
||||
|
||||
@classmethod
|
||||
def assistant_text_response(
|
||||
cls,
|
||||
content: str | Sequence[Any],
|
||||
scope: str | None = None,
|
||||
) -> "AssistantMessage":
|
||||
"""
|
||||
工厂方法:创建一条仅包含普通文本/思维过程的 Assistant (助手) 角色消息。
|
||||
|
||||
参数:
|
||||
content: 大模型生成的文本回复内容。
|
||||
scope: 可选,消息关联的会话作用域。
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
content = [TextPart(text=content)] if content and content.strip() else []
|
||||
elif not isinstance(content, list):
|
||||
content = list(content)
|
||||
msg = AssistantMessage(content=cast(Any, content))
|
||||
msg.scope = scope
|
||||
return msg
|
||||
|
||||
def add_text(self, text: str) -> Self:
|
||||
"""链式添加文本内容"""
|
||||
self.content.append(cast(Any, TextPart(text=text)))
|
||||
return self
|
||||
|
||||
def add_image_url(self, url: str) -> Self:
|
||||
"""链式添加网络图片"""
|
||||
self.content.append(cast(Any, ImagePart(url=url)))
|
||||
return self
|
||||
|
||||
def add_image_base64(self, b64_data: str, mime_type: str = "image/png") -> Self:
|
||||
"""链式添加 Base64 图片"""
|
||||
self.content.append(
|
||||
cast(Any, ImagePart(raw=base64.b64decode(b64_data), mime_type=mime_type))
|
||||
)
|
||||
return self
|
||||
|
||||
def add_file_url(self, url: str, mime_type: str | None = None) -> Self:
|
||||
"""链式添加文件链接"""
|
||||
self.content.append(cast(Any, FilePart(url=url, mime_type=mime_type)))
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def tool_response(
|
||||
cls,
|
||||
tool_call_id: str,
|
||||
function_name: str,
|
||||
result: Any,
|
||||
scope: str | None = None,
|
||||
) -> "ToolMessage":
|
||||
"""
|
||||
工厂方法:创建一条 Tool (工具) 角色消息,用于承载工具执行完毕后的返回结果。
|
||||
|
||||
参数:
|
||||
tool_call_id: 对应的大模型发出调用请求时的 ID。
|
||||
function_name: 执行的工具名称。
|
||||
result: 工具执行的结果负载 (会被自动 JSON 序列化)。
|
||||
scope: 可选,消息关联的会话作用域。
|
||||
"""
|
||||
_content = [
|
||||
ToolReturnPart(
|
||||
tool_call_id=tool_call_id, tool_name=function_name, output=result
|
||||
)
|
||||
]
|
||||
msg = ToolMessage(content=cast(Any, _content))
|
||||
msg.scope = scope
|
||||
return msg
|
||||
|
||||
@classmethod
|
||||
def system(
|
||||
cls,
|
||||
content: str | Sequence[Any],
|
||||
scope: str | None = None,
|
||||
) -> "SystemMessage":
|
||||
"""
|
||||
工厂方法:创建一条 System (系统) 角色的设定消息。
|
||||
|
||||
参数:
|
||||
content: 系统的 Prompt 指令。
|
||||
scope: 可选,消息关联的会话作用域。
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
content = [TextPart(text=content)] if content.strip() else []
|
||||
elif not isinstance(content, list):
|
||||
content = list(content)
|
||||
msg = SystemMessage(content=cast(Any, content))
|
||||
msg.scope = scope
|
||||
return msg
|
||||
|
||||
def to_storage_dict(self) -> dict[str, Any]:
|
||||
"""数据库瘦身存储,仅保留核心字段"""
|
||||
return model_dump(
|
||||
self,
|
||||
exclude_none=True,
|
||||
include={
|
||||
"role",
|
||||
"content",
|
||||
"metadata",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SystemMessage(LLMMessage[Literal["system"], TextPart]):
|
||||
"""系统消息:通常用于在对话开头向模型提供系统级指令 (System Prompt)、
|
||||
角色设定或背景上下文。"""
|
||||
|
||||
role: Literal["system"] = "system"
|
||||
content: list[Annotated[TextPart, Field(discriminator="type")]] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
class UserMessage(LLMMessage[Literal["user"], LLMContentPart]):
|
||||
"""用户消息:代表来自最终用户或外部触发源的输入,支持包含文本、图片、文件等多模态数据。"""
|
||||
|
||||
role: Literal["user"] = "user"
|
||||
content: list[LLMContentPart] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AssistantMessage(LLMMessage[Literal["assistant"], LLMContentPart]):
|
||||
"""助手消息:代表大模型 (AI) 生成的回复。
|
||||
可能包含纯文本、思维链过程或工具调用请求。"""
|
||||
|
||||
role: Literal["assistant"] = "assistant"
|
||||
content: list[LLMContentPart] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ToolMessage(LLMMessage[Literal["tool"], LLMContentPart]):
|
||||
"""工具消息:用于承载由用户端执行工具后,将结果返回给大模型的消息容器。"""
|
||||
|
||||
role: Literal["tool"] = "tool"
|
||||
content: list[LLMContentPart] = Field(default_factory=list)
|
||||
|
||||
def model_post_init(self, context: Any, /) -> None:
|
||||
"""验证消息的有效性"""
|
||||
if not self.tool_returns:
|
||||
raise ValueError("工具角色的消息必须包含 ToolReturnPart")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AssistantMessage",
|
||||
"ContentT",
|
||||
"LLMMessage",
|
||||
"RoleT",
|
||||
"SystemMessage",
|
||||
"ToolMessage",
|
||||
"UserMessage",
|
||||
]
|
||||
@@ -0,0 +1,341 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
from typing_extensions import Self
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from zhenxun.utils.pydantic_compat import model_validator
|
||||
|
||||
|
||||
class BaseContentPart(BaseModel):
|
||||
"""多态消息内容的底层基类"""
|
||||
|
||||
metadata: dict[str, Any] | None = Field(default=None)
|
||||
"""该部件的内部元数据,提供如 thought_signature、解析结果等非展示用数据"""
|
||||
|
||||
@classmethod
|
||||
def text_part(cls, text: str) -> "TextPart":
|
||||
"""创建一个纯文本片段"""
|
||||
return TextPart(text=text)
|
||||
|
||||
@classmethod
|
||||
def thought_part(cls, text: str) -> "ThoughtPart":
|
||||
"""创建一个思维链 (CoT) 思考片段"""
|
||||
return ThoughtPart(thought_text=text)
|
||||
|
||||
@classmethod
|
||||
def image_url_part(cls, url: str) -> "ImagePart":
|
||||
"""创建一个基于外网 URL 的图片片段"""
|
||||
return ImagePart(url=url)
|
||||
|
||||
@classmethod
|
||||
def image_base64_part(cls, data: str, mime_type: str = "image/png") -> "ImagePart":
|
||||
"""创建一个基于 Base64 编码数据的图片片段"""
|
||||
return ImagePart(raw=base64.b64decode(data), mime_type=mime_type)
|
||||
|
||||
@classmethod
|
||||
def audio_url_part(cls, url: str, mime_type: str = "audio/wav") -> "AudioPart":
|
||||
"""创建一个基于外网 URL 的音频片段"""
|
||||
return AudioPart(url=url, mime_type=mime_type)
|
||||
|
||||
@classmethod
|
||||
def video_url_part(cls, url: str, mime_type: str = "video/mp4") -> "VideoPart":
|
||||
"""创建一个基于外网 URL 的视频片段"""
|
||||
return VideoPart(url=url, mime_type=mime_type)
|
||||
|
||||
@classmethod
|
||||
def video_base64_part(cls, data: str, mime_type: str = "video/mp4") -> "VideoPart":
|
||||
"""创建一个基于 Base64 编码数据的视频片段"""
|
||||
return VideoPart(raw=base64.b64decode(data), mime_type=mime_type)
|
||||
|
||||
@classmethod
|
||||
def audio_base64_part(cls, data: str, mime_type: str = "audio/wav") -> "AudioPart":
|
||||
"""创建一个基于 Base64 编码数据的音频片段"""
|
||||
return AudioPart(raw=base64.b64decode(data), mime_type=mime_type)
|
||||
|
||||
@classmethod
|
||||
def file_uri_part(
|
||||
cls,
|
||||
file_uri: str,
|
||||
mime_type: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> "FilePart":
|
||||
"""创建一个基于云端 URI (如 Google Cloud Storage gs://) 的通用文件片段"""
|
||||
return FilePart(url=file_uri, mime_type=mime_type, metadata=metadata or {})
|
||||
|
||||
@classmethod
|
||||
def tool_call_part(
|
||||
cls, id: str, tool_name: str, args: dict[str, Any] | str
|
||||
) -> "ToolCallPart":
|
||||
"""创建一个工具调用请求片段"""
|
||||
return ToolCallPart(id=id, tool_name=tool_name, args=args)
|
||||
|
||||
@classmethod
|
||||
def tool_return_part(cls, call_id: str, name: str, result: Any) -> "ToolReturnPart":
|
||||
"""创建一个工具执行结果片段"""
|
||||
return ToolReturnPart(
|
||||
tool_call_id=call_id,
|
||||
tool_name=name,
|
||||
output=result,
|
||||
)
|
||||
|
||||
async def get_raw_bytes(self) -> bytes:
|
||||
"""统一获取多模态原始字节数据 (自动适配 raw、本地 path 或自动下载公网 url)"""
|
||||
raw_data = getattr(self, "raw", None)
|
||||
if isinstance(raw_data, bytes):
|
||||
return raw_data
|
||||
|
||||
path_data = getattr(self, "path", None)
|
||||
if path_data is not None:
|
||||
from pathlib import Path
|
||||
|
||||
if isinstance(path_data, Path):
|
||||
return path_data.read_bytes()
|
||||
|
||||
url_data = getattr(self, "url", None)
|
||||
if isinstance(url_data, str):
|
||||
from zhenxun.utils.http_utils import AsyncHttpx
|
||||
|
||||
content = await AsyncHttpx.get_content(url_data)
|
||||
if isinstance(content, bytes):
|
||||
return content
|
||||
return b""
|
||||
|
||||
raise ValueError(
|
||||
f"{self.__class__.__name__} 未提供有效的数据源 (url, raw, path)"
|
||||
)
|
||||
|
||||
async def get_base64_data(self) -> str:
|
||||
"""统一获取 Base64 编码的字符串数据"""
|
||||
return base64.b64encode(await self.get_raw_bytes()).decode("utf-8")
|
||||
|
||||
async def get_data_uri(self, default_mime: str = "application/octet-stream") -> str:
|
||||
"""统一获取 Data URI (data:mime;base64,...) 格式的字符串"""
|
||||
mime = getattr(self, "mime_type", None) or default_mime
|
||||
return f"data:{mime};base64,{await self.get_base64_data()}"
|
||||
|
||||
|
||||
class ImagePart(BaseContentPart):
|
||||
"""图片媒体片段"""
|
||||
|
||||
type: Literal["image"] = "image"
|
||||
url: str | None = None
|
||||
"""外网图片 URL (需能直接访问)"""
|
||||
raw: bytes | None = None
|
||||
"""图片二进制裸数据"""
|
||||
path: Path | None = None
|
||||
"""本地文件系统中的图片路径"""
|
||||
mime_type: str | None = None
|
||||
"""图片 MIME 类型 (如 image/jpeg)"""
|
||||
media_resolution: str | None = None
|
||||
"""媒体处理强制分辨率策略"""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_source(self) -> Self:
|
||||
sources = [s for s in (self.url, self.raw, self.path) if s is not None]
|
||||
if len(sources) != 1:
|
||||
raise ValueError("ImagePart 必须且只能提供 url, raw, path 中的一个")
|
||||
return self
|
||||
|
||||
|
||||
class AudioPart(BaseContentPart):
|
||||
"""音频媒体片段"""
|
||||
|
||||
type: Literal["audio"] = "audio"
|
||||
url: str | None = None
|
||||
"""外网音频 URL"""
|
||||
raw: bytes | None = None
|
||||
"""音频二进制裸数据"""
|
||||
path: Path | None = None
|
||||
"""本地文件系统中的音频路径"""
|
||||
mime_type: str | None = None
|
||||
"""音频 MIME 类型 (如 audio/mp3)"""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_source(self) -> Self:
|
||||
sources = [s for s in (self.url, self.raw, self.path) if s is not None]
|
||||
if len(sources) != 1:
|
||||
raise ValueError("AudioPart 必须且只能提供 url, raw, path 中的一个")
|
||||
return self
|
||||
|
||||
|
||||
class VideoPart(BaseContentPart):
|
||||
"""视频媒体片段"""
|
||||
|
||||
type: Literal["video"] = "video"
|
||||
url: str | None = None
|
||||
"""外网视频 URL"""
|
||||
raw: bytes | None = None
|
||||
"""视频二进制裸数据"""
|
||||
path: Path | None = None
|
||||
"""本地文件系统中的视频路径"""
|
||||
mime_type: str | None = None
|
||||
"""视频 MIME 类型 (如 video/mp4)"""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_source(self) -> Self:
|
||||
sources = [s for s in (self.url, self.raw, self.path) if s is not None]
|
||||
if len(sources) != 1:
|
||||
raise ValueError("VideoPart 必须且只能提供 url, raw, path 中的一个")
|
||||
return self
|
||||
|
||||
|
||||
class FilePart(BaseContentPart):
|
||||
"""通用文件片段 (如 PDF、代码文件等)"""
|
||||
|
||||
type: Literal["file"] = "file"
|
||||
url: str | None = None
|
||||
"""外网文件 URL (或 Google Cloud gs:// URI)"""
|
||||
raw: bytes | None = None
|
||||
"""文件二进制裸数据"""
|
||||
path: Path | None = None
|
||||
"""本地文件系统中的文件路径"""
|
||||
mime_type: str | None = None
|
||||
"""文件 MIME 类型 (如 application/pdf)"""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_source(self) -> Self:
|
||||
sources = [s for s in (self.url, self.raw, self.path) if s is not None]
|
||||
if len(sources) != 1:
|
||||
raise ValueError("FilePart 必须且只能提供 url, raw, path 中的一个")
|
||||
return self
|
||||
|
||||
|
||||
class TextPart(BaseContentPart):
|
||||
"""基础文本片段"""
|
||||
|
||||
type: Literal["text"] = "text"
|
||||
text: str
|
||||
"""具体的纯文本内容"""
|
||||
|
||||
|
||||
class ThoughtPart(BaseContentPart):
|
||||
"""思考链 (Chain of Thought) 片段,用于容纳模型的内部思维过程"""
|
||||
|
||||
type: Literal["thought"] = "thought"
|
||||
thought_text: str
|
||||
"""思考过程的文本"""
|
||||
|
||||
|
||||
class ToolCallPart(BaseContentPart):
|
||||
"""工具调用请求片段 (由模型发出)"""
|
||||
|
||||
type: Literal["tool_call"] = "tool_call"
|
||||
id: str
|
||||
"""工具调用唯一 ID"""
|
||||
tool_name: str
|
||||
"""调用的函数名称"""
|
||||
args: dict[str, Any] | str
|
||||
"""传递给工具的参数 (解析后的字典或原始 JSON 字符串)"""
|
||||
|
||||
|
||||
class ToolReturnPart(BaseContentPart):
|
||||
"""工具调用结果片段 (发给模型)"""
|
||||
|
||||
type: Literal["tool_return"] = "tool_return"
|
||||
tool_call_id: str
|
||||
"""关联的原始调用 ID"""
|
||||
tool_name: str
|
||||
"""执行的工具名称"""
|
||||
output: Any
|
||||
"""工具执行结果的有效载荷"""
|
||||
|
||||
|
||||
class TextDeltaPart(BaseModel):
|
||||
"""流式文本增量片段"""
|
||||
|
||||
type: Literal["text_delta"] = "text_delta"
|
||||
content_delta: str
|
||||
"""流式返回的文本增量字符串"""
|
||||
|
||||
|
||||
class ThoughtDeltaPart(BaseModel):
|
||||
"""流式思考链增量片段"""
|
||||
|
||||
type: Literal["thought_delta"] = "thought_delta"
|
||||
content_delta: str
|
||||
"""流式返回的思考过程增量字符串"""
|
||||
|
||||
|
||||
class ToolCallDeltaPart(BaseModel):
|
||||
"""流式工具调用增量片段"""
|
||||
|
||||
type: Literal["tool_call_delta"] = "tool_call_delta"
|
||||
tool_call_id: str | None = None
|
||||
"""(可选) 流式返回的工具调用唯一ID"""
|
||||
tool_name_delta: str | None = None
|
||||
"""(可选) 流式返回的工具名称增量"""
|
||||
args_delta: str | None = None
|
||||
"""(可选) 流式返回的 JSON 格式参数增量"""
|
||||
|
||||
|
||||
LLMContentPart = Annotated[
|
||||
TextPart
|
||||
| ImagePart
|
||||
| AudioPart
|
||||
| VideoPart
|
||||
| FilePart
|
||||
| ThoughtPart
|
||||
| ToolCallPart
|
||||
| ToolReturnPart,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
"""大模型底层标准内容片段的 Annotated 联合类型"""
|
||||
|
||||
|
||||
class EmbedPayload(BaseModel):
|
||||
"""单一的嵌入载体,包含一个或多个多模态片段(融合向量)"""
|
||||
|
||||
parts: list[LLMContentPart] = Field(default_factory=list)
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""快速提取纯文本(用于向下兼容不支持多模态的模型)"""
|
||||
return "".join(p.text for p in self.parts if isinstance(p, TextPart)).strip()
|
||||
|
||||
@property
|
||||
def has_multimodal(self) -> bool:
|
||||
"""判断是否包含图像/音频/视频/文件等非文本模态"""
|
||||
return any(not isinstance(p, TextPart) for p in self.parts)
|
||||
|
||||
|
||||
class EmbedBatch(BaseModel):
|
||||
"""一次嵌入 API 请求的批次载体"""
|
||||
|
||||
payloads: list[EmbedPayload] = Field(default_factory=list)
|
||||
|
||||
def to_text_only(self, context_name: str) -> list[str]:
|
||||
"""降级工具:将多模态的批量向量安全剔除图片等内容,回退为纯文本数组"""
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
texts = []
|
||||
for payload in self.payloads:
|
||||
if payload.has_multimodal:
|
||||
logger.warning(
|
||||
f"⚠️ 模型 {context_name} "
|
||||
"不支持多模态嵌入,已自动剔除富媒体内容,静默降级为纯文本进行向量化..."
|
||||
)
|
||||
texts.append(payload.text if payload.text else " ")
|
||||
return texts
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AudioPart",
|
||||
"BaseContentPart",
|
||||
"EmbedBatch",
|
||||
"EmbedPayload",
|
||||
"FilePart",
|
||||
"ImagePart",
|
||||
"LLMContentPart",
|
||||
"TextDeltaPart",
|
||||
"TextPart",
|
||||
"ThoughtDeltaPart",
|
||||
"ThoughtPart",
|
||||
"ToolCallDeltaPart",
|
||||
"ToolCallPart",
|
||||
"ToolReturnPart",
|
||||
"VideoPart",
|
||||
]
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from zhenxun.services.ai.core.models import ToolChoice
|
||||
from zhenxun.services.ai.core.options import (
|
||||
GenerationConfig,
|
||||
LLMEmbeddingConfig,
|
||||
TTSConfig,
|
||||
)
|
||||
from zhenxun.utils.pydantic_compat import model_dump
|
||||
|
||||
from .models import LLMMessage
|
||||
from .parts import EmbedBatch
|
||||
|
||||
|
||||
class BaseRequest(BaseModel):
|
||||
"""基础请求 DTO"""
|
||||
|
||||
timeout: float | None = Field(default=None)
|
||||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
def get_cache_hash_payload(self) -> dict[str, Any]:
|
||||
"""获取用于计算缓存 Hash 的安全载荷,排除所有运行时动态变量"""
|
||||
request_dict = model_dump(self, exclude_none=True)
|
||||
|
||||
if "config" in request_dict and isinstance(request_dict["config"], dict):
|
||||
custom_kwargs = request_dict["config"].get("custom_kwargs", {})
|
||||
if "__cache_ttl__" in custom_kwargs:
|
||||
custom_kwargs.pop("__cache_ttl__")
|
||||
|
||||
if "extra" in request_dict:
|
||||
request_dict["extra"] = {
|
||||
k: v
|
||||
for k, v in request_dict["extra"].items()
|
||||
if not k.startswith("_")
|
||||
and k not in ("run_context", "output_processor", "guardrails")
|
||||
}
|
||||
return request_dict
|
||||
|
||||
|
||||
class ChatRequest(BaseRequest):
|
||||
"""对话生成请求 DTO"""
|
||||
|
||||
messages: list[LLMMessage]
|
||||
config: GenerationConfig | None = None
|
||||
tools: list[Any] | None = None
|
||||
tool_choice: str | dict[str, Any] | ToolChoice | None = None
|
||||
|
||||
def get_cache_hash_payload(self) -> dict[str, Any]:
|
||||
payload = super().get_cache_hash_payload()
|
||||
for msg in payload.get("messages", []):
|
||||
msg.pop("created_at", None)
|
||||
msg.pop("token_cost", None)
|
||||
msg.pop("metadata", None)
|
||||
|
||||
if "tools" in payload and isinstance(payload["tools"], list):
|
||||
safe_tools = []
|
||||
for t in payload["tools"]:
|
||||
if isinstance(t, dict | str):
|
||||
safe_tools.append(t)
|
||||
else:
|
||||
safe_tools.append(getattr(t, "name", type(t).__name__))
|
||||
payload["tools"] = safe_tools
|
||||
return payload
|
||||
|
||||
|
||||
class EmbeddingRequest(BaseRequest):
|
||||
"""向量嵌入请求 DTO"""
|
||||
|
||||
batch: EmbedBatch
|
||||
"""向量嵌入的批次载体"""
|
||||
config: LLMEmbeddingConfig | None = None
|
||||
"""向量嵌入配置"""
|
||||
|
||||
|
||||
class ImageRequest(BaseRequest):
|
||||
"""图像生成请求 DTO"""
|
||||
|
||||
prompt: str
|
||||
"""图像生成提示词"""
|
||||
images: list[Any] | None = None
|
||||
"""输入参考图像列表"""
|
||||
config: GenerationConfig | None = None
|
||||
"""图像生成配置"""
|
||||
|
||||
|
||||
class SpeechRequest(BaseRequest):
|
||||
"""语音合成请求 DTO"""
|
||||
|
||||
input_text: str
|
||||
"""待合成的文本内容"""
|
||||
voice: str | None = None
|
||||
"""发音人/音色标识 (快捷覆盖参数,为空则使用模型默认音色)"""
|
||||
config: TTSConfig | None = None
|
||||
"""语音合成配置"""
|
||||
|
||||
|
||||
class RerankRequest(BaseRequest):
|
||||
"""文本重排请求 DTO"""
|
||||
|
||||
query: str
|
||||
"""检索查询词"""
|
||||
documents: list[str | dict[str, str]]
|
||||
"""待排序的候选文档列表"""
|
||||
top_n: int = 3
|
||||
"""返回的最相关文档数量"""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaseRequest",
|
||||
"ChatRequest",
|
||||
"EmbeddingRequest",
|
||||
"ImageRequest",
|
||||
"RerankRequest",
|
||||
"SpeechRequest",
|
||||
]
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from zhenxun.utils.pydantic_compat import parse_as
|
||||
|
||||
from .parts import (
|
||||
AudioPart,
|
||||
ImagePart,
|
||||
LLMContentPart,
|
||||
TextPart,
|
||||
ThoughtPart,
|
||||
ToolCallPart,
|
||||
ToolReturnPart,
|
||||
)
|
||||
from .shared import RerankResult, UsageInfo
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
"""
|
||||
文本/多模态对话响应对象,确立 SSOT (单一数据源) 架构。
|
||||
"""
|
||||
|
||||
content_parts: list[LLMContentPart] = Field(default_factory=list)
|
||||
"""经由解析、统一封装后的标准内容部件列表"""
|
||||
usage_info: dict[str, Any] | None = None
|
||||
"""厂商返回的原始用量遥测字段"""
|
||||
raw_response: dict[str, Any] | None = None
|
||||
"""完整的 API 层级原生响应字典 (未经框架清洗)"""
|
||||
grounding_metadata: Any | None = None
|
||||
"""联网搜索、位置等基底事实归因元数据"""
|
||||
parsed_obj: Any | None = Field(default=None)
|
||||
"""在结构化输出模式下,由中间件反序列化得出的强类型 Pydantic 对象实例"""
|
||||
|
||||
def get_parsed_obj(self, model_class: type[T]) -> T | None:
|
||||
"""
|
||||
从结构化生成结果中获取强类型的 Pydantic 解析对象,提供完善的 IDE 类型推导支持。
|
||||
|
||||
参数:
|
||||
model_class: 期望提取的 Pydantic 模型类。
|
||||
|
||||
返回:
|
||||
强类型的模型实例,如果不存在则返回 None
|
||||
"""
|
||||
if self.parsed_obj is None:
|
||||
return None
|
||||
if isinstance(self.parsed_obj, model_class):
|
||||
return self.parsed_obj
|
||||
|
||||
try:
|
||||
return parse_as(model_class, self.parsed_obj)
|
||||
except Exception:
|
||||
return self.parsed_obj
|
||||
|
||||
@property
|
||||
def tool_calls(self) -> list[ToolCallPart]:
|
||||
return [p for p in self.content_parts if isinstance(p, ToolCallPart)]
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""动态视图:提取并拼接所有文本块"""
|
||||
return "".join(
|
||||
p.text for p in self.content_parts if isinstance(p, TextPart)
|
||||
).strip()
|
||||
|
||||
@property
|
||||
def thought_text(self) -> str | None:
|
||||
"""动态视图:提取并拼接所有思考/推理块"""
|
||||
thoughts = [
|
||||
p.thought_text for p in self.content_parts if isinstance(p, ThoughtPart)
|
||||
]
|
||||
return "\n".join(thoughts).strip() if thoughts else None
|
||||
|
||||
@property
|
||||
def thought_signature(self) -> str | None:
|
||||
"""动态视图:获取当前响应中的思考指纹"""
|
||||
for p in reversed(self.content_parts):
|
||||
if isinstance(p, ThoughtPart | ToolCallPart | ToolReturnPart):
|
||||
if p.metadata and "thought_signature" in p.metadata:
|
||||
return p.metadata["thought_signature"]
|
||||
return None
|
||||
|
||||
@property
|
||||
def images(self) -> list[bytes | Path | str]:
|
||||
"""动态视图:提取响应中包含的所有图片数据"""
|
||||
imgs = []
|
||||
for p in self.content_parts:
|
||||
if isinstance(p, ImagePart):
|
||||
if p.url:
|
||||
imgs.append(p.url)
|
||||
elif p.raw:
|
||||
imgs.append(p.raw)
|
||||
elif p.path:
|
||||
imgs.append(p.path)
|
||||
return imgs
|
||||
|
||||
@property
|
||||
def audios(self) -> list[bytes | Path | str]:
|
||||
"""动态视图:提取音频数据"""
|
||||
audios = []
|
||||
for p in self.content_parts:
|
||||
if isinstance(p, AudioPart):
|
||||
if p.url:
|
||||
audios.append(p.url)
|
||||
elif p.raw:
|
||||
audios.append(p.raw)
|
||||
elif p.path:
|
||||
audios.append(p.path)
|
||||
return audios
|
||||
|
||||
|
||||
class EmbeddingResponse(BaseModel):
|
||||
"""Embedding 向量富响应对象"""
|
||||
|
||||
embeddings: list[list[float]]
|
||||
"""多段输入文本对应生成的高维浮点数向量数组 (二维)"""
|
||||
usage: UsageInfo = Field(default_factory=UsageInfo)
|
||||
"""执行向量化任务产生的 Token 用量开销统计"""
|
||||
model_name: str
|
||||
"""实际用于执行本次编码任务的模型名称"""
|
||||
|
||||
@property
|
||||
def vector(self) -> list[float]:
|
||||
"""便捷属性:当且仅当只需提取单一文本向量时,直接返回一维向量"""
|
||||
return self.embeddings[0] if self.embeddings else []
|
||||
|
||||
|
||||
class AudioResponse(BaseModel):
|
||||
"""统一的语音合成响应对象"""
|
||||
|
||||
audio_bytes: bytes
|
||||
"""生成的音频二进制裸数据,可直接用于发送或保存"""
|
||||
audio_format: str
|
||||
"""实际返回的音频格式 (如 mp3, wav)"""
|
||||
usage: UsageInfo = Field(default_factory=UsageInfo)
|
||||
"""Token 或 字符数消耗统计"""
|
||||
raw_response: Any | None = None
|
||||
"""原生响应体,供高级调试使用"""
|
||||
model_name: str
|
||||
"""实际执行任务的模型名称"""
|
||||
|
||||
|
||||
class ImageResponse(BaseModel):
|
||||
"""图像生成响应对象"""
|
||||
|
||||
content_parts: list[LLMContentPart] = Field(default_factory=list)
|
||||
raw_response: dict[str, Any] | None = None
|
||||
|
||||
@property
|
||||
def images(self) -> list[bytes | Path | str]:
|
||||
"""动态视图:提取响应中包含的所有图片数据"""
|
||||
imgs = []
|
||||
for p in self.content_parts:
|
||||
if isinstance(p, ImagePart):
|
||||
if p.url:
|
||||
imgs.append(p.url)
|
||||
elif p.raw:
|
||||
imgs.append(p.raw)
|
||||
elif p.path:
|
||||
imgs.append(p.path)
|
||||
return imgs
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""动态视图:提取并拼接所有 TextPart 文本内容"""
|
||||
return "".join(
|
||||
p.text for p in self.content_parts if isinstance(p, TextPart)
|
||||
).strip()
|
||||
|
||||
|
||||
class RerankResponse(BaseModel):
|
||||
"""文本重排响应对象"""
|
||||
|
||||
results: list[RerankResult]
|
||||
"""重排后的文档结果列表"""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AudioResponse",
|
||||
"ChatResponse",
|
||||
"EmbeddingResponse",
|
||||
"ImageResponse",
|
||||
"RerankResponse",
|
||||
]
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsageInfo:
|
||||
"""使用信息数据类"""
|
||||
|
||||
prompt_tokens: int = 0
|
||||
"""请求发送的 Token 消耗数 (含系统提示词与历史记录)"""
|
||||
completion_tokens: int = 0
|
||||
"""模型回复生成的 Token 消耗数"""
|
||||
total_tokens: int = 0
|
||||
"""本次交互总计产生的 Token 数"""
|
||||
cost: float = 0.0
|
||||
"""(可选) 本次交互产生的实际账单估价"""
|
||||
prompt_cache_hit_tokens: int = 0
|
||||
"""被上下文缓存系统命中的 Prompt Token 数 (往往价格更低)"""
|
||||
prompt_cache_miss_tokens: int = 0
|
||||
"""未能命中缓存、实际执行了计算的 Prompt Token 数"""
|
||||
reasoning_tokens: int = 0
|
||||
"""专门用于内部思考/推理链 (CoT) 消耗的 Token 数"""
|
||||
|
||||
@property
|
||||
def efficiency_ratio(self) -> float:
|
||||
return self.completion_tokens / max(self.prompt_tokens, 1)
|
||||
|
||||
def __add__(self, other: "UsageInfo") -> "UsageInfo":
|
||||
"""支持 UsageInfo 相加,用于汇聚子智能体的 Token 消耗"""
|
||||
if not isinstance(other, UsageInfo):
|
||||
return self
|
||||
return UsageInfo(
|
||||
prompt_tokens=self.prompt_tokens + other.prompt_tokens,
|
||||
completion_tokens=self.completion_tokens + other.completion_tokens,
|
||||
total_tokens=self.total_tokens + other.total_tokens,
|
||||
cost=self.cost + other.cost,
|
||||
prompt_cache_hit_tokens=self.prompt_cache_hit_tokens
|
||||
+ other.prompt_cache_hit_tokens,
|
||||
prompt_cache_miss_tokens=self.prompt_cache_miss_tokens
|
||||
+ other.prompt_cache_miss_tokens,
|
||||
reasoning_tokens=self.reasoning_tokens + other.reasoning_tokens,
|
||||
)
|
||||
|
||||
|
||||
class LLMCodeExecution(BaseModel):
|
||||
"""大模型代码执行(沙箱/本地)结果实体"""
|
||||
|
||||
code: str
|
||||
"""被执行的原始代码"""
|
||||
output: str | None = None
|
||||
"""标准输出 (stdout)"""
|
||||
error: str | None = None
|
||||
"""标准错误 (stderr) 或框架抛出的异常"""
|
||||
execution_time: float | None = None
|
||||
"""代码执行耗时 (秒)"""
|
||||
files_generated: list[str] | None = None
|
||||
"""代码执行过程中生成的工件(Artifacts)文件路径或名称列表"""
|
||||
|
||||
|
||||
class LLMGroundingAttribution(BaseModel):
|
||||
"""基础事实溯源引用对象 (Grounding Attribution)"""
|
||||
|
||||
title: str | None = None
|
||||
"""来源网页或文档的标题"""
|
||||
uri: str | None = None
|
||||
"""来源内容的统一资源标识符 (URL)"""
|
||||
snippet: str | None = None
|
||||
"""从来源网页中提取的、支撑当前生成内容的文本片段"""
|
||||
confidence_score: float | None = None
|
||||
"""该引用来源与生成内容之间相关性的置信度分数"""
|
||||
|
||||
|
||||
class LLMGroundingMetadata(BaseModel):
|
||||
"""检索增强/搜索引擎溯源 (Grounding) 的完整元数据字典,
|
||||
用于为大模型返回的信息提供可信背书"""
|
||||
|
||||
web_search_queries: list[str] | None = None
|
||||
"""模型在执行检索时,实际使用的底层搜索引擎 Query 查询词列表"""
|
||||
grounding_attributions: list[LLMGroundingAttribution] | None = None
|
||||
"""溯源引用的详情列表,用于在 UI 端构建点击跳转链接或角标"""
|
||||
search_suggestions: list[dict[str, Any]] | None = None
|
||||
"""随搜索返回的相关搜索建议 (Search Suggestions)"""
|
||||
search_entry_point: str | None = None
|
||||
"""一段 HTML/CSS 内容,可用于在客户端渲染标准的搜索引擎入口/建议组件"""
|
||||
map_widget_token: str | None = None
|
||||
"""用于渲染 Google Maps 交互式地点小组件 (Places widget) 的
|
||||
上下文 Token (针对 googleMaps 工具)"""
|
||||
|
||||
|
||||
class RerankDocument(BaseModel):
|
||||
"""重排候选文档 (支持纯文本或图文字典)"""
|
||||
|
||||
text: str | None = None
|
||||
"""被用于重排检索的文本内容"""
|
||||
image: str | None = None
|
||||
"""用于多模态重排的图片内容"""
|
||||
|
||||
|
||||
class RerankResult(BaseModel):
|
||||
"""重排返回结果"""
|
||||
|
||||
index: int
|
||||
"""此记录对应于输入时的原始文档数组中的索引位置"""
|
||||
relevance_score: float
|
||||
"""计算出的相关性得分 (越大相关度通常越高)"""
|
||||
document: RerankDocument | None = Field(default=None)
|
||||
"""实际被命中的文档数据"""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LLMCodeExecution",
|
||||
"LLMGroundingAttribution",
|
||||
"LLMGroundingMetadata",
|
||||
"RerankDocument",
|
||||
"RerankResult",
|
||||
"UsageInfo",
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
from typing_extensions import TypeVar
|
||||
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
from pydantic import Field
|
||||
|
||||
from .parts import (
|
||||
AudioPart,
|
||||
FilePart,
|
||||
ImagePart,
|
||||
LLMContentPart,
|
||||
TextPart,
|
||||
ThoughtPart,
|
||||
ToolCallPart,
|
||||
ToolReturnPart,
|
||||
VideoPart,
|
||||
)
|
||||
|
||||
SystemContentUnion = TextPart
|
||||
"""系统消息允许的内容片段联合类型"""
|
||||
|
||||
UserContentUnion = TextPart | ImagePart | AudioPart | VideoPart | FilePart
|
||||
"""用户消息允许的多模态内容片段联合类型"""
|
||||
|
||||
AssistantContentUnion = (
|
||||
TextPart
|
||||
| ThoughtPart
|
||||
| ToolCallPart
|
||||
| ToolReturnPart
|
||||
| ImagePart
|
||||
| AudioPart
|
||||
| VideoPart
|
||||
| FilePart
|
||||
)
|
||||
"""助手回复允许的内容片段联合类型"""
|
||||
|
||||
ToolContentUnion = ToolReturnPart | ImagePart | AudioPart | VideoPart | FilePart
|
||||
"""工具消息允许的内容片段联合类型"""
|
||||
|
||||
|
||||
RoleT = TypeVar("RoleT", default=str, covariant=True)
|
||||
"""泛型:消息参与者角色类型变量"""
|
||||
|
||||
ContentT = TypeVar("ContentT", default=LLMContentPart, covariant=True)
|
||||
"""泛型:多模态片段数组的元素内容类型变量"""
|
||||
|
||||
|
||||
from .context_events import AgentEvent
|
||||
from .models import (
|
||||
AssistantMessage,
|
||||
LLMMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
UserMessage,
|
||||
)
|
||||
|
||||
AnyLLMMessage = Annotated[
|
||||
SystemMessage | UserMessage | AssistantMessage | ToolMessage,
|
||||
Field(discriminator="role"),
|
||||
]
|
||||
"""LLM 消息类型的合集联合类型"""
|
||||
|
||||
PromptInput = str | UniMessage | LLMMessage | list[LLMContentPart] | Any
|
||||
"""支持作为 LLM 输入的提示词对象联合类型,包括纯文本、UniMessage、LLMMessage 消息实体"""
|
||||
|
||||
AgentMessage = LLMMessage | AgentEvent
|
||||
"""Agent 上下文业务事件载体与原生网络载体的联合类型"""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentMessage",
|
||||
"AnyLLMMessage",
|
||||
"AssistantContentUnion",
|
||||
"ContentT",
|
||||
"LLMContentPart",
|
||||
"PromptInput",
|
||||
"RoleT",
|
||||
"SystemContentUnion",
|
||||
"ToolContentUnion",
|
||||
"UserContentUnion",
|
||||
]
|
||||
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
模型自身设定域类型定义
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Generic, Literal, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from zhenxun.services.ai.core.options import GenerationConfig
|
||||
|
||||
ModelName = str | None
|
||||
|
||||
TReq = TypeVar("TReq", bound=BaseModel)
|
||||
"""泛型:LLM 请求体模型约束 (必须继承自 BaseModel)"""
|
||||
TRes = TypeVar("TRes", bound=BaseModel)
|
||||
"""泛型:LLM 响应体模型约束 (必须继承自 BaseModel)"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelIdentity:
|
||||
"""模型的身份标识与基础能力数据传输对象 (DTO),剥离运行时状态"""
|
||||
|
||||
provider_name: str
|
||||
"""模型提供商名称"""
|
||||
model_name: str
|
||||
"""模型名称"""
|
||||
api_type: str
|
||||
"""API 适配器类型"""
|
||||
api_base: str | None
|
||||
"""API 基础请求地址/网关终结点"""
|
||||
path_prefix: str | None
|
||||
"""中转路由的 URL 前缀"""
|
||||
capabilities: "ModelCapabilities"
|
||||
"""模型的能力配置定义描述"""
|
||||
generation_config: GenerationConfig | None
|
||||
"""模型的默认生成配置"""
|
||||
|
||||
|
||||
class CancellationToken:
|
||||
"""全局取消令牌,用于在异步链路中传递中止信号"""
|
||||
|
||||
def __init__(self):
|
||||
self._cancelled = False
|
||||
self._futures: list[asyncio.Future] = []
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._cancelled = True
|
||||
for f in self._futures:
|
||||
if not f.done():
|
||||
f.cancel()
|
||||
|
||||
def is_cancelled(self) -> bool:
|
||||
return self._cancelled
|
||||
|
||||
def raise_if_cancelled(self) -> None:
|
||||
if self._cancelled:
|
||||
raise asyncio.CancelledError(
|
||||
"任务已被主动取消 (CancellationToken triggered)"
|
||||
)
|
||||
|
||||
def link_future(self, future: asyncio.Future) -> None:
|
||||
if self._cancelled:
|
||||
future.cancel()
|
||||
else:
|
||||
self._futures.append(future)
|
||||
|
||||
|
||||
class LLMContext(BaseModel, Generic[TReq, TRes]):
|
||||
"""LLM 执行上下文,用于在中间件管道中传递请求状态"""
|
||||
|
||||
request: TReq
|
||||
"""强类型的各模态请求对象实体。"""
|
||||
|
||||
runtime_state: dict[str, Any] = Field(default_factory=dict)
|
||||
"""中间件运行时的临时状态存储。"""
|
||||
cancellation_token: CancellationToken | None = Field(default=None)
|
||||
"""全局取消令牌。"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
class ToolDefinition(BaseModel):
|
||||
"""结构化的工具定义模型"""
|
||||
|
||||
name: str = Field(...)
|
||||
"""工具名称"""
|
||||
description: str = Field(...)
|
||||
"""工具描述"""
|
||||
parameters: dict[str, Any] = Field(default_factory=dict)
|
||||
"""JSON Schema 参数"""
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
"""元数据"""
|
||||
|
||||
|
||||
class ToolChoice(BaseModel):
|
||||
"""工具选择配置"""
|
||||
|
||||
mode: Literal["auto", "none", "any", "required"] = Field(default="auto")
|
||||
"""工具选择模式"""
|
||||
allowed_function_names: list[str] | None = Field(default=None)
|
||||
"""允许调用的函数名称列表"""
|
||||
|
||||
|
||||
class ModelModality(str, Enum):
|
||||
TEXT = "text"
|
||||
IMAGE = "image"
|
||||
AUDIO = "audio"
|
||||
VIDEO = "video"
|
||||
FILE = "file"
|
||||
EMBEDDING = "embedding"
|
||||
|
||||
|
||||
class ReasoningMode(str, Enum):
|
||||
"""推理/思考模式类型"""
|
||||
|
||||
NONE = "none"
|
||||
BUDGET = "budget"
|
||||
LEVEL = "level"
|
||||
EFFORT = "effort"
|
||||
|
||||
|
||||
class ModelCapabilities(BaseModel):
|
||||
"""定义一个模型的核心能力。"""
|
||||
|
||||
input_modalities: set[ModelModality] = Field(default={ModelModality.TEXT})
|
||||
"""模型支持的输入模态集合。"""
|
||||
output_modalities: set[ModelModality] = Field(default={ModelModality.TEXT})
|
||||
"""模型支持的输出模态集合。"""
|
||||
supports_tool_calling: bool = False
|
||||
"""是否支持工具调用能力。"""
|
||||
is_embedding_model: bool = False
|
||||
"""是否为嵌入模型。"""
|
||||
is_rerank_model: bool = False
|
||||
"""是否为重排序模型。"""
|
||||
reasoning_mode: ReasoningMode = ReasoningMode.NONE
|
||||
"""推理模式类型。"""
|
||||
reasoning_visibility: Literal["visible", "hidden", "none"] = "none"
|
||||
"""推理过程可见性设置。"""
|
||||
reasoning_effort_map: dict[str, str] = Field(default_factory=dict)
|
||||
"""思考强度参数(reasoning_effort)的降级映射矩阵,如 {"max": "xhigh"}。"""
|
||||
max_input_tokens: int = Field(default=256000)
|
||||
"""最大输入 Token 数量(用于触发上下文压缩策略,未显式声明则默认为 256K)。"""
|
||||
supported_native_tools: set[str] = Field(default_factory=set)
|
||||
"""该模型实际支持的云端原生能力/内置工具。"""
|
||||
default_voice_id: str | None = None
|
||||
"""默认的语音合成音色 ID(TTS模型专用)。"""
|
||||
|
||||
features: set[str] = Field(default_factory=set)
|
||||
"""用于第三方插件动态注入的自定义能力标签。"""
|
||||
|
||||
def supports_task(self, task: str) -> bool:
|
||||
"""判断模型是否支持指定的底层任务类型"""
|
||||
if task == "embedding":
|
||||
return self.is_embedding_model
|
||||
elif task == "rerank":
|
||||
return self.is_rerank_model
|
||||
elif task == "tts":
|
||||
return ModelModality.AUDIO in self.output_modalities
|
||||
elif task == "image":
|
||||
return ModelModality.IMAGE in self.output_modalities
|
||||
elif task == "chat":
|
||||
return ModelModality.TEXT in self.output_modalities
|
||||
return False
|
||||
|
||||
def accepts_input(self, modality: ModelModality) -> bool:
|
||||
"""检查模型是否支持某种输入模态"""
|
||||
return modality in self.input_modalities
|
||||
|
||||
def accepts_output(self, modality: ModelModality) -> bool:
|
||||
"""检查模型是否支持某种输出模态"""
|
||||
return modality in self.output_modalities
|
||||
|
||||
def has_feature(self, feature: str) -> bool:
|
||||
"""检查模型是否具备某个扩展特性"""
|
||||
return feature in self.features
|
||||
|
||||
|
||||
class ModelDetail(BaseModel):
|
||||
"""模型详细信息"""
|
||||
|
||||
model_name: str
|
||||
"""模型名称。"""
|
||||
is_available: bool = True
|
||||
"""模型是否可用。"""
|
||||
temperature: float | None = None
|
||||
"""采样温度参数。"""
|
||||
generation_max_tokens: int | None = None
|
||||
"""单次生成最大 Token 数。"""
|
||||
api_type: str | None = None
|
||||
"""API 类型标识。"""
|
||||
endpoint: str | None = None
|
||||
"""模型服务端点地址。"""
|
||||
task_type: str | None = Field(default=None)
|
||||
"""显式声明的主任务类型 (如 'image_generation')。"""
|
||||
path_prefix: str | None = Field(default=None)
|
||||
"""中转路由前缀,例如 '/cogvideox' 或 '/minimax'。"""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CancellationToken",
|
||||
"LLMContext",
|
||||
"ModelCapabilities",
|
||||
"ModelDetail",
|
||||
"ModelIdentity",
|
||||
"ModelModality",
|
||||
"ModelName",
|
||||
"ReasoningMode",
|
||||
"ToolChoice",
|
||||
"ToolDefinition",
|
||||
]
|
||||
@@ -0,0 +1,408 @@
|
||||
"""
|
||||
AI 模块配置数据域类型定义
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from zhenxun.utils.pydantic_compat import model_copy, model_dump, model_validate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from zhenxun.services.ai.llm.builder import IntentBuilder
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class ResponseFormat(Enum):
|
||||
"""响应格式枚举"""
|
||||
|
||||
TEXT = "text"
|
||||
JSON = "json"
|
||||
MULTIMODAL = "multimodal"
|
||||
|
||||
|
||||
class StructuredOutputStrategy(str, Enum):
|
||||
"""结构化输出策略"""
|
||||
|
||||
NATIVE = "native"
|
||||
"""使用原生 API (如 OpenAI json_object/json_schema, Gemini mime_type)"""
|
||||
|
||||
TOOL_CALL = "tool_call"
|
||||
"""构造虚假工具调用来强制输出结构化数据 (适用于指令跟随弱但工具调用强的模型)"""
|
||||
PROMPT = "prompt"
|
||||
"""仅在 Prompt 中追加 Schema 说明,依赖文本补全"""
|
||||
|
||||
|
||||
class EmbeddingTaskType(str, Enum):
|
||||
"""
|
||||
文本嵌入任务类型 (对应 Gemini embedding 模型的 task_type 参数)
|
||||
"""
|
||||
|
||||
RETRIEVAL_QUERY = "RETRIEVAL_QUERY"
|
||||
"""指定给定的文本是搜索/检索设置中的查询 (Query)。"""
|
||||
RETRIEVAL_DOCUMENT = "RETRIEVAL_DOCUMENT"
|
||||
"""指定给定的文本是被搜索语料库中的文档 (Document)。"""
|
||||
SEMANTIC_SIMILARITY = "SEMANTIC_SIMILARITY"
|
||||
"""指定文本将用于语义文本相似度 (STS) 计算。"""
|
||||
CLASSIFICATION = "CLASSIFICATION"
|
||||
"""指定嵌入向量将用于文本分类任务。"""
|
||||
CLUSTERING = "CLUSTERING"
|
||||
"""指定嵌入向量将用于聚类任务。"""
|
||||
QUESTION_ANSWERING = "QUESTION_ANSWERING"
|
||||
"""指定文本将用于问答任务。"""
|
||||
FACT_VERIFICATION = "FACT_VERIFICATION"
|
||||
"""指定文本将用于事实核查任务。"""
|
||||
|
||||
|
||||
class BaseOutputDefinition(Generic[T]):
|
||||
"""声明式结构化输出基类"""
|
||||
|
||||
type_: type[T]
|
||||
|
||||
|
||||
class ToolOutput(BaseOutputDefinition[T]):
|
||||
"""工具输出标记:使用强制工具调用 (Tool Call) 结束任务并返回指定结构"""
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
strict: bool | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
type_: type[T],
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
strict: bool | None = None,
|
||||
):
|
||||
self.type_ = type_
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.strict = strict
|
||||
|
||||
|
||||
class ReasoningEffort(str, Enum):
|
||||
"""推理努力程度枚举"""
|
||||
|
||||
NONE = "NONE"
|
||||
"""不开启推理思考"""
|
||||
MINIMAL = "MINIMAL"
|
||||
"""极低推理努力,追求最快响应"""
|
||||
LOW = "LOW"
|
||||
"""较低推理努力"""
|
||||
MEDIUM = "MEDIUM"
|
||||
"""中等推理努力(通常是默认值)"""
|
||||
HIGH = "HIGH"
|
||||
"""高推理努力"""
|
||||
XHIGH = "XHIGH"
|
||||
"""极高推理努力"""
|
||||
MAX = "MAX"
|
||||
"""最大推理努力 (如 GLM / DeepSeek)"""
|
||||
|
||||
|
||||
class ImageAspectRatio(str, Enum):
|
||||
"""图像宽高比枚举"""
|
||||
|
||||
SQUARE = "1:1"
|
||||
"""正方形"""
|
||||
LANDSCAPE_16_9 = "16:9"
|
||||
"""横向宽屏 16:9"""
|
||||
PORTRAIT_9_16 = "9:16"
|
||||
"""竖向全屏 9:16"""
|
||||
LANDSCAPE_4_3 = "4:3"
|
||||
"""横向标准 4:3"""
|
||||
PORTRAIT_3_4 = "3:4"
|
||||
"""竖向标准 3:4"""
|
||||
LANDSCAPE_3_2 = "3:2"
|
||||
"""横向 3:2"""
|
||||
PORTRAIT_2_3 = "2:3"
|
||||
"""竖向 2:3"""
|
||||
|
||||
|
||||
class ImageResolution(str, Enum):
|
||||
"""图像分辨率/质量枚举"""
|
||||
|
||||
STANDARD = "STANDARD"
|
||||
"""标准分辨率"""
|
||||
HD = "HD"
|
||||
"""高清分辨率"""
|
||||
|
||||
|
||||
class CommonLLMConfig(BaseModel):
|
||||
"""三大厂商通用基础生成参数"""
|
||||
|
||||
temperature: float | None = Field(default=None, ge=0.0, le=2.0)
|
||||
"""采样温度。较高的值会使输出更加随机,较低的值会使其更加集中和确定。"""
|
||||
max_tokens: int | None = Field(default=None, gt=0)
|
||||
"""聊天完成时生成的最大 Token 数。"""
|
||||
top_p: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
"""核采样 (Nucleus sampling) 概率阈值。"""
|
||||
top_k: int | None = Field(default=None, gt=0)
|
||||
"""仅从概率最高的前 K 个 Token 中采样 (并非所有模型支持)。"""
|
||||
frequency_penalty: float | None = Field(default=None, ge=-2.0, le=2.0)
|
||||
"""频率惩罚。正值根据新 Token 在文本中的现有频率对其进行惩罚,
|
||||
降低模型逐字重复同一行的可能性。"""
|
||||
presence_penalty: float | None = Field(default=None, ge=-2.0, le=2.0)
|
||||
"""存在惩罚。正值根据新 Token 到目前为止是否出现在文本中对其进行惩罚,
|
||||
增加模型谈论新主题的可能性。"""
|
||||
repetition_penalty: float | None = Field(default=None, ge=0.0, le=2.0)
|
||||
"""重复惩罚系数 (部分非 OpenAI 兼容模型独有)。"""
|
||||
stop: list[str] | str | None = Field(default=None)
|
||||
"""API 停止生成后续 Token 的停止词序列。"""
|
||||
reasoning_effort: ReasoningEffort | str | None = Field(default=None)
|
||||
"""跨厂商统一的思考/推理等级意图(如 'low', 'high', 'max')。
|
||||
具体映射由底层的 Adapter 执行。"""
|
||||
|
||||
|
||||
class OutputFormatConfig(BaseModel):
|
||||
"""输出格式与结构化控制"""
|
||||
|
||||
response_format: ResponseFormat | dict[str, Any] | None = Field(default=None)
|
||||
"""响应格式类型 (枚举或字典形式的 json_schema 对象)。"""
|
||||
response_mime_type: str | None = Field(default=None)
|
||||
"""指定 MIME 类型 (如 application/json),主要用于 Gemini。"""
|
||||
response_schema: dict[str, Any] | None = Field(default=None)
|
||||
"""JSON Schema 字典,用于强制约束模型返回的 JSON 结构。"""
|
||||
response_modalities: list[str] | None = Field(default=None)
|
||||
"""允许的响应模态 (如 ["TEXT", "IMAGE"]),主要用于 Gemini。"""
|
||||
structured_output_strategy: StructuredOutputStrategy | str | None = Field(
|
||||
default=None
|
||||
)
|
||||
"""结构化输出所采用的内部策略。"""
|
||||
|
||||
|
||||
class ToolCallConfig(BaseModel):
|
||||
"""工具调用统一策略配置"""
|
||||
|
||||
mode: Literal["AUTO", "ANY", "NONE"] = Field(default="AUTO")
|
||||
"""工具调用模式 (AUTO: 自动, ANY: 强制至少调一个, NONE: 禁用)。"""
|
||||
allowed_function_names: list[str] | None = Field(default=None)
|
||||
"""允许被调用的特定函数名称白名单。"""
|
||||
include_server_side_tool_invocations: bool | None = Field(default=None)
|
||||
"""是否包含服务端侧的工具调用日志流转 (主要用于 Gemini)。"""
|
||||
|
||||
|
||||
class BaseProviderOption(BaseModel):
|
||||
"""厂商配置逃生舱基类"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") # type: ignore
|
||||
|
||||
|
||||
class OpenAIOptions(BaseProviderOption):
|
||||
"""OpenAI 专属特权参数 (适配 Responses API)"""
|
||||
|
||||
store: bool | None = Field(default=None)
|
||||
"""是否允许服务端留存本次请求的选项记录"""
|
||||
metadata: dict[str, str] | None = Field(default=None)
|
||||
"""附加在请求上的自定义元数据"""
|
||||
|
||||
|
||||
class GeminiOptions(BaseProviderOption):
|
||||
"""Gemini 专属特权参数"""
|
||||
|
||||
include_thoughts: bool | None = Field(default=None)
|
||||
"""是否在最终响应中包含模型的内部思考过程 (Thoughts)"""
|
||||
safety_settings: dict[str, str] | None = Field(default=None)
|
||||
"""Gemini 专有的各个维度的安全过滤阈值配置"""
|
||||
retrieval_config: dict[str, Any] | None = Field(default=None)
|
||||
"""检索定位配置,如 LBS 经纬度信息,配合 Google Maps 工具使用"""
|
||||
|
||||
|
||||
class DeepSeekOptions(BaseProviderOption):
|
||||
"""DeepSeek 专属特权参数"""
|
||||
|
||||
thinking: bool | None = Field(default=None)
|
||||
"""是否强制开启或关闭 R1 模型的思维链过程"""
|
||||
|
||||
|
||||
class OpenAITTSOptions(BaseProviderOption):
|
||||
"""OpenAI TTS 专属特权参数"""
|
||||
|
||||
voice_id: str | None = Field(default=None)
|
||||
"""专属的音色 ID 配置"""
|
||||
|
||||
|
||||
class GeminiTTSOptions(BaseProviderOption):
|
||||
"""Gemini TTS 专属特权参数"""
|
||||
|
||||
voice_id: str | None = Field(default=None)
|
||||
"""专属的音色 ID 配置"""
|
||||
multi_speaker: bool | None = Field(default=None)
|
||||
"""是否开启多说话人模式"""
|
||||
second_voice: str | None = Field(default=None)
|
||||
"""多说话人模式下的第二音色名称"""
|
||||
|
||||
|
||||
class MiniMaxTTSOptions(BaseProviderOption):
|
||||
"""MiniMax TTS 专属特权参数 (控制极度精细)"""
|
||||
|
||||
voice_id: str | None = Field(default=None)
|
||||
"""专属的音色 ID 配置"""
|
||||
vol: float | None = Field(default=None, gt=0.0, le=10.0)
|
||||
"""音量,范围 (0, 10]"""
|
||||
pitch: int | None = Field(default=None, ge=-12, le=12)
|
||||
"""语调,范围 [-12, 12]"""
|
||||
emotion: (
|
||||
Literal[
|
||||
"happy",
|
||||
"sad",
|
||||
"angry",
|
||||
"fearful",
|
||||
"disgusted",
|
||||
"surprised",
|
||||
"calm",
|
||||
"fluent",
|
||||
"whisper",
|
||||
]
|
||||
| None
|
||||
) = Field(default=None)
|
||||
"""情感控制"""
|
||||
timbre_weights: list[dict[str, Any]] | None = Field(default=None)
|
||||
"""音色混合权重 (最多4种)"""
|
||||
pronunciation_dict: dict[str, list[str]] | None = Field(default=None)
|
||||
"""自定义发音字典 (如: {"tone": ["处理/(chu3)(li3)"]})"""
|
||||
|
||||
|
||||
class MiMoTTSOptions(BaseProviderOption):
|
||||
"""MiMo TTS 专属特权参数"""
|
||||
|
||||
voice_id: str | None = Field(default=None)
|
||||
"""专属的音色 ID 配置"""
|
||||
|
||||
|
||||
class MediaGenerationConfig(BaseModel):
|
||||
"""多模态媒体生成/优化的全局配置"""
|
||||
|
||||
aspect_ratio: ImageAspectRatio | str | None = Field(default=None)
|
||||
"""生成的图像/视频宽高比 (如 '16:9')"""
|
||||
resolution: str | None = Field(default=None)
|
||||
"""生成的图像/视频分辨率 (如 '1K', '4K' 或 '1024x1024')"""
|
||||
quality: Literal["low", "medium", "high", "standard", "hd"] | None = Field(
|
||||
default=None
|
||||
)
|
||||
"""渲染质量及细节丰富水平"""
|
||||
|
||||
|
||||
class TTSConfig(BaseModel):
|
||||
"""文本转语音 (TTS) 全局配置"""
|
||||
|
||||
response_format: Literal["mp3", "wav", "pcm", "flac", "opus", "aac"] = Field(
|
||||
default="mp3"
|
||||
)
|
||||
"""输出音频格式"""
|
||||
speed: float = Field(default=1.0, ge=0.25, le=4.0)
|
||||
"""语速 (通用映射)"""
|
||||
|
||||
openai_options: OpenAITTSOptions = Field(default_factory=OpenAITTSOptions)
|
||||
gemini_options: GeminiTTSOptions = Field(default_factory=GeminiTTSOptions)
|
||||
minimax_options: MiniMaxTTSOptions = Field(default_factory=MiniMaxTTSOptions)
|
||||
mimo_options: MiMoTTSOptions = Field(default_factory=MiMoTTSOptions)
|
||||
|
||||
custom_kwargs: dict[str, Any] = Field(default_factory=dict)
|
||||
"""兜底逃生舱,包含的键值对将直接透传至顶层请求体中 (可用于缓存 TTL)"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
class GenerationConfig(BaseModel):
|
||||
"""
|
||||
现代化 LLM 生成基座 (Intent-Driven Base)。
|
||||
隔离通用参数与厂商私有参数,彻底终结"上帝类"。
|
||||
"""
|
||||
|
||||
common: CommonLLMConfig = Field(default_factory=CommonLLMConfig)
|
||||
output: OutputFormatConfig = Field(default_factory=OutputFormatConfig)
|
||||
tools: ToolCallConfig = Field(default_factory=ToolCallConfig)
|
||||
media: MediaGenerationConfig = Field(default_factory=MediaGenerationConfig)
|
||||
|
||||
openai_options: OpenAIOptions = Field(default_factory=OpenAIOptions)
|
||||
gemini_options: GeminiOptions = Field(default_factory=GeminiOptions)
|
||||
deepseek_options: DeepSeekOptions = Field(default_factory=DeepSeekOptions)
|
||||
|
||||
enable_caching: bool | None = Field(default=None)
|
||||
"""是否在此次生成中开启上下文缓存 (Context Caching)"""
|
||||
custom_kwargs: dict[str, Any] = Field(default_factory=dict)
|
||||
"""兜底逃生舱,包含的键值对将直接透传至顶层请求体中"""
|
||||
validation_policy: dict[str, Any] | None = Field(default=None)
|
||||
"""自定义验证策略字典"""
|
||||
response_validator: Callable[[Any], None] | None = Field(default=None)
|
||||
"""针对原始返回对象的自定义回调验证器"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
@classmethod
|
||||
def builder(cls) -> "IntentBuilder":
|
||||
from zhenxun.services.ai.llm.builder import IntentBuilder
|
||||
|
||||
return IntentBuilder()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return model_dump(self, exclude_none=True)
|
||||
|
||||
def merge_with(self, other: "GenerationConfig | None") -> "GenerationConfig":
|
||||
"""深度合并两个配置,实现配置的无损叠加"""
|
||||
if not other:
|
||||
return model_copy(self, deep=True)
|
||||
|
||||
base_dump = model_dump(self, exclude_none=True)
|
||||
other_dump = model_dump(other, exclude_none=True)
|
||||
|
||||
def deep_merge(d1: dict, d2: dict) -> dict:
|
||||
res = d1.copy()
|
||||
for k, v in d2.items():
|
||||
if isinstance(v, dict) and k in res and isinstance(res[k], dict):
|
||||
res[k] = deep_merge(res[k], v)
|
||||
else:
|
||||
res[k] = v
|
||||
return res
|
||||
|
||||
merged_dump = deep_merge(base_dump, other_dump)
|
||||
return model_validate(GenerationConfig, merged_dump)
|
||||
|
||||
|
||||
class LLMEmbeddingConfig(BaseModel):
|
||||
"""Embedding 专用配置"""
|
||||
|
||||
task_type: str | None = Field(default=None)
|
||||
"""生成意图的任务类型,参考 EmbeddingTaskType (主要用于 Gemini 和 Jina)"""
|
||||
output_dimensionality: int | None = Field(default=None)
|
||||
"""请求模型强制输出(或截断)的较低维度数,实现维度压缩"""
|
||||
title: str | None = Field(default=None)
|
||||
"""提供该文档的标题以供底层优化。仅在 task_type 为 RETRIEVAL_DOCUMENT 时有效。"""
|
||||
encoding_format: str | None = Field(default="float")
|
||||
"""向量数据在响应中的编码格式 (通常为 float 或 base64)"""
|
||||
multimodal: bool | list[str] = Field(default=False)
|
||||
"""是否允许多模态向量化。False 表示纯文本(极速安全);True 表示全部放行;
|
||||
也可传入 ['image', 'text'] 细粒度控制。"""
|
||||
custom_kwargs: dict[str, Any] = Field(default_factory=dict)
|
||||
"""兜底逃生舱,包含的键值对将直接透传至顶层请求体中 (可用于缓存 TTL)"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaseProviderOption",
|
||||
"CommonLLMConfig",
|
||||
"EmbeddingTaskType",
|
||||
"GeminiOptions",
|
||||
"GeminiTTSOptions",
|
||||
"GenerationConfig",
|
||||
"ImageAspectRatio",
|
||||
"ImageResolution",
|
||||
"LLMEmbeddingConfig",
|
||||
"MiMoTTSOptions",
|
||||
"MiniMaxTTSOptions",
|
||||
"OpenAIOptions",
|
||||
"OpenAITTSOptions",
|
||||
"OutputFormatConfig",
|
||||
"ReasoningEffort",
|
||||
"ResponseFormat",
|
||||
"StructuredOutputStrategy",
|
||||
"TTSConfig",
|
||||
"ToolCallConfig",
|
||||
"ToolOutput",
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
AI 服务协议统一导出
|
||||
"""
|
||||
|
||||
from .tool import ToolExecutable, ToolProvider, ToolResolvable
|
||||
|
||||
__all__ = [
|
||||
"ToolExecutable",
|
||||
"ToolProvider",
|
||||
"ToolResolvable",
|
||||
]
|
||||
@@ -0,0 +1,76 @@
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from zhenxun.services.ai.core.messages import (
|
||||
AudioResponse,
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
EmbeddingRequest,
|
||||
EmbeddingResponse,
|
||||
ImageRequest,
|
||||
ImageResponse,
|
||||
RerankRequest,
|
||||
RerankResponse,
|
||||
SpeechRequest,
|
||||
)
|
||||
from zhenxun.services.ai.core.models import CancellationToken
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsChat(Protocol):
|
||||
"""支持文本/多模态对话生成的协议"""
|
||||
|
||||
async def generate_response(
|
||||
self,
|
||||
request: ChatRequest,
|
||||
cancellation_token: CancellationToken | None = None,
|
||||
) -> ChatResponse:
|
||||
"""生成文本或多模态对话的回复。"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsTextEmbedding(Protocol):
|
||||
"""支持文本/多模态向量嵌入的协议"""
|
||||
|
||||
async def generate_embeddings(
|
||||
self,
|
||||
request: EmbeddingRequest,
|
||||
) -> EmbeddingResponse:
|
||||
"""生成文本或多模态向量嵌入。"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsSpeechSynthesis(Protocol):
|
||||
"""支持文本转语音(TTS)的协议"""
|
||||
|
||||
async def generate_speech(
|
||||
self,
|
||||
request: SpeechRequest,
|
||||
) -> AudioResponse:
|
||||
"""将文本转换为语音(TTS)。"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsReranking(Protocol):
|
||||
"""支持文档交叉注意力重排的协议"""
|
||||
|
||||
async def rerank(
|
||||
self,
|
||||
request: RerankRequest,
|
||||
) -> RerankResponse:
|
||||
"""对候选文档进行交叉注意力重排。"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsImageGeneration(Protocol):
|
||||
"""支持图像生成与编辑的协议"""
|
||||
|
||||
async def generate_image(
|
||||
self,
|
||||
request: ImageRequest,
|
||||
) -> ImageResponse:
|
||||
"""根据请求生成或编辑图像。"""
|
||||
...
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
LLM 中间件协议定义
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from zhenxun.services.ai.core.models import LLMContext, TReq, TRes
|
||||
|
||||
|
||||
class NextCall(Protocol[TReq, TRes]):
|
||||
"""中间件管道中下一个节点(或最终执行函数)的调用签名"""
|
||||
|
||||
async def __call__(self, context: LLMContext[TReq, TRes], /) -> TRes: ...
|
||||
|
||||
|
||||
class LLMMiddleware(Protocol[TReq, TRes]):
|
||||
"""LLM 中间件函数的调用签名,遵循洋葱模型嵌套包裹设计"""
|
||||
|
||||
async def __call__(
|
||||
self, context: LLMContext[TReq, TRes], next_call: NextCall[TReq, TRes], /
|
||||
) -> TRes: ...
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
工具执行与管理协议定义
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
||||
|
||||
from zhenxun.services.ai.core.models import ToolDefinition
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from zhenxun.services.ai.run import RunContext
|
||||
from zhenxun.services.ai.tools.models import (
|
||||
ResolvedToolPayload,
|
||||
ToolResult,
|
||||
)
|
||||
|
||||
|
||||
class ToolExecutable(Protocol):
|
||||
"""
|
||||
一个协议,定义了所有可被LLM调用的工具必须实现的行为。
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""工具的名称标识"""
|
||||
|
||||
async def get_definition(
|
||||
self, context: "RunContext | None" = None
|
||||
) -> ToolDefinition | None:
|
||||
"""
|
||||
异步地获取一个结构化的工具定义。如果返回 None,则该工具对大模型不可见。
|
||||
"""
|
||||
...
|
||||
|
||||
async def execute(
|
||||
self, context: "RunContext | None" = None, **kwargs: Any
|
||||
) -> ToolResult:
|
||||
"""
|
||||
异步执行工具并返回一个结构化的结果。
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ToolResolvable(Protocol):
|
||||
"""
|
||||
鸭子类型解析协议。任何实现了此协议的对象,
|
||||
都可以直接被 Agent 或 LLM 的 tools 参数接收。
|
||||
"""
|
||||
|
||||
async def resolve(
|
||||
self, context: "RunContext | None" = None
|
||||
) -> "ResolvedToolPayload": ...
|
||||
|
||||
|
||||
class ToolProvider(Protocol):
|
||||
"""
|
||||
一个协议,定义了"工具提供者"的行为。
|
||||
工具提供者负责发现或实例化具体的 ToolExecutable 对象。
|
||||
"""
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""
|
||||
异步初始化提供者。
|
||||
"""
|
||||
...
|
||||
|
||||
async def discover_tools(
|
||||
self,
|
||||
allowed_servers: list[str] | None = None,
|
||||
excluded_servers: list[str] | None = None,
|
||||
) -> dict[str, ToolExecutable]:
|
||||
"""
|
||||
异步发现此提供者提供的所有工具。
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_tool_executable(
|
||||
self, name: str, config: dict[str, Any]
|
||||
) -> ToolExecutable | None:
|
||||
"""
|
||||
如果此提供者能处理名为 'name' 的工具,则返回一个可执行实例。
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from nonebot.utils import is_coroutine_callable
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
|
||||
class AgentStreamEvent(BaseModel):
|
||||
"""
|
||||
Agent 局部流事件与生命周期事件的绝对统一基类
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
|
||||
class LLMStartEvent(AgentStreamEvent):
|
||||
"""大模型网络请求开始事件"""
|
||||
|
||||
model_name: str
|
||||
"""请求的大模型名称"""
|
||||
messages: list[Any]
|
||||
"""发往大模型的历史消息列表"""
|
||||
|
||||
|
||||
class LLMEndEvent(AgentStreamEvent):
|
||||
"""大模型网络请求结束事件"""
|
||||
|
||||
response: Any
|
||||
"""大模型返回的完整响应 (ChatResponse)"""
|
||||
|
||||
|
||||
class ToolCallStartEvent(AgentStreamEvent):
|
||||
"""工具调用开始事件"""
|
||||
|
||||
tool_name: str
|
||||
"""调用的工具名称"""
|
||||
arguments: dict[str, Any]
|
||||
"""工具调用参数"""
|
||||
intent: str | None = None
|
||||
"""从大模型调用参数中剥离出的意图 (_intent)"""
|
||||
|
||||
|
||||
class ToolCallEndEvent(AgentStreamEvent):
|
||||
"""工具调用结束事件"""
|
||||
|
||||
tool_name: str
|
||||
"""工具名称"""
|
||||
result: Any
|
||||
"""工具最终返回的结果"""
|
||||
is_error: bool
|
||||
"""工具执行是否失败"""
|
||||
|
||||
|
||||
class ToolStreamChunkEvent(AgentStreamEvent):
|
||||
"""工具或后台任务流式进度反馈事件"""
|
||||
|
||||
tool_name: str
|
||||
"""工具名称"""
|
||||
content: str
|
||||
"""当前流式输出的文本片段"""
|
||||
metadata: dict[str, Any] | None = None
|
||||
"""流式的附加元数据 (如进度比例等)"""
|
||||
|
||||
|
||||
class UserCustomEvent(AgentStreamEvent):
|
||||
"""自定义用户界面交互事件"""
|
||||
|
||||
display: Any
|
||||
"""用于前端渲染的展示对象 (如 UniMessage, str, ImagePart)"""
|
||||
log_content: str | None = None
|
||||
"""用于后台打印的日志摘要"""
|
||||
|
||||
|
||||
class ControlFlowEvent(AgentStreamEvent):
|
||||
"""控制流中断与流转事件"""
|
||||
|
||||
action: str
|
||||
"""控制流动作,如 'handoff', 'abort', 'end_run'"""
|
||||
payload: Any = None
|
||||
"""附加的数据载荷"""
|
||||
|
||||
|
||||
T_Event = TypeVar("T_Event", bound=AgentStreamEvent)
|
||||
"""泛型变量:用于绑定具体的事件类型,提供完美的 IDE 类型推导"""
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""
|
||||
局部事件总线 (Event Bus)
|
||||
EventBus 支持异步迭代与发布-订阅(Pub/Sub)模式
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
初始化 EventBus 实例。
|
||||
"""
|
||||
self._queue = asyncio.Queue()
|
||||
self._finished = False
|
||||
self._subscribers: dict[type[AgentStreamEvent], list[Callable[[Any], Any]]] = (
|
||||
defaultdict(list)
|
||||
)
|
||||
self._background_tasks = set()
|
||||
|
||||
def subscribe(
|
||||
self, event_type: type[T_Event], handler: Callable[[T_Event], Any]
|
||||
) -> None:
|
||||
"""
|
||||
注册事件监听器,用于订阅特定类型的事件。
|
||||
|
||||
参数:
|
||||
event_type: 要订阅的事件类型(需为 AgentStreamEvent 的子类)。
|
||||
handler: 事件处理回调函数。当对应事件发布时被触发,参数为事件实例。
|
||||
"""
|
||||
self._subscribers[event_type].append(handler)
|
||||
|
||||
async def emit(self, event: AgentStreamEvent) -> None:
|
||||
"""
|
||||
发布事件,触发所有匹配的订阅者,并将事件放入迭代队列中。
|
||||
|
||||
参数:
|
||||
event: 要发布的事件实例(需继承自 AgentStreamEvent)。
|
||||
"""
|
||||
handlers = []
|
||||
for ev_type, cb_list in self._subscribers.items():
|
||||
if isinstance(event, ev_type):
|
||||
handlers.extend(cb_list)
|
||||
|
||||
for handler in handlers:
|
||||
if is_coroutine_callable(handler):
|
||||
|
||||
async def _run_handler(h=handler, e=event):
|
||||
try:
|
||||
await h(e)
|
||||
except Exception as err:
|
||||
logger.error(f"EventBus 订阅者执行异常: {err}")
|
||||
|
||||
task = asyncio.create_task(_run_handler())
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
else:
|
||||
try:
|
||||
handler(event)
|
||||
except Exception as err:
|
||||
logger.error(f"EventBus 订阅者执行异常: {err}")
|
||||
|
||||
if not self._finished:
|
||||
await self._queue.put(event)
|
||||
|
||||
async def end(self):
|
||||
"""
|
||||
结束事件总线。等待所有后台任务执行完毕,并向队列中投放结束标记以终止异步迭代。
|
||||
"""
|
||||
if self._background_tasks:
|
||||
tasks = list(self._background_tasks)
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
self._finished = True
|
||||
await self._queue.put(None)
|
||||
|
||||
async def __aiter__(self):
|
||||
"""
|
||||
支持异步迭代,可通过 async for 循环消费事件总线中的事件。
|
||||
|
||||
返回:
|
||||
AsyncIterator: 异步事件流生成器。
|
||||
"""
|
||||
while True:
|
||||
event = await self._queue.get()
|
||||
if event is None:
|
||||
break
|
||||
yield event
|
||||
@@ -0,0 +1,103 @@
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, Generic, cast
|
||||
from typing_extensions import TypeVar
|
||||
|
||||
from jinja2 import Environment
|
||||
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.pydantic_compat import model_dump
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from zhenxun.services.ai.run.context import RunContext
|
||||
|
||||
AgentDepsT = TypeVar("AgentDepsT", default=Any)
|
||||
|
||||
|
||||
class PromptTemplate(Generic[AgentDepsT]):
|
||||
"""
|
||||
核心 Prompt 渲染引擎。
|
||||
基于 Jinja2 提供强大的模板变量替换功能。
|
||||
支持沙盒隔离的自定义过滤器(filters)、全局函数(globals),
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
template_string: str,
|
||||
custom_filters: dict[str, Callable] | None = None,
|
||||
custom_globals: dict[str, Any] | None = None,
|
||||
):
|
||||
"""
|
||||
初始化 Prompt 渲染引擎。
|
||||
|
||||
参数:
|
||||
template_string: Jinja2 模板格式的提示词原文本。
|
||||
custom_filters: 挂载到 Jinja2 渲染环境的自定义过滤器字典,默认 None。
|
||||
custom_globals: 挂载到 Jinja2 渲染环境的全局变量或辅助函数字典,默认 None。
|
||||
"""
|
||||
self.template_string = template_string
|
||||
|
||||
self._env = Environment(autoescape=False)
|
||||
if custom_filters:
|
||||
self._env.filters.update(custom_filters)
|
||||
if custom_globals:
|
||||
self._env.globals.update(custom_globals)
|
||||
|
||||
try:
|
||||
self._template = (
|
||||
self._env.from_string(template_string) if template_string else None
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"PromptTemplate 模板语法编译失败: {e}")
|
||||
self._template = None
|
||||
|
||||
def format_with_context(self, context: "RunContext[AgentDepsT]") -> str:
|
||||
"""从运行上下文中自动提取依赖和状态字典,用于模板渲染"""
|
||||
|
||||
vars_dict: dict[str, Any] = {
|
||||
"ctx": context,
|
||||
"ctx_deps": context.deps,
|
||||
"ctx_state": context.state,
|
||||
"ctx_shared": context.shared_state,
|
||||
}
|
||||
|
||||
flat_deps = {}
|
||||
if context.deps is not None:
|
||||
if hasattr(context.deps, "model_dump"):
|
||||
flat_deps.update(model_dump(cast(Any, context.deps), exclude_none=True))
|
||||
elif hasattr(context.deps, "__dict__"):
|
||||
flat_deps.update(context.deps.__dict__)
|
||||
elif isinstance(context.deps, dict):
|
||||
flat_deps.update(context.deps)
|
||||
|
||||
vars_dict.update(flat_deps)
|
||||
|
||||
if context.state:
|
||||
collisions = set(flat_deps.keys()) & set(context.state.keys())
|
||||
if collisions:
|
||||
logger.warning(
|
||||
f"Prompt 模板变量发生名称冲突: {collisions}。"
|
||||
"`state` 已覆盖 `deps` 中的同名变量。"
|
||||
"建议在模板中使用安全隔离对象获取(如 {{ ctx_state.xxx }})。"
|
||||
)
|
||||
vars_dict.update(context.state)
|
||||
|
||||
return self.render(**vars_dict)
|
||||
|
||||
def render(self, **variables: Any) -> str:
|
||||
if not self._template:
|
||||
return self.template_string or ""
|
||||
|
||||
try:
|
||||
return self._template.render(**variables)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Jinja2 Prompt 模板渲染失败!\n"
|
||||
f"错误信息: {e}\n"
|
||||
f"请检查传入的变量或 Prompt 模板语法是否有误。\n"
|
||||
f"模板原内容截断: {self.template_string[:150]}...",
|
||||
e=e,
|
||||
)
|
||||
raise ValueError(f"Prompt 模板渲染异常: {e}") from e
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.template_string
|
||||
Reference in New Issue
Block a user