web2api / core /api /schemas.py
ohmyapi's picture
feat: align hosted Space deployment with latest upstream
77169b4
"""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)