Skip to content

Commit 15ccd29

Browse files
committed
add multimodal input support for image, pdf, and audio
1 parent 27642bd commit 15ccd29

14 files changed

Lines changed: 798 additions & 18 deletions

File tree

docs/docs/advanced/multimodal.md

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
# Multimodal Input
2+
3+
Send images, PDFs, and audio alongside text in user messages. Each provider has a different wire format; `@threaded/ai` normalizes these into a single `ContentPart` vocabulary and translates per provider.
4+
5+
## The `message()` Helper
6+
7+
The easiest way to build a multimodal user message.
8+
9+
```typescript
10+
import { compose, model, message } from "@threaded/ai";
11+
import { readFileSync } from "fs";
12+
13+
const png = readFileSync("chart.png").toString("base64");
14+
15+
const userMessage = message("What's in this chart?", {
16+
images: [{ kind: "base64", mediaType: "image/png", data: png }],
17+
});
18+
19+
const result = await compose(model({ model: "openai/gpt-4o-mini" }))({
20+
history: [userMessage],
21+
});
22+
```
23+
24+
`message()` returns a `Message` whose `content` is a `ContentPart[]`. Drop it directly into `ctx.history`.
25+
26+
### Image from URL
27+
28+
```typescript
29+
const userMessage = message("Describe this photo.", {
30+
images: ["https://example.com/photo.jpg"],
31+
});
32+
```
33+
34+
A bare string in the `images` array is treated as a URL. Pass `{ kind, mediaType, data }` for base64.
35+
36+
### Multiple Images
37+
38+
```typescript
39+
const userMessage = message("Compare these three charts.", {
40+
images: [
41+
{ kind: "base64", mediaType: "image/png", data: pngA },
42+
{ kind: "base64", mediaType: "image/png", data: pngB },
43+
"https://example.com/chart-c.png",
44+
],
45+
});
46+
```
47+
48+
### PDF Documents
49+
50+
```typescript
51+
const pdf = readFileSync("report.pdf").toString("base64");
52+
53+
const userMessage = message("Summarize this report.", {
54+
documents: [
55+
{
56+
source: { kind: "base64", mediaType: "application/pdf", data: pdf },
57+
filename: "report.pdf",
58+
},
59+
],
60+
});
61+
```
62+
63+
`filename` is optional but useful — OpenAI in particular uses it as a display hint when the assistant references the file.
64+
65+
### Audio
66+
67+
```typescript
68+
const wav = readFileSync("clip.wav").toString("base64");
69+
70+
const userMessage = message("Transcribe this clip.", {
71+
audio: [{ kind: "base64", mediaType: "audio/wav", data: wav }],
72+
});
73+
```
74+
75+
Audio input is only supported on audio-capable models (see the capability matrix below).
76+
77+
## Content Parts Directly
78+
79+
For finer control, build the `ContentPart[]` yourself instead of using `message()`.
80+
81+
```typescript
82+
import { Message } from "@threaded/ai";
83+
84+
const userMessage: Message = {
85+
role: "user",
86+
content: [
87+
{ type: "text", text: "What's in this image?" },
88+
{
89+
type: "image",
90+
source: { kind: "base64", mediaType: "image/png", data: png },
91+
},
92+
],
93+
};
94+
```
95+
96+
The four part types.
97+
98+
```typescript
99+
type ContentPart =
100+
| { type: "text"; text: string }
101+
| { type: "image"; source: MediaSource }
102+
| { type: "document"; source: MediaSource; filename?: string }
103+
| { type: "audio"; source: MediaSource };
104+
105+
type MediaSource =
106+
| { kind: "base64"; mediaType: string; data: string }
107+
| { kind: "url"; url: string };
108+
```
109+
110+
A message may contain any mix of text and media parts in any order. Assistant replies always come back as plain string content on the chat endpoints.
111+
112+
## Provider Capability Matrix
113+
114+
| Part | OpenAI | Anthropic | Google | xAI | Ollama |
115+
|----------|---------------------------------|--------------|--------------|--------------|--------|
116+
| image | all vision models | all models | all models | grok-2-vision, grok-4 | llava, llama3.2-vision, etc. |
117+
| document | vision models (base64 PDF) | all models | all models | not supported | not supported |
118+
| audio | gpt-4o-audio-preview, gpt-4o-mini-audio-preview | not supported | all models | not supported | not supported |
119+
120+
Unsupported combinations throw a clear error at the adapter boundary rather than silently dropping content. If you attach audio to a non-audio provider, the call raises instead of the model receiving only the text.
121+
122+
### Source Kind Compatibility
123+
124+
Not every provider accepts every source kind for every media type.
125+
126+
- **Images**: base64 and URL accepted on OpenAI, Anthropic, and xAI. Google accepts base64 directly and routes `kind: "url"` through its Files API (`file_data.file_uri`) — plain public URLs are not fetched by the Gemini server and should be uploaded first.
127+
- **Documents**: base64 works everywhere that supports documents. URL works on Anthropic (native) and Google (via Files API). OpenAI requires base64 — for large PDFs, upload via the Files API and reference by `file_id` in a text message.
128+
- **Audio**: base64 only, and only on providers in the matrix above.
129+
130+
## Running the Same Prompt Across Providers
131+
132+
Because the adapter layer hides the wire-format differences, the same `ContentPart[]` works across every multimodal provider.
133+
134+
```typescript
135+
import { compose, model, message } from "@threaded/ai";
136+
137+
const userMessage = message("What color is this?", {
138+
images: [{ kind: "base64", mediaType: "image/png", data: png }],
139+
});
140+
141+
const providers = [
142+
"openai/gpt-4o-mini",
143+
"anthropic/claude-sonnet-4-5",
144+
"google/gemini-2.5-flash-lite",
145+
];
146+
147+
for (const provider of providers) {
148+
const result = await compose(model({ model: provider }))({
149+
history: [userMessage],
150+
});
151+
console.log(provider, "->", result.lastResponse?.content);
152+
}
153+
```
154+
155+
## PDF Example
156+
157+
```typescript
158+
import { compose, model, message } from "@threaded/ai";
159+
import { readFileSync } from "fs";
160+
161+
const pdf = readFileSync("invoice.pdf").toString("base64");
162+
163+
const result = await compose(model({ model: "openai/gpt-4o-mini" }))({
164+
history: [
165+
message("Extract the line items from this invoice as JSON.", {
166+
documents: [
167+
{
168+
source: { kind: "base64", mediaType: "application/pdf", data: pdf },
169+
filename: "invoice.pdf",
170+
},
171+
],
172+
}),
173+
],
174+
});
175+
176+
console.log(result.lastResponse?.content);
177+
```
178+
179+
Works identically against `google/gemini-2.5-flash` or `anthropic/claude-sonnet-4-5`.
180+
181+
## Audio Example
182+
183+
```typescript
184+
import { compose, model, message } from "@threaded/ai";
185+
import { readFileSync } from "fs";
186+
187+
const wav = readFileSync("meeting.wav").toString("base64");
188+
189+
const result = await compose(
190+
model({ model: "openai/gpt-4o-audio-preview" }),
191+
)({
192+
history: [
193+
message("Transcribe this meeting and list the action items.", {
194+
audio: [{ kind: "base64", mediaType: "audio/wav", data: wav }],
195+
}),
196+
],
197+
});
198+
```
199+
200+
When a message contains audio, the OpenAI adapter automatically adds `modalities: ["text"]` to the request body. Without that, the audio-preview models refuse to describe the input.
201+
202+
Supported audio formats on OpenAI: `audio/wav`, `audio/mp3`. Gemini accepts `audio/wav`, `audio/mpeg` (mp3), `audio/aiff`, `audio/aac`, `audio/ogg`, and `audio/flac`.
203+
204+
## Mixing Media and Tools
205+
206+
Multimodal input composes with tool execution the same way text does.
207+
208+
```typescript
209+
import { compose, scope, model, message } from "@threaded/ai";
210+
211+
const saveNote = {
212+
name: "save_note",
213+
description: "Save a note to the database",
214+
schema: { text: { type: "string", description: "The note content" } },
215+
execute: async ({ text }) => ({ ok: true, text }),
216+
};
217+
218+
const result = await compose(
219+
scope({ tools: [saveNote] }, model({ model: "openai/gpt-4o-mini" })),
220+
)({
221+
history: [
222+
message("Read the sticky note in this photo, then save it.", {
223+
images: [{ kind: "base64", mediaType: "image/jpeg", data: jpeg }],
224+
}),
225+
],
226+
});
227+
```
228+
229+
## Threads
230+
231+
Persistent threads work transparently with multimodal content — `thread.message()` only takes a string today, so use `thread.generate()` to push a pre-built multimodal message into history.
232+
233+
```typescript
234+
import { getOrCreateThread, compose, model, message } from "@threaded/ai";
235+
236+
const thread = getOrCreateThread("user-42");
237+
238+
await thread.generate(async (ctx) => ({
239+
...ctx,
240+
history: [
241+
...ctx.history,
242+
message("What's in this chart?", {
243+
images: [{ kind: "base64", mediaType: "image/png", data: png }],
244+
}),
245+
],
246+
}));
247+
248+
await thread.generate(compose(model({ model: "openai/gpt-4o-mini" })));
249+
```
250+
251+
History is persisted via the thread's store — the `ContentPart[]` survives JSON serialization cleanly.
252+
253+
## Error Handling
254+
255+
Unsupported combinations throw at call time, not later.
256+
257+
```typescript
258+
try {
259+
await compose(model({ model: "xai/grok-4" }))({
260+
history: [
261+
message("What does this say?", {
262+
documents: [
263+
{ source: { kind: "base64", mediaType: "application/pdf", data: pdf } },
264+
],
265+
}),
266+
],
267+
});
268+
} catch (err) {
269+
// "xAI does not support document/PDF input on the chat completions API"
270+
}
271+
```
272+
273+
Catch at the composition boundary and retry against a provider that supports the media type.

