fix(provider): Dsable OpenAI SDK built-in retries to prevent request multiplication from stacked retry layers (#9669)

* fix(provider): disable OpenAI SDK built-in retries under AstrBot retry layer (#9663)

AsyncOpenAI/AsyncAzureOpenAI inherit the SDK default max_retries=2, which
nests underneath AstrBot's tenacity-based retry_provider_request(). As a
result provider_settings.request_max_retries no longer reflects the true
attempt count (e.g. request_max_retries=1 still sends up to 3 HTTP
requests) and delays fallback-provider switchover. Pass max_retries=0 so
AstrBot's retry layer is the single source of truth for retries.

* test(provider): add retry-count regression test, parameterize client retry tests

Address review feedback on #9669: merge the near-identical official/Azure
client construction tests into a parametrized test, and add a regression
test asserting _query() performs exactly request_max_retries attempts
through retry_provider_request() when the call keeps failing, guarding
against multiplicative retries being reintroduced.

* fix(media): guard oversized image inputs and attachment uploads

为模型图片输入与聊天附件上传增加大小护栏 / Add size guards for model
image inputs and chat attachment uploads:

- _convert_image_bytes_sync 复用原始编码字节的分支增加 2MB 上限;更大的
  输入重编码,输出始终受像素尺寸与质量约束
- prepare_model_image 在读入前检查文件大小,超过 32MB 跳过并记录日志
- 聊天附件上传上限 100MB:路由先检查 Content-Length,服务层落盘后复核
  实际大小

- Reuse the original encoded bytes in _convert_image_bytes_sync only for
  stills up to 2MB; larger inputs are re-encoded so the output stays
  bounded by pixel size and quality.
- prepare_model_image checks the file size before reading and skips
  inputs above 32MB with a warning log.
- Chat attachment uploads are capped at 100MB: the route checks
  Content-Length first, and save_uploaded_file re-checks the on-disk
  size after saving.

Tests: tests/test_media_utils.py, tests/test_chat_route.py

* fix(media): sniff image headers instead of decoding for MIME detection

MIME 探测改为读取文件头魔数,不再解码整个图片 / Detect image MIME
types by reading header magic bytes instead of decoding the whole file:

- detect_image_mime_type 仅读取前 32 字节识别 PNG/JPEG/GIF/WebP/BMP/
  TIFF/AVIF,内存占用与文件大小无关
- 聊天附件上传上限从 100MB 放宽到 512MB

- detect_image_mime_type reads only the first 32 bytes and recognizes
  PNG/JPEG/GIF/WebP/BMP/TIFF/AVIF, so memory usage no longer scales
  with file size
- Chat attachment upload limit raised from 100MB to 512MB

Tests: tests/test_media_utils.py

* test: remove obsolete image assertion and fix upload fixtures

* test: mock plugin marketplace requests in dashboard tests

---------

Co-authored-by: RuochenPan <badbatch0x01@gmail.com>
This commit is contained in:
这只猫猫加了糖
2026-09-17 21:15:01 +08:00
committed by GitHub
co-authored by RuochenPan
parent 2704b4c0d3
commit 6ddcb2fc08
10 changed files with 264 additions and 71 deletions
@@ -371,6 +371,9 @@ class ProviderOpenAIOfficial(Provider):
default_headers=self.custom_headers,
base_url=provider_config.get("api_base") or None,
timeout=self.timeout,
# Retry is handled by retry_provider_request(); disable the
# SDK built-in retry to avoid stacking request attempts.
max_retries=0,
http_client=self._create_http_client(provider_config),
)
else:
@@ -380,6 +383,9 @@ class ProviderOpenAIOfficial(Provider):
base_url=provider_config.get("api_base") or None,
default_headers=self.custom_headers,
timeout=self.timeout,
# Retry is handled by retry_provider_request(); disable the
# SDK built-in retry to avoid stacking request attempts.
max_retries=0,
http_client=self._create_http_client(provider_config),
)
+47 -29
View File
@@ -42,6 +42,11 @@ IMAGE_COMPRESS_DEFAULT_MAX_SIZE = 1280
IMAGE_COMPRESS_DEFAULT_QUALITY = 95
IMAGE_COMPRESS_DEFAULT_OPTIMIZE = True
IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_MB = 1.0
# Model image inputs larger than this are skipped before decoding.
MODEL_IMAGE_MAX_INPUT_BYTES = 32 * 1024 * 1024
# Original encoded bytes are reused only for small stills; larger inputs are
# re-encoded so the output stays bounded by pixel size and quality.
MODEL_IMAGE_REUSE_MAX_BYTES = 2 * 1024 * 1024
MEDIA_MIME_EXTENSIONS = {
"audio/wav": ".wav",
@@ -67,15 +72,17 @@ MEDIA_MIME_EXTENSIONS = {
"video/quicktime": ".mov",
}
IMAGE_FORMAT_MIME_TYPES = {
"JPEG": "image/jpeg",
"PNG": "image/png",
"GIF": "image/gif",
"WEBP": "image/webp",
"BMP": "image/bmp",
"TIFF": "image/tiff",
"AVIF": "image/avif",
}
# Magic-byte prefixes for O(1) image MIME sniffing; unknown headers fall
# back to the caller-provided default instead of decoding the file.
_IMAGE_MAGIC_MIME_TYPES = (
(b"\x89PNG\r\n\x1a\n", "image/png"),
(b"\xff\xd8\xff", "image/jpeg"),
(b"GIF87a", "image/gif"),
(b"GIF89a", "image/gif"),
(b"BM", "image/bmp"),
(b"II*\x00", "image/tiff"),
(b"MM\x00*", "image/tiff"),
)
ANIMATED_MONTAGE_GRID = 3
"""Animated images become a grid x grid frame montage (contact sheet)."""
@@ -385,36 +392,38 @@ def detect_image_mime_type(
*,
default_mime_type: str | None = "image/jpeg",
) -> str | None:
"""Detect an image MIME type from encoded bytes or a local path.
"""Detect an image MIME type by sniffing the file header.
Only the first bytes of the input are read, so detection cost and memory
stay constant regardless of file size.
Args:
image_source: Encoded image bytes or a local image path to inspect.
default_mime_type: MIME type to return when detection fails.
Returns:
The detected MIME type, ``application/octet-stream`` for a recognized
format without a registered MIME, or ``default_mime_type`` when detection
fails.
The detected MIME type, or ``default_mime_type`` when the header does
not match a known image format.
"""
try:
image_file = (
io.BytesIO(image_source)
if isinstance(image_source, bytes)
else image_source
)
with PILImage.open(image_file) as image:
image.verify()
image_format = str(image.format or "").upper()
except Exception as exc:
if not is_recoverable_image_error(exc):
raise
if isinstance(image_source, bytes):
header = image_source[:32]
else:
with open(image_source, "rb") as image_file:
header = image_file.read(32)
except OSError:
return default_mime_type
# A decoded format must never inherit an unverified input MIME hint.
return IMAGE_FORMAT_MIME_TYPES.get(
image_format, PILImage.MIME.get(image_format, "application/octet-stream")
)
if len(header) >= 12:
if header[:4] == b"RIFF" and header[8:12] == b"WEBP":
return "image/webp"
if header[4:8] == b"ftyp" and (b"avif" in header[8:] or b"avis" in header[8:]):
return "image/avif"
for magic, mime_type in _IMAGE_MAGIC_MIME_TYPES:
if header.startswith(magic):
return mime_type
return default_mime_type
async def detect_image_mime_type_async(
@@ -1235,13 +1244,14 @@ def _convert_image_bytes_sync(
Returns:
Single-frame JPEG or PNG bytes. An oriented JPEG or PNG within the size
limit is reused unchanged; anything else is re-encoded.
and reuse-byte limits is reused unchanged; anything else is re-encoded.
"""
with PILImage.open(io.BytesIO(source_bytes)) as image:
if (
image.format in {"PNG", "JPEG"}
and image.getexif().get(274, 1) == 1
and max(image.size) <= max_size
and len(source_bytes) <= MODEL_IMAGE_REUSE_MAX_BYTES
):
return source_bytes
cache_key = _image_convert_cache_key(
@@ -1389,6 +1399,14 @@ async def prepare_model_image(
"""
try:
async with MediaResolver(image_ref, media_type="image").as_path() as source:
input_size = source.path.stat().st_size
if input_size > MODEL_IMAGE_MAX_INPUT_BYTES:
logger.warning(
"Skipping oversized image input (%d bytes): %s",
input_size,
source.path,
)
return None
image_bytes = await asyncio.to_thread(source.read_bytes)
frame_count = await asyncio.to_thread(_inspect_image, image_bytes)
is_montage = frame_count > 1
+7
View File
@@ -16,6 +16,8 @@ from astrbot.dashboard.schemas import (
ChatThreadMessageRequest,
)
from astrbot.dashboard.services.chat_service import (
MAX_UPLOAD_FILE_SIZE_BYTES,
MAX_UPLOAD_FILE_SIZE_MB,
ChatService,
ChatServiceError,
)
@@ -548,6 +550,11 @@ async def dashboard_post_file(
service: ChatService = Depends(get_service),
):
try:
content_length = int(request.headers.get("content-length") or 0)
if content_length > MAX_UPLOAD_FILE_SIZE_BYTES:
raise ChatServiceError(
f"File too large (limit {MAX_UPLOAD_FILE_SIZE_MB} MB)"
)
upload = await single_upload(request)
if upload is None:
raise ChatServiceError("Missing key: file")
@@ -36,6 +36,9 @@ from astrbot.core.utils.media_utils import (
SSE_HEARTBEAT = ": heartbeat\n\n"
CHAT_RUN_SUBSCRIBER_QUEUE_SIZE = 256
# Uploaded chat attachments larger than this are rejected.
MAX_UPLOAD_FILE_SIZE_MB = 512
MAX_UPLOAD_FILE_SIZE_BYTES = MAX_UPLOAD_FILE_SIZE_MB * 1024 * 1024
WEBCHAT_IMAGE_MIME_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
@@ -596,6 +599,10 @@ class ChatService:
return await self.resolve_attachment_file(attachment_id)
async def save_uploaded_file(self, file) -> dict:
if (file.content_length or 0) > MAX_UPLOAD_FILE_SIZE_BYTES:
raise ChatServiceError(
f"File too large (limit {MAX_UPLOAD_FILE_SIZE_MB} MB)"
)
filename = sanitize_upload_filename(file.filename)
content_type = file.content_type or "application/octet-stream"
@@ -614,6 +621,11 @@ class ChatService:
raise ChatServiceError("Invalid filename")
await file.save(str(file_path))
if file_path.stat().st_size > MAX_UPLOAD_FILE_SIZE_BYTES:
file_path.unlink(missing_ok=True)
raise ChatServiceError(
f"File too large (limit {MAX_UPLOAD_FILE_SIZE_MB} MB)"
)
if attach_type == "image":
detected_mime_type = await detect_image_mime_type_async(
file_path,
+36
View File
@@ -508,3 +508,39 @@ async def test_chat_stream_forwards_follow_up_status_by_default(
run.task.cancel()
await asyncio.gather(run.task, return_exceptions=True)
chat_service.webchat_queue_mgr.remove_queues(session_id)
@pytest.mark.asyncio
async def test_save_uploaded_file_rejects_oversized_content_length(
chat_service_instance,
):
class FakeUpload:
filename = "big.bin"
content_type = "application/octet-stream"
content_length = chat_service.MAX_UPLOAD_FILE_SIZE_BYTES + 1
with pytest.raises(ChatServiceError, match="File too large"):
await chat_service_instance.save_uploaded_file(FakeUpload())
@pytest.mark.asyncio
async def test_save_uploaded_file_rejects_oversized_saved_file(
chat_service_instance,
):
from pathlib import Path
class FakeUpload:
filename = "big.bin"
content_type = "application/octet-stream"
content_length = None
async def save(self, path):
saved = Path(path)
saved.write_bytes(b"x")
with saved.open("rb+") as f:
f.truncate(chat_service.MAX_UPLOAD_FILE_SIZE_BYTES + 1)
with pytest.raises(ChatServiceError, match="File too large"):
await chat_service_instance.save_uploaded_file(FakeUpload())
assert not list(Path(chat_service_instance.attachments_dir).iterdir())
+15 -3
View File
@@ -10,7 +10,7 @@ import zipfile
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
from urllib.parse import parse_qs, urlsplit, urlunsplit
import jwt
@@ -2560,7 +2560,7 @@ async def test_plugins(
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
):
"""测试插件 API 端点,使用 Mock 避免真实网络调用。"""
"""Test plugin endpoints with mocked marketplace and repository operations."""
test_client = app.test_client()
# 已经安装的插件
@@ -2577,7 +2577,15 @@ async def test_plugins(
assert isinstance(installed_at, str)
datetime.fromisoformat(installed_at)
# 插件市场
# Keep marketplace requests independent of external services and local caches.
market_plugins = {
"test/market-plugin": {
"name": "market-plugin",
"repo": "https://github.com/test/market-plugin",
}
}
get_online_plugins = AsyncMock(return_value=(market_plugins, None))
monkeypatch.setattr(PluginService, "get_online_plugins", get_online_plugins)
response = await test_client.get(
"/api/plugin/market_list",
headers=authenticated_header,
@@ -2585,6 +2593,10 @@ async def test_plugins(
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"] == market_plugins
get_online_plugins.assert_awaited_once_with(
custom_registry=None, force_refresh=False
)
# 使用 MockPluginBuilder 创建测试插件
plugin_store_path = core_lifecycle_td.plugin_manager.plugin_store_path
+85
View File
@@ -207,6 +207,41 @@ def test_detect_image_mime_type_accepts_path(tmp_path):
)
def test_detect_image_mime_type_sniffs_common_headers():
"""Header sniffing recognizes common formats from bytes alone."""
assert (
media_utils.detect_image_mime_type(b"\x89PNG\r\n\x1a\n" + b"\x00" * 24)
== "image/png"
)
assert (
media_utils.detect_image_mime_type(b"\xff\xd8\xff\xe0" + b"\x00" * 28)
== "image/jpeg"
)
assert media_utils.detect_image_mime_type(b"GIF89a" + b"\x00" * 26) == "image/gif"
assert (
media_utils.detect_image_mime_type(b"RIFF\x00\x00\x00\x00WEBPVP8 ")
== "image/webp"
)
assert (
media_utils.detect_image_mime_type(b"\x00\x00\x00\x20ftypavif" + b"\x00" * 20)
== "image/avif"
)
def test_detect_image_mime_type_returns_default_for_unknown_input():
"""Unknown or empty headers fall back to the provided default."""
assert (
media_utils.detect_image_mime_type(
b"definitely not an image", default_mime_type=None
)
is None
)
assert (
media_utils.detect_image_mime_type(b"", default_mime_type="image/jpeg")
== "image/jpeg"
)
@pytest.mark.asyncio
async def test_resolve_image_ref_to_base64_data_decodes_data_uri(tmp_path, monkeypatch):
from PIL import Image as PILImage
@@ -873,3 +908,53 @@ async def test_wav_to_tencent_silk_skips_resample_for_supported_rate(
assert len(fake.calls) == 1
assert fake.calls[0]["sample_rate"] == 24000
@pytest.mark.asyncio
async def test_prepare_model_image_skips_oversized_input(tmp_path, monkeypatch):
"""Inputs above the model-image byte cap must be skipped before decoding."""
from PIL import Image as PILImage
monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(tmp_path))
image_path = tmp_path / "oversized.png"
PILImage.new("RGB", (4, 4)).save(image_path, format="PNG")
with image_path.open("ab") as f:
f.truncate(media_utils.MODEL_IMAGE_MAX_INPUT_BYTES + 1)
result = await media_utils.prepare_model_image(
str(image_path), max_size=1280, output_dir=tmp_path
)
assert result is None
def test_convert_image_bytes_reuses_small_in_range_input():
"""A small oriented in-range PNG keeps its original bytes."""
from PIL import Image as PILImage
buffer = BytesIO()
PILImage.new("RGB", (10, 10), (255, 0, 0)).save(buffer, format="PNG")
source = buffer.getvalue()
result = media_utils._convert_image_bytes_sync(source, 1280, 95)
assert result is source
def test_convert_image_bytes_reencodes_large_in_range_input(tmp_path, monkeypatch):
"""An in-range but byte-heavy PNG is re-encoded instead of reused."""
from PIL import Image as PILImage
monkeypatch.setattr(media_utils, "get_astrbot_temp_path", lambda: str(tmp_path))
img = PILImage.new("RGB", (1024, 1024))
img.frombytes(os.urandom(1024 * 1024 * 3))
buffer = BytesIO()
img.save(buffer, format="PNG")
source = buffer.getvalue()
assert len(source) > media_utils.MODEL_IMAGE_REUSE_MAX_BYTES
result = media_utils._convert_image_bytes_sync(source, 1280, 95)
assert result is not source
assert result[:2] == b"\xff\xd8"
assert len(result) < len(source)
+55
View File
@@ -5,6 +5,7 @@ from types import SimpleNamespace
import httpx
import pytest
from openai import AsyncAzureOpenAI, AsyncOpenAI
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from PIL import Image as PILImage
@@ -62,6 +63,60 @@ def _make_groq_provider(overrides: dict | None = None) -> ProviderGroq:
)
@pytest.mark.parametrize(
("overrides", "expected_client"),
[
({}, AsyncOpenAI),
(
{
"api_version": "2024-02-01",
"api_base": "https://example.openai.azure.com/openai",
},
AsyncAzureOpenAI,
),
],
)
@pytest.mark.asyncio
async def test_provider_client_disables_sdk_builtin_retries(overrides, expected_client):
provider = _make_provider(overrides)
try:
assert isinstance(provider.client, expected_client)
assert provider.client.max_retries == 0
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_attempts_exactly_request_max_retries_times(monkeypatch):
monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MIN_S", 0)
monkeypatch.setattr(request_retry, "REQUEST_RETRY_WAIT_MAX_S", 0)
provider = _make_provider()
try:
calls = 0
async def failing_create(**kwargs):
nonlocal calls
calls += 1
raise httpx.ConnectError("temporary connection failure")
monkeypatch.setattr(provider.client.chat.completions, "create", failing_create)
with pytest.raises(httpx.ConnectError):
await provider._query(
payloads={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "hi"}],
},
tools=None,
request_max_retries=2,
)
assert calls == 2
finally:
await provider.terminate()
def test_create_http_client_uses_openai_httpx_module(monkeypatch):
captured: dict[str, object] = {}
fake_httpx_module = object()
-39
View File
@@ -4,7 +4,6 @@ import asyncio
import base64
import copy
import inspect
import os
from contextlib import nullcontext
from pathlib import Path
from types import SimpleNamespace
@@ -271,44 +270,6 @@ async def test_cua_runtime_keeps_input_image_geometry(
assert Path(req.image_urls[0]).read_bytes() == original
@pytest.mark.asyncio
@pytest.mark.parametrize(
("runtime", "booter", "big"),
[
("sandbox", "cua", True),
("sandbox", "cua", False),
("sandbox", "shipyard_neo", True),
],
)
async def test_cua_oversize_image_warns(
harness, tmp_path, monkeypatch, runtime, booter, big
):
"""Byte-exact CUA passthrough warns when an image may exceed upload limits."""
dims = (1500, 1500) if big else (60, 30)
path = tmp_path / "shot.png"
if big:
# Noise stays incompressible, keeping the PNG above the 5 MB threshold.
PILImage.frombytes("RGB", dims, os.urandom(dims[0] * dims[1] * 3)).save(
path, "PNG"
)
else:
PILImage.new("RGB", dims, "red").save(path, "PNG")
original = path.read_bytes()
harness.config["provider_settings"]["computer_use_runtime"] = runtime
harness.config["provider_settings"]["sandbox"] = {"booter": booter}
warning = MagicMock()
monkeypatch.setattr(internal.logger, "warning", warning)
event = make_event([Image(file=str(path))], text="")
await process_event(harness, event, preprocess_first=True)
assert len(harness.captured) == 1
req = harness.captured[0].req
gated = runtime == "sandbox" and booter == "cua"
if gated:
assert Path(req.image_urls[0]).read_bytes() == original
warned = any("upload limits" in str(call) for call in warning.call_args_list)
assert warned == (big and gated)
@pytest.mark.asyncio
async def test_cua_montage_keeps_configured_cap(harness, tmp_path):
"""Animated inputs keep the configured montage cap under CUA pixel mode."""
@@ -18,6 +18,7 @@ async def test_webchat_upload_uses_detected_image_type(tmp_path):
class FakeUploadFile:
filename = "pasted.png"
content_type = "image/png"
content_length = None
async def save(self, destination):
with open(destination, "wb") as output: