feat: 并入 responses 接口并兼容翻译插件 provider options
This commit is contained in:
1 parent
4ef2ad0db1
commit
755fa30afa
7 files changed
+1482
-1
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
@@ -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""
|
||||
else:
|
||||
return f""
|
||||
|
||||
|
||||
__all__ = [
|
||||
"make_response_id",
|
||||
"make_chat_chunk",
|
||||
"make_chat_response",
|
||||
"wrap_image_content",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
# 静态文件服务
|
||||
|
||||
Reference in new issue
Block a user