docs/docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ npm install @threaded/ai
1717
- Explicit conversation history management
1818
- Built-in tool execution with approval flows
1919
- Multi-provider support: OpenAI, Anthropic, Google, xAI, Ollama, HuggingFace
20+
- Multimodal input (images, PDFs, audio) with provider-agnostic content parts
2021
- Text embeddings and image generation
2122

2223
Workflows are functions that transform conversation context. Compose them together to build complex agentic behaviors from simple primitives.

docs/docs/quick-start.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,27 @@ const result = await workflow("what's the weather in san francisco?");
116116

117117
The model calls the tool automatically and uses the results in its response.
118118

119+
## Multimodal Input
120+
121+
Attach images, PDFs, or audio to a user message with the `message()` helper.
122+
123+
```typescript
124+
import { compose, model, message } from "@threaded/ai";
125+
import { readFileSync } from "fs";
126+
127+
const png = readFileSync("chart.png").toString("base64");
128+
129+
const result = await compose(model({ model: "openai/gpt-4o-mini" }))({
130+
history: [
131+
message("What's in this chart?", {
132+
images: [{ kind: "base64", mediaType: "image/png", data: png }],
133+
}),
134+
],
135+
});
136+
```
137+
138+
The same message format works across OpenAI, Anthropic, and Google. See [Multimodal Input](advanced/multimodal.md) for PDFs, audio, URLs, and the full capability matrix.
139+
119140
## Streaming
120141

