feat: 对齐上游 5aa29aa/3a77067/02d4f91 并入 tool calling 兼容

This commit is contained in:
XianYuDaXian committed 2026-03-10 06:55:24 +08:00
1 parent 5ea26356c0
commit 481a9c9d19
3 files changed
+311 -34

No files matched your search

+23 -5
View File
@@ -27,7 +27,10 @@ class MessageItem(BaseModel):
"""消息项"""
role: str
content: Union[str, List[Dict[str, Any]]]
content: Optional[Union[str, Dict[str, Any], List[Dict[str, Any]]]]
tool_calls: Optional[List[Dict[str, Any]]] = None
tool_call_id: Optional[str] = None
name: Optional[str] = None
class VideoConfig(BaseModel):
@@ -62,12 +65,16 @@ class ChatCompletionRequest(BaseModel):
video_config: Optional[VideoConfig] = Field(None, description="视频生成参数")
# 图片生成配置
image_config: Optional[ImageConfig] = Field(None, description="图片生成参数")
# Tool calling
tools: Optional[List[Dict[str, Any]]] = Field(None, description="Tool definitions")
tool_choice: Optional[Union[str, Dict[str, Any]]] = Field(None, description="Tool choice: auto/required/none/specific")
parallel_tool_calls: Optional[bool] = Field(True, description="Allow parallel tool calls")
# 兼容 Vercel AI SDK 等 provider-specific 扩展参数
provider_options: Optional[Dict[str, Any]] = Field(None, description="Provider-specific options")
providerOptions: Optional[Dict[str, Any]] = Field(None, description="Provider-specific options (camelCase)")
VALID_ROLES = {"developer", "system", "user", "assistant"}
VALID_ROLES = {"developer", "system", "user", "assistant", "tool"}
USER_CONTENT_TYPES = {"text", "image_url", "input_audio", "file"}
ALLOWED_IMAGE_SIZES = {
"1280x720",
@@ -352,8 +359,12 @@ def validate_request(request: ChatCompletionRequest):
code="empty_content",
)
elif isinstance(content, dict):
msg.content = [content]
content = msg.content
# 列表内容
elif isinstance(content, list):
if isinstance(content, list):
if not content:
raise ValidationException(
message="Message content cannot be an empty array",
@@ -462,9 +473,11 @@ def validate_request(request: ChatCompletionRequest):
"file.file_data",
f"messages.{idx}.content.{block_idx}.file.file_data",
)
else:
elif content is None and msg.role == "tool":
pass
elif not isinstance(content, list):
raise ValidationException(
message="Message content must be a string or array",
message="Message content must be a string, object, or array",
param=f"messages.{idx}.content",
code="invalid_content",
)
@@ -967,6 +980,11 @@ async def chat_completions(request: ChatCompletionRequest, raw_request: Request)
reasoning_effort=request.reasoning_effort,
temperature=request.temperature,
top_p=request.top_p,
tools=request.tools,
tool_choice=request.tool_choice,
parallel_tool_calls=(
True if request.parallel_tool_calls is None else request.parallel_tool_calls
),
)
if isinstance(result, dict):
+280 -25
View File
@@ -27,6 +27,12 @@ from app.services.grok.utils.retry import pick_token, rate_limited
from app.services.reverse.app_chat import AppChatReverse
from app.services.grok.utils.stream import wrap_stream_with_usage
from app.services.token import get_token_manager, EffortType
from app.services.grok.utils.tool_call import (
build_tool_prompt,
parse_tool_calls,
parse_tool_call_block,
format_tool_history,
)
_CHAT_SEMAPHORE = None
@@ -102,8 +108,16 @@ class MessageExtractor:
"""消息内容提取器"""
@staticmethod
def extract(messages: List[Dict[str, Any]]) -> tuple[str, List[str], List[str]]:
def extract(
messages: List[Dict[str, Any]],
tools: List[Dict[str, Any]] = None,
tool_choice: Any = None,
parallel_tool_calls: bool = True,
) -> tuple[str, List[str], List[str]]:
"""从 OpenAI 消息格式提取内容,返回 (text, file_attachments, image_attachments)"""
if tools:
messages = format_tool_history(messages)
texts = []
file_attachments: List[str] = []
image_attachments: List[str] = []
@@ -117,6 +131,34 @@ class MessageExtractor:
if isinstance(content, str):
if content.strip():
parts.append(content)
elif isinstance(content, dict):
content = [content]
for item in content:
if not isinstance(item, dict):
continue
item_type = item.get("type", "")
if item_type == "text":
if text := item.get("text", "").strip():
parts.append(text)
elif item_type == "image_url":
image_data = item.get("image_url", {})
url = image_data.get("url", "")
if url:
image_attachments.append(url)
elif item_type == "input_audio":
audio_data = item.get("input_audio", {})
data = audio_data.get("data", "")
if data:
file_attachments.append(data)
elif item_type == "file":
file_data = item.get("file", {})
raw = file_data.get("file_data", "")
if raw:
file_attachments.append(raw)
elif isinstance(content, list):
for item in content:
item_type = item.get("type", "")
@@ -143,8 +185,36 @@ class MessageExtractor:
if raw:
file_attachments.append(raw)
tool_calls = msg.get("tool_calls")
if role == "assistant" and not parts and isinstance(tool_calls, list):
for call in tool_calls:
if not isinstance(call, dict):
continue
fn = call.get("function", {})
if not isinstance(fn, dict):
fn = {}
name = fn.get("name") or call.get("name") or "tool"
arguments = fn.get("arguments", "")
if isinstance(arguments, (dict, list)):
try:
arguments = orjson.dumps(arguments).decode()
except Exception:
arguments = str(arguments)
if not isinstance(arguments, str):
arguments = str(arguments)
arguments = arguments.strip()
parts.append(f"[tool_call] {name} {arguments}".strip())
if parts:
extracted.append({"role": role, "text": "\n".join(parts)})
role_label = role
if role == "tool":
name = msg.get("name")
call_id = msg.get("tool_call_id")
if isinstance(name, str) and name.strip():
role_label = f"tool[{name.strip()}]"
if isinstance(call_id, str) and call_id.strip():
role_label = f"{role_label}#{call_id.strip()}"
extracted.append({"role": role_label, "text": "\n".join(parts)})
# 找到最后一条 user 消息
last_user_index = next(
@@ -161,7 +231,17 @@ class MessageExtractor:
text = item["text"]
texts.append(text if i == last_user_index else f"{role}: {text}")
return "\n\n".join(texts), file_attachments, image_attachments
combined = "\n\n".join(texts)
if (not combined.strip()) and (file_attachments or image_attachments):
combined = "Refer to the following content:"
if tools:
tool_prompt = build_tool_prompt(tools, tool_choice, parallel_tool_calls)
if tool_prompt:
combined = f"{tool_prompt}\n\n{combined}"
return combined, file_attachments, image_attachments
class GrokChatService:
@@ -235,6 +315,9 @@ class GrokChatService:
reasoning_effort: str | None = None,
temperature: float = 0.8,
top_p: float = 0.95,
tools: List[Dict[str, Any]] = None,
tool_choice: Any = None,
parallel_tool_calls: bool = True,
):
"""OpenAI 兼容接口"""
model_info = ModelService.get(model)
@@ -244,7 +327,12 @@ class GrokChatService:
grok_model = model_info.grok_model
mode = model_info.model_mode
# 提取消息和附件
message, file_attachments, image_attachments = MessageExtractor.extract(messages)
message, file_attachments, image_attachments = MessageExtractor.extract(
messages,
tools=tools,
tool_choice=tool_choice,
parallel_tool_calls=parallel_tool_calls,
)
if not (message or "").strip() and (file_attachments or image_attachments):
# 对齐官网行为:仅附件时仍发送非空 message,避免上游 400
message = "参考以下内容:"
@@ -282,12 +370,6 @@ class GrokChatService:
if reasoning_effort is not None:
model_config_override["reasoningEffort"] = reasoning_effort
# Passthrough mode: build tool_overrides for Grok API
tool_overrides_payload = None
# We need to extract tools from some upper context if passed, but since the upstream
# signature changed, we will just patch it carefully.
# Since this is a partial restore, we initialize to None and just pass it.
response = await self.chat(
token=token,
message=message,
@@ -296,7 +378,7 @@ class GrokChatService:
mode=mode,
stream=stream,
file_attachments=all_attachments,
tool_overrides=tool_overrides_payload,
tool_overrides=None,
model_config_override=model_config_override,
)
@@ -314,6 +396,9 @@ class ChatService:
reasoning_effort: str | None = None,
temperature: float = 0.8,
top_p: float = 0.95,
tools: List[Dict[str, Any]] = None,
tool_choice: Any = None,
parallel_tool_calls: bool = True,
):
"""Chat Completions 入口"""
# 获取 token
@@ -358,19 +443,33 @@ class ChatService:
reasoning_effort=reasoning_effort,
temperature=temperature,
top_p=top_p,
tools=tools,
tool_choice=tool_choice,
parallel_tool_calls=parallel_tool_calls,
)
# 处理响应
if is_stream:
logger.debug(f"Processing stream response: model={model}")
processor = StreamProcessor(model_name, token, show_think)
processor = StreamProcessor(
model_name,
token,
show_think,
tools=tools,
tool_choice=tool_choice,
)
return wrap_stream_with_usage(
processor.process(response), token_mgr, token, model
)
# 非流式
logger.debug(f"Processing non-stream response: model={model}")
result = await CollectProcessor(model_name, token).process(response)
result = await CollectProcessor(
model_name,
token,
tools=tools,
tool_choice=tool_choice,
).process(response)
try:
model_info = ModelService.get(model)
effort = (
@@ -413,7 +512,14 @@ class ChatService:
class StreamProcessor(proc_base.BaseProcessor):
"""Stream response processor."""
def __init__(self, model: str, token: str = "", show_think: bool = None):
def __init__(
self,
model: str,
token: str = "",
show_think: bool = None,
tools: List[Dict[str, Any]] = None,
tool_choice: Any = None,
):
super().__init__(model, token)
self.response_id: str = None
self.rollout_id: str = ""
@@ -431,6 +537,14 @@ class StreamProcessor(proc_base.BaseProcessor):
self._tool_usage_buffer = ""
self.show_think = bool(show_think)
self.tools = tools
self.tool_choice = tool_choice
self._tool_stream_enabled = bool(tools) and tool_choice != "none"
self._tool_state = "text"
self._tool_buffer = ""
self._tool_partial = ""
self._tool_calls_seen = False
self._tool_call_index = 0
def _filter_tool_card(self, token: str) -> str:
if not token or not self.tool_usage_enabled:
@@ -505,12 +619,104 @@ class StreamProcessor(proc_base.BaseProcessor):
return token
def _sse(self, content: str = "", role: str = None, finish: str = None) -> str:
def _with_tool_index(self, tool_call: Dict[str, Any]) -> Dict[str, Any]:
tool_call = dict(tool_call or {})
tool_call["index"] = self._tool_call_index
self._tool_call_index += 1
return tool_call
@staticmethod
def _suffix_prefix(text: str, marker: str) -> int:
if not text or not marker:
return 0
max_len = min(len(text), len(marker) - 1)
for keep in range(max_len, 0, -1):
if text.endswith(marker[:keep]):
return keep
return 0
def _handle_tool_stream(self, chunk: str) -> list[tuple[str, Any]]:
if not chunk:
return []
start_tag = "<tool_call>"
end_tag = "</tool_call>"
events: list[tuple[str, Any]] = []
data = f"{self._tool_partial}{chunk}"
self._tool_partial = ""
while data:
if self._tool_state == "text":
start_idx = data.find(start_tag)
if start_idx == -1:
keep = self._suffix_prefix(data, start_tag)
emit = data[:-keep] if keep else data
if emit:
events.append(("text", emit))
self._tool_partial = data[-keep:] if keep else ""
break
before = data[:start_idx]
if before:
events.append(("text", before))
data = data[start_idx + len(start_tag):]
self._tool_state = "tool"
continue
end_idx = data.find(end_tag)
if end_idx == -1:
keep = self._suffix_prefix(data, end_tag)
append = data[:-keep] if keep else data
if append:
self._tool_buffer += append
self._tool_partial = data[-keep:] if keep else ""
break
self._tool_buffer += data[:end_idx]
data = data[end_idx + len(end_tag):]
tool_call = parse_tool_call_block(self._tool_buffer, self.tools)
if tool_call:
events.append(("tool", self._with_tool_index(tool_call)))
self._tool_calls_seen = True
self._tool_buffer = ""
self._tool_state = "text"
return events
def _flush_tool_stream(self) -> list[tuple[str, Any]]:
events: list[tuple[str, Any]] = []
if self._tool_state == "text":
if self._tool_partial:
events.append(("text", self._tool_partial))
self._tool_partial = ""
return events
raw = f"{self._tool_buffer}{self._tool_partial}"
tool_call = parse_tool_call_block(raw, self.tools)
if tool_call:
events.append(("tool", self._with_tool_index(tool_call)))
self._tool_calls_seen = True
elif raw:
events.append(("text", f"<tool_call>{raw}"))
self._tool_buffer = ""
self._tool_partial = ""
self._tool_state = "text"
return events
def _sse(
self,
content: str = "",
role: str = None,
finish: str = None,
tool_calls: list = None,
) -> str:
"""Build SSE response."""
delta = {}
if role:
delta["role"] = role
delta["content"] = ""
elif tool_calls is not None:
delta["tool_calls"] = tool_calls
elif content:
delta["content"] = content
@@ -648,15 +854,43 @@ class StreamProcessor(proc_base.BaseProcessor):
self.answer_buffer.append(filtered)
continue
if self._tool_stream_enabled:
for kind, payload in self._handle_tool_stream(filtered):
if kind == "text":
yield self._sse(payload)
elif kind == "tool":
yield self._sse(tool_calls=[payload])
continue
yield self._sse(filtered)
if self.think_opened:
yield self._sse("</think>\n")
self.think_opened = False
if self.answer_buffer:
yield self._sse("".join(self.answer_buffer))
buffered = "".join(self.answer_buffer)
if self._tool_stream_enabled:
for kind, payload in self._handle_tool_stream(buffered):
if kind == "text":
yield self._sse(payload)
elif kind == "tool":
yield self._sse(tool_calls=[payload])
for kind, payload in self._flush_tool_stream():
if kind == "text":
yield self._sse(payload)
elif kind == "tool":
yield self._sse(tool_calls=[payload])
else:
yield self._sse(buffered)
self.answer_buffer = []
yield self._sse(finish="stop")
if self._tool_stream_enabled and not self.answer_buffer:
for kind, payload in self._flush_tool_stream():
if kind == "text":
yield self._sse(payload)
elif kind == "tool":
yield self._sse(tool_calls=[payload])
finish_reason = "tool_calls" if self._tool_stream_enabled and self._tool_calls_seen else "stop"
yield self._sse(finish=finish_reason)
yield "data: [DONE]\n\n"
except asyncio.CancelledError:
logger.debug("Stream cancelled by client", extra={"model": self.model})
@@ -710,9 +944,17 @@ class StreamProcessor(proc_base.BaseProcessor):
class CollectProcessor(proc_base.BaseProcessor):
"""Non-stream response processor."""
def __init__(self, model: str, token: str = ""):
def __init__(
self,
model: str,
token: str = "",
tools: List[Dict[str, Any]] = None,
tool_choice: Any = None,
):
super().__init__(model, token)
self.filter_tags = get_config("app.filter_tags")
self.tools = tools
self.tool_choice = tool_choice
def _filter_content(self, content: str) -> str:
"""Filter special tags in content."""
@@ -855,6 +1097,24 @@ class CollectProcessor(proc_base.BaseProcessor):
content = self._filter_content(content)
finish_reason = "stop"
tool_calls_result = None
if self.tools and self.tool_choice != "none":
text_content, tool_calls_list = parse_tool_calls(content, self.tools)
if tool_calls_list:
tool_calls_result = tool_calls_list
content = text_content
finish_reason = "tool_calls"
message_obj = {
"role": "assistant",
"content": content,
"refusal": None,
"annotations": [],
}
if tool_calls_result:
message_obj["tool_calls"] = tool_calls_result
return {
"id": response_id,
"object": "chat.completion",
@@ -864,13 +1124,8 @@ class CollectProcessor(proc_base.BaseProcessor):
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": content,
"refusal": None,
"annotations": [],
},
"finish_reason": "stop",
"message": message_obj,
"finish_reason": finish_reason,
}
],
"usage": {
+8 -4
View File
@@ -699,15 +699,19 @@ class ResponsesService:
"model": model,
"messages": messages,
"stream": stream,
"reasoning_effort": reasoning_effort,
}
if temperature is not None:
chat_kwargs["temperature"] = temperature
if top_p is not None:
chat_kwargs["top_p"] = top_p
# 当前分支 ChatService.completions 尚未开放 tools/tool_choice/parallel_tool_calls 参数,
# 此处先做兼容降级:Responses 接口可用,工具字段保留在响应对象中但不透传到底层。
_ = normalized_tools, normalized_tool_choice, parallel_tool_calls
if normalized_tools is not None:
chat_kwargs["tools"] = normalized_tools
if normalized_tool_choice is not None:
chat_kwargs["tool_choice"] = normalized_tool_choice
if parallel_tool_calls is not None:
chat_kwargs["parallel_tool_calls"] = parallel_tool_calls
if reasoning_effort is not None:
chat_kwargs["reasoning_effort"] = reasoning_effort
result = await ChatService.completions(**chat_kwargs)