diff --git a/app/api/v1/chat.py b/app/api/v1/chat.py
index 01ae373..ae8ae1e 100644
--- a/app/api/v1/chat.py
+++ b/app/api/v1/chat.py
@@ -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):
diff --git a/app/services/grok/services/chat.py b/app/services/grok/services/chat.py
index dae6ecc..cbfb6ba 100644
--- a/app/services/grok/services/chat.py
+++ b/app/services/grok/services/chat.py
@@ -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 = ""
+ end_tag = ""
+ 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"{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("\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": {
diff --git a/app/services/grok/services/responses.py b/app/services/grok/services/responses.py
index 01d1818..66cb234 100644
--- a/app/services/grok/services/responses.py
+++ b/app/services/grok/services/responses.py
@@ -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)