121142
Stream content and tool execution updates in real time.

docs/mkdocs.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ plugins:
1515
it provides primitives for managing conversation history, tool execution,
1616
and function composition. supports openai, anthropic, google, and xai models.
1717
includes built-in streaming, tool approval, retry logic, thread persistence,
18-
text embeddings, and image generation.
18+
text embeddings, image generation, and multimodal input (image, pdf, audio).
1919
sections:
2020
Getting Started:
2121
- index.md: overview and core principles
@@ -29,6 +29,7 @@ plugins:
2929
- advanced/mcp.md: model context protocol integration
3030
- advanced/embedding.md: text embeddings
3131
- advanced/image-generation.md: image generation with openai, xai, google
32+
- advanced/multimodal.md: image, pdf, and audio input across providers
3233
- advanced/approval.md: tool approval system
3334
- advanced/helpers.md: utility functions
3435

@@ -89,5 +90,6 @@ nav:
8990
- MCP Integration: advanced/mcp.md
9091
- Embeddings: advanced/embedding.md
9192
- Image Generation: advanced/image-generation.md
93+
- Multimodal Input: advanced/multimodal.md
9294
- Tool Approval: advanced/approval.md
9395
- Helpers: advanced/helpers.md

threads/src/composition/model.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ export const model = ({
5656
}
5757

5858
const systemMessage = currentCtx.history.find((m) => m.role === "system");
59-
const instructions = systemMessage?.content;
59+
const instructions =
60+
typeof systemMessage?.content === "string" ? systemMessage.content : undefined;
6061

6162
do {
6263
if (currentCtx.abortSignal?.aborted) {

threads/src/helpers.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ConversationContext, StepFunction } from "./types.js";
2+
import { getText } from "./utils.js";
23
import { when } from "./composition/when.js";
34

45
/**
@@ -31,14 +32,14 @@ export const everyNTokens = (n: number, step: StepFunction): StepFunction => {
3132
return when(
3233
(ctx) => {
3334
const totalTokens = ctx.history.reduce(
34-
(acc, msg) => acc + Math.ceil(msg.content.length / 4),
35+
(acc, msg) => acc + Math.ceil(getText(msg.content).length / 4),
3536
0,
3637
);
3738
return Math.floor(totalTokens / n) > Math.floor(lastTriggeredAt / n);
3839
},
3940
async (ctx) => {
4041
const totalTokens = ctx.history.reduce(
41-
(acc, msg) => acc + Math.ceil(msg.content.length / 4),
42+
(acc, msg) => acc + Math.ceil(getText(msg.content).length / 4),
4243
0,
4344
);
4445
lastTriggeredAt = totalTokens;
@@ -60,9 +61,13 @@ export const appendToLastRequest = (content: string): StepFunction => {
6061
if (lastUserIndex === -1) return ctx;
6162

6263
const newHistory = [...ctx.history];
64+
const existing = newHistory[lastUserIndex].content;
6365
newHistory[lastUserIndex] = {
6466
...newHistory[lastUserIndex],
65-
content: newHistory[lastUserIndex].content + content,
67+
content:
68+
typeof existing === "string"
69+
? existing + content
70+
: [...existing, { type: "text", text: content }],
6671
};
6772

6873
return {

0 commit comments

Comments
 (0)