Skip to content

Commit c606b66

Browse files
committed
fix(integrations): langchain add multimodal content transformation functions for images, audio, and files
1 parent 3d3ce5b commit c606b66

File tree

2 files changed

+363
-1
lines changed

2 files changed

+363
-1
lines changed

sentry_sdk/integrations/langchain.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,124 @@
116116
"top_p": SPANDATA.GEN_AI_REQUEST_TOP_P,
117117
}
118118

119+
# Map LangChain content types to Sentry modalities
120+
LANGCHAIN_TYPE_TO_MODALITY = {
121+
"image": "image",
122+
"image_url": "image",
123+
"audio": "audio",
124+
"video": "video",
125+
"file": "document",
126+
}
127+
128+
129+
def _transform_langchain_content_block(
130+
content_block: "Dict[str, Any]",
131+
) -> "Dict[str, Any]":
132+
"""
133+
Transform a LangChain content block to Sentry-compatible format.
134+
135+
Handles multimodal content (images, audio, video, documents) by converting them
136+
to the standardized format:
137+
- base64 encoded data -> type: "blob"
138+
- URL references -> type: "uri"
139+
- file_id references -> type: "file"
140+
"""
141+
if not isinstance(content_block, dict):
142+
return content_block
143+
144+
block_type = content_block.get("type")
145+
146+
# Handle standard multimodal content types (image, audio, video, file)
147+
if block_type in ("image", "audio", "video", "file"):
148+
modality = LANGCHAIN_TYPE_TO_MODALITY.get(block_type, block_type)
149+
mime_type = content_block.get("mime_type", "")
150+
151+
# Check for base64 encoded content
152+
if "base64" in content_block:
153+
return {
154+
"type": "blob",
155+
"modality": modality,
156+
"mime_type": mime_type,
157+
"content": content_block.get("base64", ""),
158+
}
159+
# Check for URL reference
160+
elif "url" in content_block:
161+
return {
162+
"type": "uri",
163+
"modality": modality,
164+
"mime_type": mime_type,
165+
"uri": content_block.get("url", ""),
166+
}
167+
# Check for file_id reference
168+
elif "file_id" in content_block:
169+
return {
170+
"type": "file",
171+
"modality": modality,
172+
"mime_type": mime_type,
173+
"file_id": content_block.get("file_id", ""),
174+
}
175+
176+
# Handle legacy image_url format (OpenAI style)
177+
elif block_type == "image_url":
178+
image_url_data = content_block.get("image_url", {})
179+
if isinstance(image_url_data, dict):
180+
url = image_url_data.get("url", "")
181+
else:
182+
url = str(image_url_data)
183+
184+
# Check if it's a data URI (base64 encoded)
185+
if url.startswith("data:"):
186+
# Parse data URI: data:mime_type;base64,content
187+
try:
188+
# Format: data:image/jpeg;base64,/9j/4AAQ...
189+
header, content = url.split(",", 1)
190+
mime_type = header.split(":")[1].split(";")[0] if ":" in header else ""
191+
return {
192+
"type": "blob",
193+
"modality": "image",
194+
"mime_type": mime_type,
195+
"content": content,
196+
}
197+
except (ValueError, IndexError):
198+
# If parsing fails, return as URI
199+
return {
200+
"type": "uri",
201+
"modality": "image",
202+
"mime_type": "",
203+
"uri": url,
204+
}
205+
else:
206+
# Regular URL
207+
return {
208+
"type": "uri",
209+
"modality": "image",
210+
"mime_type": "",
211+
"uri": url,
212+
}
213+
214+
# For text blocks and other types, return as-is
215+
return content_block
216+
217+
218+
def _transform_langchain_message_content(content: "Any") -> "Any":
219+
"""
220+
Transform LangChain message content, handling both string content and
221+
list of content blocks.
222+
"""
223+
if isinstance(content, str):
224+
return content
225+
226+
if isinstance(content, (list, tuple)):
227+
transformed = []
228+
for block in content:
229+
if isinstance(block, dict):
230+
transformed.append(_transform_langchain_content_block(block))
231+
else:
232+
transformed.append(block)
233+
return transformed
234+
235+
return content
236+
119237

