feat: 并入 responses 接口并兼容翻译插件 provider options

This commit is contained in:
XianYuDaXian committed 2026-02-28 22:38:33 +08:00
1 parent 4ef2ad0db1
commit 755fa30afa
7 files changed
+1482 -1

No files matched your search

+41
View File
@@ -62,6 +62,9 @@ class ChatCompletionRequest(BaseModel):
video_config: Optional[VideoConfig] = Field(None, description="视频生成参数")
# 图片生成配置
image_config: Optional[ImageConfig] = Field(None, description="图片生成参数")
# 兼容 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"}
@@ -77,6 +80,35 @@ DEFAULT_VIDEO_PROMPT = (
"Animate this."
)
_ALLOWED_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"}
def _resolve_reasoning_effort_from_provider_options(
provider_options: Optional[Dict[str, Any]],
) -> Optional[str]:
if not isinstance(provider_options, dict):
return None
# 优先读取通用层字段
for key in ("reasoning_effort", "reasoningEffort"):
value = provider_options.get(key)
if isinstance(value, str):
effort = value.strip().lower()
if effort in _ALLOWED_REASONING_EFFORTS:
return effort
# 再读取各 provider 命名空间(如 xai/openai/anthropic)
for _, conf in provider_options.items():
if not isinstance(conf, dict):
continue
for key in ("reasoning_effort", "reasoningEffort"):
value = conf.get(key)
if isinstance(value, str):
effort = value.strip().lower()
if effort in _ALLOWED_REASONING_EFFORTS:
return effort
return None
def _validate_media_input(value: str, field_name: str, param: str):
"""Verify media input is a valid URL or data URI"""
@@ -692,6 +724,15 @@ def _build_streaming_response(stream_obj: Any, req: Request, model: str) -> Stre
@router.post("/chat/completions")
async def chat_completions(request: ChatCompletionRequest, raw_request: Request):
"""Chat Completions API - 兼容 OpenAI"""
# OpenAI 兼容:未显式传 stream 时默认非流式(false)
if request.stream is None:
request.stream = False
# 兼容 provider-specific options 中的推理强度配置
if request.reasoning_effort is None:
provider_opts = request.provider_options or request.providerOptions
request.reasoning_effort = _resolve_reasoning_effort_from_provider_options(provider_opts)
model_info = ModelService.get(request.model)
if model_info and model_info.is_video:
_ensure_video_default_prompt(request.messages)
+117
View File
@@ -0,0 +1,117 @@
"""
Responses API 路由 (OpenAI compatible).
"""
from typing import Any, Dict, List, Optional, Union
from fastapi import APIRouter
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from app.core.exceptions import ValidationException
from app.services.grok.services.responses import ResponsesService
router = APIRouter(tags=["Responses"])
class ResponseCreateRequest(BaseModel):
model: str = Field(..., description="Model name")
input: Optional[Any] = Field(None, description="Input content")
instructions: Optional[str] = Field(None, description="System instructions")
stream: Optional[bool] = Field(False, description="Stream response")
max_output_tokens: Optional[int] = Field(None, description="Max output tokens")
temperature: Optional[float] = Field(None, description="Sampling temperature")
top_p: Optional[float] = Field(None, description="Nucleus sampling")
tools: Optional[List[Dict[str, Any]]] = Field(None, description="Tool definitions")
tool_choice: Optional[Union[str, Dict[str, Any]]] = Field(None, description="Tool choice")
parallel_tool_calls: Optional[bool] = Field(True, description="Allow parallel tool calls")
reasoning: Optional[Dict[str, Any]] = Field(None, description="Reasoning options")
metadata: Optional[Dict[str, Any]] = Field(None, description="Metadata")
user: Optional[str] = Field(None, description="User identifier")
store: Optional[bool] = Field(None, description="Store response")
previous_response_id: Optional[str] = Field(None, description="Previous response id")
truncation: Optional[str] = Field(None, description="Truncation behavior")
provider_options: Optional[Dict[str, Any]] = Field(None, description="Provider-specific options")
providerOptions: Optional[Dict[str, Any]] = Field(None, description="Provider-specific options (camelCase)")
class Config:
extra = "allow"
@router.post("/responses")
async def create_response(request: ResponseCreateRequest):
if not request.model:
raise ValidationException(message="model is required", param="model", code="invalid_request_error")
if request.input is None:
raise ValidationException(message="input is required", param="input", code="invalid_request_error")
reasoning_effort = None
if isinstance(request.reasoning, dict):
reasoning_effort = request.reasoning.get("effort") or request.reasoning.get("reasoning_effort")
if not reasoning_effort:
provider_options = request.provider_options or request.providerOptions
if isinstance(provider_options, dict):
for key in ("reasoning_effort", "reasoningEffort"):
val = provider_options.get(key)
if isinstance(val, str) and val.strip():
reasoning_effort = val.strip().lower()
break
if not reasoning_effort:
for _, conf in provider_options.items():
if not isinstance(conf, dict):
continue
for key in ("reasoning_effort", "reasoningEffort"):
val = conf.get(key)
if isinstance(val, str) and val.strip():
reasoning_effort = val.strip().lower()
break
if reasoning_effort:
break
try:
result = await ResponsesService.create(
model=request.model,
input_value=request.input,
instructions=request.instructions,
stream=bool(request.stream),
temperature=request.temperature,
top_p=request.top_p,
tools=request.tools,
tool_choice=request.tool_choice,
parallel_tool_calls=request.parallel_tool_calls,
reasoning_effort=reasoning_effort,
max_output_tokens=request.max_output_tokens,
metadata=request.metadata,
user=request.user,
store=request.store,
previous_response_id=request.previous_response_id,
truncation=request.truncation,
)
if request.stream:
return StreamingResponse(
result,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
return JSONResponse(content=result)
except ValidationException:
raise
except Exception as e:
return JSONResponse(
status_code=500,
content={
"error": {
"message": str(e) or "responses request failed",
"type": "server_error",
"param": None,
"code": "responses_internal_error",
}
},
)
__all__ = ["router"]
+1 -1
View File
@@ -321,7 +321,7 @@ class ChatService:
# 跨 Token 重试循环
tried_tokens = set()
max_token_retries = int(get_config("retry.max_retry"))
max_token_retries = int(get_config("retry.max_retry", 3) or 3)
last_error = None
for attempt in range(max_token_retries):
+820
View File
@@ -0,0 +1,820 @@
"""
Responses API bridge service (OpenAI-compatible).
"""
import time
import uuid
from typing import Any, AsyncGenerator, Dict, List, Optional
import orjson
from app.services.grok.services.chat import ChatService
from app.services.grok.utils import process as proc_base
_TOOL_OUTPUT_TYPES = {
"tool_output",
"function_call_output",
"tool_call_output",
"input_tool_output",
}
_BUILTIN_TOOL_TYPES = {
"web_search",
"web_search_2025_08_26",
"file_search",
"code_interpreter",
}
def _now_ts() -> int:
return int(time.time())
def _new_response_id() -> str:
return f"resp_{uuid.uuid4().hex[:24]}"
def _new_message_id() -> str:
return f"msg_{uuid.uuid4().hex[:24]}"
def _new_tool_call_id() -> str:
return f"call_{uuid.uuid4().hex[:24]}"
def _new_function_call_id() -> str:
return f"fc_{uuid.uuid4().hex[:24]}"
def _normalize_tool_choice(tool_choice: Any) -> Any:
if isinstance(tool_choice, dict):
t_type = tool_choice.get("type")
if t_type and t_type != "function":
return {"type": "function", "function": {"name": t_type}}
return tool_choice
def _normalize_tools_for_chat(tools: Optional[List[Dict[str, Any]]]) -> Optional[List[Dict[str, Any]]]:
if not tools:
return None
normalized: List[Dict[str, Any]] = []
for tool in tools:
if not isinstance(tool, dict):
continue
tool_type = tool.get("type")
if tool_type == "function":
normalized.append(tool)
continue
if tool_type in _BUILTIN_TOOL_TYPES:
if tool_type.startswith("web_search"):
normalized.append(
{
"type": "function",
"function": {
"name": tool_type,
"description": "Search the web for information and return results.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}
)
elif tool_type == "file_search":
normalized.append(
{
"type": "function",
"function": {
"name": tool_type,
"description": "Search provided files for relevant information.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}
)
elif tool_type == "code_interpreter":
normalized.append(
{
"type": "function",
"function": {
"name": tool_type,
"description": "Execute code to solve tasks and return results.",
"parameters": {
"type": "object",
"properties": {"code": {"type": "string"}},
"required": ["code"],
},
},
}
)
return normalized or None
def _content_item_from_input(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if not isinstance(item, dict):
return None
item_type = item.get("type")
if item_type in {"input_text", "text", "output_text"}:
text = item.get("text") or item.get("content") or ""
return {"type": "text", "text": text}
if item_type in {"input_image", "image", "image_url", "output_image"}:
image_url = item.get("image_url")
url = ""
detail = None
if isinstance(image_url, dict):
url = image_url.get("url") or ""
detail = image_url.get("detail")
elif isinstance(image_url, str):
url = image_url
else:
url = item.get("url") or item.get("image") or ""
if not url:
return None
image_payload = {"url": url}
if detail:
image_payload["detail"] = detail
return {"type": "image_url", "image_url": image_payload}
if item_type in {"input_file", "file"}:
file_data = item.get("file_data")
file_id = item.get("file_id")
if not file_data and isinstance(item.get("file"), dict):
file_data = item["file"].get("file_data")
file_id = item["file"].get("file_id")
file_payload: Dict[str, Any] = {}
if file_data:
file_payload["file_data"] = file_data
if file_id:
file_payload["file_id"] = file_id
if not file_payload:
return None
return {"type": "file", "file": file_payload}
if item_type in {"input_audio", "audio"}:
audio = item.get("audio") or {}
data = audio.get("data") or item.get("data")
if not data:
return None
return {"type": "input_audio", "input_audio": {"data": data}}
return None
def _message_from_item(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
if not isinstance(item, dict):
return None
if item.get("type") == "message":
role = item.get("role") or "user"
content = item.get("content")
return {"role": role, "content": _coerce_content(content)}
if "role" in item and "content" in item:
return {"role": item.get("role") or "user", "content": _coerce_content(item.get("content"))}
return None
def _coerce_content(content: Any) -> Any:
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, dict):
content = [content]
if isinstance(content, list):
blocks: List[Dict[str, Any]] = []
for item in content:
if isinstance(item, dict) and item.get("type") in {"input_text", "output_text"}:
blocks.append({"type": "text", "text": item.get("text", "")})
continue
block = _content_item_from_input(item) if isinstance(item, dict) else None
if block:
blocks.append(block)
return blocks if blocks else ""
return str(content)
def _coerce_input_to_messages(input_value: Any) -> List[Dict[str, Any]]:
if input_value is None:
return []
if isinstance(input_value, str):
return [{"role": "user", "content": input_value}]
if isinstance(input_value, dict):
msg = _message_from_item(input_value)
if msg:
return [msg]
content_item = _content_item_from_input(input_value)
if content_item:
return [{"role": "user", "content": [content_item]}]
return []
if not isinstance(input_value, list):
return [{"role": "user", "content": str(input_value)}]
messages: List[Dict[str, Any]] = []
pending_blocks: List[Dict[str, Any]] = []
def _flush_pending():
nonlocal pending_blocks
if pending_blocks:
messages.append({"role": "user", "content": pending_blocks})
pending_blocks = []
for item in input_value:
if isinstance(item, dict):
msg = _message_from_item(item)
if msg:
_flush_pending()
messages.append(msg)
continue
item_type = item.get("type")
if item_type in _TOOL_OUTPUT_TYPES:
_flush_pending()
call_id = (
item.get("call_id")
or item.get("tool_call_id")
or item.get("id")
or _new_tool_call_id()
)
output = item.get("output") or item.get("content") or ""
messages.append({"role": "tool", "tool_call_id": call_id, "content": output})
continue
block = _content_item_from_input(item)
if block:
pending_blocks.append(block)
continue
if isinstance(item, str):
pending_blocks.append({"type": "text", "text": item})
_flush_pending()
return messages
def _build_output_message(
text: str,
*,
message_id: Optional[str] = None,
status: str = "completed",
) -> Dict[str, Any]:
message_id = message_id or _new_message_id()
return {
"id": message_id,
"type": "message",
"role": "assistant",
"status": status,
"content": [
{
"type": "output_text",
"text": text,
"annotations": [],
}
],
}
def _build_output_tool_call(
tool_call: Dict[str, Any],
*,
item_id: Optional[str] = None,
status: str = "completed",
) -> Dict[str, Any]:
fn = tool_call.get("function") or {}
call_id = tool_call.get("id") or _new_tool_call_id()
item_id = item_id or _new_function_call_id()
return {
"id": item_id,
"type": "function_call",
"status": status,
"call_id": call_id,
"name": fn.get("name"),
"arguments": fn.get("arguments"),
}
def _build_response_object(
*,
model: str,
output_text: Optional[str] = None,
tool_calls: Optional[List[Dict[str, Any]]] = None,
response_id: Optional[str] = None,
usage: Optional[Dict[str, Any]] = None,
created_at: Optional[int] = None,
completed_at: Optional[int] = None,
status: str = "completed",
instructions: Optional[str] = None,
max_output_tokens: Optional[int] = None,
parallel_tool_calls: Optional[bool] = None,
previous_response_id: Optional[str] = None,
reasoning_effort: Optional[str] = None,
store: Optional[bool] = None,
temperature: Optional[float] = None,
tool_choice: Optional[Any] = None,
tools: Optional[List[Dict[str, Any]]] = None,
top_p: Optional[float] = None,
truncation: Optional[str] = None,
user: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
response_id = response_id or _new_response_id()
created_at = created_at or _now_ts()
if status == "completed" and completed_at is None:
completed_at = _now_ts()
output: List[Dict[str, Any]] = []
if output_text is not None:
output.append(_build_output_message(output_text))
if tool_calls:
for call in tool_calls:
output.append(_build_output_tool_call(call))
return {
"id": response_id,
"object": "response",
"created_at": created_at,
"completed_at": completed_at,
"status": status,
"error": None,
"incomplete_details": None,
"instructions": instructions,
"max_output_tokens": max_output_tokens,
"model": model,
"output": output,
"parallel_tool_calls": True if parallel_tool_calls is None else parallel_tool_calls,
"previous_response_id": previous_response_id,
"reasoning": {"effort": reasoning_effort, "summary": None},
"store": True if store is None else store,
"temperature": 1.0 if temperature is None else temperature,
"text": {"format": {"type": "text"}},
"tool_choice": tool_choice or "auto",
"tools": tools or [],
"top_p": 1.0 if top_p is None else top_p,
"truncation": truncation or "disabled",
"usage": usage,
"user": user,
"metadata": metadata or {},
}
class ResponseStreamAdapter:
def __init__(
self,
*,
model: str,
response_id: str,
created_at: int,
instructions: Optional[str],
max_output_tokens: Optional[int],
parallel_tool_calls: Optional[bool],
previous_response_id: Optional[str],
reasoning_effort: Optional[str],
store: Optional[bool],
temperature: Optional[float],
tool_choice: Optional[Any],
tools: Optional[List[Dict[str, Any]]],
top_p: Optional[float],
truncation: Optional[str],
user: Optional[str],
metadata: Optional[Dict[str, Any]],
):
self.model = model
self.response_id = response_id
self.created_at = created_at
self.instructions = instructions
self.max_output_tokens = max_output_tokens
self.parallel_tool_calls = parallel_tool_calls
self.previous_response_id = previous_response_id
self.reasoning_effort = reasoning_effort
self.store = store
self.temperature = temperature
self.tool_choice = tool_choice
self.tools = tools
self.top_p = top_p
self.truncation = truncation
self.user = user
self.metadata = metadata
self.output_text_parts: List[str] = []
self.tool_calls_by_index: Dict[int, Dict[str, Any]] = {}
self.tool_items: Dict[int, Dict[str, Any]] = {}
self.next_output_index = 0
self.content_index = 0
self.message_id = _new_message_id()
self.message_started = False
self.message_output_index: Optional[int] = None
def _event(self, event_type: str, payload: Dict[str, Any]) -> str:
return f"event: {event_type}\ndata: {orjson.dumps(payload).decode()}\n\n"
def _response_payload(self, *, status: str, output_text: Optional[str], usage: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
tool_calls = None
if status == "completed" and self.tool_calls_by_index:
tool_calls = [
self.tool_calls_by_index[idx]
for idx in sorted(self.tool_calls_by_index.keys())
]
return _build_response_object(
model=self.model,
output_text=output_text,
tool_calls=tool_calls,
response_id=self.response_id,
usage=usage,
created_at=self.created_at,
status=status,
instructions=self.instructions,
max_output_tokens=self.max_output_tokens,
parallel_tool_calls=self.parallel_tool_calls,
previous_response_id=self.previous_response_id,
reasoning_effort=self.reasoning_effort,
store=self.store,
temperature=self.temperature,
tool_choice=self.tool_choice,
tools=self.tools,
top_p=self.top_p,
truncation=self.truncation,
user=self.user,
metadata=self.metadata,
)
def _alloc_output_index(self) -> int:
idx = self.next_output_index
self.next_output_index += 1
return idx
def created_event(self) -> str:
payload = {
"type": "response.created",
"response": self._response_payload(status="in_progress", output_text=None, usage=None),
}
return self._event("response.created", payload)
def in_progress_event(self) -> str:
payload = {
"type": "response.in_progress",
"response": self._response_payload(status="in_progress", output_text=None, usage=None),
}
return self._event("response.in_progress", payload)
def ensure_message_started(self) -> List[str]:
if self.message_started:
return []
self.message_started = True
self.message_output_index = self._alloc_output_index()
item = _build_output_message("", message_id=self.message_id, status="in_progress")
item["content"] = []
events = [
self._event(
"response.output_item.added",
{
"type": "response.output_item.added",
"response_id": self.response_id,
"output_index": self.message_output_index,
"item": item,
},
),
self._event(
"response.content_part.added",
{
"type": "response.content_part.added",
"response_id": self.response_id,
"item_id": self.message_id,
"output_index": self.message_output_index,
"content_index": self.content_index,
"part": {"type": "output_text", "text": "", "annotations": []},
},
),
]
return events
def output_delta_event(self, delta: str) -> str:
return self._event(
"response.output_text.delta",
{
"type": "response.output_text.delta",
"response_id": self.response_id,
"item_id": self.message_id,
"output_index": self.message_output_index,
"content_index": self.content_index,
"delta": delta,
},
)
def output_done_events(self, text: str) -> List[str]:
if self.message_output_index is None:
return []
return [
self._event(
"response.output_text.done",
{
"type": "response.output_text.done",
"response_id": self.response_id,
"item_id": self.message_id,
"output_index": self.message_output_index,
"content_index": self.content_index,
"text": text,
},
),
self._event(
"response.content_part.done",
{
"type": "response.content_part.done",
"response_id": self.response_id,
"item_id": self.message_id,
"output_index": self.message_output_index,
"content_index": self.content_index,
"part": {"type": "output_text", "text": text, "annotations": []},
},
),
self._event(
"response.output_item.done",
{
"type": "response.output_item.done",
"response_id": self.response_id,
"output_index": self.message_output_index,
"item": _build_output_message(
text, message_id=self.message_id, status="completed"
),
},
),
]
def ensure_tool_item(self, tool_index: int, call_id: str, name: Optional[str]) -> List[str]:
if tool_index in self.tool_items:
item = self.tool_items[tool_index]
if name and not item.get("name"):
item["name"] = name
return []
output_index = self._alloc_output_index()
item_id = _new_function_call_id()
self.tool_items[tool_index] = {
"item_id": item_id,
"output_index": output_index,
"call_id": call_id,
"name": name,
"arguments": "",
}
tool_item = _build_output_tool_call(
{"id": call_id, "function": {"name": name, "arguments": ""}},
item_id=item_id,
status="in_progress",
)
return [
self._event(
"response.output_item.added",
{
"type": "response.output_item.added",
"response_id": self.response_id,
"output_index": output_index,
"item": tool_item,
},
)
]
def tool_arguments_delta_event(self, tool_index: int, delta: str) -> Optional[str]:
if not delta:
return None
item = self.tool_items.get(tool_index)
if not item:
return None
item["arguments"] += delta
return self._event(
"response.function_call_arguments.delta",
{
"type": "response.function_call_arguments.delta",
"response_id": self.response_id,
"item_id": item["item_id"],
"output_index": item["output_index"],
"delta": delta,
},
)
def tool_arguments_done_events(self) -> List[str]:
events: List[str] = []
for tool_index, item in sorted(
self.tool_items.items(), key=lambda kv: kv[1]["output_index"]
):
events.append(
self._event(
"response.function_call_arguments.done",
{
"type": "response.function_call_arguments.done",
"response_id": self.response_id,
"item_id": item["item_id"],
"output_index": item["output_index"],
"arguments": item["arguments"],
},
)
)
tool_item = _build_output_tool_call(
{
"id": item["call_id"],
"function": {"name": item.get("name"), "arguments": item["arguments"]},
},
item_id=item["item_id"],
status="completed",
)
events.append(
self._event(
"response.output_item.done",
{
"type": "response.output_item.done",
"response_id": self.response_id,
"output_index": item["output_index"],
"item": tool_item,
},
)
)
return events
def record_tool_call(self, tool_index: int, call_id: str, name: Optional[str], arguments_delta: str) -> None:
tool_call = self.tool_calls_by_index.get(tool_index)
if not tool_call:
tool_call = {
"id": call_id or _new_tool_call_id(),
"type": "function",
"function": {"name": name, "arguments": ""},
}
self.tool_calls_by_index[tool_index] = tool_call
if name and not tool_call["function"].get("name"):
tool_call["function"]["name"] = name
if arguments_delta:
tool_call["function"]["arguments"] += arguments_delta
def completed_event(self, usage: Optional[Dict[str, Any]] = None) -> str:
response = self._response_payload(
status="completed",
output_text="".join(self.output_text_parts) if self.message_started else None,
usage=usage
or {"total_tokens": 0, "input_tokens": 0, "output_tokens": 0},
)
payload = {"type": "response.completed", "response": response}
return self._event("response.completed", payload)
class ResponsesService:
@staticmethod
async def create(
*,
model: str,
input_value: Any,
instructions: Optional[str] = None,
stream: bool = False,
temperature: Optional[float] = None,
top_p: Optional[float] = None,
tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Any = None,
parallel_tool_calls: Optional[bool] = None,
reasoning_effort: Optional[str] = None,
max_output_tokens: Optional[int] = None,
metadata: Optional[Dict[str, Any]] = None,
user: Optional[str] = None,
store: Optional[bool] = None,
previous_response_id: Optional[str] = None,
truncation: Optional[str] = None,
) -> Any:
messages = _coerce_input_to_messages(input_value)
if instructions:
messages = [{"role": "system", "content": instructions}] + messages
if not messages:
raise ValueError("input is required")
normalized_tools = _normalize_tools_for_chat(tools)
normalized_tool_choice = _normalize_tool_choice(tool_choice)
chat_kwargs: Dict[str, Any] = {
"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
result = await ChatService.completions(**chat_kwargs)
if not stream:
if not isinstance(result, dict):
raise ValueError("Unexpected stream response for non-stream request")
choice = (result.get("choices") or [{}])[0]
message = choice.get("message") or {}
content = message.get("content") or ""
tool_calls = message.get("tool_calls")
return _build_response_object(
model=model,
output_text=content,
tool_calls=tool_calls,
usage=result.get("usage")
or {"total_tokens": 0, "input_tokens": 0, "output_tokens": 0},
status="completed",
instructions=instructions,
max_output_tokens=max_output_tokens,
parallel_tool_calls=parallel_tool_calls,
previous_response_id=previous_response_id,
reasoning_effort=reasoning_effort,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=tools,
top_p=top_p,
truncation=truncation,
user=user,
metadata=metadata,
)
if not hasattr(result, "__aiter__"):
raise ValueError("Unexpected non-stream response for stream request")
created_at = _now_ts()
response_id = _new_response_id()
adapter = ResponseStreamAdapter(
model=model,
response_id=response_id,
created_at=created_at,
instructions=instructions,
max_output_tokens=max_output_tokens,
parallel_tool_calls=parallel_tool_calls,
previous_response_id=previous_response_id,
reasoning_effort=reasoning_effort,
store=store,
temperature=temperature,
tool_choice=tool_choice,
tools=tools,
top_p=top_p,
truncation=truncation,
user=user,
metadata=metadata,
)
async def _stream() -> AsyncGenerator[str, None]:
yield adapter.created_event()
yield adapter.in_progress_event()
async for chunk in result:
line = proc_base._normalize_line(chunk)
if not line:
continue
try:
data = orjson.loads(line)
except orjson.JSONDecodeError:
continue
if data.get("object") == "chat.completion.chunk":
delta = (data.get("choices") or [{}])[0].get("delta") or {}
if "content" in delta and delta["content"]:
for event in adapter.ensure_message_started():
yield event
adapter.output_text_parts.append(delta["content"])
yield adapter.output_delta_event(delta["content"])
tool_calls = delta.get("tool_calls")
if isinstance(tool_calls, list):
for tool in tool_calls:
if not isinstance(tool, dict):
continue
tool_index = tool.get("index", 0)
call_id = tool.get("id") or _new_tool_call_id()
fn = tool.get("function") or {}
name = fn.get("name")
args_delta = fn.get("arguments") or ""
adapter.record_tool_call(
tool_index, call_id, name, args_delta
)
for event in adapter.ensure_tool_item(
tool_index, call_id, name
):
yield event
delta_event = adapter.tool_arguments_delta_event(
tool_index, args_delta
)
if delta_event:
yield delta_event
full_text = "".join(adapter.output_text_parts)
if full_text and adapter.message_started:
for event in adapter.output_done_events(full_text):
yield event
for event in adapter.tool_arguments_done_events():
yield event
yield adapter.completed_event()
return _stream()
__all__ = ["ResponsesService"]
+144
View File
@@ -0,0 +1,144 @@
"""
Response formatting utilities for OpenAI-compatible API responses.
"""
import os
import time
import uuid
from typing import Optional
def make_response_id() -> str:
"""Generate a unique response ID."""
return f"chatcmpl-{int(time.time() * 1000)}{os.urandom(4).hex()}"
def make_chat_chunk(
response_id: str,
model: str,
content: str,
index: int = 0,
role: str = "assistant",
is_final: bool = False,
) -> dict:
"""
Create an OpenAI-compatible chat completion chunk.
Args:
response_id: Unique response ID
model: Model name
content: Content to send
index: Choice index
role: Role (assistant)
is_final: Whether this is the final chunk (includes finish_reason)
Returns:
Chat completion chunk dict
"""
choice: dict = {
"index": index,
"delta": {
"role": role,
"content": content,
},
}
if is_final:
choice["finish_reason"] = "stop"
chunk: dict = {
"id": response_id,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [choice],
}
if is_final:
chunk["usage"] = {
"total_tokens": 0,
"input_tokens": 0,
"output_tokens": 0,
"input_tokens_details": {"text_tokens": 0, "image_tokens": 0},
}
return chunk
def make_chat_response(
model: str,
content: str,
response_id: Optional[str] = None,
index: int = 0,
usage: Optional[dict] = None,
) -> dict:
"""
Create an OpenAI-compatible non-streaming chat completion response.
Args:
model: Model name
content: Response content
response_id: Unique response ID (generated if not provided)
index: Choice index
usage: Custom usage dict (defaults to zeros)
Returns:
Chat completion response dict
"""
if response_id is None:
response_id = f"chatcmpl-{uuid.uuid4().hex[:8]}"
if usage is None:
usage = {
"total_tokens": 0,
"input_tokens": 0,
"output_tokens": 0,
"input_tokens_details": {"text_tokens": 0, "image_tokens": 0},
}
return {
"id": response_id,
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": index,
"message": {
"role": "assistant",
"content": content,
"refusal": None,
},
"finish_reason": "stop",
}
],
"usage": usage,
}
def wrap_image_content(content: str, response_format: str = "url") -> str:
"""
Wrap image content in markdown format for chat interface.
Args:
content: Image URL or base64 data
response_format: "url" or "b64_json"/"base64"
Returns:
Markdown-wrapped image content
"""
if not content:
return content
if response_format == "url":
return f"![image]({content})"
else:
return f"![image](data:image/png;base64,{content})"
__all__ = [
"make_response_id",
"make_chat_chunk",
"make_chat_response",
"wrap_image_content",
]
+351
View File
@@ -0,0 +1,351 @@
"""
Tool call utilities for OpenAI-compatible function calling.
Provides prompt-based emulation of tool calls by injecting tool definitions
into the system prompt and parsing structured responses.
"""
import json
import re
import uuid
from typing import Any, Dict, List, Optional, Tuple
def build_tool_prompt(
tools: List[Dict[str, Any]],
tool_choice: Optional[Any] = None,
parallel_tool_calls: bool = True,
) -> str:
"""Generate a system prompt block describing available tools.
Args:
tools: List of OpenAI-format tool definitions.
tool_choice: "auto", "required", "none", or {"type":"function","function":{"name":"..."}}.
parallel_tool_calls: Whether multiple tool calls are allowed.
Returns:
System prompt string to prepend to the conversation.
"""
if not tools:
return ""
# tool_choice="none" means don't mention tools at all
if tool_choice == "none":
return ""
lines = [
"# Available Tools",
"",
"You have access to the following tools. To call a tool, output a <tool_call> block with a JSON object containing \"name\" and \"arguments\".",
"",
"Format:",
"<tool_call>",
'{"name": "function_name", "arguments": {"param": "value"}}',
"</tool_call>",
"",
]
if parallel_tool_calls:
lines.append("You may make multiple tool calls in a single response by using multiple <tool_call> blocks.")
lines.append("")
# Describe each tool
lines.append("## Tool Definitions")
lines.append("")
for tool in tools:
if tool.get("type") != "function":
continue
func = tool.get("function", {})
name = func.get("name", "")
desc = func.get("description", "")
params = func.get("parameters", {})
lines.append(f"### {name}")
if desc:
lines.append(f"{desc}")
if params:
lines.append(f"Parameters: {json.dumps(params, ensure_ascii=False)}")
lines.append("")
# Handle tool_choice directives
if tool_choice == "required":
lines.append("IMPORTANT: You MUST call at least one tool in your response. Do not respond with only text.")
elif isinstance(tool_choice, dict):
func_info = tool_choice.get("function", {})
forced_name = func_info.get("name", "")
if forced_name:
lines.append(f"IMPORTANT: You MUST call the tool \"{forced_name}\" in your response.")
else:
# "auto" or default
lines.append("Decide whether to call a tool based on the user's request. If you don't need a tool, respond normally with text only.")
lines.append("")
lines.append("When you call a tool, you may include text before or after the <tool_call> blocks, but the tool call blocks must be valid JSON.")
return "\n".join(lines)
_TOOL_CALL_RE = re.compile(
r"<tool_call>\s*(.*?)\s*</tool_call>",
re.DOTALL,
)
def _strip_code_fences(text: str) -> str:
if not text:
return text
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```[a-zA-Z0-9_-]*\s*", "", cleaned)
cleaned = re.sub(r"\s*```$", "", cleaned)
return cleaned.strip()
def _extract_json_object(text: str) -> str:
if not text:
return text
start = text.find("{")
if start == -1:
return text
end = text.rfind("}")
if end == -1:
return text[start:]
if end < start:
return text
return text[start : end + 1]
def _remove_trailing_commas(text: str) -> str:
if not text:
return text
return re.sub(r",\s*([}\]])", r"\1", text)
def _balance_braces(text: str) -> str:
if not text:
return text
open_count = 0
close_count = 0
in_string = False
escape = False
for ch in text:
if escape:
escape = False
continue
if ch == "\\" and in_string:
escape = True
continue
if ch == '"':
in_string = not in_string
continue
if in_string:
continue
if ch == "{":
open_count += 1
elif ch == "}":
close_count += 1
if open_count > close_count:
text = text + ("}" * (open_count - close_count))
return text
def _repair_json(text: str) -> Optional[Any]:
if not text:
return None
cleaned = _strip_code_fences(text)
cleaned = _extract_json_object(cleaned)
cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n")
cleaned = cleaned.replace("\n", " ")
cleaned = _remove_trailing_commas(cleaned)
cleaned = _balance_braces(cleaned)
try:
return json.loads(cleaned)
except json.JSONDecodeError:
return None
def parse_tool_call_block(
raw_json: str,
tools: Optional[List[Dict[str, Any]]] = None,
) -> Optional[Dict[str, Any]]:
if not raw_json:
return None
parsed = None
try:
parsed = json.loads(raw_json)
except json.JSONDecodeError:
parsed = _repair_json(raw_json)
if not isinstance(parsed, dict):
return None
name = parsed.get("name")
arguments = parsed.get("arguments", {})
if not name:
return None
valid_names = set()
if tools:
for tool in tools:
func = tool.get("function", {})
tool_name = func.get("name")
if tool_name:
valid_names.add(tool_name)
if valid_names and name not in valid_names:
return None
if isinstance(arguments, dict):
arguments_str = json.dumps(arguments, ensure_ascii=False)
elif isinstance(arguments, str):
arguments_str = arguments
else:
arguments_str = json.dumps(arguments, ensure_ascii=False)
return {
"id": f"call_{uuid.uuid4().hex[:24]}",
"type": "function",
"function": {"name": name, "arguments": arguments_str},
}
def parse_tool_calls(
content: str,
tools: Optional[List[Dict[str, Any]]] = None,
) -> Tuple[Optional[str], Optional[List[Dict[str, Any]]]]:
"""Parse tool call blocks from model output.
Detects ``<tool_call>...</tool_call>`` blocks, parses JSON from each block,
and returns OpenAI-format tool call objects.
Args:
content: Raw model output text.
tools: Optional list of tool definitions for name validation.
Returns:
Tuple of (text_content, tool_calls_list).
- text_content: text outside <tool_call> blocks (None if empty).
- tool_calls_list: list of OpenAI tool call dicts, or None if no calls found.
"""
if not content:
return content, None
matches = list(_TOOL_CALL_RE.finditer(content))
if not matches:
return content, None
tool_calls = []
for match in matches:
raw_json = match.group(1).strip()
tool_call = parse_tool_call_block(raw_json, tools)
if tool_call:
tool_calls.append(tool_call)
if not tool_calls:
return content, None
# Extract text outside of tool_call blocks
text_parts = []
last_end = 0
for match in matches:
before = content[last_end:match.start()]
if before.strip():
text_parts.append(before.strip())
last_end = match.end()
trailing = content[last_end:]
if trailing.strip():
text_parts.append(trailing.strip())
text_content = "\n".join(text_parts) if text_parts else None
return text_content, tool_calls
def build_tool_overrides(tools: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Convert OpenAI tool format to Grok's toolOverrides format (experimental).
Best-effort mapping for passthrough mode.
Args:
tools: List of OpenAI-format tool definitions.
Returns:
Dict suitable for the toolOverrides field in Grok API payload.
"""
if not tools:
return {}
tool_overrides = {}
for tool in tools:
if tool.get("type") != "function":
continue
func = tool.get("function", {})
name = func.get("name", "")
if not name:
continue
tool_overrides[name] = {
"enabled": True,
"description": func.get("description", ""),
"parameters": func.get("parameters", {}),
}
return tool_overrides
def format_tool_history(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Convert assistant messages with tool_calls and tool role messages into text format.
Since Grok's web API only accepts a single message string, this converts
tool-related messages back to a text representation for multi-turn conversations.
Args:
messages: List of OpenAI-format messages that may contain tool_calls and tool roles.
Returns:
List of messages with tool content converted to text format.
"""
result = []
for msg in messages:
role = msg.get("role", "")
content = msg.get("content")
tool_calls = msg.get("tool_calls")
tool_call_id = msg.get("tool_call_id")
name = msg.get("name")
if role == "assistant" and tool_calls:
# Convert assistant tool_calls to text representation
parts = []
if content:
parts.append(content if isinstance(content, str) else str(content))
for tc in tool_calls:
func = tc.get("function", {})
tc_name = func.get("name", "")
tc_args = func.get("arguments", "{}")
tc_id = tc.get("id", "")
parts.append(f'<tool_call>{{"name":"{tc_name}","arguments":{tc_args}}}</tool_call>')
result.append({
"role": "assistant",
"content": "\n".join(parts),
})
elif role == "tool":
# Convert tool result to text format
tool_name = name or "unknown"
call_id = tool_call_id or ""
content_str = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False) if content else ""
result.append({
"role": "user",
"content": f"tool ({tool_name}, {call_id}): {content_str}",
})
else:
result.append(msg)
return result
__all__ = [
"build_tool_prompt",
"parse_tool_calls",
"build_tool_overrides",
"format_tool_history",
"parse_tool_call_block",
]
+8
View File
@@ -41,6 +41,7 @@ from app.api.v1.image import router as image_router # noqa: E402
from app.api.v1.nsfw import router as nsfw_router # noqa: E402
from app.api.v1.files import router as files_router # noqa: E402
from app.api.v1.models import router as models_router # noqa: E402
from app.api.v1.response import router as responses_router # noqa: E402
from app.services.token import get_scheduler # noqa: E402
from app.api.v1.admin_api import router as admin_router
from app.api.v1.public_api import router as public_router
@@ -207,6 +208,13 @@ def create_app() -> FastAPI:
app.include_router(
models_router, prefix="/v1", dependencies=[Depends(verify_api_key)]
)
app.include_router(
responses_router, prefix="/v1", dependencies=[Depends(verify_api_key)]
)
# 兼容部分客户端直接请求 /responses(不带 /v1)
app.include_router(
responses_router, dependencies=[Depends(verify_api_key)]
)
app.include_router(files_router, prefix="/v1/files")
# 静态文件服务