fix: harden OpenAI attachment recovery (#7004)

* fix: harden OpenAI attachment recovery

* fix: refine OpenAI image loading

* fix: restore OpenAI image encoding errors

* refactor: streamline OpenAI image helpers

* refactor: simplify OpenAI attachment helpers

* refactor: simplify OpenAI helper flow

* refactor: clarify OpenAI image modes

* refactor: reduce OpenAI materialization copies
This commit is contained in:
エイカク
2026-03-27 00:49:19 +09:00
committed by GitHub
parent 6db9aef3ea
commit cd4e999526
2 changed files with 816 additions and 28 deletions

View File

@@ -1,11 +1,15 @@
import asyncio
import base64
import copy
import inspect
import json
import random
import re
from collections.abc import AsyncGenerator
from typing import Any
from io import BytesIO
from pathlib import Path
from typing import Any, Literal
from urllib.parse import unquote, urlparse
import httpx
from openai import AsyncAzureOpenAI, AsyncOpenAI
@@ -14,6 +18,8 @@ from openai.lib.streaming.chat._completions import ChatCompletionStreamState
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.completion_usage import CompletionUsage
from PIL import Image as PILImage
from PIL import UnidentifiedImageError
import astrbot.core.message.components as Comp
from astrbot import logger
@@ -133,6 +139,186 @@ class ProviderOpenAIOfficial(Provider):
return True
return False
def _is_invalid_attachment_error(self, error: Exception) -> bool:
body = getattr(error, "body", None)
code: str | None = None
message: str | None = None
if isinstance(body, dict):
err_obj = body.get("error")
if isinstance(err_obj, dict):
raw_code = err_obj.get("code")
raw_message = err_obj.get("message")
code = raw_code.lower() if isinstance(raw_code, str) else None
message = raw_message.lower() if isinstance(raw_message, str) else None
if code == "invalid_attachment":
return True
text_sources: list[str] = []
if message:
text_sources.append(message)
if code:
text_sources.append(code)
text_sources.extend(map(str, self._extract_error_text_candidates(error)))
error_text = " ".join(text.lower() for text in text_sources if text)
if "invalid_attachment" in error_text:
return True
if "download attachment" in error_text and "404" in error_text:
return True
return False
@classmethod
def _encode_image_file_to_data_url(
cls,
image_path: str,
*,
mode: Literal["safe", "strict"],
) -> str | None:
try:
image_bytes = Path(image_path).read_bytes()
except OSError:
if mode == "strict":
raise
return None
try:
with PILImage.open(BytesIO(image_bytes)) as image:
image.verify()
image_format = str(image.format or "").upper()
except (OSError, UnidentifiedImageError):
if mode == "strict":
raise ValueError(f"Invalid image file: {image_path}")
return None
mime_type = {
"JPEG": "image/jpeg",
"PNG": "image/png",
"GIF": "image/gif",
"WEBP": "image/webp",
"BMP": "image/bmp",
}.get(image_format, "image/jpeg")
image_bs64 = base64.b64encode(image_bytes).decode("utf-8")
return f"data:{mime_type};base64,{image_bs64}"
@staticmethod
def _file_uri_to_path(file_uri: str) -> str:
"""Normalize file URIs to paths.
`file://localhost/...` and drive-letter forms are treated as local paths.
Other non-empty hosts are preserved as UNC-style paths.
"""
parsed = urlparse(file_uri)
if parsed.scheme != "file":
return file_uri
netloc = unquote(parsed.netloc or "")
path = unquote(parsed.path or "")
if re.fullmatch(r"[A-Za-z]:", netloc):
return str(Path(f"{netloc}{path}"))
if re.match(r"^/[A-Za-z]:/", path):
path = path[1:]
if netloc and netloc != "localhost":
path = f"//{netloc}{path}"
return str(Path(path))
async def _image_ref_to_data_url(
self,
image_ref: str,
*,
mode: Literal["safe", "strict"] = "safe",
) -> str | None:
if image_ref.startswith("base64://"):
return image_ref.replace("base64://", "data:image/jpeg;base64,")
if image_ref.startswith("http"):
image_path = await download_image_by_url(image_ref)
elif image_ref.startswith("file://"):
image_path = self._file_uri_to_path(image_ref)
else:
image_path = image_ref
return self._encode_image_file_to_data_url(
image_path,
mode=mode,
)
async def _resolve_image_part(
self,
image_url: str,
*,
image_detail: str | None = None,
) -> dict | None:
if image_url.startswith("data:"):
image_payload = {"url": image_url}
else:
image_data = await self._image_ref_to_data_url(image_url, mode="safe")
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
return None
image_payload = {"url": image_data}
if image_detail:
image_payload["detail"] = image_detail
return {
"type": "image_url",
"image_url": image_payload,
}
def _extract_image_part_info(self, part: dict) -> tuple[str | None, str | None]:
if not isinstance(part, dict) or part.get("type") != "image_url":
return None, None
image_url_data = part.get("image_url")
if not isinstance(image_url_data, dict):
logger.warning("图片内容块格式无效,将保留原始内容。")
return None, None
url = image_url_data.get("url")
if not isinstance(url, str) or not url:
logger.warning("图片内容块缺少有效 URL将保留原始内容。")
return None, None
image_detail = image_url_data.get("detail")
if not isinstance(image_detail, str):
image_detail = None
return url, image_detail
async def _transform_content_part(self, part: dict) -> dict:
url, image_detail = self._extract_image_part_info(part)
if not url:
return part
try:
resolved_part = await self._resolve_image_part(
url, image_detail=image_detail
)
except Exception as exc:
logger.warning(
"图片 %s 预处理失败,将保留原始内容。错误: %s",
url,
exc,
)
return part
return resolved_part or part
async def _materialize_message_image_parts(self, message: dict) -> dict:
content = message.get("content")
if not isinstance(content, list):
return {**message}
new_content = [await self._transform_content_part(part) for part in content]
return {**message, "content": new_content}
async def _materialize_context_image_parts(
self, context_query: list[dict]
) -> list[dict]:
return [
await self._materialize_message_image_parts(message)
for message in context_query
]
async def _fallback_to_text_only_and_retry(
self,
payloads: dict,
@@ -604,7 +790,7 @@ class ProviderOpenAIOfficial(Provider):
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
context_query = self._ensure_message_to_dicts(contexts)
context_query = copy.deepcopy(self._ensure_message_to_dicts(contexts))
if new_record:
context_query.append(new_record)
if system_prompt:
@@ -622,6 +808,9 @@ class ProviderOpenAIOfficial(Provider):
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
if self._context_contains_image(context_query):
context_query = await self._materialize_context_image_parts(context_query)
model = model or self.get_model()
payloads = {**kwargs, "messages": context_query, "model": model}
@@ -721,6 +910,18 @@ class ProviderOpenAIOfficial(Provider):
"image_content_moderated",
image_fallback_used=True,
)
if self._is_invalid_attachment_error(e):
if image_fallback_used or not self._context_contains_image(context_query):
raise e
return await self._fallback_to_text_only_and_retry(
payloads,
context_query,
chosen_key,
available_api_keys,
func_tool,
"invalid_attachment",
image_fallback_used=True,
)
if (
"Function calling is not enabled" in str(e)
@@ -922,23 +1123,6 @@ class ProviderOpenAIOfficial(Provider):
) -> dict:
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
async def resolve_image_part(image_url: str) -> dict | None:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
image_data = await self.encode_image_bs64(image_path)
elif image_url.startswith("file:///"):
image_path = image_url.replace("file:///", "")
image_data = await self.encode_image_bs64(image_path)
else:
image_data = await self.encode_image_bs64(image_url)
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
return None
return {
"type": "image_url",
"image_url": {"url": image_data},
}
# 构建内容块列表
content_blocks = []
@@ -958,7 +1142,9 @@ class ProviderOpenAIOfficial(Provider):
if isinstance(part, TextPart):
content_blocks.append({"type": "text", "text": part.text})
elif isinstance(part, ImageURLPart):
image_part = await resolve_image_part(part.image_url.url)
image_part = await self._resolve_image_part(
part.image_url.url,
)
if image_part:
content_blocks.append(image_part)
else:
@@ -967,7 +1153,7 @@ class ProviderOpenAIOfficial(Provider):
# 3. 图片内容
if image_urls:
for image_url in image_urls:
image_part = await resolve_image_part(image_url)
image_part = await self._resolve_image_part(image_url)
if image_part:
content_blocks.append(image_part)
@@ -986,11 +1172,10 @@ class ProviderOpenAIOfficial(Provider):
async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
if image_url.startswith("base64://"):
return image_url.replace("base64://", "data:image/jpeg;base64,")
with open(image_url, "rb") as f:
image_bs64 = base64.b64encode(f.read()).decode("utf-8")
return "data:image/jpeg;base64," + image_bs64
image_data = await self._image_ref_to_data_url(image_url, mode="strict")
if image_data is None:
raise RuntimeError(f"Failed to encode image data: {image_url}")
return image_data
async def terminate(self):
if self.client:

View File

@@ -2,6 +2,7 @@ from types import SimpleNamespace
import pytest
from openai.types.chat.chat_completion import ChatCompletion
from PIL import Image as PILImage
from astrbot.core.provider.sources.groq_source import ProviderGroq
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
@@ -234,7 +235,9 @@ async def test_openai_payload_keeps_reasoning_content_in_assistant_history():
provider._finally_convert_payload(payloads)
assistant_message = payloads["messages"][0]
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
assert assistant_message["content"] == [
{"type": "text", "text": "final answer"}
]
assert assistant_message["reasoning_content"] == "step 1"
finally:
await provider.terminate()
@@ -259,7 +262,9 @@ async def test_groq_payload_drops_reasoning_content_from_assistant_history():
provider._finally_convert_payload(payloads)
assistant_message = payloads["messages"][0]
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
assert assistant_message["content"] == [
{"type": "text", "text": "final answer"}
]
assert "reasoning_content" not in assistant_message
assert "reasoning" not in assistant_message
finally:
@@ -450,6 +455,604 @@ async def test_handle_api_error_unknown_image_error_raises():
await provider.terminate()
@pytest.mark.asyncio
async def test_handle_api_error_invalid_attachment_removes_images_and_retries_text_only():
provider = _make_provider()
try:
payloads = {
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "hello"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,abcd"},
},
],
}
]
}
context_query = payloads["messages"]
err = _ErrorWithBody(
"upstream error",
{
"error": {
"code": "INVALID_ATTACHMENT",
"message": "download attachment: unexpected status 404",
}
},
)
success, *_rest = await provider._handle_api_error(
err,
payloads=payloads,
context_query=context_query,
func_tool=None,
chosen_key="test-key",
available_api_keys=["test-key"],
retry_cnt=0,
max_retries=10,
)
assert success is False
assert payloads["messages"][0]["content"] == [{"type": "text", "text": "hello"}]
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_handle_api_error_invalid_attachment_without_images_raises():
provider = _make_provider()
try:
payloads = {
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": "hello"}],
}
]
}
context_query = payloads["messages"]
err = _ErrorWithBody(
"upstream error",
{
"error": {
"code": "INVALID_ATTACHMENT",
"message": "download attachment: unexpected status 404",
}
},
)
with pytest.raises(_ErrorWithBody, match="upstream error"):
await provider._handle_api_error(
err,
payloads=payloads,
context_query=context_query,
func_tool=None,
chosen_key="test-key",
available_api_keys=["test-key"],
retry_cnt=0,
max_retries=10,
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_handle_api_error_invalid_attachment_after_fallback_raises():
provider = _make_provider()
try:
payloads = {
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "hello"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,abcd"},
},
],
}
]
}
context_query = payloads["messages"]
err = _ErrorWithBody(
"upstream error",
{
"error": {
"code": "INVALID_ATTACHMENT",
"message": "download attachment: unexpected status 404",
}
},
)
with pytest.raises(_ErrorWithBody, match="upstream error"):
await provider._handle_api_error(
err,
payloads=payloads,
context_query=context_query,
func_tool=None,
chosen_key="test-key",
available_api_keys=["test-key"],
retry_cnt=1,
max_retries=10,
image_fallback_used=True,
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_materializes_context_http_image_urls(monkeypatch):
provider = _make_provider()
try:
async def fake_download(url: str) -> str:
assert url == "https://example.com/quoted.png"
return "/tmp/quoted.png"
def fake_encode(image_path: str, **_kwargs) -> str:
assert image_path == "/tmp/quoted.png"
return "data:image/png;base64,abcd"
monkeypatch.setattr(
"astrbot.core.provider.sources.openai_source.download_image_by_url",
fake_download,
)
monkeypatch.setattr(provider, "_encode_image_file_to_data_url", fake_encode)
contexts = [
{
"role": "user",
"metadata": {"source": "quoted"},
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/quoted.png",
"id": "ctx-img",
"detail": "high",
},
},
],
}
]
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=contexts,
)
assert payloads["messages"][0]["content"] == [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,abcd",
"detail": "high",
},
},
]
assert payloads["messages"][0]["content"][1]["image_url"].get("id") is None
assert contexts[0]["content"][1]["image_url"] == {
"url": "https://example.com/quoted.png",
"id": "ctx-img",
"detail": "high",
}
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_skips_materialization_for_text_only_context(
monkeypatch,
):
provider = _make_provider()
try:
async def fail_if_called(_context_query):
raise AssertionError("materialization should be skipped")
monkeypatch.setattr(
provider, "_materialize_context_image_parts", fail_if_called
)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[{"role": "user", "content": "hello"}],
)
assert payloads["messages"] == [{"role": "user", "content": "hello"}]
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_skips_materialization_for_text_only_parts(
monkeypatch,
):
provider = _make_provider()
try:
async def fail_if_called(_context_query):
raise AssertionError("materialization should be skipped")
monkeypatch.setattr(
provider, "_materialize_context_image_parts", fail_if_called
)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [{"type": "text", "text": "hello"}],
}
],
)
assert payloads["messages"] == [
{
"role": "user",
"content": [{"type": "text", "text": "hello"}],
}
]
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_materializes_context_http_image_urls_with_detected_mime(
monkeypatch, tmp_path
):
provider = _make_provider()
try:
image_path = tmp_path / "quoted-image.png"
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
async def fake_download(url: str) -> str:
assert url == "https://example.com/quoted.png"
return str(image_path)
monkeypatch.setattr(
"astrbot.core.provider.sources.openai_source.download_image_by_url",
fake_download,
)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/quoted.png",
},
},
],
}
],
)
image_payload = payloads["messages"][0]["content"][1]["image_url"]
assert image_payload["url"].startswith("data:image/png;base64,")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_materializes_context_file_uri_image_urls(tmp_path):
provider = _make_provider()
try:
image_path = tmp_path / "quoted-image.png"
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": image_path.as_uri(),
},
},
],
}
],
)
image_payload = payloads["messages"][0]["content"][1]["image_url"]
assert image_payload["url"].startswith("data:image/png;base64,")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_file_uri_to_path_preserves_windows_drive_letter():
provider = _make_provider()
try:
assert provider._file_uri_to_path("file:///C:/tmp/quoted-image.png") == (
"C:/tmp/quoted-image.png"
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_file_uri_to_path_preserves_windows_netloc_drive_letter():
provider = _make_provider()
try:
assert provider._file_uri_to_path("file://C:/tmp/quoted-image.png") == (
"C:/tmp/quoted-image.png"
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_file_uri_to_path_preserves_remote_netloc_as_unc_path():
provider = _make_provider()
try:
assert provider._file_uri_to_path("file://server/share/quoted-image.png") == (
"//server/share/quoted-image.png"
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_resolve_image_part_rejects_invalid_local_file(tmp_path):
provider = _make_provider()
try:
invalid_file = tmp_path / "not-image.txt"
invalid_file.write_text("not an image")
assert await provider._resolve_image_part(str(invalid_file)) is None
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_resolve_image_part_rejects_invalid_file_uri(tmp_path):
provider = _make_provider()
try:
invalid_file = tmp_path / "not-image.txt"
invalid_file.write_text("not an image")
assert await provider._resolve_image_part(invalid_file.as_uri()) is None
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_image_ref_to_data_url_mode_controls_invalid_file_behavior(tmp_path):
provider = _make_provider()
try:
invalid_file = tmp_path / "not-image.txt"
invalid_file.write_text("not an image")
assert (
await provider._image_ref_to_data_url(str(invalid_file), mode="safe")
is None
)
with pytest.raises(ValueError, match="Invalid image file"):
await provider._image_ref_to_data_url(str(invalid_file), mode="strict")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_materialize_context_image_parts_returns_new_messages(monkeypatch):
provider = _make_provider()
try:
context_query = [
{
"role": "user",
"metadata": {"source": "quoted"},
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/quoted.png",
"detail": "high",
},
},
],
},
{"role": "assistant", "content": "plain text"},
]
async def fake_resolve(image_url: str, *, image_detail: str | None = None):
assert image_url == "https://example.com/quoted.png"
assert image_detail == "high"
return {
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,abcd",
"detail": "high",
},
}
monkeypatch.setattr(provider, "_resolve_image_part", fake_resolve)
materialized = await provider._materialize_context_image_parts(context_query)
assert materialized is not context_query
assert materialized[0] is not context_query[0]
assert materialized[0]["metadata"] is context_query[0]["metadata"]
assert materialized[0]["content"][0] is context_query[0]["content"][0]
assert (
materialized[0]["content"][1]["image_url"]["url"]
== "data:image/png;base64,abcd"
)
assert (
context_query[0]["content"][1]["image_url"]["url"]
== "https://example.com/quoted.png"
)
assert materialized[1] is not context_query[1]
assert materialized[1]["content"] == "plain text"
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_encode_image_bs64_missing_file_raises(tmp_path):
provider = _make_provider()
try:
missing_path = tmp_path / "missing-image.png"
with pytest.raises(FileNotFoundError):
await provider.encode_image_bs64(str(missing_path))
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_encode_image_bs64_invalid_file_raises(tmp_path):
provider = _make_provider()
try:
invalid_file = tmp_path / "not-image.txt"
invalid_file.write_text("not an image")
with pytest.raises(ValueError, match="Invalid image file"):
await provider.encode_image_bs64(str(invalid_file))
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_encode_image_bs64_supports_base64_scheme():
provider = _make_provider()
try:
image_data = await provider.encode_image_bs64("base64://abcd")
assert image_data == "data:image/jpeg;base64,abcd"
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_encode_image_bs64_supports_file_uri(tmp_path):
provider = _make_provider()
try:
image_path = tmp_path / "quoted-image.png"
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
image_data = await provider.encode_image_bs64(image_path.as_uri())
assert image_data.startswith("data:image/png;base64,")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_resolve_image_part_supports_base64_scheme():
provider = _make_provider()
try:
assert await provider._resolve_image_part("base64://abcd") == {
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,abcd"},
}
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_materializes_context_localhost_file_uri_image_urls(
tmp_path,
):
provider = _make_provider()
try:
image_path = tmp_path / "quoted-image.png"
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
localhost_uri = f"file://localhost{image_path.as_posix()}"
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": localhost_uri,
},
},
],
}
],
)
image_payload = payloads["messages"][0]["content"][1]["image_url"]
assert image_payload["url"].startswith("data:image/png;base64,")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_keeps_original_context_image_when_materialization_fails(
monkeypatch,
):
provider = _make_provider()
try:
async def fake_download(url: str) -> str:
assert url == "https://example.com/expired.png"
return "/tmp/not-an-image"
monkeypatch.setattr(
"astrbot.core.provider.sources.openai_source.download_image_by_url",
fake_download,
)
monkeypatch.setattr(
provider,
"_encode_image_file_to_data_url",
lambda _image_path, **_kwargs: None,
)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/expired.png",
},
},
],
}
],
)
assert payloads["messages"][0]["content"] == [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/expired.png",
},
},
]
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_apply_provider_specific_extra_body_overrides_disables_ollama_thinking():
provider = _make_provider(