| """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 选择来源填充。", |
| ) |
| |
| 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] = [] |
|
|
| |
| 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) |
|
|