Files
grok2api/app/api/v1/chat.py
T

297 lines
11 KiB
Python

"""
Chat Completions API 路由
"""
from typing import Any, Dict, List, Optional, Union
from fastapi import APIRouter
from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel, Field, field_validator
from app.services.grok.services.chat import ChatService
from app.services.grok.models.model import ModelService
from app.core.exceptions import ValidationException
router = APIRouter(tags=["Chat"])
VALID_ROLES = ["developer", "system", "user", "assistant", "tool"]
# 角色别名映射 (OpenAI 兼容: function -> tool)
ROLE_ALIASES = {"function": "tool"}
USER_CONTENT_TYPES = ["text", "image_url", "input_audio", "file"]
class MessageItem(BaseModel):
"""消息项"""
role: str
content: Union[str, List[Dict[str, Any]]]
tool_call_id: Optional[str] = None # tool 角色需要的字段
name: Optional[str] = None # function 角色的函数名
@field_validator("role")
@classmethod
def validate_role(cls, v):
# 大小写归一化
v_lower = v.lower() if isinstance(v, str) else v
# 别名映射
v_normalized = ROLE_ALIASES.get(v_lower, v_lower)
if v_normalized not in VALID_ROLES:
raise ValueError(f"role must be one of {VALID_ROLES}")
return v_normalized
class VideoConfig(BaseModel):
"""视频生成配置"""
aspect_ratio: Optional[str] = Field(
"3:2", description="视频比例: 3:2, 16:9, 1:1 等"
)
video_length: Optional[int] = Field(6, description="视频时长(秒): 6 / 10 / 15")
resolution_name: Optional[str] = Field("480p", description="视频分辨率: 480p, 720p")
preset: Optional[str] = Field("custom", description="风格预设: fun, normal, spicy")
@field_validator("aspect_ratio")
@classmethod
def validate_aspect_ratio(cls, v):
allowed = ["2:3", "3:2", "1:1", "9:16", "16:9"]
if v and v not in allowed:
raise ValidationException(
message=f"aspect_ratio must be one of {allowed}",
param="video_config.aspect_ratio",
code="invalid_aspect_ratio",
)
return v
@field_validator("video_length")
@classmethod
def validate_video_length(cls, v):
if v is not None:
if v not in (6, 10, 15):
raise ValidationException(
message="video_length must be 6, 10, or 15 seconds",
param="video_config.video_length",
code="invalid_video_length",
)
return v
@field_validator("resolution_name")
@classmethod
def validate_resolution(cls, v):
allowed = ["480p", "720p"]
if v and v not in allowed:
raise ValidationException(
message=f"resolution_name must be one of {allowed}",
param="video_config.resolution_name",
code="invalid_resolution",
)
return v
@field_validator("preset")
@classmethod
def validate_preset(cls, v):
# 允许为空,默认 custom
if not v:
return "custom"
allowed = ["fun", "normal", "spicy", "custom"]
if v not in allowed:
raise ValidationException(
message=f"preset must be one of {allowed}",
param="video_config.preset",
code="invalid_preset",
)
return v
class ChatCompletionRequest(BaseModel):
"""Chat Completions 请求"""
model: str = Field(..., description="模型名称")
messages: List[MessageItem] = Field(..., description="消息数组")
stream: Optional[bool] = Field(None, description="是否流式输出")
thinking: Optional[str] = Field(None, description="思考模式: enabled/disabled/None")
# 视频生成配置
video_config: Optional[VideoConfig] = Field(None, description="视频生成参数")
@field_validator("stream", mode="before")
@classmethod
def validate_stream(cls, v):
"""确保 stream 参数被正确解析为布尔值"""
if v is None:
return None
if isinstance(v, bool):
return v
if isinstance(v, str):
if v.lower() in ("true", "1", "yes"):
return True
if v.lower() in ("false", "0", "no"):
return False
# 未识别的字符串值抛出错误
raise ValueError(
f"Invalid stream value '{v}'. Must be a boolean or one of: true, false, 1, 0, yes, no"
)
# 非布尔非字符串类型抛出错误
raise ValueError(
f"Invalid stream value type '{type(v).__name__}'. Must be a boolean or string."
)
model_config = {"extra": "ignore"}
def validate_request(request: ChatCompletionRequest):
"""验证请求参数"""
# 验证模型
if not ModelService.valid(request.model):
raise ValidationException(
message=f"The model `{request.model}` does not exist or you do not have access to it.",
param="model",
code="model_not_found",
)
# 验证消息
for idx, msg in enumerate(request.messages):
content = msg.content
# 字符串内容
if isinstance(content, str):
if not content.strip():
raise ValidationException(
message="Message content cannot be empty",
param=f"messages.{idx}.content",
code="empty_content",
)
# 列表内容
elif isinstance(content, list):
if not content:
raise ValidationException(
message="Message content cannot be an empty array",
param=f"messages.{idx}.content",
code="empty_content",
)
for block_idx, block in enumerate(content):
# 检查空对象
if not block:
raise ValidationException(
message="Content block cannot be empty",
param=f"messages.{idx}.content.{block_idx}",
code="empty_block",
)
# 检查 type 字段
if "type" not in block:
raise ValidationException(
message="Content block must have a 'type' field",
param=f"messages.{idx}.content.{block_idx}",
code="missing_type",
)
block_type = block.get("type")
# 检查 type 空值
if (
not block_type
or not isinstance(block_type, str)
or not block_type.strip()
):
raise ValidationException(
message="Content block 'type' cannot be empty",
param=f"messages.{idx}.content.{block_idx}.type",
code="empty_type",
)
# 验证 type 有效性
if msg.role == "user":
if block_type not in USER_CONTENT_TYPES:
raise ValidationException(
message=f"Invalid content block type: '{block_type}'",
param=f"messages.{idx}.content.{block_idx}.type",
code="invalid_type",
)
elif msg.role in ("tool", "function"):
# tool/function 角色只支持 text 类型,但内容可以是 JSON 字符串
if block_type != "text":
raise ValidationException(
message=f"The `{msg.role}` role only supports 'text' type, got '{block_type}'",
param=f"messages.{idx}.content.{block_idx}.type",
code="invalid_type",
)
elif block_type != "text":
raise ValidationException(
message=f"The `{msg.role}` role only supports 'text' type, got '{block_type}'",
param=f"messages.{idx}.content.{block_idx}.type",
code="invalid_type",
)
# 验证字段是否存在 & 非空
if block_type == "text":
text = block.get("text", "")
if not isinstance(text, str) or not text.strip():
raise ValidationException(
message="Text content cannot be empty",
param=f"messages.{idx}.content.{block_idx}.text",
code="empty_text",
)
elif block_type == "image_url":
image_url = block.get("image_url")
if not image_url or not (
isinstance(image_url, dict) and image_url.get("url")
):
raise ValidationException(
message="image_url must have a 'url' field",
param=f"messages.{idx}.content.{block_idx}.image_url",
code="missing_url",
)
@router.post("/chat/completions")
async def chat_completions(request: ChatCompletionRequest):
"""Chat Completions API - 兼容 OpenAI"""
from app.core.logger import logger
# 参数验证
validate_request(request)
logger.debug(f"Chat request: model={request.model}, stream={request.stream}")
# 检测视频模型
model_info = ModelService.get(request.model)
if model_info and model_info.is_video:
from app.services.grok.services.media import VideoService
# 提取视频配置 (默认值在 Pydantic 模型中处理)
v_conf = request.video_config or VideoConfig()
result = await VideoService.completions(
model=request.model,
messages=[msg.model_dump() for msg in request.messages],
stream=request.stream,
thinking=request.thinking,
aspect_ratio=v_conf.aspect_ratio,
video_length=v_conf.video_length,
resolution=v_conf.resolution_name,
preset=v_conf.preset,
)
else:
result = await ChatService.completions(
model=request.model,
messages=[msg.model_dump() for msg in request.messages],
stream=request.stream,
thinking=request.thinking,
)
if isinstance(result, dict):
return JSONResponse(content=result)
else:
return StreamingResponse(
result,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
__all__ = ["router"]