fix(providers): use non-descriptive placeholder when stripping images

The image-strip fallback (triggered when a model errors on image input)
replaced image_url blocks with [image: <path>] or [image omitted]. Both
read like a live, available image to the LLM, causing it to:

1. hallucinate about image contents it never received
2. attempt read_file on the leaked server path
3. expose internal file paths to the model

Replace with an explicit '[Image not delivered to model — do not describe
or reference it]' placeholder that tells the LLM the image was stripped.

Fixes #4345
This commit is contained in:
michaelxer
2026-06-19 14:59:52 +08:00
committed by Xubin Ren
parent bbd7bbd7f5
commit 9ed3905a23
2 changed files with 181 additions and 6 deletions
+8 -6
View File
@@ -15,8 +15,6 @@ from typing import Any
import json_repair
from loguru import logger
from nanobot.utils.helpers import image_placeholder_text
STREAM_IDLE_TIMEOUT_ENV = "NANOBOT_STREAM_IDLE_TIMEOUT_S"
DEFAULT_STREAM_IDLE_TIMEOUT_S = 90.0
MAX_STREAM_IDLE_TIMEOUT_S = 3600.0
@@ -564,8 +562,10 @@ class LLMProvider(ABC):
new_content = []
for b in content:
if isinstance(b, dict) and b.get("type") == "image_url":
path = (b.get("_meta") or {}).get("path", "")
placeholder = image_placeholder_text(path, empty="[image omitted]")
placeholder = (
"[Image not delivered to model — "
"do not describe or reference it]"
)
new_content.append({"type": "text", "text": placeholder})
found = True
else:
@@ -589,8 +589,10 @@ class LLMProvider(ABC):
if isinstance(content, list):
for i, b in enumerate(content):
if isinstance(b, dict) and b.get("type") == "image_url":
path = (b.get("_meta") or {}).get("path", "")
placeholder = image_placeholder_text(path, empty="[image omitted]")
placeholder = (
"[Image not delivered to model — "
"do not describe or reference it]"
)
content[i] = {"type": "text", "text": placeholder}
found = True
return found
+173
View File
@@ -0,0 +1,173 @@
"""Tests for LLMProvider._strip_image_content and _strip_image_content_inplace.
Regression test for #4345: image-strip fallback should not leak file paths
or produce text that makes the model hallucinate about unseen images.
"""
from __future__ import annotations
from nanobot.providers.base import LLMProvider
# ---------------------------------------------------------------------------
# _strip_image_content (returns new list)
# ---------------------------------------------------------------------------
class TestStripImageContent:
"""Tests for the non-mutating _strip_image_content method."""
def test_replaces_image_url_with_nondescriptive_placeholder(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is this?"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abc"},
"_meta": {"path": "/tmp/screenshot.png"},
},
],
}
]
result = LLMProvider._strip_image_content(messages)
assert result is not None
content = result[0]["content"]
assert len(content) == 2
assert content[0] == {"type": "text", "text": "What is this?"}
assert content[1]["type"] == "text"
placeholder = content[1]["text"]
# Must NOT leak the file path
assert "/tmp/screenshot.png" not in placeholder
assert "screenshot" not in placeholder
# Must clearly indicate the image was not delivered
assert "not delivered" in placeholder.lower() or "not describe" in placeholder.lower()
def test_returns_none_when_no_images(self):
messages = [
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
]
assert LLMProvider._strip_image_content(messages) is None
def test_handles_multiple_images(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Look at these:"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abc"},
"_meta": {"path": "/a.png"},
},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,def"},
"_meta": {"path": "/b.png"},
},
],
}
]
result = LLMProvider._strip_image_content(messages)
assert result is not None
content = result[0]["content"]
# text + 2 placeholders
assert len(content) == 3
for block in content[1:]:
assert block["type"] == "text"
assert "/a.png" not in block["text"]
assert "/b.png" not in block["text"]
def test_handles_image_without_meta(self):
messages = [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abc"},
},
],
}
]
result = LLMProvider._strip_image_content(messages)
assert result is not None
placeholder = result[0]["content"][0]["text"]
assert "[image" not in placeholder.lower() or "not delivered" in placeholder.lower()
def test_preserves_non_image_content(self):
messages = [
{"role": "system", "content": "You are helpful."},
{
"role": "user",
"content": [
{"type": "text", "text": "Hello"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abc"},
"_meta": {"path": "/x.png"},
},
],
},
{"role": "assistant", "content": "Hi there!"},
]
result = LLMProvider._strip_image_content(messages)
assert result is not None
# system and assistant messages unchanged
assert result[0]["content"] == "You are helpful."
assert result[2]["content"] == "Hi there!"
# user message has image replaced
assert result[1]["content"][0]["type"] == "text"
# ---------------------------------------------------------------------------
# _strip_image_content_inplace (mutates in place)
# ---------------------------------------------------------------------------
class TestStripImageContentInplace:
"""Tests for the mutating _strip_image_content_inplace method."""
def test_replaces_image_url_with_nondescriptive_placeholder(self):
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is this?"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abc"},
"_meta": {"path": "/tmp/photo.jpg"},
},
],
}
]
found = LLMProvider._strip_image_content_inplace(messages)
assert found is True
content = messages[0]["content"]
assert len(content) == 2
placeholder = content[1]["text"]
assert "/tmp/photo.jpg" not in placeholder
assert "not delivered" in placeholder.lower() or "not describe" in placeholder.lower()
def test_returns_false_when_no_images(self):
messages = [
{"role": "user", "content": [{"type": "text", "text": "hello"}]}
]
assert LLMProvider._strip_image_content_inplace(messages) is False
def test_mutates_original_messages(self):
original_content = [
{"type": "text", "text": "see this"},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,abc"},
"_meta": {"path": "/img.png"},
},
]
messages = [{"role": "user", "content": original_content}]
LLMProvider._strip_image_content_inplace(messages)
# The original list was mutated
assert original_content[1]["type"] == "text"
assert "/img.png" not in original_content[1]["text"]