File size: 6,359 Bytes
77169b4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 | """OpenAI 兼容的请求/响应模型。"""
from typing import Any
from pydantic import BaseModel, Field
from core.api.conv_parser import strip_session_id_suffix
class OpenAIContentPart(BaseModel):
type: str
text: str | None = None
image_url: dict[str, Any] | str | None = None
class InputAttachment(BaseModel):
filename: str
mime_type: str
data: bytes
class OpenAIMessage(BaseModel):
role: str = Field(..., description="system | user | assistant | tool")
content: str | list[OpenAIContentPart] | None = ""
tool_calls: list[dict[str, Any]] | None = Field(
default=None, description="assistant 发起的工具调用"
)
tool_call_id: str | None = Field(
default=None, description="tool 消息对应的 call id"
)
model_config = {"extra": "allow"}
class OpenAIChatRequest(BaseModel):
"""OpenAI Chat Completions API 兼容请求体。"""
model: str = Field(default="", description="模型名,可忽略")
messages: list[OpenAIMessage] = Field(..., description="对话列表")
stream: bool = Field(default=False, description="是否流式返回")
tools: list[dict] | None = Field(
default=None,
description='工具列表,每项为 {"type":"function","function":{name,description,parameters,strict?}}',
)
tool_choice: str | dict | None = Field(
default=None,
description='工具选择: "auto"|"required"|"none" 或 {"type":"function","name":"xxx"}',
)
parallel_tool_calls: bool | None = Field(
default=None,
description="是否允许单次响应中并行多个 tool_call,false 时仅 0 或 1 个",
)
resume_session_id: str | None = Field(default=None, exclude=True)
upstream_model: str | None = Field(default=None, exclude=True)
attachment_files: list[InputAttachment] = Field(
default_factory=list,
exclude=True,
description="本次实际要发送给站点的附件,由 ChatHandler 根据 full_history 选择来源填充。",
)
# 仅供内部调度使用:最后一条 user 消息里的附件 & 所有 user 消息里的附件
attachment_files_last_user: list[InputAttachment] = Field(
default_factory=list, exclude=True
)
attachment_files_all_users: list[InputAttachment] = Field(
default_factory=list, exclude=True
)
def _norm_content(c: str | list[OpenAIContentPart] | None) -> str:
"""将 content 转为单段字符串。仅支持官方格式:字符串或 type=text 的 content part(取 text 字段)。"""
if c is None:
return ""
if isinstance(c, str):
return strip_session_id_suffix(c)
if not isinstance(c, list):
return ""
return strip_session_id_suffix(
" ".join(
p.text or ""
for p in c
if isinstance(p, OpenAIContentPart) and p.type == "text" and p.text
)
)
REACT_STRICT_SUFFIX = (
"(严格 ReAct 执行模式;禁止输出「无法执行工具所以直接给方案」等解释或替代内容)"
)
def extract_user_content(
messages: list[OpenAIMessage],
*,
has_tools: bool = False,
react_prompt_prefix: str = "",
full_history: bool = False,
) -> str:
"""
从 messages 中提取对话,拼成发给模型的 prompt。
网页/会话侧已有完整历史,只取尾部:最后一条为 user 时,从后向前找到最后一个 assistant(不包含),
取该 assistant 之后到末尾;最后一条为 tool 时,从后向前找到最后一个 user(不包含),取该 user 之后到末尾。
支持 user、assistant、tool 角色;assistant 的 tool_calls 与 tool 结果会拼回。
ReAct 模式:完整 ReAct Prompt 仅第一次对话传入(按完整 messages 判断 is_first_turn);后续只传尾部内容。
"""
if not messages:
return ""
parts: list[str] = []
# 重建会话时会把完整历史重新回放给站点,因此 tools 指令也需要重新注入。
is_first_turn = not any(m.role in ("assistant", "tool") for m in messages)
if has_tools and react_prompt_prefix and (full_history or is_first_turn):
parts.append(react_prompt_prefix)
if full_history:
tail = messages
else:
last = messages[-1]
if last.role == "user":
i = len(messages) - 1
while i >= 0 and messages[i].role != "assistant":
i -= 1
tail = messages[i + 1 :]
elif last.role == "tool":
i = len(messages) - 1
while i >= 0 and messages[i].role != "user":
i -= 1
tail = messages[i + 1 :]
else:
tail = messages[-2:]
for m in tail:
if m.role == "system":
txt = _norm_content(m.content)
if txt:
parts.append(f"System:{txt}")
elif m.role == "user":
txt = _norm_content(m.content)
if txt:
if has_tools:
parts.append(f"**User**: {txt} {REACT_STRICT_SUFFIX}")
else:
parts.append(f"User:{txt}")
elif m.role == "assistant":
tool_calls_list = list(m.tool_calls or [])
if tool_calls_list:
for tc in tool_calls_list:
fn = tc.get("function") or {}
call_id = tc.get("id", "")
name = fn.get("name", "")
args = fn.get("arguments", "{}")
parts.append(
f"**Assistant**:\n\n```\nAction: {name}\nAction Input: {args}\nCall ID: {call_id}\n```"
)
else:
txt = _norm_content(m.content)
if txt:
if has_tools:
parts.append(f"**Assistant**:\n\n{txt}")
else:
parts.append(f"Assistant:{txt}")
elif m.role == "tool":
txt = _norm_content(m.content)
call_id = m.tool_call_id or ""
parts.append(
f"**Observation(Call ID: {call_id})**: {txt}\n\n请根据以上观察结果继续。如需调用工具,输出 Thought / Action / Action Input;若任务已完成,输出 Final Answer。"
)
return "\n".join(parts)
|