refactor: streamline chat and video processing services, enhance validation, and remove unused components

This commit is contained in:
Chenyme committed 2026-02-13 20:22:52 +08:00
1 parent 6257b68527
commit d04bc4ee4c
13 files changed
+760 -643

No files matched your search

+226 -114
View File
@@ -3,106 +3,35 @@ Chat Completions API 路由
"""
from typing import Any, Dict, List, Optional, Union
import base64
import binascii
from fastapi import APIRouter
from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field
from app.services.grok.services.chat import ChatService
from app.services.grok.services.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
model_config = {"extra": "ignore"}
class VideoConfig(BaseModel):
"""视频生成配置"""
aspect_ratio: Optional[str] = Field(
"3:2", description="视频比例: 3:2, 16:9, 1:1 等"
)
aspect_ratio: Optional[str] = Field("3:2", description="视频比例: 1280x720(16:9), 720x1280(9:16), 1792x1024(3:2), 1024x1792(2:3), 1024x1024(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 请求"""
@@ -110,36 +39,165 @@ class ChatCompletionRequest(BaseModel):
model: str = Field(..., description="模型名称")
messages: List[MessageItem] = Field(..., description="消息数组")
stream: Optional[bool] = Field(None, description="是否流式输出")
thinking: Optional[str] = Field(None, description="思考模式: enabled/disabled/None")
reasoning_effort: Optional[str] = Field(None, description="推理强度: none/minimal/low/medium/high/xhigh")
temperature: Optional[float] = Field(0.8, description="采样温度: 0-2")
top_p: Optional[float] = Field(0.95, description="nucleus 采样: 0-1")
# 视频生成配置
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"}
VALID_ROLES = {"developer", "system", "user", "assistant"}
USER_CONTENT_TYPES = {"text", "image_url", "input_audio", "file"}
def _validate_media_input(value: str, field_name: str, param: str):
if not isinstance(value, str) or not value.strip():
raise ValidationException(
message=f"{field_name} cannot be empty",
param=param,
code="empty_media",
)
value = value.strip()
if value.startswith("data:"):
return
if value.startswith("http://") or value.startswith("https://"):
return
candidate = "".join(value.split())
if len(candidate) >= 32 and len(candidate) % 4 == 0:
try:
base64.b64decode(candidate, validate=True)
raise ValidationException(
message=f"{field_name} base64 must be provided as a data URI (data:<mime>;base64,...)",
param=param,
code="invalid_media",
)
except binascii.Error:
pass
raise ValidationException(
message=f"{field_name} must be a URL or data URI",
param=param,
code="invalid_media",
)
def _normalize_stream(value: Any) -> Optional[bool]:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, str):
if value.lower() in ("true", "1", "yes"):
return True
if value.lower() in ("false", "0", "no"):
return False
raise ValidationException(
message="stream must be a boolean",
param="stream",
code="invalid_stream",
)
def _validate_reasoning_effort(value: Any) -> Optional[str]:
allowed = {"none", "minimal", "low", "medium", "high", "xhigh"}
if value is None:
return None
if not isinstance(value, str) or value not in allowed:
raise ValidationException(
message=f"reasoning_effort must be one of {sorted(allowed)}",
param="reasoning_effort",
code="invalid_reasoning_effort",
)
return value
def _validate_temperature(value: Any) -> float:
if value is None:
return 0.8
try:
val = float(value)
except Exception:
raise ValidationException(
message="temperature must be a float",
param="temperature",
code="invalid_temperature",
)
if not (0 <= val <= 2):
raise ValidationException(
message="temperature must be between 0 and 2",
param="temperature",
code="invalid_temperature",
)
return val
def _validate_top_p(value: Any) -> float:
if value is None:
return 0.95
try:
val = float(value)
except Exception:
raise ValidationException(
message="top_p must be a float",
param="top_p",
code="invalid_top_p",
)
if not (0 <= val <= 1):
raise ValidationException(
message="top_p must be between 0 and 1",
param="top_p",
code="invalid_top_p",
)
return val
def _normalize_video_config(config: Optional[VideoConfig]) -> VideoConfig:
if config is None:
config = VideoConfig()
ratio_map = {
"1280x720": "16:9",
"720x1280": "9:16",
"1792x1024": "3:2",
"1024x1792": "2:3",
"1024x1024": "1:1",
"16:9": "16:9",
"9:16": "9:16",
"3:2": "3:2",
"2:3": "2:3",
"1:1": "1:1",
}
if config.aspect_ratio is None:
config.aspect_ratio = "3:2"
if config.aspect_ratio not in ratio_map:
raise ValidationException(
message=f"aspect_ratio must be one of {list(ratio_map.keys())}",
param="video_config.aspect_ratio",
code="invalid_aspect_ratio",
)
config.aspect_ratio = ratio_map[config.aspect_ratio]
if config.video_length 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",
)
if config.resolution_name not in ("480p", "720p"):
raise ValidationException(
message="resolution_name must be one of ['480p', '720p']",
param="video_config.resolution_name",
code="invalid_resolution",
)
if config.preset not in ("fun", "normal", "spicy", "custom"):
raise ValidationException(
message="preset must be one of ['fun', 'normal', 'spicy', 'custom']",
param="video_config.preset",
code="invalid_preset",
)
return config
def validate_request(request: ChatCompletionRequest):
"""验证请求参数"""
# 验证模型
@@ -152,6 +210,12 @@ def validate_request(request: ChatCompletionRequest):
# 验证消息
for idx, msg in enumerate(request.messages):
if not isinstance(msg.role, str) or msg.role not in VALID_ROLES:
raise ValidationException(
message=f"role must be one of {sorted(VALID_ROLES)}",
param=f"messages.{idx}.role",
code="invalid_role",
)
content = msg.content
# 字符串内容
@@ -174,6 +238,12 @@ def validate_request(request: ChatCompletionRequest):
for block_idx, block in enumerate(content):
# 检查空对象
if not isinstance(block, dict):
raise ValidationException(
message="Content block must be an object",
param=f"messages.{idx}.content.{block_idx}",
code="invalid_block",
)
if not block:
raise ValidationException(
message="Content block cannot be empty",
@@ -211,20 +281,13 @@ def validate_request(request: ChatCompletionRequest):
param=f"messages.{idx}.content.{block_idx}.type",
code="invalid_type",
)
elif msg.role in ("tool", "function"):
# tool/function 角色只支持 text 类型,但内容可以是 JSON 字符串
else:
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":
@@ -237,14 +300,61 @@ def validate_request(request: ChatCompletionRequest):
)
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")
):
if not image_url or not isinstance(image_url, dict):
raise ValidationException(
message="image_url must have a 'url' field",
param=f"messages.{idx}.content.{block_idx}.image_url",
code="missing_url",
)
_validate_media_input(
image_url.get("url", ""),
"image_url.url",
f"messages.{idx}.content.{block_idx}.image_url.url",
)
elif block_type == "input_audio":
audio = block.get("input_audio")
if not audio or not isinstance(audio, dict):
raise ValidationException(
message="input_audio must have a 'data' field",
param=f"messages.{idx}.content.{block_idx}.input_audio",
code="missing_audio",
)
_validate_media_input(
audio.get("data", ""),
"input_audio.data",
f"messages.{idx}.content.{block_idx}.input_audio.data",
)
elif block_type == "file":
file_data = block.get("file")
if not file_data or not isinstance(file_data, dict):
raise ValidationException(
message="file must have a 'file_data' field",
param=f"messages.{idx}.content.{block_idx}.file",
code="missing_file",
)
_validate_media_input(
file_data.get("file_data", ""),
"file.file_data",
f"messages.{idx}.content.{block_idx}.file.file_data",
)
else:
raise ValidationException(
message="Message content must be a string or array",
param=f"messages.{idx}.content",
code="invalid_content",
)
request.stream = _normalize_stream(request.stream)
request.reasoning_effort = _validate_reasoning_effort(request.reasoning_effort)
request.temperature = _validate_temperature(request.temperature)
request.top_p = _validate_top_p(request.top_p)
model_info = ModelService.get(request.model)
if model_info and model_info.is_video:
request.video_config = _normalize_video_config(request.video_config)
router = APIRouter(tags=["Chat"])
@router.post("/chat/completions")
@@ -269,7 +379,7 @@ async def chat_completions(request: ChatCompletionRequest):
model=request.model,
messages=[msg.model_dump() for msg in request.messages],
stream=request.stream,
thinking=request.thinking,
reasoning_effort=request.reasoning_effort,
aspect_ratio=v_conf.aspect_ratio,
video_length=v_conf.video_length,
resolution=v_conf.resolution_name,
@@ -280,7 +390,9 @@ async def chat_completions(request: ChatCompletionRequest):
model=request.model,
messages=[msg.model_dump() for msg in request.messages],
stream=request.stream,
thinking=request.thinking,
reasoning_effort=request.reasoning_effort,
temperature=request.temperature,
top_p=request.top_p,
)
if isinstance(result, dict):
+9
View File
@@ -101,6 +101,14 @@ class UpstreamException(AppException):
self.details = details
class StreamIdleTimeoutError(Exception):
"""流空闲超时错误"""
def __init__(self, idle_seconds: float):
self.idle_seconds = idle_seconds
super().__init__(f"Stream idle timeout after {idle_seconds}s")
# ============= 异常处理器 =============
@@ -219,6 +227,7 @@ __all__ = [
"ValidationException",
"AuthenticationException",
"UpstreamException",
"StreamIdleTimeoutError",
"error_response",
"register_exception_handlers",
]
+1 -1
View File
@@ -51,7 +51,7 @@ class NSFWService:
status = err.details["status"]
else:
status = getattr(err, "status_code", None)
if status in (401, 403):
if status == 401:
await mgr.record_fail(token, status, reason)
return status or 0
-6
View File
@@ -2,8 +2,6 @@
OpenAI 响应格式处理器
"""
from .base import BaseProcessor, StreamIdleTimeoutError
from .chat import StreamProcessor, CollectProcessor
from .video import VideoStreamProcessor, VideoCollectProcessor
from .image import (
ImageStreamProcessor,
@@ -13,10 +11,6 @@ from .image import (
)
__all__ = [
"BaseProcessor",
"StreamIdleTimeoutError",
"StreamProcessor",
"CollectProcessor",
"VideoStreamProcessor",
"VideoCollectProcessor",
"ImageStreamProcessor",
-348
View File
@@ -1,348 +0,0 @@
"""
Chat response processors.
"""
import asyncio
import uuid
import re
from typing import Any, AsyncGenerator, AsyncIterable
import orjson
from curl_cffi.requests.errors import RequestsError
from app.core.config import get_config
from app.core.logger import logger
from app.core.exceptions import UpstreamException
from .base import (
BaseProcessor,
StreamIdleTimeoutError,
_with_idle_timeout,
_normalize_stream_line,
_collect_image_urls,
_is_http2_stream_error,
)
class StreamProcessor(BaseProcessor):
"""Stream response processor."""
def __init__(self, model: str, token: str = "", think: bool = None):
super().__init__(model, token)
self.response_id: str = None
self.fingerprint: str = ""
self.think_opened: bool = False
self.role_sent: bool = False
self.filter_tags = get_config("chat.filter_tags")
self._tag_buffer: str = ""
self._in_filter_tag: bool = False
if think is None:
self.show_think = get_config("chat.thinking")
else:
self.show_think = think
def _filter_token(self, token: str) -> str:
"""Filter special tags (supports cross-token tag filtering)."""
if not self.filter_tags:
return token
result = []
i = 0
while i < len(token):
char = token[i]
if self._in_filter_tag:
self._tag_buffer += char
if char == ">":
if "/>" in self._tag_buffer:
self._in_filter_tag = False
self._tag_buffer = ""
else:
for tag in self.filter_tags:
if f"</{tag}>" in self._tag_buffer:
self._in_filter_tag = False
self._tag_buffer = ""
break
i += 1
continue
if char == "<":
remaining = token[i:]
tag_started = False
for tag in self.filter_tags:
if remaining.startswith(f"<{tag}"):
tag_started = True
break
if len(remaining) < len(tag) + 1:
for j in range(1, len(remaining) + 1):
if f"<{tag}".startswith(remaining[:j]):
tag_started = True
break
if tag_started:
self._in_filter_tag = True
self._tag_buffer = char
i += 1
continue
result.append(char)
i += 1
return "".join(result)
def _sse(self, content: str = "", role: str = None, finish: str = None) -> str:
"""Build SSE response."""
delta = {}
if role:
delta["role"] = role
delta["content"] = ""
elif content:
delta["content"] = content
chunk = {
"id": self.response_id or f"chatcmpl-{uuid.uuid4().hex[:24]}",
"object": "chat.completion.chunk",
"created": self.created,
"model": self.model,
"system_fingerprint": self.fingerprint,
"choices": [
{"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish}
],
}
return f"data: {orjson.dumps(chunk).decode()}\n\n"
async def process(
self, response: AsyncIterable[bytes]
) -> AsyncGenerator[str, None]:
"""Process stream response."""
idle_timeout = get_config("timeout.stream_idle_timeout")
try:
async for line in _with_idle_timeout(response, idle_timeout, self.model):
line = _normalize_stream_line(line)
if not line:
continue
try:
data = orjson.loads(line)
except orjson.JSONDecodeError:
continue
resp = data.get("result", {}).get("response", {})
if (llm := resp.get("llmInfo")) and not self.fingerprint:
self.fingerprint = llm.get("modelHash", "")
if rid := resp.get("responseId"):
self.response_id = rid
if not self.role_sent:
yield self._sse(role="assistant")
self.role_sent = True
# Image generation progress
if img := resp.get("streamingImageGenerationResponse"):
if self.show_think:
if not self.think_opened:
yield self._sse("<think>\n")
self.think_opened = True
idx = img.get("imageIndex", 0) + 1
progress = img.get("progress", 0)
yield self._sse(
f"正在生成第{idx}张图片中,当前进度{progress}%\n"
)
continue
# modelResponse
if mr := resp.get("modelResponse"):
if self.think_opened and self.show_think:
if msg := mr.get("message"):
yield self._sse(msg + "\n")
yield self._sse("</think>\n")
self.think_opened = False
# Handle generated images
for url in _collect_image_urls(mr):
parts = url.split("/")
img_id = parts[-2] if len(parts) >= 2 else "image"
dl_service = self._get_dl()
rendered = await dl_service.render_image(
url, self.token, img_id
)
yield self._sse(f"{rendered}\n")
if (
(meta := mr.get("metadata", {}))
.get("llm_info", {})
.get("modelHash")
):
self.fingerprint = meta["llm_info"]["modelHash"]
continue
# Normal token
if (token := resp.get("token")) is not None:
if token:
filtered = self._filter_token(token)
if filtered:
yield self._sse(filtered)
if self.think_opened:
yield self._sse("</think>\n")
yield self._sse(finish="stop")
yield "data: [DONE]\n\n"
except asyncio.CancelledError:
logger.debug("Stream cancelled by client", extra={"model": self.model})
except StreamIdleTimeoutError as e:
raise UpstreamException(
message=f"Stream idle timeout after {e.idle_seconds}s",
status_code=504,
details={
"error": str(e),
"type": "stream_idle_timeout",
"idle_seconds": e.idle_seconds,
},
)
except RequestsError as e:
if _is_http2_stream_error(e):
logger.warning(f"HTTP/2 stream error: {e}", extra={"model": self.model})
raise UpstreamException(
message="Upstream connection closed unexpectedly",
status_code=502,
details={"error": str(e), "type": "http2_stream_error"},
)
logger.error(f"Stream request error: {e}", extra={"model": self.model})
raise UpstreamException(
message=f"Upstream request failed: {e}",
status_code=502,
details={"error": str(e)},
)
except Exception as e:
logger.error(
f"Stream processing error: {e}",
extra={"model": self.model, "error_type": type(e).__name__},
)
raise
finally:
await self.close()
class CollectProcessor(BaseProcessor):
"""Non-stream response processor."""
def __init__(self, model: str, token: str = ""):
super().__init__(model, token)
self.filter_tags = get_config("chat.filter_tags")
def _filter_content(self, content: str) -> str:
"""Filter special tags in content."""
if not content or not self.filter_tags:
return content
result = content
for tag in self.filter_tags:
pattern = rf"<{re.escape(tag)}[^>]*>.*?</{re.escape(tag)}>|<{re.escape(tag)}[^>]*/>"
result = re.sub(pattern, "", result, flags=re.DOTALL)
return result
async def process(self, response: AsyncIterable[bytes]) -> dict[str, Any]:
"""Process and collect full response."""
response_id = ""
fingerprint = ""
content = ""
idle_timeout = get_config("timeout.stream_idle_timeout")
try:
async for line in _with_idle_timeout(response, idle_timeout, self.model):
line = _normalize_stream_line(line)
if not line:
continue
try:
data = orjson.loads(line)
except orjson.JSONDecodeError:
continue
resp = data.get("result", {}).get("response", {})
if (llm := resp.get("llmInfo")) and not fingerprint:
fingerprint = llm.get("modelHash", "")
if mr := resp.get("modelResponse"):
response_id = mr.get("responseId", "")
content = mr.get("message", "")
if urls := _collect_image_urls(mr):
content += "\n"
for url in urls:
parts = url.split("/")
img_id = parts[-2] if len(parts) >= 2 else "image"
dl_service = self._get_dl()
rendered = await dl_service.render_image(
url, self.token, img_id
)
content += f"{rendered}\n"
if (
(meta := mr.get("metadata", {}))
.get("llm_info", {})
.get("modelHash")
):
fingerprint = meta["llm_info"]["modelHash"]
except asyncio.CancelledError:
logger.debug("Collect cancelled by client", extra={"model": self.model})
except StreamIdleTimeoutError as e:
logger.warning(f"Collect idle timeout: {e}", extra={"model": self.model})
except RequestsError as e:
if _is_http2_stream_error(e):
logger.warning(
f"HTTP/2 stream error in collect: {e}", extra={"model": self.model}
)
else:
logger.error(f"Collect request error: {e}", extra={"model": self.model})
except Exception as e:
logger.error(
f"Collect processing error: {e}",
extra={"model": self.model, "error_type": type(e).__name__},
)
finally:
await self.close()
content = self._filter_content(content)
return {
"id": response_id,
"object": "chat.completion",
"created": self.created,
"model": self.model,
"system_fingerprint": fingerprint,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": content,
"refusal": None,
"annotations": [],
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"prompt_tokens_details": {
"cached_tokens": 0,
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0,
},
"completion_tokens_details": {
"text_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
},
},
}
__all__ = ["StreamProcessor", "CollectProcessor"]
+11 -12
View File
@@ -15,14 +15,13 @@ from curl_cffi.requests.errors import RequestsError
from app.core.config import get_config
from app.core.logger import logger
from app.core.storage import DATA_DIR
from app.core.exceptions import UpstreamException
from .base import (
from app.core.exceptions import UpstreamException, StreamIdleTimeoutError
from app.services.grok.utils.process import (
BaseProcessor,
StreamIdleTimeoutError,
_with_idle_timeout,
_normalize_stream_line,
_collect_image_urls,
_is_http2_stream_error,
_normalize_line,
_collect_images,
_is_http2_error,
)
@@ -57,7 +56,7 @@ class ImageStreamProcessor(BaseProcessor):
try:
async for line in _with_idle_timeout(response, idle_timeout, self.model):
line = _normalize_stream_line(line)
line = _normalize_line(line)
if not line:
continue
try:
@@ -90,7 +89,7 @@ class ImageStreamProcessor(BaseProcessor):
# modelResponse
if mr := resp.get("modelResponse"):
if urls := _collect_image_urls(mr):
if urls := _collect_images(mr):
for url in urls:
if self.response_format == "url":
processed = await self.process_url(url, "image")
@@ -155,7 +154,7 @@ class ImageStreamProcessor(BaseProcessor):
},
)
except RequestsError as e:
if _is_http2_stream_error(e):
if _is_http2_error(e):
logger.warning(f"HTTP/2 stream error in image: {e}")
raise UpstreamException(
message="Upstream connection closed unexpectedly",
@@ -192,7 +191,7 @@ class ImageCollectProcessor(BaseProcessor):
try:
async for line in _with_idle_timeout(response, idle_timeout, self.model):
line = _normalize_stream_line(line)
line = _normalize_line(line)
if not line:
continue
try:
@@ -203,7 +202,7 @@ class ImageCollectProcessor(BaseProcessor):
resp = data.get("result", {}).get("response", {})
if mr := resp.get("modelResponse"):
if urls := _collect_image_urls(mr):
if urls := _collect_images(mr):
for url in urls:
if self.response_format == "url":
processed = await self.process_url(url, "image")
@@ -234,7 +233,7 @@ class ImageCollectProcessor(BaseProcessor):
except StreamIdleTimeoutError as e:
logger.warning(f"Image collect idle timeout: {e}")
except RequestsError as e:
if _is_http2_stream_error(e):
if _is_http2_error(e):
logger.warning(f"HTTP/2 stream error in image collect: {e}")
else:
logger.error(f"Image collect request error: {e}")
+10 -14
View File
@@ -11,29 +11,25 @@ from curl_cffi.requests.errors import RequestsError
from app.core.config import get_config
from app.core.logger import logger
from app.core.exceptions import UpstreamException
from .base import (
from app.core.exceptions import UpstreamException, StreamIdleTimeoutError
from app.services.grok.utils.process import (
BaseProcessor,
StreamIdleTimeoutError,
_with_idle_timeout,
_normalize_stream_line,
_is_http2_stream_error,
_normalize_line,
_is_http2_error,
)
class VideoStreamProcessor(BaseProcessor):
"""Video stream response processor."""
def __init__(self, model: str, token: str = "", think: bool = None):
def __init__(self, model: str, token: str = "", show_think: bool = None):
super().__init__(model, token)
self.response_id: Optional[str] = None
self.think_opened: bool = False
self.role_sent: bool = False
if think is None:
self.show_think = get_config("chat.thinking")
else:
self.show_think = think
self.show_think = bool(show_think)
def _sse(self, content: str = "", role: str = None, finish: str = None) -> str:
"""Build SSE response."""
@@ -63,7 +59,7 @@ class VideoStreamProcessor(BaseProcessor):
try:
async for line in _with_idle_timeout(response, idle_timeout, self.model):
line = _normalize_stream_line(line)
line = _normalize_line(line)
if not line:
continue
try:
@@ -127,7 +123,7 @@ class VideoStreamProcessor(BaseProcessor):
},
)
except RequestsError as e:
if _is_http2_stream_error(e):
if _is_http2_error(e):
logger.warning(
f"HTTP/2 stream error in video: {e}", extra={"model": self.model}
)
@@ -167,7 +163,7 @@ class VideoCollectProcessor(BaseProcessor):
try:
async for line in _with_idle_timeout(response, idle_timeout, self.model):
line = _normalize_stream_line(line)
line = _normalize_line(line)
if not line:
continue
try:
@@ -199,7 +195,7 @@ class VideoCollectProcessor(BaseProcessor):
f"Video collect idle timeout: {e}", extra={"model": self.model}
)
except RequestsError as e:
if _is_http2_stream_error(e):
if _is_http2_error(e):
logger.warning(
f"HTTP/2 stream error in video collect: {e}",
extra={"model": self.model},
+476 -106
View File
@@ -2,10 +2,14 @@
Grok Chat 服务
"""
from typing import Dict, List, Any
from dataclasses import dataclass
import asyncio
import re
import uuid
from typing import Dict, List, Any, AsyncGenerator, AsyncIterable
import orjson
from curl_cffi.requests import AsyncSession
from curl_cffi.requests.errors import RequestsError
from app.core.logger import logger
from app.core.config import get_config
@@ -14,39 +18,29 @@ from app.core.exceptions import (
ValidationException,
ErrorType,
UpstreamException,
StreamIdleTimeoutError,
)
from app.services.grok.services.model import ModelService
from app.services.grok.utils.upload import UploadService
from app.services.grok.processors import StreamProcessor, CollectProcessor
from app.services.grok.utils import process as proc_base
from app.services.reverse.app_chat import AppChatReverse
from app.services.grok.utils.stream import wrap_stream_with_usage
from app.services.token import get_token_manager, EffortType
@dataclass
class ChatRequest:
"""聊天请求数据"""
model: str
messages: List[Dict[str, Any]]
stream: bool = None
think: bool = None
class MessageExtractor:
"""消息内容提取器"""
@staticmethod
def extract(
messages: List[Dict[str, Any]], is_video: bool = False
) -> tuple[str, List[tuple[str, str]]]:
"""从 OpenAI 消息格式提取内容,返回 (text, attachments)"""
def extract(messages: List[Dict[str, Any]]) -> tuple[str, List[str], List[str]]:
"""从 OpenAI 消息格式提取内容,返回 (text, file_attachments, image_attachments)"""
texts = []
attachments = []
file_attachments: List[str] = []
image_attachments: List[str] = []
extracted = []
for msg in messages:
role = msg.get("role", "")
role = msg.get("role", "") or "user"
content = msg.get("content", "")
parts = []
@@ -63,35 +57,21 @@ class MessageExtractor:
elif item_type == "image_url":
image_data = item.get("image_url", {})
url = (
image_data.get("url", "")
if isinstance(image_data, dict)
else str(image_data)
)
url = image_data.get("url", "")
if url:
attachments.append(("image", url))
image_attachments.append(url)
elif item_type == "input_audio":
if is_video:
raise ValueError("视频模型不支持 input_audio 类型")
audio_data = item.get("input_audio", {})
data = (
audio_data.get("data", "")
if isinstance(audio_data, dict)
else str(audio_data)
)
data = audio_data.get("data", "")
if data:
attachments.append(("audio", data))
file_attachments.append(data)
elif item_type == "file":
if is_video:
raise ValueError("视频模型不支持 file 类型")
file_data = item.get("file", {})
url = file_data.get("url", "") or file_data.get("data", "")
if isinstance(file_data, str):
url = file_data
if url:
attachments.append(("file", url))
raw = file_data.get("file_data", "")
if raw:
file_attachments.append(raw)
if parts:
extracted.append({"role": role, "text": "\n".join(parts)})
@@ -111,36 +91,12 @@ class MessageExtractor:
text = item["text"]
texts.append(text if i == last_user_index else f"{role}: {text}")
return "\n\n".join(texts), attachments
class ChatRequestBuilder:
"""请求构造器"""
@staticmethod
def build_payload(
message: str,
model: str,
mode: str = None,
file_attachments: List[str] = None,
image_attachments: List[str] = None,
) -> Dict[str, Any]:
"""构造请求体"""
return AppChatReverse.build_payload(
message=message,
model=model,
mode=mode,
file_attachments=file_attachments,
image_attachments=image_attachments,
)
return "\n\n".join(texts), file_attachments, image_attachments
class GrokChatService:
"""Grok API 调用服务"""
def __init__(self):
pass
async def chat(
self,
token: str,
@@ -149,7 +105,6 @@ class GrokChatService:
mode: str = None,
stream: bool = None,
file_attachments: List[str] = None,
image_attachments: List[str] = None,
tool_overrides: Dict[str, Any] = None,
model_config_override: Dict[str, Any] = None,
):
@@ -171,7 +126,6 @@ class GrokChatService:
model=model,
mode=mode,
file_attachments=file_attachments,
image_attachments=image_attachments,
tool_overrides=tool_overrides,
model_config_override=model_config_override,
)
@@ -182,44 +136,58 @@ class GrokChatService:
return stream_response
async def chat_openai(self, token: str, request: ChatRequest):
async def chat_openai(
self,
token: str,
model: str,
messages: List[Dict[str, Any]],
stream: bool = None,
reasoning_effort: str | None = None,
temperature: float = 0.8,
top_p: float = 0.95,
):
"""OpenAI 兼容接口"""
model_info = ModelService.get(request.model)
model_info = ModelService.get(model)
if not model_info:
raise ValidationException(f"Unknown model: {request.model}")
raise ValidationException(f"Unknown model: {model}")
grok_model = model_info.grok_model
mode = model_info.model_mode
is_video = model_info.is_video
# 提取消息和附件
try:
message, attachments = MessageExtractor.extract(
request.messages, is_video=is_video
)
logger.debug(
f"Extracted message length={len(message)}, attachments={len(attachments)}"
)
except ValueError as e:
raise ValidationException(str(e))
message, file_attachments, image_attachments = MessageExtractor.extract(messages)
logger.debug(
"Extracted message length=%s, files=%s, images=%s",
len(message),
len(file_attachments),
len(image_attachments),
)
# 上传附件
file_ids = []
if attachments:
file_ids: List[str] = []
image_ids: List[str] = []
if file_attachments or image_attachments:
upload_service = UploadService()
try:
for attach_type, attach_data in attachments:
for attach_data in file_attachments:
file_id, _ = await upload_service.upload_file(attach_data, token)
file_ids.append(file_id)
logger.debug(
f"Attachment uploaded: type={attach_type}, file_id={file_id}"
)
logger.debug(f"Attachment uploaded: type=file, file_id={file_id}")
for attach_data in image_attachments:
file_id, _ = await upload_service.upload_file(attach_data, token)
image_ids.append(file_id)
logger.debug(f"Attachment uploaded: type=image, file_id={file_id}")
finally:
await upload_service.close()
stream = (
request.stream if request.stream is not None else get_config("chat.stream")
)
all_attachments = file_ids + image_ids
stream = stream if stream is not None else get_config("chat.stream")
model_config_override = {
"temperature": temperature,
"topP": top_p,
}
if reasoning_effort is not None:
model_config_override["reasoningEffort"] = reasoning_effort
response = await self.chat(
token,
@@ -227,11 +195,11 @@ class GrokChatService:
grok_model,
mode,
stream,
file_attachments=file_ids,
image_attachments=[],
file_attachments=all_attachments,
model_config_override=model_config_override,
)
return response, stream, request.model
return response, stream, model
class ChatService:
@@ -242,29 +210,29 @@ class ChatService:
model: str,
messages: List[Dict[str, Any]],
stream: bool = None,
thinking: str = None,
reasoning_effort: str | None = None,
temperature: float = 0.8,
top_p: float = 0.95,
):
"""Chat Completions 入口"""
# 获取 token
token_mgr = await get_token_manager()
await token_mgr.reload_if_stale()
# 解析参数(只需解析一次)
think = {"enabled": True, "disabled": False}.get(thinking)
# 解析参数
if reasoning_effort is None:
show_think = get_config("chat.thinking")
else:
show_think = reasoning_effort != "none"
is_stream = stream if stream is not None else get_config("chat.stream")
# 构造请求(只需构造一次)
chat_request = ChatRequest(
model=model, messages=messages, stream=is_stream, think=think
)
# 跨 Token 重试循环
tried_tokens = set()
max_token_retries = int(get_config("retry.max_retry"))
last_error = None
for attempt in range(max_token_retries):
# 选择 token(排除已失败的)
# 选择 token
token = None
for pool_name in ModelService.pool_candidates_for_model(model):
token = token_mgr.get_token(pool_name, exclude=tried_tokens)
@@ -296,12 +264,20 @@ class ChatService:
try:
# 请求 Grok
service = GrokChatService()
response, _, model_name = await service.chat_openai(token, chat_request)
response, _, model_name = await service.chat_openai(
token,
model,
messages,
stream=is_stream,
reasoning_effort=reasoning_effort,
temperature=temperature,
top_p=top_p,
)
# 处理响应
if is_stream:
logger.debug(f"Processing stream response: model={model}")
processor = StreamProcessor(model_name, token, think)
processor = StreamProcessor(model_name, token, show_think)
return wrap_stream_with_usage(
processor.process(response), token_mgr, token, model
)
@@ -349,10 +325,404 @@ class ChatService:
)
class StreamProcessor(proc_base.BaseProcessor):
"""Stream response processor."""
def __init__(self, model: str, token: str = "", show_think: bool = None):
super().__init__(model, token)
self.response_id: str = None
self.fingerprint: str = ""
self.think_opened: bool = False
self.role_sent: bool = False
self.filter_tags = get_config("chat.filter_tags")
self._tag_buffer: str = ""
self._in_filter_tag: bool = False
self.show_think = bool(show_think)
def _filter_token(self, token: str) -> str:
"""Filter special tags (supports cross-token tag filtering)."""
if not self.filter_tags:
return token
result = []
i = 0
while i < len(token):
char = token[i]
if self._in_filter_tag:
self._tag_buffer += char
if char == ">":
if "/>" in self._tag_buffer:
self._in_filter_tag = False
self._tag_buffer = ""
else:
for tag in self.filter_tags:
if f"</{tag}>" in self._tag_buffer:
self._in_filter_tag = False
self._tag_buffer = ""
break
i += 1
continue
if char == "<":
remaining = token[i:]
tag_started = False
for tag in self.filter_tags:
if remaining.startswith(f"<{tag}"):
tag_started = True
break
if len(remaining) < len(tag) + 1:
for j in range(1, len(remaining) + 1):
if f"<{tag}".startswith(remaining[:j]):
tag_started = True
break
if tag_started:
self._in_filter_tag = True
self._tag_buffer = char
i += 1
continue
result.append(char)
i += 1
return "".join(result)
def _sse(self, content: str = "", role: str = None, finish: str = None) -> str:
"""Build SSE response."""
delta = {}
if role:
delta["role"] = role
delta["content"] = ""
elif content:
delta["content"] = content
chunk = {
"id": self.response_id or f"chatcmpl-{uuid.uuid4().hex[:24]}",
"object": "chat.completion.chunk",
"created": self.created,
"model": self.model,
"system_fingerprint": self.fingerprint,
"choices": [
{"index": 0, "delta": delta, "logprobs": None, "finish_reason": finish}
],
}
return f"data: {orjson.dumps(chunk).decode()}\n\n"
async def process(self, response: AsyncIterable[bytes]) -> AsyncGenerator[str, None]:
"""Process stream response.
Args:
response: AsyncIterable[bytes], async iterable of bytes
Returns:
AsyncGenerator[str, None], async generator of strings
"""
idle_timeout = get_config("timeout.stream_idle_timeout")
try:
async for line in proc_base._with_idle_timeout(
response, idle_timeout, self.model
):
line = proc_base._normalize_line(line)
if not line:
continue
try:
data = orjson.loads(line)
except orjson.JSONDecodeError:
continue
resp = data.get("result", {}).get("response", {})
is_thinking = bool(resp.get("isThinking"))
# isThinking controls <think> tagging
# when absent, treat as False
if (llm := resp.get("llmInfo")) and not self.fingerprint:
self.fingerprint = llm.get("modelHash", "")
if rid := resp.get("responseId"):
self.response_id = rid
if not self.role_sent:
yield self._sse(role="assistant")
self.role_sent = True
if img := resp.get("streamingImageGenerationResponse"):
if not self.show_think:
continue
if is_thinking and not self.think_opened:
yield self._sse("<think>\n")
self.think_opened = True
if (not is_thinking) and self.think_opened:
yield self._sse("\n</think>\n")
self.think_opened = False
idx = img.get("imageIndex", 0) + 1
progress = img.get("progress", 0)
yield self._sse(
f"正在生成第{idx}张图片中,当前进度{progress}%\n"
)
continue
if mr := resp.get("modelResponse"):
for url in proc_base._collect_images(mr):
parts = url.split("/")
img_id = parts[-2] if len(parts) >= 2 else "image"
dl_service = self._get_dl()
rendered = await dl_service.render_image(
url, self.token, img_id
)
yield self._sse(f"{rendered}\n")
if (
(meta := mr.get("metadata", {}))
.get("llm_info", {})
.get("modelHash")
):
self.fingerprint = meta["llm_info"]["modelHash"]
continue
if card := resp.get("cardAttachment"):
json_data = card.get("jsonData")
if isinstance(json_data, str) and json_data.strip():
try:
card_data = orjson.loads(json_data)
except orjson.JSONDecodeError:
card_data = None
if isinstance(card_data, dict):
image = card_data.get("image") or {}
original = image.get("original")
title = image.get("title") or ""
if original:
title_safe = title.replace("\n", " ").strip()
if title_safe:
yield self._sse(f"![{title_safe}]({original})\n")
else:
yield self._sse(f"![image]({original})\n")
continue
if (token := resp.get("token")) is not None:
if not token:
continue
filtered = self._filter_token(token)
if not filtered:
continue
if is_thinking:
if not self.show_think:
continue
if not self.think_opened:
yield self._sse("<think>\n")
self.think_opened = True
else:
if self.think_opened:
yield self._sse("\n</think>\n")
self.think_opened = False
yield self._sse(filtered)
if self.think_opened:
yield self._sse("</think>\n")
yield self._sse(finish="stop")
yield "data: [DONE]\n\n"
except asyncio.CancelledError:
logger.debug("Stream cancelled by client", extra={"model": self.model})
except StreamIdleTimeoutError as e:
raise UpstreamException(
message=f"Stream idle timeout after {e.idle_seconds}s",
status_code=504,
details={
"error": str(e),
"type": "stream_idle_timeout",
"idle_seconds": e.idle_seconds,
},
)
except RequestsError as e:
if proc_base._is_http2_error(e):
logger.warning(f"HTTP/2 stream error: {e}", extra={"model": self.model})
raise UpstreamException(
message="Upstream connection closed unexpectedly",
status_code=502,
details={"error": str(e), "type": "http2_stream_error"},
)
logger.error(f"Stream request error: {e}", extra={"model": self.model})
raise UpstreamException(
message=f"Upstream request failed: {e}",
status_code=502,
details={"error": str(e)},
)
except Exception as e:
logger.error(
f"Stream processing error: {e}",
extra={"model": self.model, "error_type": type(e).__name__},
)
raise
finally:
await self.close()
class CollectProcessor(proc_base.BaseProcessor):
"""Non-stream response processor."""
def __init__(self, model: str, token: str = ""):
super().__init__(model, token)
self.filter_tags = get_config("chat.filter_tags")
def _filter_content(self, content: str) -> str:
"""Filter special tags in content."""
if not content or not self.filter_tags:
return content
result = content
for tag in self.filter_tags:
pattern = rf"<{re.escape(tag)}[^>]*>.*?</{re.escape(tag)}>|<{re.escape(tag)}[^>]*/>"
result = re.sub(pattern, "", result, flags=re.DOTALL)
return result
async def process(self, response: AsyncIterable[bytes]) -> dict[str, Any]:
"""Process and collect full response."""
response_id = ""
fingerprint = ""
content = ""
idle_timeout = get_config("timeout.stream_idle_timeout")
try:
async for line in proc_base._with_idle_timeout(
response, idle_timeout, self.model
):
line = proc_base._normalize_line(line)
if not line:
continue
try:
data = orjson.loads(line)
except orjson.JSONDecodeError:
continue
resp = data.get("result", {}).get("response", {})
if (llm := resp.get("llmInfo")) and not fingerprint:
fingerprint = llm.get("modelHash", "")
if mr := resp.get("modelResponse"):
response_id = mr.get("responseId", "")
content = mr.get("message", "")
card_map: dict[str, tuple[str, str]] = {}
for raw in mr.get("cardAttachmentsJson") or []:
if not isinstance(raw, str) or not raw.strip():
continue
try:
card_data = orjson.loads(raw)
except orjson.JSONDecodeError:
continue
if not isinstance(card_data, dict):
continue
card_id = card_data.get("id")
image = card_data.get("image") or {}
original = image.get("original")
if not card_id or not original:
continue
title = image.get("title") or ""
card_map[card_id] = (title, original)
if content and card_map:
def _render_card(match: re.Match) -> str:
card_id = match.group(1)
item = card_map.get(card_id)
if not item:
return ""
title, original = item
title_safe = title.replace("\n", " ").strip() or "image"
prefix = ""
if match.start() > 0:
prev = content[match.start() - 1]
if prev not in ("\n", "\r"):
prefix = "\n"
return f"{prefix}![{title_safe}]({original})"
content = re.sub(
r'<grok:render[^>]*card_id="([^"]+)"[^>]*>.*?</grok:render>',
_render_card,
content,
flags=re.DOTALL,
)
if urls := proc_base._collect_images(mr):
content += "\n"
for url in urls:
parts = url.split("/")
img_id = parts[-2] if len(parts) >= 2 else "image"
dl_service = self._get_dl()
rendered = await dl_service.render_image(
url, self.token, img_id
)
content += f"{rendered}\n"
if (
(meta := mr.get("metadata", {}))
.get("llm_info", {})
.get("modelHash")
):
fingerprint = meta["llm_info"]["modelHash"]
except asyncio.CancelledError:
logger.debug("Collect cancelled by client", extra={"model": self.model})
except StreamIdleTimeoutError as e:
logger.warning(f"Collect idle timeout: {e}", extra={"model": self.model})
except RequestsError as e:
if proc_base._is_http2_error(e):
logger.warning(
f"HTTP/2 stream error in collect: {e}", extra={"model": self.model}
)
else:
logger.error(f"Collect request error: {e}", extra={"model": self.model})
except Exception as e:
logger.error(
f"Collect processing error: {e}",
extra={"model": self.model, "error_type": type(e).__name__},
)
finally:
await self.close()
content = self._filter_content(content)
return {
"id": response_id,
"object": "chat.completion",
"created": self.created,
"model": self.model,
"system_fingerprint": fingerprint,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": content,
"refusal": None,
"annotations": [],
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"prompt_tokens_details": {
"cached_tokens": 0,
"text_tokens": 0,
"audio_tokens": 0,
"image_tokens": 0,
},
"completion_tokens_details": {
"text_tokens": 0,
"audio_tokens": 0,
"reasoning_tokens": 0,
},
},
}
__all__ = [
"GrokChatService",
"ChatRequest",
"ChatRequestBuilder",
"MessageExtractor",
"ChatService",
]
+15 -14
View File
@@ -224,7 +224,7 @@ class VideoService:
model: str,
messages: list,
stream: bool = None,
thinking: str = None,
reasoning_effort: str | None = None,
aspect_ratio: str = "3:2",
video_length: int = 6,
resolution: str = "480p",
@@ -256,29 +256,30 @@ class VideoService:
if token.startswith("sso="):
token = token[4:]
think = {"enabled": True, "disabled": False}.get(thinking)
if reasoning_effort is None:
show_think = get_config("chat.thinking")
else:
show_think = reasoning_effort != "none"
is_stream = stream if stream is not None else get_config("chat.stream")
# Extract content.
from app.services.grok.services.chat import MessageExtractor
from app.services.grok.utils.upload import UploadService
try:
prompt, attachments = MessageExtractor.extract(messages, is_video=True)
except ValueError as e:
raise ValidationException(str(e))
prompt, file_attachments, image_attachments = MessageExtractor.extract(
messages, is_video=True
)
# Handle image attachments.
image_url = None
if attachments:
if image_attachments:
upload_service = UploadService()
try:
for attach_type, attach_data in attachments:
if attach_type == "image":
_, file_uri = await upload_service.upload_file(attach_data, token)
image_url = f"https://assets.grok.com/{file_uri}"
logger.info(f"Image uploaded for video: {image_url}")
break
for attach_data in image_attachments:
_, file_uri = await upload_service.upload_file(attach_data, token)
image_url = f"https://assets.grok.com/{file_uri}"
logger.info(f"Image uploaded for video: {image_url}")
break
finally:
await upload_service.close()
@@ -295,7 +296,7 @@ class VideoService:
# Process response.
if is_stream:
processor = VideoStreamProcessor(model, token, think)
processor = VideoStreamProcessor(model, token, show_think)
return wrap_stream_with_usage(
processor.process(response), token_mgr, token, model
)
@@ -8,19 +8,20 @@ from typing import Any, AsyncGenerator, Optional, AsyncIterable, List, TypeVar
from app.core.config import get_config
from app.core.logger import logger
from app.core.exceptions import StreamIdleTimeoutError
from app.services.grok.utils.download import DownloadService
T = TypeVar("T")
def _is_http2_stream_error(e: Exception) -> bool:
def _is_http2_error(e: Exception) -> bool:
"""检查是否为 HTTP/2 流错误"""
err_str = str(e).lower()
return "http/2" in err_str or "curl: (92)" in err_str or "stream" in err_str
def _normalize_stream_line(line: Any) -> Optional[str]:
def _normalize_line(line: Any) -> Optional[str]:
"""规范化流式响应行,兼容 SSE data 前缀与空行"""
if line is None:
return None
@@ -38,7 +39,7 @@ def _normalize_stream_line(line: Any) -> Optional[str]:
return text
def _collect_image_urls(obj: Any) -> List[str]:
def _collect_images(obj: Any) -> List[str]:
"""递归收集响应中的图片 URL"""
urls: List[str] = []
seen = set()
@@ -69,14 +70,6 @@ def _collect_image_urls(obj: Any) -> List[str]:
return urls
class StreamIdleTimeoutError(Exception):
"""流空闲超时错误"""
def __init__(self, idle_seconds: float):
self.idle_seconds = idle_seconds
super().__init__(f"Stream idle timeout after {idle_seconds}s")
async def _with_idle_timeout(
iterable: AsyncIterable[T], idle_timeout: float, model: str = ""
) -> AsyncGenerator[T, None]:
@@ -138,9 +131,8 @@ class BaseProcessor:
__all__ = [
"BaseProcessor",
"StreamIdleTimeoutError",
"_with_idle_timeout",
"_normalize_stream_line",
"_collect_image_urls",
"_is_http2_stream_error",
"_normalize_line",
"_collect_images",
"_is_http2_error",
]
+1 -9
View File
@@ -25,17 +25,12 @@ class AppChatReverse:
model: str,
mode: str = None,
file_attachments: List[str] = None,
image_attachments: List[str] = None,
tool_overrides: Dict[str, Any] = None,
model_config_override: Dict[str, Any] = None,
) -> Dict[str, Any]:
"""Build chat payload for Grok app-chat API."""
attachments = []
if file_attachments:
attachments.extend(file_attachments)
if image_attachments:
attachments.extend(image_attachments)
attachments = file_attachments or []
payload = {
"deviceEnvInfo": {
@@ -86,7 +81,6 @@ class AppChatReverse:
model: str,
mode: str = None,
file_attachments: List[str] = None,
image_attachments: List[str] = None,
tool_overrides: Dict[str, Any] = None,
model_config_override: Dict[str, Any] = None,
) -> Any:
@@ -99,7 +93,6 @@ class AppChatReverse:
model: str, the model to use.
mode: str, the mode to use.
file_attachments: List[str], the file attachments to send.
image_attachments: List[str], the image attachments to send.
tool_overrides: Dict[str, Any], the tool overrides to use.
model_config_override: Dict[str, Any], the model config override to use.
@@ -125,7 +118,6 @@ class AppChatReverse:
model=model,
mode=mode,
file_attachments=file_attachments,
image_attachments=image_attachments,
tool_overrides=tool_overrides,
model_config_override=model_config_override,
)
+2 -2
View File
@@ -389,7 +389,7 @@ class TokenManager:
status = e.details["status"]
else:
status = getattr(e, "status_code", None)
if status in (401, 403):
if status == 401:
await self.record_fail(token_str, status, "rate_limits_auth_failed")
logger.warning(
f"Token {raw_token[:10]}...: API sync failed, fallback to local ({e})"
@@ -424,7 +424,7 @@ class TokenManager:
for pool in self.pools.values():
token = pool.get(raw_token)
if token:
if status_code in (401, 403):
if status_code == 401:
token.record_fail(status_code, reason)
logger.warning(
f"Token {raw_token[:10]}...: recorded {status_code} failure "
+2 -2
View File
@@ -130,8 +130,8 @@ class TokenInfo(BaseModel):
def record_fail(self, status_code: int = 401, reason: str = ""):
"""记录失败,达到阈值后自动标记为 expired"""
# 401/403 错误计入失败(都表示认证/授权失败)
if status_code not in (401, 403):
# 仅 401 计入失败
if status_code != 401:
return
self.fail_count += 1