120238
# Contextvar to track agent names in a stack for re-entrant agent support
121239
_agent_stack: "contextvars.ContextVar[Optional[List[Optional[str]]]]" = (
@@ -234,7 +352,9 @@ def _handle_error(self, run_id: "UUID", error: "Any") -> None:
234352
del self.span_map[run_id]
235353

236354
def _normalize_langchain_message(self, message: "BaseMessage") -> "Any":
237-
parsed = {"role": message.type, "content": message.content}
355+
# Transform content to handle multimodal data (images, audio, video, files)
356+
transformed_content = _transform_langchain_message_content(message.content)
357+
parsed = {"role": message.type, "content": transformed_content}
238358
parsed.update(message.additional_kwargs)
239359
return parsed
240360

tests/integrations/langchain/test_langchain.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
from sentry_sdk.integrations.langchain import (
2626
LangchainIntegration,
2727
SentryLangchainCallback,
28+
_transform_langchain_content_block,
29+
_transform_langchain_message_content,
2830
)
2931

3032
try:
@@ -1747,3 +1749,243 @@ def test_langchain_response_model_extraction(
17471749
assert llm_span["data"][SPANDATA.GEN_AI_RESPONSE_MODEL] == expected_model
17481750
else:
17491751
assert SPANDATA.GEN_AI_RESPONSE_MODEL not in llm_span.get("data", {})
1752+
1753+
1754+
# Tests for multimodal content transformation functions
1755+
1756+
1757+
class TestTransformLangchainContentBlock:
1758+
"""Tests for _transform_langchain_content_block function."""
1759+
1760+
def test_transform_image_base64(self):
1761+
"""Test transformation of base64-encoded image content."""
1762+
content_block = {
1763+
"type": "image",
1764+
"base64": "/9j/4AAQSkZJRgABAQAAAQABAAD...",
1765+
"mime_type": "image/jpeg",
1766+
}
1767+
result = _transform_langchain_content_block(content_block)
1768+
assert result == {
1769+
"type": "blob",
1770+
"modality": "image",
1771+
"mime_type": "image/jpeg",
1772+
"content": "/9j/4AAQSkZJRgABAQAAAQABAAD...",
1773+
}
1774+
1775+
def test_transform_image_url(self):
1776+
"""Test transformation of URL-referenced image content."""
1777+
content_block = {
1778+
"type": "image",
1779+
"url": "https://example.com/image.jpg",
1780+
"mime_type": "image/jpeg",
1781+
}
1782+
result = _transform_langchain_content_block(content_block)
1783+
assert result == {
1784+
"type": "uri",
1785+
"modality": "image",
1786+
"mime_type": "image/jpeg",
1787+
"uri": "https://example.com/image.jpg",
1788+
}
1789+
1790+
def test_transform_image_file_id(self):
1791+
"""Test transformation of file_id-referenced image content."""
1792+
content_block = {
1793+
"type": "image",
1794+
"file_id": "file-abc123",
1795+
"mime_type": "image/png",
1796+
}
1797+
result = _transform_langchain_content_block(content_block)
1798+
assert result == {
1799+
"type": "file",
1800+
"modality": "image",
1801+
"mime_type": "image/png",
1802+
"file_id": "file-abc123",
1803+
}
1804+
1805+
def test_transform_image_url_legacy_with_data_uri(self):
1806+
"""Test transformation of legacy image_url format with data: URI (base64)."""
1807+
content_block = {
1808+
"type": "image_url",
1809+
"image_url": {"url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD"},
1810+
}
1811+
result = _transform_langchain_content_block(content_block)
1812+
assert result == {
1813+
"type": "blob",
1814+
"modality": "image",
1815+
"mime_type": "image/jpeg",
1816+
"content": "/9j/4AAQSkZJRgABAQAAAQABAAD",
1817+
}
1818+
1819+
def test_transform_image_url_legacy_with_http_url(self):
1820+
"""Test transformation of legacy image_url format with HTTP URL."""
1821+
content_block = {
1822+
"type": "image_url",
1823+
"image_url": {"url": "https://example.com/image.png"},
1824+
}
1825+
result = _transform_langchain_content_block(content_block)
1826+
assert result == {
1827+
"type": "uri",
1828+
"modality": "image",
1829+
"mime_type": "",
1830+
"uri": "https://example.com/image.png",
1831+
}
1832+
1833+
def test_transform_image_url_legacy_string_url(self):
1834+
"""Test transformation of legacy image_url format with string URL."""
1835+
content_block = {
1836+
"type": "image_url",
1837+
"image_url": "https://example.com/image.gif",
1838+
}
1839+
result = _transform_langchain_content_block(content_block)
1840+
assert result == {
1841+
"type": "uri",
1842+
"modality": "image",
1843+
"mime_type": "",
1844+
"uri": "https://example.com/image.gif",
1845+
}
1846+
1847+
def test_transform_image_url_legacy_data_uri_png(self):
1848+
"""Test transformation of legacy image_url format with PNG data URI."""
1849+
content_block = {
1850+
"type": "image_url",
1851+
"image_url": {
1852+
"url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
1853+
},
1854+
}
1855+
result = _transform_langchain_content_block(content_block)
1856+
assert result == {
1857+
"type": "blob",
1858+
"modality": "image",
1859+
"mime_type": "image/png",
1860+
"content": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
1861+
}
1862+
1863+
def test_transform_missing_mime_type(self):
1864+
"""Test transformation when mime_type is not provided."""
1865+
content_block = {
1866+
"type": "image",
1867+
"base64": "/9j/4AAQSkZJRgABAQAAAQABAAD...",
1868+
}
1869+
result = _transform_langchain_content_block(content_block)
1870+
assert result == {
1871+
"type": "blob",
1872+
"modality": "image",
1873+
"mime_type": "",
1874+
"content": "/9j/4AAQSkZJRgABAQAAAQABAAD...",
1875+
}
1876+
1877+
1878+
class TestTransformLangchainMessageContent:
1879+
"""Tests for _transform_langchain_message_content function."""
1880+
1881+
def test_transform_string_content(self):
1882+
"""Test that string content is returned unchanged."""
1883+
result = _transform_langchain_message_content("Hello, world!")
1884+
assert result == "Hello, world!"
1885+
1886+
def test_transform_list_with_text_blocks(self):
1887+
"""Test transformation of list with text blocks (unchanged)."""
1888+
content = [
1889+
{"type": "text", "text": "First message"},
1890+
{"type": "text", "text": "Second message"},
1891+
]
1892+
result = _transform_langchain_message_content(content)
1893+
assert result == content
1894+
1895+
def test_transform_list_with_image_blocks(self):
1896+
"""Test transformation of list containing image blocks."""
1897+
content = [
1898+
{"type": "text", "text": "Check out this image:"},
1899+
{
1900+
"type": "image",
1901+
"base64": "/9j/4AAQSkZJRgABAQAAAQABAAD...",
1902+
"mime_type": "image/jpeg",
1903+
},
1904+
]
1905+
result = _transform_langchain_message_content(content)
1906+
assert len(result) == 2
1907+
assert result[0] == {"type": "text", "text": "Check out this image:"}
1908+
assert result[1] == {
1909+
"type": "blob",
1910+
"modality": "image",
1911+
"mime_type": "image/jpeg",
1912+
"content": "/9j/4AAQSkZJRgABAQAAAQABAAD...",
1913+
}
1914+
1915+
def test_transform_list_with_mixed_content(self):
1916+
"""Test transformation of list with mixed content types."""
1917+
content = [
1918+
{"type": "text", "text": "Here are some files:"},
1919+
{
1920+
"type": "image",
1921+
"url": "https://example.com/image.jpg",
1922+
"mime_type": "image/jpeg",
1923+
},
1924+
{
1925+
"type": "file",
1926+
"file_id": "doc-123",
1927+
"mime_type": "application/pdf",
1928+
},
1929+
{"type": "audio", "base64": "audio_data...", "mime_type": "audio/mp3"},
1930+
]
1931+
result = _transform_langchain_message_content(content)
1932+
assert len(result) == 4
1933+
assert result[0] == {"type": "text", "text": "Here are some files:"}
1934+
assert result[1] == {
1935+
"type": "uri",
1936+
"modality": "image",
1937+
"mime_type": "image/jpeg",
1938+
"uri": "https://example.com/image.jpg",
1939+
}
1940+
assert result[2] == {
1941+
"type": "file",
1942+
"modality": "document",
1943+
"mime_type": "application/pdf",
1944+
"file_id": "doc-123",
1945+
}
1946+
assert result[3] == {
1947+
"type": "blob",
1948+
"modality": "audio",
1949+
"mime_type": "audio/mp3",
1950+
"content": "audio_data...",
1951+
}
1952+
1953+
def test_transform_list_with_non_dict_items(self):
1954+
"""Test transformation handles non-dict items in list."""
1955+
content = ["plain string", {"type": "text", "text": "dict text"}]
1956+
result = _transform_langchain_message_content(content)
1957+
assert result == ["plain string", {"type": "text", "text": "dict text"}]
1958+
1959+
def test_transform_tuple_content(self):
1960+
"""Test transformation of tuple content."""
1961+
content = (
1962+
{"type": "text", "text": "Message"},
1963+
{"type": "image", "base64": "data...", "mime_type": "image/png"},
1964+
)
1965+
result = _transform_langchain_message_content(content)
1966+
assert len(result) == 2
1967+
assert result[1] == {
1968+
"type": "blob",
1969+
"modality": "image",
1970+
"mime_type": "image/png",
1971+
"content": "data...",
1972+
}
1973+
1974+
def test_transform_list_with_legacy_image_url(self):
1975+
"""Test transformation of list containing legacy image_url blocks."""
1976+
content = [
1977+
{"type": "text", "text": "Check this:"},
1978+
{
1979+
"type": "image_url",
1980+
"image_url": {"url": "data:image/jpeg;base64,/9j/4AAQ..."},
1981+
},
1982+
]
1983+
result = _transform_langchain_message_content(content)
1984+
assert len(result) == 2
1985+
assert result[0] == {"type": "text", "text": "Check this:"}
1986+
assert result[1] == {
1987+
"type": "blob",
1988+
"modality": "image",
1989+
"mime_type": "image/jpeg",
1990+
"content": "/9j/4AAQ...",
1991+
}

0 commit comments

Comments
 (0)