Skip to content

Commit ef92a49

Browse files
committed
Implement multimodal inputs
1 parent e8b4f7a commit ef92a49

22 files changed

Lines changed: 1866 additions & 72 deletions

File tree

examples/samples/multimodal.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Multimodal input example: send an image URL to the model.
2+
3+
Usage:
4+
uv run examples/samples/multimodal.py
5+
"""
6+
7+
import asyncio
8+
9+
import vercel_ai_sdk as ai
10+
11+
IMAGE_URL = "https://4kwallpapers.com/images/wallpapers/hatsune-miku-3840x2160-15479.jpg"
12+
13+
14+
async def agent(llm: ai.LanguageModel, user_query: str) -> ai.StreamResult:
15+
return await ai.stream_loop(
16+
llm,
17+
messages=[
18+
ai.Message(
19+
role="user",
20+
parts=[
21+
ai.TextPart(text=user_query),
22+
ai.FilePart.from_url(IMAGE_URL),
23+
],
24+
)
25+
],
26+
tools=[],
27+
)
28+
29+
30+
async def main() -> None:
31+
llm = ai.ai_gateway.GatewayModel(model="anthropic/claude-opus-4.6")
32+
33+
async for msg in ai.run(agent, llm, "What's in this image? Be concise."):
34+
if msg.text_delta:
35+
print(msg.text_delta, end="", flush=True)
36+
print()
37+
38+
39+
if __name__ == "__main__":
40+
asyncio.run(main())

src/vercel_ai_sdk/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
# Re-export core types
88
from .core.messages import (
9+
FilePart,
910
HookPart,
1011
Message,
1112
Part,
@@ -40,6 +41,7 @@
4041
"ToolPart",
4142
"ToolDelta",
4243
"ReasoningPart",
44+
"FilePart",
4345
"ToolLike",
4446
"ToolSchema",
4547
"Tool",

src/vercel_ai_sdk/ai_gateway/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ async def stream_events(
106106
output_type: type[pydantic.BaseModel] | None = None,
107107
) -> AsyncGenerator[core.llm.StreamEvent]:
108108
"""Yield ``StreamEvent`` objects from the gateway SSE stream."""
109-
body = protocol_.build_request_body(
109+
body = await protocol_.build_request_body(
110110
messages,
111111
tools=tools,
112112
output_type=output_type,

src/vercel_ai_sdk/ai_gateway/protocol.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,30 @@
2424
# ---------------------------------------------------------------------------
2525

2626

27-
def messages_to_v3_prompt(
27+
async def _file_part_to_v3(part: core.messages.FilePart) -> dict[str, Any]:
28+
"""Convert an internal :class:`FilePart` to a v3 ``file`` content part.
29+
30+
Binary data is converted to a ``data:`` URL for JSON transport (matching
31+
the JS SDK gateway's ``maybeEncodeFileParts``). HTTP(S) URLs are
32+
downloaded and converted to ``data:`` URLs because the gateway wire
33+
format does not accept raw HTTP URLs for file content.
34+
"""
35+
data = part.data
36+
if isinstance(data, str) and core.media.data.is_downloadable_url(data):
37+
downloaded, _ = await core.media.download.download(data)
38+
data = downloaded
39+
40+
entry: dict[str, Any] = {
41+
"type": "file",
42+
"mediaType": part.media_type,
43+
"data": core.media.data.data_to_data_url(data, part.media_type),
44+
}
45+
if part.filename is not None:
46+
entry["filename"] = part.filename
47+
return entry
48+
49+
50+
async def messages_to_v3_prompt(
2851
messages: list[core.messages.Message],
2952
) -> list[dict[str, Any]]:
3053
"""Convert internal ``Message`` list to ``LanguageModelV3Prompt``.
@@ -55,11 +78,12 @@ def messages_to_v3_prompt(
5578
result.append({"role": "system", "content": text})
5679

5780
case "user":
58-
content: list[dict[str, Any]] = [
59-
{"type": "text", "text": p.text}
60-
for p in msg.parts
61-
if isinstance(p, core.messages.TextPart)
62-
]
81+
content: list[dict[str, Any]] = []
82+
for p in msg.parts:
83+
if isinstance(p, core.messages.TextPart):
84+
content.append({"type": "text", "text": p.text})
85+
elif isinstance(p, core.messages.FilePart):
86+
content.append(await _file_part_to_v3(p))
6387
result.append({"role": "user", "content": content})
6488

6589
case "assistant":
@@ -135,15 +159,15 @@ def messages_to_v3_prompt(
135159
# ---------------------------------------------------------------------------
136160

137161

138-
def build_request_body(
162+
async def build_request_body(
139163
messages: list[core.messages.Message],
140164
tools: Sequence[core.tools.ToolLike] | None = None,
141165
output_type: type[Any] | None = None,
142166
provider_options: dict[str, Any] | None = None,
143167
) -> dict[str, Any]:
144168
"""Build the full ``LanguageModelV3CallOptions`` request body."""
145169
body: dict[str, Any] = {
146-
"prompt": messages_to_v3_prompt(messages),
170+
"prompt": await messages_to_v3_prompt(messages),
147171
}
148172
if tools:
149173
body["tools"] = [

src/vercel_ai_sdk/ai_sdk_ui/adapter.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,9 +467,17 @@ def to_messages(
467467
)
468468
resolved_any_approval = True
469469

470+
case ui_message.UIFilePart() as fp:
471+
internal_parts.append(
472+
core.messages.FilePart(
473+
data=fp.url,
474+
media_type=fp.media_type,
475+
filename=fp.filename,
476+
)
477+
)
478+
470479
case (
471480
ui_message.UIStepStartPart()
472-
| ui_message.UIFilePart()
473481
| ui_message.UISourceUrlPart()
474482
| ui_message.UISourceDocumentPart()
475483
):

src/vercel_ai_sdk/anthropic/__init__.py

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,73 @@ def _tools_to_anthropic(tools: Sequence[core.tools.ToolLike]) -> list[dict[str,
2323
]
2424

2525

26-
def _messages_to_anthropic(
26+
def _file_part_to_anthropic(part: core.messages.FilePart) -> dict[str, Any]:
27+
"""Convert a :class:`FilePart` to an Anthropic content block.
28+
29+
* ``image/*`` → ``{"type": "image", "source": ...}``
30+
* ``application/pdf`` → ``{"type": "document", "source": ...}``
31+
* ``text/plain`` → ``{"type": "document", "source": {"type": "text", ...}}``
32+
* anything else → ``ValueError``
33+
"""
34+
mt = part.media_type
35+
36+
if mt.startswith("image/"):
37+
media_type = "image/jpeg" if mt == "image/*" else mt
38+
if isinstance(part.data, str) and core.media.data.is_url(part.data):
39+
return {
40+
"type": "image",
41+
"source": {"type": "url", "url": part.data},
42+
}
43+
return {
44+
"type": "image",
45+
"source": {
46+
"type": "base64",
47+
"media_type": media_type,
48+
"data": core.media.data.data_to_base64(part.data),
49+
},
50+
}
51+
52+
if mt == "application/pdf":
53+
if isinstance(part.data, str) and core.media.data.is_url(part.data):
54+
return {
55+
"type": "document",
56+
"source": {"type": "url", "url": part.data},
57+
}
58+
return {
59+
"type": "document",
60+
"source": {
61+
"type": "base64",
62+
"media_type": "application/pdf",
63+
"data": core.media.data.data_to_base64(part.data),
64+
},
65+
}
66+
67+
if mt == "text/plain":
68+
# Anthropic accepts text documents with source.type="text"
69+
if isinstance(part.data, bytes):
70+
text_data = part.data.decode("utf-8")
71+
elif core.media.data.is_url(part.data):
72+
return {
73+
"type": "document",
74+
"source": {"type": "url", "url": part.data},
75+
}
76+
else:
77+
import base64 as _b64
78+
79+
text_data = _b64.b64decode(part.data).decode("utf-8")
80+
return {
81+
"type": "document",
82+
"source": {
83+
"type": "text",
84+
"media_type": "text/plain",
85+
"data": text_data,
86+
},
87+
}
88+
89+
raise ValueError(f"Unsupported media type for Anthropic: {mt}")
90+
91+
92+
async def _messages_to_anthropic(
2793
messages: list[core.messages.Message],
2894
) -> tuple[str | None, list[dict[str, Any]]]:
2995
"""Convert internal messages to Anthropic API format.
@@ -89,12 +155,21 @@ def _messages_to_anthropic(
89155
result.append({"role": "assistant", "content": content})
90156
if tool_results:
91157
result.append({"role": "user", "content": tool_results})
92-
else:
93-
# User messages
94-
content_text = "".join(
95-
p.text for p in msg.parts if isinstance(p, core.messages.TextPart)
96-
)
97-
result.append({"role": "user", "content": content_text})
158+
elif msg.role == "user":
159+
has_files = any(isinstance(p, core.messages.FilePart) for p in msg.parts)
160+
if not has_files:
161+
content_text = "".join(
162+
p.text for p in msg.parts if isinstance(p, core.messages.TextPart)
163+
)
164+
result.append({"role": "user", "content": content_text})
165+
else:
166+
user_content: list[dict[str, Any]] = []
167+
for p in msg.parts:
168+
if isinstance(p, core.messages.TextPart):
169+
user_content.append({"type": "text", "text": p.text})
170+
elif isinstance(p, core.messages.FilePart):
171+
user_content.append(_file_part_to_anthropic(p))
172+
result.append({"role": "user", "content": user_content})
98173

99174
# Merge consecutive same-role messages (e.g. synthetic user(tool_result)
100175
# followed by a real user message).
@@ -162,7 +237,7 @@ async def stream_events(
162237
output_type: type[pydantic.BaseModel] | None = None,
163238
) -> AsyncGenerator[core.llm.StreamEvent]:
164239
"""Yield raw stream events from Anthropic API."""
165-
system_prompt, anthropic_messages = _messages_to_anthropic(messages)
240+
system_prompt, anthropic_messages = await _messages_to_anthropic(messages)
166241
anthropic_tools = _tools_to_anthropic(tools) if tools else None
167242

168243
kwargs: dict[str, Any] = {

src/vercel_ai_sdk/core/__init__.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1-
from . import hooks, llm, messages, runtime, telemetry, tools
1+
from . import (
2+
hooks,
3+
llm,
4+
media,
5+
messages,
6+
runtime,
7+
telemetry,
8+
tools,
9+
)
210

3-
__all__ = ["messages", "tools", "runtime", "hooks", "llm", "telemetry"]
11+
__all__ = [
12+
"hooks",
13+
"llm",
14+
"media",
15+
"messages",
16+
"runtime",
17+
"telemetry",
18+
"tools",
19+
]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Media handling: detection, download, and data-format helpers."""
2+
3+
from . import data, detect_media_type, download
4+
5+
__all__ = ["data", "detect_media_type", "download"]
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Data-format helpers for multimodal content.
2+
3+
URL detection, ``data:`` URL parsing, base-64 encoding/decoding, and
4+
media-type inference utilities used by :class:`~vercel_ai_sdk.core.messages.FilePart`
5+
and the provider converters.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import base64
11+
import mimetypes
12+
13+
# -- URL helpers -----------------------------------------------------------
14+
15+
16+
def is_url(data: str) -> bool:
17+
"""Return True if *data* looks like a URL rather than raw base-64."""
18+
return data.startswith(("http://", "https://", "data:"))
19+
20+
21+
def is_downloadable_url(data: str) -> bool:
22+
"""Return True if *data* is an ``http(s)://`` URL that can be fetched."""
23+
return data.startswith(("http://", "https://"))
24+
25+
26+
def split_data_url(url: str) -> tuple[str | None, str | None]:
27+
"""Parse a ``data:`` URL into ``(media_type, base64_content)``.
28+
29+
Returns ``(None, None)`` if the input is not a valid ``data:`` URL.
30+
31+
Example::
32+
33+
>>> split_data_url("data:image/png;base64,iVBOR...")
34+
("image/png", "iVBOR...")
35+
"""
36+
if not url.startswith("data:"):
37+
return None, None
38+
try:
39+
header, b64_content = url.split(",", 1)
40+
# header = "data:image/png;base64"
41+
mt = header.split(";")[0].split(":", 1)[1]
42+
return (mt or None), (b64_content or None)
43+
except (ValueError, IndexError):
44+
return None, None
45+
46+
47+
# -- encoding helpers ------------------------------------------------------
48+
49+
50+
def data_to_base64(data: str | bytes) -> str:
51+
"""Ensure *data* is a base-64 encoded string.
52+
53+
* ``bytes`` -> base-64 encoded.
54+
* ``str`` that is a ``data:`` URL -> base-64 content extracted.
55+
* ``str`` that is an ``http(s)://`` URL -> returned as-is (caller
56+
must handle).
57+
* ``str`` that is not a URL -> assumed to already be base-64.
58+
"""
59+
if isinstance(data, bytes):
60+
return base64.b64encode(data).decode("ascii")
61+
if data.startswith("data:"):
62+
_, b64 = split_data_url(data)
63+
if b64 is not None:
64+
return b64
65+
return data
66+
67+
68+
def data_to_data_url(data: str | bytes, media_type: str) -> str:
69+
"""Convert *data* to a ``data:`` URL. Passes through existing URLs."""
70+
if isinstance(data, str) and is_url(data):
71+
return data
72+
b64 = data_to_base64(data)
73+
return f"data:{media_type};base64,{b64}"
74+
75+
76+
# -- media-type inference --------------------------------------------------
77+
78+
79+
def infer_media_type(url: str) -> str:
80+
"""Infer IANA media type from a URL.
81+
82+
* ``data:image/png;base64,...`` -> ``"image/png"``
83+
* ``https://example.com/cat.jpg`` -> ``"image/jpeg"`` (via :mod:`mimetypes`)
84+
* Unknown -> raises :class:`ValueError`
85+
"""
86+
if url.startswith("data:"):
87+
# data:[<mediatype>][;base64],<data>
88+
rest = url[5:] # strip "data:"
89+
sep = rest.find(",")
90+
meta = rest[:sep] if sep != -1 else rest
91+
mt = meta.split(";")[0]
92+
if mt:
93+
return mt
94+
else:
95+
guessed, _ = mimetypes.guess_type(url)
96+
if guessed:
97+
return guessed
98+
raise ValueError(
99+
f"Cannot infer media_type from URL: {url!r}. Provide media_type explicitly."
100+
)

0 commit comments

Comments
 (0)