feat: 对齐上游 5aa29aa/3a77067/02d4f91 并入 tool calling 兼容
This commit is contained in:
1 parent
5ea26356c0
commit
481a9c9d19
3 files changed
+311
-34
No files matched your search
+23
-5
@@ -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):
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user