diff --git a/.changeset/native-files-api-support.md b/.changeset/native-files-api-support.md
new file mode 100644
index 000000000..361362ba5
--- /dev/null
+++ b/.changeset/native-files-api-support.md
@@ -0,0 +1,22 @@
+---
+'@tanstack/ai': minor
+'@tanstack/ai-event-client': minor
+'@tanstack/openai-base': minor
+'@tanstack/ai-openai': minor
+'@tanstack/ai-anthropic': minor
+'@tanstack/ai-gemini': minor
+'@tanstack/ai-fal': minor
+'@tanstack/ai-mistral': patch
+'@tanstack/ai-grok': patch
+'@tanstack/ai-openrouter': patch
+'@tanstack/ai-ollama': patch
+'@tanstack/ai-bedrock': patch
+---
+
+feat(ai): native Files API support across providers (upload adapters + `file` content source)
+
+Adds first-class support for provider **Files / storage APIs** so callers can upload media once and reference it by a provider-issued handle instead of re-sending base64 or a public URL each request (lower latency/bandwidth, no re-buffering on memory-constrained runtimes).
+
+- **New tree-shakeable `files` adapter kind** — `openaiFiles()`, `anthropicFiles()`, `geminiFiles()`, and `falFiles()`. Each exposes `upload()`, and (where the provider has a lifecycle API) `get()` / `delete()`. Drive them with the new `uploadFile()` / `getFile()` / `deleteFile()` activity functions. fal is upload-only.
+- **New `{ type: 'file' }` arm on `ContentPartSource`** — reference an uploaded handle in a chat message. Adapters map it to the right wire field: OpenAI (Responses) `input_image`/`input_file` `file_id`, Anthropic `file_id` message source (with the `files-api-2025-04-14` beta), Gemini `fileData.fileUri`, fal storage URL passthrough. Use `fileSourceFromHandle(handle)` to build the source from an uploaded `FileHandle`.
+- **Runtime provider routing** — a file handle only routes to the provider that issued it; adapters throw a clear error on a cross-provider handle, and providers/endpoints that can't consume a handle (image edits, Veo, Chat Completions images, Bedrock, Mistral, Grok, OpenRouter, Ollama) throw a clear "unsupported file source" error instead of silently mis-mapping.
diff --git a/docs/advanced/files-api.md b/docs/advanced/files-api.md
new file mode 100644
index 000000000..86c74e54b
--- /dev/null
+++ b/docs/advanced/files-api.md
@@ -0,0 +1,133 @@
+---
+title: Files API
+id: files-api
+description: "Upload media once and reference it by a provider-issued handle with TanStack AI's tree-shakeable files adapters (OpenAI, Anthropic, Gemini, fal)."
+keywords:
+ - tanstack ai
+ - files api
+ - file upload
+ - file_id
+ - fileData
+ - multimodal
+---
+
+Provider **Files / storage APIs** let you upload a media asset once and reference it later by a lightweight handle, instead of re-sending base64 (or relying on the provider to re-fetch a public URL) on every request. That means large or reused inputs are uploaded a single time — lower latency and bandwidth, no re-buffering of base64 on memory-constrained runtimes (e.g. Cloudflare Workers) — plus access to provider-side file lifecycle (TTL, deletion).
+
+TanStack AI exposes this as a tree-shakeable **`files` adapter** per provider, paired with a `{ type: 'file' }` [content source](./multimodal-content.md#file-handle-files-api) you drop into a message.
+
+## Files adapters
+
+Each provider with a native surface has a factory: `openaiFiles()`, `anthropicFiles()`, `geminiFiles()`, and `falFiles()`. They read the same API-key env var as the provider's other adapters, or accept an explicit key.
+
+```typescript
+import { openaiFiles } from '@tanstack/ai-openai'
+import { geminiFiles } from '@tanstack/ai-gemini'
+import { anthropicFiles } from '@tanstack/ai-anthropic'
+import { falFiles } from '@tanstack/ai-fal'
+
+const files = openaiFiles() // reads OPENAI_API_KEY
+```
+
+### upload
+
+`upload()` accepts a `Blob` (memory-efficient — preferred for large assets) or `{ data, mimeType }` where `data` is base64. It returns a `FileHandle`:
+
+```typescript
+const handle = await openaiFiles().upload({
+ data: pdfBase64,
+ mimeType: 'application/pdf',
+})
+// handle: { id, provider, uri?, mimeType?, sizeBytes?, expiresAt?, filename? }
+```
+
+- `id` — the provider handle used for `get` / `delete` (OpenAI/Anthropic `file_id`, Gemini file resource name, fal storage URL).
+- `uri` — the handle's URL form when the provider exposes one (Gemini file URI, fal storage URL); `undefined` for OpenAI/Anthropic, whose handles are opaque ids.
+- `expiresAt` — epoch milliseconds, when the provider schedules the handle to expire.
+
+> **Runtime note (Gemini upload).** `geminiFiles().upload()` uses `@google/genai`'s
+> resumable upload, which sets an explicit `Content-Length` header on a `Blob`-body
+> request. Some server runtimes reject that with `fetch failed` /
+> `InvalidArgumentError: invalid content-length header`. On **TanStack Start / Nitro**
+> this fails on older Nitro (observed on `nitro@3.0.1-alpha.2`) and works on current
+> Nitro (verified on `nitro@3.0.260610-beta`) — upgrade Nitro if you hit it. Native
+> Node (and the production `node-server` build) are unaffected. OpenAI, Anthropic, and
+> fal uploads use different transports and don't exercise this path.
+
+### get and delete
+
+Providers with a lifecycle API expose `get()` and `delete()`:
+
+```typescript
+const meta = await openaiFiles().get(handle.id)
+await openaiFiles().delete(handle.id)
+```
+
+> fal storage is **upload-only** — `falFiles()` has no `get` / `delete`, and calling them throws a clear error.
+
+## Referencing a handle in a message
+
+Use `fileSourceFromHandle(handle)` to turn a `FileHandle` into a `{ type: 'file' }` content source. Each adapter maps it to the provider's native reference (OpenAI/Anthropic `file_id`, Gemini `fileData.fileUri`, fal storage URL). A handle only works with the provider that issued it — passing it elsewhere throws.
+
+### Server: upload + reference
+
+```typescript
+import { chat, fileSourceFromHandle } from '@tanstack/ai'
+import { anthropicText } from '@tanstack/ai-anthropic'
+import { anthropicFiles } from '@tanstack/ai-anthropic'
+
+export async function askAboutPdf(pdfBase64: string, request: string) {
+ // Upload once; reuse the handle across turns.
+ const handle = await anthropicFiles().upload({
+ data: pdfBase64,
+ mimeType: 'application/pdf',
+ })
+
+ return chat({
+ adapter: anthropicText('claude-sonnet-5'),
+ messages: [
+ {
+ role: 'user',
+ content: [
+ { type: 'text', content: request },
+ { type: 'document', source: fileSourceFromHandle(handle) },
+ ],
+ },
+ ],
+ })
+}
+```
+
+### Client: reuse a handle across requests
+
+Upload happens server-side (it needs the provider key), so the client works with the returned handle. Persist `{ id, provider, uri, mimeType }` and rebuild the source on each turn:
+
+```typescript
+import { fileSourceFromHandle } from '@tanstack/ai'
+import type { FileHandle } from '@tanstack/ai'
+
+// `handle` was returned by your server's upload endpoint and stored client-side.
+function imageMessage(handle: FileHandle, prompt: string) {
+ return {
+ role: 'user' as const,
+ content: [
+ { type: 'text' as const, content: prompt },
+ { type: 'image' as const, source: fileSourceFromHandle(handle) },
+ ],
+ }
+}
+```
+
+## Provider support
+
+| Provider | Adapter | Handle referenced as | Lifecycle |
+| --- | --- | --- | --- |
+| OpenAI | `openaiFiles()` | Responses `input_image` / `input_file` `file_id` | `get`, `delete` |
+| Anthropic | `anthropicFiles()` | `file_id` message source (sends the `files-api-2025-04-14` beta) | `get`, `delete` |
+| Gemini | `geminiFiles()` | `fileData.fileUri` (the handle URI) | `get`, `delete` |
+| fal | `falFiles()` | storage URL (used like any URL) | upload-only |
+
+Gemini and fal handles are URLs, so they also round-trip through a plain `{ type: 'url' }` source; OpenAI and Anthropic handles are opaque ids that require the `{ type: 'file' }` source.
+
+### Endpoints that require raw bytes
+
+Some endpoints have no "reference an uploaded handle" option — OpenAI's `images/edits` and Sora `input_reference`, and Gemini's Veo, need the actual bytes (or, for Veo, a `gs://` URI). The OpenAI **Chat Completions** image path also references images only by URL/data URI, not `file_id` — use the Responses adapter (`openaiText`) for `file_id` images. Passing a `{ type: 'file' }` source to any of these throws a clear error rather than silently mis-mapping.
diff --git a/docs/advanced/multimodal-content.md b/docs/advanced/multimodal-content.md
index 0bea1a795..030cd899f 100644
--- a/docs/advanced/multimodal-content.md
+++ b/docs/advanced/multimodal-content.md
@@ -258,6 +258,36 @@ const imagePart = {
**Note:** Not all providers support URL-based content for all modalities. Check provider documentation for specifics.
+### File Handle (Files API)
+
+Use `type: 'file'` to reference media you uploaded once via a provider's [Files API](./files-api.md) — the provider stores the bytes and you pass a lightweight handle instead of re-sending base64 or a public URL every request. A handle only works with the provider that issued it, so the `provider` field is required and validated at request time.
+
+```typescript
+import { openaiFiles } from '@tanstack/ai-openai'
+import { openaiText, chat, fileSourceFromHandle } from '@tanstack/ai'
+
+// Upload once...
+const handle = await openaiFiles().upload({ data: pdfBase64, mimeType: 'application/pdf' })
+
+// ...then reference the handle by id in as many requests as you like.
+for await (const chunk of chat({
+ adapter: openaiText('gpt-5.5'),
+ messages: [
+ {
+ role: 'user',
+ content: [
+ { type: 'text', content: 'Summarize this document' },
+ { type: 'document', source: fileSourceFromHandle(handle) },
+ ],
+ },
+ ],
+})) {
+ // ...
+}
+```
+
+`fileSourceFromHandle(handle)` builds the `{ type: 'file', value, provider }` source for you (picking the handle URL for Gemini/fal or the opaque id for OpenAI/Anthropic). Each adapter maps it to the provider's native reference (`file_id`, `fileData.fileUri`, or storage URL). Passing a handle to a different provider — or to an endpoint that requires raw bytes (image edits, Veo) — throws a clear error. See [Files API](./files-api.md) for uploading, retrieving, and deleting handles.
+
## Backward Compatibility
String content continues to work as before:
diff --git a/docs/config.json b/docs/config.json
index 7debc1ba4..4900ea868 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -417,7 +417,13 @@
{
"label": "Multimodal Content",
"to": "advanced/multimodal-content",
- "addedAt": "2026-04-15"
+ "addedAt": "2026-04-15",
+ "updatedAt": "2026-07-08"
+ },
+ {
+ "label": "Files API",
+ "to": "advanced/files-api",
+ "addedAt": "2026-07-08"
},
{
"label": "Per-Model Type Safety",
diff --git a/examples/ts-react-media/package.json b/examples/ts-react-media/package.json
index 9adf242d1..6905204a7 100644
--- a/examples/ts-react-media/package.json
+++ b/examples/ts-react-media/package.json
@@ -19,7 +19,7 @@
"@tanstack/react-start": "^1.159.0",
"@tanstack/router-plugin": "^1.158.4",
"lucide-react": "^0.561.0",
- "nitro": "3.0.1-alpha.2",
+ "nitro": "latest",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"tailwindcss": "^4.1.18",
diff --git a/examples/ts-react-media/src/components/ImageGenerator.tsx b/examples/ts-react-media/src/components/ImageGenerator.tsx
index 09e2eb7d4..80e881587 100644
--- a/examples/ts-react-media/src/components/ImageGenerator.tsx
+++ b/examples/ts-react-media/src/components/ImageGenerator.tsx
@@ -206,7 +206,8 @@ export default function ImageGenerator({
Supported by Gemini multimodal models only
- (gemini-3.1-flash-image-preview, gemini-3-pro-image-preview)
+ (gemini-3.1-flash-image-preview, gemini-3-pro-image-preview) —
+ uploaded once via the Gemini Files API
diff --git a/examples/ts-react-media/src/lib/server-functions.ts b/examples/ts-react-media/src/lib/server-functions.ts
index 53aa3f10c..bd6020018 100644
--- a/examples/ts-react-media/src/lib/server-functions.ts
+++ b/examples/ts-react-media/src/lib/server-functions.ts
@@ -1,9 +1,15 @@
import { createServerFn } from '@tanstack/react-start'
-import { falImage, falVideo } from '@tanstack/ai-fal'
-import { geminiImage, geminiVideo } from '@tanstack/ai-gemini'
+import { falFiles, falImage, falVideo } from '@tanstack/ai-fal'
+import { geminiFiles, geminiImage, geminiVideo } from '@tanstack/ai-gemini'
import { grokImage, grokVideo } from '@tanstack/ai-grok'
-import { generateImage, generateVideo, getVideoJobStatus } from '@tanstack/ai'
+import {
+ fileSourceFromHandle,
+ generateImage,
+ generateVideo,
+ getVideoJobStatus,
+} from '@tanstack/ai'
+import type { FilesAdapter } from '@tanstack/ai'
import type {
ImagePart,
MediaInputMetadata,
@@ -88,6 +94,31 @@ function asImageToVideoPrompt(
return narrowed
}
+/**
+ * Upload each inline (base64 `data`) image input to the provider's Files API and
+ * swap in a `{ type: 'file' }` handle. A reference image / start frame is then
+ * uploaded once via the tree-shakeable files adapter (`geminiFiles()` /
+ * `falFiles()`) instead of being re-sent inline as base64 on the generation
+ * request — the memory-safe path for large inputs. URL and already-uploaded
+ * sources pass through untouched.
+ */
+async function uploadInlineImageInputs(
+ prompt: string | Array
>,
+ files: FilesAdapter,
+): Promise>> {
+ if (typeof prompt === 'string') return prompt
+ return Promise.all(
+ prompt.map(async (part) => {
+ if (part.type !== 'image' || part.source.type !== 'data') return part
+ const handle = await files.upload({
+ data: part.source.value,
+ mimeType: part.source.mimeType,
+ })
+ return { ...part, source: fileSourceFromHandle(handle) }
+ }),
+ )
+}
+
/**
* Resolves the video adapter for a UI model id. The native grok-imagine
* entries hit xAI's Imagine API directly via the `grokVideo` adapter
@@ -187,9 +218,14 @@ export const generateImageFn = createServerFn({ method: 'POST' })
})
}
case 'gemini-3.1-flash-image-preview': {
+ // Reference images are uploaded once via the Gemini Files API and
+ // referenced by handle (fileData.fileUri) rather than inlined as base64.
return generateImage({
adapter: geminiImage('gemini-3.1-flash-image-preview'),
- prompt: asImagePrompt(data.prompt),
+ prompt: await uploadInlineImageInputs(
+ asImagePrompt(data.prompt),
+ geminiFiles(),
+ ),
numberOfImages: 1,
size: '16:9_4K',
})
@@ -197,7 +233,10 @@ export const generateImageFn = createServerFn({ method: 'POST' })
case 'gemini-3-pro-image-preview': {
return generateImage({
adapter: geminiImage('gemini-3-pro-image-preview'),
- prompt: asImagePrompt(data.prompt),
+ prompt: await uploadInlineImageInputs(
+ asImagePrompt(data.prompt),
+ geminiFiles(),
+ ),
numberOfImages: 1,
size: '16:9_4K',
})
@@ -315,11 +354,16 @@ export const createVideoJobFn = createServerFn({ method: 'POST' })
size: '16:9_2160p',
})
}
- // Image-to-video models
+ // Image-to-video models. The start frame is uploaded once to fal storage
+ // via the Files API (`falFiles()`) and referenced by its storage-URL
+ // handle, instead of being inlined as a base64 data: URI on the request.
case 'fal-ai/kling-video/v3/pro/image-to-video': {
return generateVideo({
adapter: falVideo('fal-ai/kling-video/v3/pro/image-to-video'),
- prompt: asImageToVideoPrompt(data.prompt),
+ prompt: await uploadInlineImageInputs(
+ asImageToVideoPrompt(data.prompt),
+ falFiles(),
+ ),
modelOptions: {
generate_audio: true,
duration: '5',
@@ -329,7 +373,10 @@ export const createVideoJobFn = createServerFn({ method: 'POST' })
case 'fal-ai/veo3.1/image-to-video': {
return generateVideo({
adapter: falVideo('fal-ai/veo3.1/image-to-video'),
- prompt: asImageToVideoPrompt(data.prompt),
+ prompt: await uploadInlineImageInputs(
+ asImageToVideoPrompt(data.prompt),
+ falFiles(),
+ ),
size: '16:9_1080p',
modelOptions: {
duration: '4s',
@@ -339,7 +386,10 @@ export const createVideoJobFn = createServerFn({ method: 'POST' })
case 'xai/grok-imagine-video/image-to-video': {
return generateVideo({
adapter: falVideo('xai/grok-imagine-video/image-to-video'),
- prompt: asImageToVideoPrompt(data.prompt),
+ prompt: await uploadInlineImageInputs(
+ asImageToVideoPrompt(data.prompt),
+ falFiles(),
+ ),
size: '16:9_720p',
modelOptions: {
duration: 5,
@@ -360,7 +410,10 @@ export const createVideoJobFn = createServerFn({ method: 'POST' })
case 'fal-ai/ltx-2.3/image-to-video/fast': {
return generateVideo({
adapter: falVideo('fal-ai/ltx-2.3/image-to-video/fast'),
- prompt: asImageToVideoPrompt(data.prompt),
+ prompt: await uploadInlineImageInputs(
+ asImageToVideoPrompt(data.prompt),
+ falFiles(),
+ ),
size: '16:9_2160p',
})
}
diff --git a/packages/ai-anthropic/src/adapters/files.ts b/packages/ai-anthropic/src/adapters/files.ts
new file mode 100644
index 000000000..b897efd58
--- /dev/null
+++ b/packages/ai-anthropic/src/adapters/files.ts
@@ -0,0 +1,85 @@
+import { toFile } from '@anthropic-ai/sdk'
+import {
+ BaseFilesAdapter,
+ normalizeFileUploadInput,
+} from '@tanstack/ai/adapters'
+import {
+ createAnthropicClient,
+ getAnthropicApiKeyFromEnv,
+} from '../utils/client'
+import type Anthropic_SDK from '@anthropic-ai/sdk'
+import type { FileMetadata } from '@anthropic-ai/sdk/resources/beta/files'
+import type { FileHandle, FileUploadInput } from '@tanstack/ai/adapters'
+import type { AnthropicClientConfig } from '../utils/client'
+
+/** Beta header required for the Anthropic Files API. */
+const FILES_API_BETA = 'files-api-2025-04-14'
+
+export interface AnthropicFilesConfig extends AnthropicClientConfig {}
+
+/**
+ * Anthropic Files adapter — uploads media to the Anthropic Files API (beta) and
+ * references it by `file_id`. Pair with `anthropicText()`: reference the
+ * returned handle in an image/document message via `fileSourceFromHandle`.
+ */
+export class AnthropicFilesAdapter extends BaseFilesAdapter {
+ readonly name = 'anthropic' as const
+ private readonly client: Anthropic_SDK
+
+ constructor(config: AnthropicFilesConfig) {
+ super()
+ this.client = createAnthropicClient(config)
+ }
+
+ async upload(input: FileUploadInput): Promise {
+ const { blob, mimeType, filename } = normalizeFileUploadInput(input)
+ const file = await toFile(blob, filename, {
+ ...(mimeType ? { type: mimeType } : {}),
+ })
+ const result = await this.client.beta.files.upload({
+ file,
+ betas: [FILES_API_BETA],
+ })
+ return toFileHandle(result)
+ }
+
+ async get(id: string): Promise {
+ const result = await this.client.beta.files.retrieveMetadata(id, {
+ betas: [FILES_API_BETA],
+ })
+ return toFileHandle(result)
+ }
+
+ async delete(id: string): Promise {
+ await this.client.beta.files.delete(id, { betas: [FILES_API_BETA] })
+ }
+}
+
+function toFileHandle(file: FileMetadata): FileHandle {
+ return {
+ id: file.id,
+ provider: 'anthropic',
+ mimeType: file.mime_type,
+ sizeBytes: file.size_bytes,
+ filename: file.filename,
+ }
+}
+
+/**
+ * Create an Anthropic Files adapter with an explicit API key.
+ */
+export function createAnthropicFiles(
+ apiKey: string,
+ config?: Omit,
+): AnthropicFilesAdapter {
+ return new AnthropicFilesAdapter({ apiKey, ...config })
+}
+
+/**
+ * Create an Anthropic Files adapter, reading the API key from `ANTHROPIC_API_KEY`.
+ */
+export function anthropicFiles(
+ config?: Omit,
+): AnthropicFilesAdapter {
+ return createAnthropicFiles(getAnthropicApiKeyFromEnv(), config)
+}
diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts
index d431f5486..d87099183 100644
--- a/packages/ai-anthropic/src/adapters/text.ts
+++ b/packages/ai-anthropic/src/adapters/text.ts
@@ -1,4 +1,9 @@
-import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
+import {
+ EventType,
+ assertOwnFileSource,
+ isFileSource,
+ normalizeSystemPrompts,
+} from '@tanstack/ai'
import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import { convertToolsToProviderFormat } from '../tools/tool-converter'
@@ -29,20 +34,25 @@ import type {
} from '@tanstack/ai/adapters'
import type { InternalLogger } from '@tanstack/ai/adapter-internals'
import type {
- Base64ImageSource,
- Base64PDFSource,
- ContentBlockParam,
- DocumentBlockParam,
- ImageBlockParam,
ServerToolUseBlockParam,
TextBlockParam,
ThinkingBlockParam,
ToolUseBlockParam,
- URLImageSource,
- URLPDFSource,
WebFetchToolResultBlockParam,
WebSearchToolResultBlockParam,
} from '@anthropic-ai/sdk/resources/messages'
+import type {
+ BetaBase64ImageSource,
+ BetaBase64PDFSource,
+ BetaContentBlockParam,
+ BetaFileDocumentSource,
+ BetaFileImageSource,
+ BetaImageBlockParam,
+ BetaRequestDocumentBlock,
+ BetaTextBlockParam,
+ BetaURLImageSource,
+ BetaURLPDFSource,
+} from '@anthropic-ai/sdk/resources/beta/messages'
import type Anthropic_SDK from '@anthropic-ai/sdk'
import type { AnthropicBeta } from '@anthropic-ai/sdk/resources/beta/beta'
import type {
@@ -143,11 +153,26 @@ function buildServerToolResultBlock(
}
}
+/**
+ * True when any message carries a provider file-handle source, so the request
+ * must send the Files API beta header.
+ */
+export function messagesHaveFileSource(messages: Array): boolean {
+ return messages.some(
+ (message) =>
+ Array.isArray(message.content) &&
+ message.content.some(
+ (part) => 'source' in part && isFileSource(part.source),
+ ),
+ )
+}
+
/**
* Computes the `betas` array for a Messages request. Unions:
* - `interleaved-thinking-2025-05-14` when interleaved thinking is enabled,
* - `code-execution-2025-08-25` when a `code_execution` tool is present,
- * - `skills-2025-10-02` when that tool carries skills.
+ * - `skills-2025-10-02` when that tool carries skills,
+ * - `files-api-2025-04-14` when a message references an uploaded file handle.
* Returns `undefined` when none apply (so the call site omits `betas`).
*/
export function computeAnthropicBetas(
@@ -160,9 +185,12 @@ export function computeAnthropicBetas(
}
}
| undefined,
+ hasFileSource = false,
): Array | undefined {
const betas = new Set()
+ if (hasFileSource) betas.add('files-api-2025-04-14')
+
const useInterleavedThinking =
modelOptions?.thinking?.type === 'enabled' &&
typeof modelOptions.thinking.budget_tokens === 'number' &&
@@ -287,7 +315,11 @@ export class AnthropicTextAdapter<
// `betas` is attached at the call site rather than in the shared mapper
// because the beta set depends on both the tools and the modelOptions.
- const betas = computeAnthropicBetas(options.tools, options.modelOptions)
+ const betas = computeAnthropicBetas(
+ options.tools,
+ options.modelOptions,
+ messagesHaveFileSource(options.messages),
+ )
// `client.beta.messages` is Anthropic's permanent staging surface, not a
// sunset path: it's a superset of `client.messages` that additionally
@@ -377,6 +409,7 @@ export class AnthropicTextAdapter<
const betas = computeAnthropicBetas(
chatOptions.tools,
chatOptions.modelOptions,
+ messagesHaveFileSource(chatOptions.messages),
)
// Make non-streaming request with tool_choice forced to our structured output tool
const response = await this.client.beta.messages.create(
@@ -617,7 +650,7 @@ export class AnthropicTextAdapter<
private convertContentPartToAnthropic(
part: ContentPart,
- ): TextBlockParam | ImageBlockParam | DocumentBlockParam {
+ ): BetaTextBlockParam | BetaImageBlockParam | BetaRequestDocumentBlock {
switch (part.type) {
case 'text': {
const metadata = part.metadata as AnthropicTextMetadata | undefined
@@ -630,21 +663,29 @@ export class AnthropicTextAdapter<
case 'image': {
const metadata = part.metadata as AnthropicImageMetadata | undefined
- const imageSource: Base64ImageSource | URLImageSource =
- part.source.type === 'data'
- ? {
- type: 'base64',
- data: part.source.value,
- media_type: part.source.mimeType as
- | 'image/jpeg'
- | 'image/png'
- | 'image/gif'
- | 'image/webp',
- }
- : {
- type: 'url',
- url: part.source.value,
- }
+ let imageSource:
+ | BetaBase64ImageSource
+ | BetaURLImageSource
+ | BetaFileImageSource
+ if (isFileSource(part.source)) {
+ assertOwnFileSource(part.source, this.name)
+ imageSource = { type: 'file', file_id: part.source.value }
+ } else if (part.source.type === 'data') {
+ imageSource = {
+ type: 'base64',
+ data: part.source.value,
+ media_type: part.source.mimeType as
+ | 'image/jpeg'
+ | 'image/png'
+ | 'image/gif'
+ | 'image/webp',
+ }
+ } else {
+ imageSource = {
+ type: 'url',
+ url: part.source.value,
+ }
+ }
return {
type: 'image',
source: imageSource,
@@ -653,17 +694,25 @@ export class AnthropicTextAdapter<
}
case 'document': {
const metadata = part.metadata as AnthropicDocumentMetadata | undefined
- const docSource: Base64PDFSource | URLPDFSource =
- part.source.type === 'data'
- ? {
- type: 'base64',
- data: part.source.value,
- media_type: part.source.mimeType as 'application/pdf',
- }
- : {
- type: 'url',
- url: part.source.value,
- }
+ let docSource:
+ | BetaBase64PDFSource
+ | BetaURLPDFSource
+ | BetaFileDocumentSource
+ if (isFileSource(part.source)) {
+ assertOwnFileSource(part.source, this.name)
+ docSource = { type: 'file', file_id: part.source.value }
+ } else if (part.source.type === 'data') {
+ docSource = {
+ type: 'base64',
+ data: part.source.value,
+ media_type: part.source.mimeType as 'application/pdf',
+ }
+ } else {
+ docSource = {
+ type: 'url',
+ url: part.source.value,
+ }
+ }
return {
type: 'document',
source: docSource,
@@ -714,7 +763,7 @@ export class AnthropicTextAdapter<
}
if (role === 'assistant' && message.toolCalls?.length) {
- const contentBlocks: Array = []
+ const contentBlocks: Array = []
this.appendThinkingBlocks(contentBlocks, message.thinking)
@@ -776,7 +825,7 @@ export class AnthropicTextAdapter<
}
if (role === 'assistant') {
- const contentBlocks: Array = []
+ const contentBlocks: Array = []
this.appendThinkingBlocks(contentBlocks, message.thinking)
if (Array.isArray(message.content)) {
@@ -828,7 +877,7 @@ export class AnthropicTextAdapter<
}
private appendThinkingBlocks(
- contentBlocks: Array,
+ contentBlocks: Array,
thinkingParts: ModelMessage['thinking'],
): void {
if (!thinkingParts?.length) return
diff --git a/packages/ai-anthropic/src/index.ts b/packages/ai-anthropic/src/index.ts
index 468edaf5f..539a7649e 100644
--- a/packages/ai-anthropic/src/index.ts
+++ b/packages/ai-anthropic/src/index.ts
@@ -19,6 +19,14 @@ export {
type AnthropicSummarizeConfig,
type AnthropicSummarizeModel,
} from './adapters/summarize'
+
+// Files adapter - upload media to the Anthropic Files API (beta) by file_id
+export {
+ AnthropicFilesAdapter,
+ createAnthropicFiles,
+ anthropicFiles,
+ type AnthropicFilesConfig,
+} from './adapters/files'
// ============================================================================
// Type Exports
// ============================================================================
diff --git a/packages/ai-anthropic/src/text/text-provider-options.ts b/packages/ai-anthropic/src/text/text-provider-options.ts
index 9a1811df3..f5b416ee7 100644
--- a/packages/ai-anthropic/src/text/text-provider-options.ts
+++ b/packages/ai-anthropic/src/text/text-provider-options.ts
@@ -1,15 +1,13 @@
import type {
BetaContextManagementConfig,
+ BetaMessageParam,
BetaToolChoiceAny,
BetaToolChoiceAuto,
BetaToolChoiceTool,
} from '@anthropic-ai/sdk/resources/beta/messages/messages'
import type { CacheControlEphemeral } from '@anthropic-ai/sdk/resources'
import type { AnthropicContainerSkill, AnthropicTool } from '../tools/index'
-import type {
- MessageParam,
- TextBlockParam,
-} from '@anthropic-ai/sdk/resources/messages'
+import type { TextBlockParam } from '@anthropic-ai/sdk/resources/messages'
/**
* Per-prompt metadata Anthropic understands on `systemPrompts` entries.
@@ -303,7 +301,7 @@ export type ExternalTextProviderOptions = AnthropicContainerOptions &
export interface InternalTextProviderOptions extends ExternalTextProviderOptions {
model: string
- messages: Array
+ messages: Array
/**
* The maximum number of tokens to generate before stopping. This parameter only specifies the absolute maximum number of tokens to generate.
diff --git a/packages/ai-anthropic/tests/files-source.test.ts b/packages/ai-anthropic/tests/files-source.test.ts
new file mode 100644
index 000000000..f176d04b4
--- /dev/null
+++ b/packages/ai-anthropic/tests/files-source.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it, vi } from 'vitest'
+import { chat } from '@tanstack/ai'
+import { AnthropicTextAdapter } from '../src/adapters/text'
+import type { StreamChunk } from '@tanstack/ai'
+
+const mocks = vi.hoisted(() => {
+ const betaMessagesCreate = vi.fn()
+ const client = { beta: { messages: { create: betaMessagesCreate } } }
+ return { betaMessagesCreate, client }
+})
+
+vi.mock('@anthropic-ai/sdk', () => {
+ const { client } = mocks
+ class MockAnthropic {
+ beta = client.beta
+ constructor(_: { apiKey: string }) {}
+ }
+ return { default: MockAnthropic }
+})
+
+function mockEmptyStream() {
+ mocks.betaMessagesCreate.mockResolvedValueOnce(
+ (async function* () {
+ yield {
+ type: 'message_delta',
+ delta: { stop_reason: 'end_turn' },
+ usage: { output_tokens: 1 },
+ }
+ yield { type: 'message_stop' }
+ })(),
+ )
+}
+
+async function drain(iterable: AsyncIterable) {
+ for await (const _ of iterable) {
+ // consume
+ }
+}
+
+describe('anthropic file content source', () => {
+ it('maps an anthropic file handle to a file_id source and sends the Files beta', async () => {
+ mockEmptyStream()
+ const adapter = new AnthropicTextAdapter({ apiKey: 'k' }, 'claude-opus-4-1')
+
+ await drain(
+ chat({
+ adapter,
+ messages: [
+ {
+ role: 'user',
+ content: [
+ { type: 'text', content: 'describe' },
+ {
+ type: 'image',
+ source: {
+ type: 'file',
+ value: 'file_anthropic_123',
+ provider: 'anthropic',
+ },
+ },
+ ],
+ },
+ ],
+ }),
+ )
+
+ const [payload] = mocks.betaMessagesCreate.mock.calls[0]!
+ const userMsg = payload.messages.at(-1)
+ const imageBlock = userMsg.content.find((b: any) => b.type === 'image')
+ expect(imageBlock.source).toEqual({
+ type: 'file',
+ file_id: 'file_anthropic_123',
+ })
+ expect(payload.betas).toContain('files-api-2025-04-14')
+ })
+
+ it('errors when a foreign provider file handle reaches the anthropic adapter', async () => {
+ mockEmptyStream()
+ const adapter = new AnthropicTextAdapter({ apiKey: 'k' }, 'claude-opus-4-1')
+
+ const chunks: Array = []
+ for await (const chunk of chat({
+ adapter,
+ messages: [
+ {
+ role: 'user',
+ content: [
+ {
+ type: 'image',
+ source: {
+ type: 'file',
+ value: 'file-openai-1',
+ provider: 'openai',
+ },
+ },
+ ],
+ },
+ ],
+ })) {
+ chunks.push(chunk)
+ }
+
+ const runError = chunks.find((c) => c.type === 'RUN_ERROR')
+ expect(runError).toBeDefined()
+ if (runError?.type === 'RUN_ERROR') {
+ expect(runError.message).toMatch(/anthropic/)
+ }
+ })
+})
diff --git a/packages/ai-bedrock/src/converse/message-converter.ts b/packages/ai-bedrock/src/converse/message-converter.ts
index dfe1c02e3..c75beb70d 100644
--- a/packages/ai-bedrock/src/converse/message-converter.ts
+++ b/packages/ai-bedrock/src/converse/message-converter.ts
@@ -1,4 +1,8 @@
-import { normalizeSystemPrompts } from '@tanstack/ai'
+import {
+ isFileSource,
+ normalizeSystemPrompts,
+ unsupportedFileSourceError,
+} from '@tanstack/ai'
import type {
ContentPart,
ContentPartDataSource,
@@ -106,6 +110,7 @@ function contentPartToBlock(part: ContentPart, docIndex: number): ContentBlock {
if (isImagePart(part)) {
const { source } = part
+ if (isFileSource(source)) throw unsupportedFileSourceError('bedrock')
if (!isDataSource(source)) {
throw new Error(
'Bedrock Converse requires inline image bytes; URL image sources are not supported.',
@@ -121,6 +126,7 @@ function contentPartToBlock(part: ContentPart, docIndex: number): ContentBlock {
if (isDocumentPart(part)) {
const { source } = part
+ if (isFileSource(source)) throw unsupportedFileSourceError('bedrock')
if (!isDataSource(source)) {
throw new Error(
'Bedrock Converse requires inline document bytes; URL document sources are not supported.',
diff --git a/packages/ai-event-client/src/index.ts b/packages/ai-event-client/src/index.ts
index 0bd1fdc12..c5c623e9e 100644
--- a/packages/ai-event-client/src/index.ts
+++ b/packages/ai-event-client/src/index.ts
@@ -33,7 +33,17 @@ export interface ContentPartUrlSource {
mimeType?: string
}
-export type ContentPartSource = ContentPartDataSource | ContentPartUrlSource
+export interface ContentPartFileSource {
+ type: 'file'
+ value: string
+ provider: string
+ mimeType?: string
+}
+
+export type ContentPartSource =
+ | ContentPartDataSource
+ | ContentPartUrlSource
+ | ContentPartFileSource
export interface TextPart {
type: 'text'
diff --git a/packages/ai-fal/src/adapters/files.ts b/packages/ai-fal/src/adapters/files.ts
new file mode 100644
index 000000000..aa782ab7c
--- /dev/null
+++ b/packages/ai-fal/src/adapters/files.ts
@@ -0,0 +1,57 @@
+import { fal } from '@fal-ai/client'
+import {
+ BaseFilesAdapter,
+ normalizeFileUploadInput,
+} from '@tanstack/ai/adapters'
+import { configureFalClient } from '../utils/client'
+import type { StorageSettings } from '@fal-ai/client'
+import type { FileHandle, FileUploadInput } from '@tanstack/ai/adapters'
+import type { FalClientConfig } from '../utils/client'
+
+export interface FalFilesConfig extends FalClientConfig {
+ /**
+ * Optional lifecycle for uploaded objects — one of fal's presets
+ * (`'1h' | '1d' | '7d' | '30d' | '1y' | 'never' | 'immediate'`) or a number
+ * of seconds. Applied as the object's `X-Fal-Object-Lifecycle-Preference`.
+ */
+ expiresIn?: StorageSettings['expiresIn']
+}
+
+/**
+ * fal Files adapter — uploads media to fal storage via `fal.storage.upload`.
+ * The handle is a storage URL, so `fileSourceFromHandle(handle)` references it
+ * as a normal URL (fal endpoints accept it directly). Upload-only: fal storage
+ * has no retrieval/deletion API, so `get`/`delete` are unavailable.
+ */
+export class FalFilesAdapter extends BaseFilesAdapter {
+ readonly name = 'fal' as const
+ private readonly expiresIn?: StorageSettings['expiresIn']
+
+ constructor(config?: FalFilesConfig) {
+ super()
+ configureFalClient(config)
+ this.expiresIn = config?.expiresIn
+ }
+
+ async upload(input: FileUploadInput): Promise {
+ const { blob, mimeType } = normalizeFileUploadInput(input)
+ const url = await fal.storage.upload(
+ blob,
+ this.expiresIn ? { lifecycle: { expiresIn: this.expiresIn } } : undefined,
+ )
+ return {
+ id: url,
+ provider: 'fal',
+ uri: url,
+ ...(mimeType ? { mimeType } : {}),
+ }
+ }
+}
+
+/**
+ * Create a fal Files adapter. Reads the API key from `FAL_KEY` unless one is
+ * provided in `config`.
+ */
+export function falFiles(config?: FalFilesConfig): FalFilesAdapter {
+ return new FalFilesAdapter(config)
+}
diff --git a/packages/ai-fal/src/adapters/video.ts b/packages/ai-fal/src/adapters/video.ts
index e2d19e9b0..4fffcdb5b 100644
--- a/packages/ai-fal/src/adapters/video.ts
+++ b/packages/ai-fal/src/adapters/video.ts
@@ -7,7 +7,10 @@ import {
} from '../utils/client'
import { buildFalUsage, takeBillableUnits } from '../utils/billing'
import { mapVideoSizeToFalFormat } from '../video/video-provider-options'
-import { mapImageInputsToFalVideoFields } from '../image/image-inputs'
+import {
+ contentSourceToFalUrl,
+ mapImageInputsToFalVideoFields,
+} from '../image/image-inputs'
import type {
AudioPart,
MediaInputMetadata,
@@ -70,17 +73,12 @@ function mapAudioInputsToFalFields(
)
}
return {
- audio_url:
- part.source.type === 'url'
- ? part.source.value
- : `data:${part.source.mimeType};base64,${part.source.value}`,
+ audio_url: contentSourceToFalUrl(part.source),
}
}
function videoPartToUrl(part: VideoPart): string {
- return part.source.type === 'url'
- ? part.source.value
- : `data:${part.source.mimeType};base64,${part.source.value}`
+ return contentSourceToFalUrl(part.source)
}
type FalQueueStatus = 'IN_QUEUE' | 'IN_PROGRESS' | 'COMPLETED'
diff --git a/packages/ai-fal/src/image/image-inputs.ts b/packages/ai-fal/src/image/image-inputs.ts
index 6196627c6..692cf0237 100644
--- a/packages/ai-fal/src/image/image-inputs.ts
+++ b/packages/ai-fal/src/image/image-inputs.ts
@@ -1,11 +1,31 @@
+import { assertOwnFileSource, isFileSource } from '@tanstack/ai'
import { FAL_IMAGE_FIELD_OVERRIDES } from './generated/image-field-overrides'
import type {
FalImageFieldName,
FalImageFieldOverride,
} from './generated/image-field-overrides'
-import type { ImagePart, MediaInputMetadata } from '@tanstack/ai'
+import type {
+ ContentPartSource,
+ ImagePart,
+ MediaInputMetadata,
+} from '@tanstack/ai'
import type { FalModel, FalModelInput } from '../model-meta'
+/**
+ * Convert a content source into a URL string for fal's URL-based input fields.
+ * URL sources pass through; fal storage handles (a storage URL) pass through
+ * after a provider check; base64 data becomes a `data:;base64,`
+ * URI which fal endpoints accept on the wire.
+ */
+export function contentSourceToFalUrl(source: ContentPartSource): string {
+ if (isFileSource(source)) {
+ assertOwnFileSource(source, 'fal')
+ return source.value
+ }
+ if (source.type === 'url') return source.value
+ return `data:${source.mimeType};base64,${source.value}`
+}
+
/**
* The image-conditioning fields the mappers may set, narrowed to the ones
* that actually exist on the given endpoint's input type. For endpoints
@@ -237,10 +257,8 @@ export function mapImageInputsToFalVideoFields(
/**
* Convert a TanStack ImagePart into a string suitable for fal's URL-based
- * input fields. URL sources pass through; data sources are emitted as a
- * `data:;base64,` URI which fal endpoints accept on the wire.
+ * input fields.
*/
function imagePartToUrl(part: ImagePart): string {
- if (part.source.type === 'url') return part.source.value
- return `data:${part.source.mimeType};base64,${part.source.value}`
+ return contentSourceToFalUrl(part.source)
}
diff --git a/packages/ai-fal/src/index.ts b/packages/ai-fal/src/index.ts
index d4a73058f..16ab46fe8 100644
--- a/packages/ai-fal/src/index.ts
+++ b/packages/ai-fal/src/index.ts
@@ -31,6 +31,16 @@ export {
export { FalAudioAdapter, falAudio } from './adapters/audio'
+// ============================================================================
+// Files Adapter (storage upload)
+// ============================================================================
+
+export {
+ FalFilesAdapter,
+ falFiles,
+ type FalFilesConfig,
+} from './adapters/files'
+
// ============================================================================
// Model Types (from fal.ai's type system)
// ============================================================================
diff --git a/packages/ai-fal/tests/content-source-to-fal-url.test.ts b/packages/ai-fal/tests/content-source-to-fal-url.test.ts
new file mode 100644
index 000000000..e8dbd01f4
--- /dev/null
+++ b/packages/ai-fal/tests/content-source-to-fal-url.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest'
+import { contentSourceToFalUrl } from '../src/image/image-inputs'
+
+describe('contentSourceToFalUrl', () => {
+ it('passes a fal storage handle through as its URL', () => {
+ expect(
+ contentSourceToFalUrl({
+ type: 'file',
+ value: 'https://fal.media/files/abc.png',
+ provider: 'fal',
+ }),
+ ).toBe('https://fal.media/files/abc.png')
+ })
+
+ it('rejects a file handle issued by another provider', () => {
+ expect(() =>
+ contentSourceToFalUrl({
+ type: 'file',
+ value: 'file-openai-123',
+ provider: 'openai',
+ }),
+ ).toThrow(/fal/)
+ })
+
+ it('passes URL sources through and encodes data sources', () => {
+ expect(
+ contentSourceToFalUrl({ type: 'url', value: 'https://x/y.png' }),
+ ).toBe('https://x/y.png')
+ expect(
+ contentSourceToFalUrl({
+ type: 'data',
+ value: 'AAAA',
+ mimeType: 'image/png',
+ }),
+ ).toBe('data:image/png;base64,AAAA')
+ })
+})
diff --git a/packages/ai-gemini/src/adapters/files.ts b/packages/ai-gemini/src/adapters/files.ts
new file mode 100644
index 000000000..0cb9ce3ea
--- /dev/null
+++ b/packages/ai-gemini/src/adapters/files.ts
@@ -0,0 +1,84 @@
+import {
+ BaseFilesAdapter,
+ normalizeFileUploadInput,
+} from '@tanstack/ai/adapters'
+import { createGeminiClient, getGeminiApiKeyFromEnv } from '../utils/client'
+import type { File as GeminiFile, GoogleGenAI } from '@google/genai'
+import type { FileHandle, FileUploadInput } from '@tanstack/ai/adapters'
+import type { GeminiClientConfig } from '../utils/client'
+
+export interface GeminiFilesConfig extends GeminiClientConfig {}
+
+/**
+ * Gemini Files adapter — uploads media to the Gemini Files API and references
+ * it by its file URI. Pair with `geminiText()` / `geminiImage()`: reference the
+ * returned handle via `fileSourceFromHandle(handle)`, which uses the handle URI
+ * (Gemini fetches it server-side as `fileData.fileUri`).
+ */
+export class GeminiFilesAdapter extends BaseFilesAdapter {
+ readonly name = 'gemini' as const
+ private readonly client: GoogleGenAI
+
+ constructor(config: GeminiFilesConfig) {
+ super()
+ this.client = createGeminiClient(config)
+ }
+
+ async upload(input: FileUploadInput): Promise {
+ const { blob, mimeType } = normalizeFileUploadInput(input)
+ const file = await this.client.files.upload({
+ file: blob,
+ ...(mimeType ? { config: { mimeType } } : {}),
+ })
+ return toFileHandle(file)
+ }
+
+ async get(id: string): Promise {
+ return toFileHandle(await this.client.files.get({ name: id }))
+ }
+
+ async delete(id: string): Promise {
+ await this.client.files.delete({ name: id })
+ }
+}
+
+function toFileHandle(file: GeminiFile): FileHandle {
+ // `name` (e.g. "files/abc-123") is the lifecycle id; `uri` is the URL Gemini
+ // fetches when the handle is referenced in a message.
+ if (!file.name) {
+ throw new Error('gemini: files.upload returned a file without a name')
+ }
+ const expiresAt = file.expirationTime
+ ? Date.parse(file.expirationTime)
+ : undefined
+ return {
+ id: file.name,
+ provider: 'gemini',
+ ...(file.uri ? { uri: file.uri } : {}),
+ ...(file.mimeType ? { mimeType: file.mimeType } : {}),
+ ...(file.sizeBytes ? { sizeBytes: Number(file.sizeBytes) } : {}),
+ ...(expiresAt !== undefined && !Number.isNaN(expiresAt)
+ ? { expiresAt }
+ : {}),
+ }
+}
+
+/**
+ * Create a Gemini Files adapter with an explicit API key.
+ */
+export function createGeminiFiles(
+ apiKey: string,
+ config?: Omit,
+): GeminiFilesAdapter {
+ return new GeminiFilesAdapter({ apiKey, ...config })
+}
+
+/**
+ * Create a Gemini Files adapter, reading the API key from `GOOGLE_API_KEY` /
+ * `GEMINI_API_KEY`.
+ */
+export function geminiFiles(
+ config?: Omit,
+): GeminiFilesAdapter {
+ return createGeminiFiles(getGeminiApiKeyFromEnv(), config)
+}
diff --git a/packages/ai-gemini/src/adapters/image.ts b/packages/ai-gemini/src/adapters/image.ts
index 85284d282..e7d9b2205 100644
--- a/packages/ai-gemini/src/adapters/image.ts
+++ b/packages/ai-gemini/src/adapters/image.ts
@@ -1,4 +1,8 @@
-import { resolveMediaPrompt } from '@tanstack/ai'
+import {
+ assertOwnFileSource,
+ isFileSource,
+ resolveMediaPrompt,
+} from '@tanstack/ai'
import { BaseImageAdapter } from '@tanstack/ai/adapters'
import {
createGeminiClient,
@@ -261,6 +265,11 @@ export class GeminiImageAdapter<
},
}
}
+ // A Gemini Files API handle from another provider is a bug — reject it
+ // before it's passed through as a fileData URI.
+ if (isFileSource(part.source)) {
+ assertOwnFileSource(part.source, this.name)
+ }
// URL sources (public HTTPS, Files API URIs, gs://) pass through as
// `fileData` and Gemini fetches them server-side — same as the chat
// adapter. Fetching locally and inlining as base64 double-buffers the
diff --git a/packages/ai-gemini/src/adapters/text.ts b/packages/ai-gemini/src/adapters/text.ts
index 1b7587bb6..aadfab453 100644
--- a/packages/ai-gemini/src/adapters/text.ts
+++ b/packages/ai-gemini/src/adapters/text.ts
@@ -1,5 +1,10 @@
import { FinishReason } from '@google/genai'
-import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
+import {
+ EventType,
+ assertOwnFileSource,
+ isFileSource,
+ normalizeSystemPrompts,
+} from '@tanstack/ai'
import { toRunErrorRawEvent } from '@tanstack/ai/adapter-internals'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import { convertToolsToProviderFormat } from '../tools/tool-converter'
@@ -662,6 +667,12 @@ export class GeminiTextAdapter<
},
}
} else {
+ // File handles (Gemini Files API) and public URLs both pass through as
+ // `fileData`; Gemini fetches the URI server-side. Reject a handle from
+ // another provider before it's sent.
+ if (isFileSource(part.source)) {
+ assertOwnFileSource(part.source, this.name)
+ }
// For URL sources, use provided mimeType or fall back to reasonable defaults
const defaultMimeType = {
image: 'image/jpeg',
@@ -766,6 +777,9 @@ export class GeminiTextAdapter<
},
})
} else {
+ if (isFileSource(part.source)) {
+ assertOwnFileSource(part.source, this.name)
+ }
const defaultMimeType = {
image: 'image/jpeg',
audio: 'audio/mp3',
diff --git a/packages/ai-gemini/src/adapters/video.ts b/packages/ai-gemini/src/adapters/video.ts
index 4fc2897fd..18e5157ed 100644
--- a/packages/ai-gemini/src/adapters/video.ts
+++ b/packages/ai-gemini/src/adapters/video.ts
@@ -2,7 +2,11 @@ import {
GenerateVideosOperation,
VideoGenerationReferenceType,
} from '@google/genai'
-import { resolveMediaPrompt } from '@tanstack/ai'
+import {
+ isFileSource,
+ resolveMediaPrompt,
+ unsupportedFileSourceError,
+} from '@tanstack/ai'
import { BaseVideoAdapter, snapToDurationOption } from '@tanstack/ai/adapters'
import { arrayBufferToBase64 } from '@tanstack/ai-utils'
import { createGeminiClient, getGeminiApiKeyFromEnv } from '../utils'
@@ -93,6 +97,14 @@ async function imagePartToVeoImage(
mimeType: part.source.mimeType || 'image/png',
}
}
+ if (isFileSource(part.source)) {
+ // Veo's predict API accepts only inline bytes or a gs:// reference — there's
+ // no way to reference a Files API handle here.
+ throw unsupportedFileSourceError(
+ 'gemini',
+ 'for Veo video generation, which needs inline image bytes or a gs:// reference — pass a data: URI or gs:// URL',
+ )
+ }
const url = part.source.value
if (url.startsWith('gs://')) {
return {
diff --git a/packages/ai-gemini/src/experimental/text-interactions/adapter.ts b/packages/ai-gemini/src/experimental/text-interactions/adapter.ts
index 9d1de8ffb..2173f2874 100644
--- a/packages/ai-gemini/src/experimental/text-interactions/adapter.ts
+++ b/packages/ai-gemini/src/experimental/text-interactions/adapter.ts
@@ -1,4 +1,4 @@
-import { EventType } from '@tanstack/ai'
+import { EventType, assertOwnFileSource, isFileSource } from '@tanstack/ai'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import { parse as parsePartialJSON } from 'partial-json'
import {
@@ -728,6 +728,11 @@ function contentPartToBlock(part: ContentPart): ContentBlock {
if (part.type === 'text') {
return { type: 'text', text: part.content }
}
+ // A file handle from another provider is a bug; a Gemini handle maps to the
+ // `uri` field (isData stays false), same as a public URL.
+ if (isFileSource(part.source)) {
+ assertOwnFileSource(part.source, 'gemini')
+ }
const isData = part.source.type === 'data'
switch (part.type) {
case 'image': {
diff --git a/packages/ai-gemini/src/index.ts b/packages/ai-gemini/src/index.ts
index 245f892cb..49611ed55 100644
--- a/packages/ai-gemini/src/index.ts
+++ b/packages/ai-gemini/src/index.ts
@@ -19,6 +19,14 @@ export {
type GeminiSummarizeModel,
} from './adapters/summarize'
+// Files adapter - upload media to the Gemini Files API and reference by file URI
+export {
+ GeminiFilesAdapter,
+ createGeminiFiles,
+ geminiFiles,
+ type GeminiFilesConfig,
+} from './adapters/files'
+
// Image adapter
export {
GeminiImageAdapter,
diff --git a/packages/ai-grok/src/adapters/image.ts b/packages/ai-grok/src/adapters/image.ts
index 50bd38e51..f26ea0a2a 100644
--- a/packages/ai-grok/src/adapters/image.ts
+++ b/packages/ai-grok/src/adapters/image.ts
@@ -1,5 +1,9 @@
import OpenAI from 'openai'
-import { resolveMediaPrompt } from '@tanstack/ai'
+import {
+ isFileSource,
+ resolveMediaPrompt,
+ unsupportedFileSourceError,
+} from '@tanstack/ai'
import { BaseImageAdapter } from '@tanstack/ai/adapters'
import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
import { buildImagesUsage } from '@tanstack/openai-base'
@@ -62,6 +66,7 @@ function imagineSizeParams(size: string | undefined): {
* sources become base64 data URIs.
*/
function imagePartToUrl(part: ImagePart): string {
+ if (isFileSource(part.source)) throw unsupportedFileSourceError('grok')
if (part.source.type === 'url') return part.source.value
return `data:${part.source.mimeType};base64,${part.source.value}`
}
diff --git a/packages/ai-grok/src/adapters/video.ts b/packages/ai-grok/src/adapters/video.ts
index 21807360e..02954bb49 100644
--- a/packages/ai-grok/src/adapters/video.ts
+++ b/packages/ai-grok/src/adapters/video.ts
@@ -1,4 +1,8 @@
-import { resolveMediaPrompt } from '@tanstack/ai'
+import {
+ isFileSource,
+ resolveMediaPrompt,
+ unsupportedFileSourceError,
+} from '@tanstack/ai'
import { BaseVideoAdapter, snapToDurationOption } from '@tanstack/ai/adapters'
import { toRunErrorPayload } from '@tanstack/ai/adapter-internals'
import { getGrokApiKeyFromEnv, withGrokDefaults } from '../utils/client'
@@ -67,6 +71,7 @@ interface GrokVideoStatusResponse {
* sources become base64 data URIs.
*/
function imagePartToUrl(part: ImagePart): string {
+ if (isFileSource(part.source)) throw unsupportedFileSourceError('grok')
if (part.source.type === 'url') return part.source.value
return `data:${part.source.mimeType};base64,${part.source.value}`
}
diff --git a/packages/ai-mistral/src/adapters/text.ts b/packages/ai-mistral/src/adapters/text.ts
index ed208e698..8634bd510 100644
--- a/packages/ai-mistral/src/adapters/text.ts
+++ b/packages/ai-mistral/src/adapters/text.ts
@@ -1,3 +1,4 @@
+import { isFileSource, unsupportedFileSourceError } from '@tanstack/ai'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import { convertToolsToProviderFormat } from '../tools/tool-converter'
import {
@@ -1007,6 +1008,9 @@ export class MistralTextAdapter<
}
if (part.type === 'image') {
+ if (isFileSource(part.source)) {
+ throw unsupportedFileSourceError('mistral')
+ }
const imageMetadata = part.metadata as MistralImageMetadata | undefined
const imageValue = part.source.value
const imageUrl =
diff --git a/packages/ai-ollama/src/adapters/text.ts b/packages/ai-ollama/src/adapters/text.ts
index b7ae351f2..75c920ef7 100644
--- a/packages/ai-ollama/src/adapters/text.ts
+++ b/packages/ai-ollama/src/adapters/text.ts
@@ -1,4 +1,9 @@
-import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
+import {
+ EventType,
+ isFileSource,
+ normalizeSystemPrompts,
+ unsupportedFileSourceError,
+} from '@tanstack/ai'
import {
toRunErrorPayload,
toRunErrorRawEvent,
@@ -469,11 +474,10 @@ export class OllamaTextAdapter extends BaseTextAdapter<
if (part.type === 'text') {
textContent += part.content
} else if (part.type === 'image') {
- if (part.source.type === 'data') {
- images.push(part.source.value)
- } else {
- images.push(part.source.value)
+ if (isFileSource(part.source)) {
+ throw unsupportedFileSourceError('ollama')
}
+ images.push(part.source.value)
}
}
} else {
diff --git a/packages/ai-openai/src/adapters/files.ts b/packages/ai-openai/src/adapters/files.ts
new file mode 100644
index 000000000..81e76029e
--- /dev/null
+++ b/packages/ai-openai/src/adapters/files.ts
@@ -0,0 +1,86 @@
+import { OpenAI, toFile } from 'openai'
+import {
+ BaseFilesAdapter,
+ normalizeFileUploadInput,
+} from '@tanstack/ai/adapters'
+import { getOpenAIApiKeyFromEnv } from '../utils/client'
+import type { FileHandle, FileUploadInput } from '@tanstack/ai/adapters'
+import type { FileObject, FilePurpose } from 'openai/resources/files'
+import type { OpenAIClientConfig } from '../utils/client'
+
+export interface OpenAIFilesConfig extends OpenAIClientConfig {
+ /**
+ * Default `purpose` for uploads. Files uploaded for vision/document input to
+ * the Responses API use `'user_data'` (the flexible default). Override per
+ * upload need — e.g. `'vision'` — via this config.
+ * @default 'user_data'
+ */
+ purpose?: FilePurpose
+}
+
+/**
+ * OpenAI Files adapter — uploads media to the OpenAI Files API and references
+ * it by `file_id`. Pair with `openaiText()` (Responses API): reference the
+ * returned handle in a message via `fileSourceFromHandle(handle)`.
+ */
+export class OpenAIFilesAdapter extends BaseFilesAdapter {
+ readonly name = 'openai' as const
+ protected client: OpenAI
+ private readonly purpose: FilePurpose
+
+ constructor(config: OpenAIFilesConfig) {
+ super()
+ const { purpose, ...clientOptions } = config
+ this.client = new OpenAI(clientOptions)
+ this.purpose = purpose ?? 'user_data'
+ }
+
+ async upload(input: FileUploadInput): Promise {
+ const { blob, mimeType, filename } = normalizeFileUploadInput(input)
+ const file = await toFile(blob, filename, {
+ ...(mimeType ? { type: mimeType } : {}),
+ })
+ const result = await this.client.files.create({
+ file,
+ purpose: this.purpose,
+ })
+ return toFileHandle(result)
+ }
+
+ async get(id: string): Promise {
+ return toFileHandle(await this.client.files.retrieve(id))
+ }
+
+ async delete(id: string): Promise {
+ await this.client.files.delete(id)
+ }
+}
+
+function toFileHandle(file: FileObject): FileHandle {
+ return {
+ id: file.id,
+ provider: 'openai',
+ sizeBytes: file.bytes,
+ filename: file.filename,
+ ...(file.expires_at ? { expiresAt: file.expires_at * 1000 } : {}),
+ }
+}
+
+/**
+ * Create an OpenAI Files adapter with an explicit API key.
+ */
+export function createOpenaiFiles(
+ apiKey: string,
+ config?: Omit,
+): OpenAIFilesAdapter {
+ return new OpenAIFilesAdapter({ apiKey, ...config })
+}
+
+/**
+ * Create an OpenAI Files adapter, reading the API key from `OPENAI_API_KEY`.
+ */
+export function openaiFiles(
+ config?: Omit,
+): OpenAIFilesAdapter {
+ return createOpenaiFiles(getOpenAIApiKeyFromEnv(), config)
+}
diff --git a/packages/ai-openai/src/image/image-input-to-file.ts b/packages/ai-openai/src/image/image-input-to-file.ts
index 77c3f2a30..28b864cad 100644
--- a/packages/ai-openai/src/image/image-input-to-file.ts
+++ b/packages/ai-openai/src/image/image-input-to-file.ts
@@ -1,4 +1,5 @@
import { base64ToArrayBuffer } from '@tanstack/ai-utils'
+import { isFileSource, unsupportedFileSourceError } from '@tanstack/ai'
import type { ImagePart, MediaInputMetadata } from '@tanstack/ai'
const DEFAULT_MIME = 'image/png'
@@ -44,6 +45,15 @@ export async function imagePartToFile(
): Promise {
ensureFileSupport()
+ if (isFileSource(part.source)) {
+ // The edits / Sora input_reference endpoints are multipart and require the
+ // actual bytes; there's no "reference an uploaded file_id" option here.
+ throw unsupportedFileSourceError(
+ 'openai',
+ 'on the images/edits + Sora input_reference endpoints, which require uploaded bytes — pass a data: URI or inline image data',
+ )
+ }
+
if (part.source.type === 'data') {
const mimeType = part.source.mimeType || DEFAULT_MIME
const bytes = base64ToArrayBuffer(part.source.value)
diff --git a/packages/ai-openai/src/index.ts b/packages/ai-openai/src/index.ts
index fdeb79d57..bfc435930 100644
--- a/packages/ai-openai/src/index.ts
+++ b/packages/ai-openai/src/index.ts
@@ -78,6 +78,14 @@ export {
} from './adapters/transcription'
export type { OpenAITranscriptionProviderOptions } from './audio/transcription-provider-options'
+// Files adapter - upload media to the OpenAI Files API and reference by file_id
+export {
+ OpenAIFilesAdapter,
+ createOpenaiFiles,
+ openaiFiles,
+ type OpenAIFilesConfig,
+} from './adapters/files'
+
// ============================================================================
// Type Exports
// ============================================================================
diff --git a/packages/ai-openai/tests/files-source.test.ts b/packages/ai-openai/tests/files-source.test.ts
new file mode 100644
index 000000000..88c923846
--- /dev/null
+++ b/packages/ai-openai/tests/files-source.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it, vi } from 'vitest'
+import { chat } from '@tanstack/ai'
+import { OpenAITextAdapter } from '../src/adapters/text'
+import type { StreamChunk } from '@tanstack/ai'
+
+function mockResponsesStream(): AsyncIterable> {
+ return {
+ // eslint-disable-next-line @typescript-eslint/require-await
+ async *[Symbol.asyncIterator]() {
+ yield {
+ type: 'response.created',
+ response: { id: 'r1', model: 'gpt-4o-mini', status: 'in_progress' },
+ }
+ yield {
+ type: 'response.completed',
+ response: {
+ id: 'r1',
+ model: 'gpt-4o-mini',
+ status: 'completed',
+ output: [],
+ usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
+ },
+ }
+ },
+ }
+}
+
+function withMockClient(create: ReturnType) {
+ const adapter = new OpenAITextAdapter({ apiKey: 'k' }, 'gpt-4o-mini')
+ ;(adapter as unknown as { client: unknown }).client = {
+ responses: { create },
+ }
+ return adapter
+}
+
+async function drain(iterable: AsyncIterable) {
+ for await (const _ of iterable) {
+ // consume
+ }
+}
+
+describe('openai file content source', () => {
+ it('maps an openai file handle to input_image.file_id on the Responses API', async () => {
+ const create = vi.fn().mockResolvedValueOnce(mockResponsesStream())
+ const adapter = withMockClient(create)
+
+ await drain(
+ chat({
+ adapter,
+ messages: [
+ {
+ role: 'user',
+ content: [
+ { type: 'text', content: 'look' },
+ {
+ type: 'image',
+ source: {
+ type: 'file',
+ value: 'file-openai-abc',
+ provider: 'openai',
+ },
+ },
+ ],
+ },
+ ],
+ }),
+ )
+
+ const [payload] = create.mock.calls[0]!
+ const userItem = payload.input.find(
+ (item: any) => item.type === 'message' && item.role === 'user',
+ )
+ const imageContent = userItem.content.find(
+ (c: any) => c.type === 'input_image',
+ )
+ expect(imageContent.file_id).toBe('file-openai-abc')
+ expect(imageContent.image_url).toBeUndefined()
+ })
+
+ it('errors when a foreign provider file handle reaches the openai adapter', async () => {
+ const create = vi.fn().mockResolvedValueOnce(mockResponsesStream())
+ const adapter = withMockClient(create)
+
+ const chunks: Array = []
+ for await (const chunk of chat({
+ adapter,
+ messages: [
+ {
+ role: 'user',
+ content: [
+ {
+ type: 'image',
+ source: {
+ type: 'file',
+ value: 'files/gemini-xyz',
+ provider: 'gemini',
+ },
+ },
+ ],
+ },
+ ],
+ })) {
+ chunks.push(chunk)
+ }
+
+ const runError = chunks.find((c) => c.type === 'RUN_ERROR')
+ expect(runError).toBeDefined()
+ if (runError?.type === 'RUN_ERROR') {
+ expect(runError.message).toMatch(/openai/)
+ }
+ })
+})
diff --git a/packages/ai-openrouter/src/adapters/image.ts b/packages/ai-openrouter/src/adapters/image.ts
index daf0ecb9d..e17cb5824 100644
--- a/packages/ai-openrouter/src/adapters/image.ts
+++ b/packages/ai-openrouter/src/adapters/image.ts
@@ -1,5 +1,9 @@
import { OpenRouter } from '@openrouter/sdk'
-import { resolveMediaPrompt } from '@tanstack/ai'
+import {
+ isFileSource,
+ resolveMediaPrompt,
+ unsupportedFileSourceError,
+} from '@tanstack/ai'
import { BaseImageAdapter } from '@tanstack/ai/adapters'
import {
getOpenRouterApiKeyFromEnv,
@@ -51,6 +55,7 @@ const SIZE_TO_ASPECT_RATIO: Record = {
* base64 data URIs.
*/
function imagePartToUrl(part: ImagePart): string {
+ if (isFileSource(part.source)) throw unsupportedFileSourceError('openrouter')
if (part.source.type === 'url') return part.source.value
return `data:${part.source.mimeType};base64,${part.source.value}`
}
diff --git a/packages/ai-openrouter/src/adapters/responses-text.ts b/packages/ai-openrouter/src/adapters/responses-text.ts
index 7659366eb..d4be0f367 100644
--- a/packages/ai-openrouter/src/adapters/responses-text.ts
+++ b/packages/ai-openrouter/src/adapters/responses-text.ts
@@ -1,5 +1,10 @@
import { OpenRouter } from '@openrouter/sdk'
-import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
+import {
+ EventType,
+ isFileSource,
+ normalizeSystemPrompts,
+ unsupportedFileSourceError,
+} from '@tanstack/ai'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import {
toRunErrorPayload,
@@ -1680,6 +1685,9 @@ export class OpenRouterResponsesTextAdapter<
protected convertContentPartToInput(
part: ContentPart,
): ResponsesInputContent {
+ if ('source' in part && isFileSource(part.source)) {
+ throw unsupportedFileSourceError(this.name)
+ }
switch (part.type) {
case 'text':
return {
diff --git a/packages/ai-openrouter/src/adapters/text.ts b/packages/ai-openrouter/src/adapters/text.ts
index 09df05b35..b7c9cd43c 100644
--- a/packages/ai-openrouter/src/adapters/text.ts
+++ b/packages/ai-openrouter/src/adapters/text.ts
@@ -1,5 +1,10 @@
import { OpenRouter } from '@openrouter/sdk'
-import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
+import {
+ EventType,
+ isFileSource,
+ normalizeSystemPrompts,
+ unsupportedFileSourceError,
+} from '@tanstack/ai'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import {
toRunErrorPayload,
@@ -1297,6 +1302,9 @@ export class OpenRouterTextAdapter<
/** OpenRouter content-part converter (camelCase imageUrl/inputAudio/videoUrl). */
protected convertContentPart(part: ContentPart): ChatContentItems | null {
+ if ('source' in part && isFileSource(part.source)) {
+ throw unsupportedFileSourceError(this.name)
+ }
switch (part.type) {
case 'text':
return { type: 'text', text: part.content }
diff --git a/packages/ai/src/activities/files/adapter.ts b/packages/ai/src/activities/files/adapter.ts
new file mode 100644
index 000000000..5d62ad0a4
--- /dev/null
+++ b/packages/ai/src/activities/files/adapter.ts
@@ -0,0 +1,106 @@
+/**
+ * Files Adapter
+ *
+ * Base class and interface for the `files` activity — a provider's native Files
+ * API (upload a media asset once, reference it later by the returned handle
+ * instead of re-sending base64 or a public URL each request).
+ *
+ * Providers with a native surface expose a factory (`openaiFiles()`,
+ * `anthropicFiles()`, `geminiFiles()`, `falFiles()`). `upload` is required;
+ * `get`/`delete` are optional because not every provider has a lifecycle API
+ * (fal's storage is upload-only).
+ */
+
+import { base64ToArrayBuffer } from '@tanstack/ai-utils'
+
+/**
+ * Input to {@link FilesAdapter.upload}. Either a `Blob` (memory-efficient,
+ * preferred for large assets) or base64 `data` plus its `mimeType`.
+ */
+export type FileUploadInput =
+ | Blob
+ | {
+ /** Base64-encoded file bytes. */
+ data: string
+ /** MIME type of the bytes (e.g. `'image/png'`, `'application/pdf'`). */
+ mimeType: string
+ /** Optional filename hint sent to providers that accept one. */
+ filename?: string
+ }
+
+/**
+ * A provider-issued file handle returned by {@link FilesAdapter.upload} /
+ * {@link FilesAdapter.get}. Reference it in a message via a `{ type: 'file' }`
+ * content source — use {@link fileSourceFromHandle} to build one.
+ */
+export interface FileHandle {
+ /**
+ * Provider handle used for lifecycle operations (`get`/`delete`): the
+ * OpenAI/Anthropic `file_id`, the Gemini file resource name (`files/...`), or
+ * the fal storage URL.
+ */
+ id: string
+ /** The provider that issued the handle (`'openai'`, `'gemini'`, ...). */
+ provider: string
+ /**
+ * The handle's URL form when the provider exposes one (Gemini file URI, fal
+ * storage URL). For providers whose handle is an opaque id (OpenAI,
+ * Anthropic) this is `undefined`.
+ */
+ uri?: string
+ /** MIME type reported by the provider (or echoed from the upload input). */
+ mimeType?: string
+ /** File size in bytes when the provider reports it. */
+ sizeBytes?: number
+ /** Expiry as epoch milliseconds when the handle is scheduled to expire. */
+ expiresAt?: number
+ /** Original filename when the provider reports it. */
+ filename?: string
+}
+
+/**
+ * The `files` adapter contract. `upload` is required; `get`/`delete` are
+ * optional and present only when the provider has a lifecycle API.
+ */
+export interface FilesAdapter {
+ readonly kind: 'files'
+ readonly name: string
+ upload: (input: FileUploadInput) => Promise
+ get?: (id: string) => Promise
+ delete?: (id: string) => Promise
+}
+
+export type AnyFilesAdapter = FilesAdapter
+
+/**
+ * Normalize a {@link FileUploadInput} to a `Blob` (plus best-effort MIME /
+ * filename) so provider adapters can hand it straight to their SDK. A `Blob`
+ * input passes through; base64 `{ data }` is decoded to bytes. Shared so the
+ * four provider files adapters don't each re-implement the decode.
+ */
+export function normalizeFileUploadInput(input: FileUploadInput): {
+ blob: Blob
+ mimeType?: string
+ filename?: string
+} {
+ if (input instanceof Blob) {
+ return { blob: input, mimeType: input.type || undefined }
+ }
+ const bytes = base64ToArrayBuffer(input.data)
+ return {
+ blob: new Blob([bytes], { type: input.mimeType }),
+ mimeType: input.mimeType,
+ filename: input.filename,
+ }
+}
+
+/**
+ * Abstract base for provider files adapters. Subclasses set `name`, implement
+ * `upload`, and optionally implement `get`/`delete`.
+ */
+export abstract class BaseFilesAdapter implements FilesAdapter {
+ readonly kind = 'files' as const
+ abstract readonly name: string
+
+ abstract upload(input: FileUploadInput): Promise
+}
diff --git a/packages/ai/src/activities/files/index.ts b/packages/ai/src/activities/files/index.ts
new file mode 100644
index 000000000..5a098fa78
--- /dev/null
+++ b/packages/ai/src/activities/files/index.ts
@@ -0,0 +1,94 @@
+/**
+ * Files Activity
+ *
+ * Dispatch functions for provider Files APIs. Each takes `{ adapter, ... }` and
+ * calls the adapter method directly (mirrors the other activity dispatchers).
+ * `get`/`delete` are optional on the adapter; the dispatchers throw a clear
+ * error when the selected provider has no lifecycle API.
+ */
+
+import type { ContentPartFileSource } from '../../types'
+import type { AnyFilesAdapter, FileHandle, FileUploadInput } from './adapter'
+
+/** The adapter kind this activity handles */
+export const kind = 'files' as const
+
+/**
+ * Upload a file to a provider's Files API and return its handle.
+ *
+ * @example
+ * ```ts
+ * const files = openaiFiles()
+ * const handle = await uploadFile({ adapter: files, input: { data, mimeType: 'image/png' } })
+ * ```
+ */
+export async function uploadFile(options: {
+ adapter: TAdapter & { kind: typeof kind }
+ input: FileUploadInput
+}): Promise {
+ return options.adapter.upload(options.input)
+}
+
+/**
+ * Fetch metadata for a previously uploaded file by its handle id.
+ *
+ * @throws if the provider's files adapter has no `get` (e.g. fal storage).
+ */
+export async function getFile(options: {
+ adapter: TAdapter & { kind: typeof kind }
+ id: string
+}): Promise {
+ const { adapter, id } = options
+ if (!adapter.get) {
+ throw new Error(
+ `${adapter.name}: files adapter does not support get() — this provider ` +
+ `has no file-retrieval API.`,
+ )
+ }
+ return adapter.get(id)
+}
+
+/**
+ * Delete a previously uploaded file by its handle id.
+ *
+ * @throws if the provider's files adapter has no `delete` (e.g. fal storage).
+ */
+export async function deleteFile(options: {
+ adapter: TAdapter & { kind: typeof kind }
+ id: string
+}): Promise {
+ const { adapter, id } = options
+ if (!adapter.delete) {
+ throw new Error(
+ `${adapter.name}: files adapter does not support delete() — this ` +
+ `provider has no file-deletion API.`,
+ )
+ }
+ return adapter.delete(id)
+}
+
+/**
+ * Build a `{ type: 'file' }` content source from an uploaded {@link FileHandle},
+ * for use in a chat message (image/audio/document part `source`).
+ *
+ * Picks the right `value`: the handle URL when the provider exposes one
+ * (Gemini/fal), otherwise the opaque id (OpenAI/Anthropic).
+ *
+ * @example
+ * ```ts
+ * const handle = await uploadFile({ adapter: openaiFiles(), input })
+ * messages.push({ role: 'user', content: [
+ * { type: 'image', source: fileSourceFromHandle(handle) },
+ * ] })
+ * ```
+ */
+export function fileSourceFromHandle(
+ handle: FileHandle,
+): ContentPartFileSource {
+ return {
+ type: 'file',
+ value: handle.uri ?? handle.id,
+ provider: handle.provider,
+ ...(handle.mimeType ? { mimeType: handle.mimeType } : {}),
+ }
+}
diff --git a/packages/ai/src/activities/index.ts b/packages/ai/src/activities/index.ts
index 07fdbe73a..56e683eb1 100644
--- a/packages/ai/src/activities/index.ts
+++ b/packages/ai/src/activities/index.ts
@@ -21,6 +21,7 @@ import type { AnyAudioAdapter } from './generateAudio/adapter'
import type { AnyVideoAdapter } from './generateVideo/adapter'
import type { AnyTTSAdapter } from './generateSpeech/adapter'
import type { AnyTranscriptionAdapter } from './generateTranscription/adapter'
+import type { AnyFilesAdapter } from './files/adapter'
// ===========================
// Chat Activity
@@ -170,11 +171,32 @@ export {
type AnyTranscriptionAdapter,
} from './generateTranscription/adapter'
+// ===========================
+// Files Activity
+// ===========================
+
+export {
+ kind as filesKind,
+ uploadFile,
+ getFile,
+ deleteFile,
+ fileSourceFromHandle,
+} from './files/index'
+
+export {
+ BaseFilesAdapter,
+ normalizeFileUploadInput,
+ type FilesAdapter,
+ type AnyFilesAdapter,
+ type FileHandle,
+ type FileUploadInput,
+} from './files/adapter'
+
// ===========================
// Adapter Union Types
// ===========================
-/** Union of all adapter types that can be passed to chat() */
+/** Union of all adapter types across every activity kind */
export type AIAdapter =
| AnyTextAdapter
| AnySummarizeAdapter
@@ -183,6 +205,7 @@ export type AIAdapter =
| AnyVideoAdapter
| AnyTTSAdapter
| AnyTranscriptionAdapter
+ | AnyFilesAdapter
/** Union type of all adapter kinds */
export type AdapterKind =
@@ -193,3 +216,4 @@ export type AdapterKind =
| 'video'
| 'tts'
| 'transcription'
+ | 'files'
diff --git a/packages/ai/src/client.ts b/packages/ai/src/client.ts
index 254daf736..6cebed972 100644
--- a/packages/ai/src/client.ts
+++ b/packages/ai/src/client.ts
@@ -92,6 +92,7 @@ export type {
AudioPart,
ContentPart,
ContentPartDataSource,
+ ContentPartFileSource,
ContentPartSource,
ContentPartUrlSource,
CustomEvent,
diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts
index 102987c02..2bc993e9d 100644
--- a/packages/ai/src/index.ts
+++ b/packages/ai/src/index.ts
@@ -8,6 +8,10 @@ export {
getVideoJobStatus,
generateSpeech,
generateTranscription,
+ uploadFile,
+ getFile,
+ deleteFile,
+ fileSourceFromHandle,
} from './activities/index'
// Create options functions - for pre-defining typed configurations
@@ -36,6 +40,10 @@ export type {
TranscriptionAdapter,
AnyVideoAdapter,
VideoAdapter,
+ FilesAdapter,
+ AnyFilesAdapter,
+ FileHandle,
+ FileUploadInput,
} from './activities/index'
// Tool definition
@@ -257,6 +265,11 @@ export {
isContentPartArray,
normalizeToolResult,
} from './utilities/tool-result'
+export {
+ assertOwnFileSource,
+ isFileSource,
+ unsupportedFileSourceError,
+} from './utilities/content-source'
export {
getProviderExecutedMetadata,
diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts
index 983e220e8..0d7c04f99 100644
--- a/packages/ai/src/types.ts
+++ b/packages/ai/src/types.ts
@@ -233,13 +233,52 @@ export interface ContentPartUrlSource {
mimeType?: string
}
+/**
+ * Source specification for a provider-issued file handle (Files API).
+ *
+ * The media is uploaded once via a `files` adapter (`openaiFiles()`,
+ * `anthropicFiles()`, `geminiFiles()`, `falFiles()`) and referenced here by the
+ * returned handle instead of re-sending base64 or a public URL each request.
+ *
+ * A handle only routes to the provider that issued it — the `provider` field is
+ * validated at map time, and adapters throw if it doesn't match. The `value` is
+ * the provider's opaque id (OpenAI/Anthropic `file_id`) or handle URL (Gemini
+ * file URI, fal storage URL); use {@link fileSourceFromHandle} to build one from
+ * a {@link FileHandle} without worrying about the id-vs-uri distinction.
+ */
+export interface ContentPartFileSource {
+ /**
+ * Indicates this references a provider-issued file handle.
+ */
+ type: 'file'
+ /**
+ * The provider handle: an OpenAI/Anthropic `file_id`, a Gemini file URI, or a
+ * fal storage URL.
+ */
+ value: string
+ /**
+ * The provider that issued the handle. A handle is only valid for its issuer;
+ * passing (e.g.) an OpenAI `file-...` id to a Gemini adapter is an error.
+ */
+ provider: string
+ /**
+ * Optional MIME type hint for cases where the provider can't infer it.
+ */
+ mimeType?: string
+}
+
/**
* Source specification for multimodal content.
- * Discriminated union supporting both inline data (base64) and URL-based content.
+ * Discriminated union supporting inline data (base64), URL-based content, and
+ * provider-issued file handles.
* - For 'data' sources: mimeType is required
* - For 'url' sources: mimeType is optional
+ * - For 'file' sources: a provider-issued handle plus its issuing `provider`
*/
-export type ContentPartSource = ContentPartDataSource | ContentPartUrlSource
+export type ContentPartSource =
+ | ContentPartDataSource
+ | ContentPartUrlSource
+ | ContentPartFileSource
/**
* Image content part for multimodal messages.
diff --git a/packages/ai/src/utilities/content-source.ts b/packages/ai/src/utilities/content-source.ts
new file mode 100644
index 000000000..f61ad3432
--- /dev/null
+++ b/packages/ai/src/utilities/content-source.ts
@@ -0,0 +1,57 @@
+import type { ContentPartFileSource, ContentPartSource } from '../types'
+
+/**
+ * Narrow a {@link ContentPartSource} to the provider-file-handle arm.
+ *
+ * Every adapter that maps a content part's `source` onto a provider wire format
+ * must handle `{ type: 'file' }` explicitly — either mapping it to the provider's
+ * native file-reference field (issuers) or rejecting it (everyone else). Using
+ * this guard keeps that branch consistent across the ~dozen adapter packages.
+ */
+export function isFileSource(
+ source: ContentPartSource,
+): source is ContentPartFileSource {
+ return source.type === 'file'
+}
+
+/**
+ * Assert that a file source's handle was issued by `providerName`. A provider
+ * file handle is only valid for the provider that created it (an OpenAI
+ * `file-...` id sent to Gemini is a bug), so issuer adapters call this before
+ * mapping the handle onto their wire format.
+ *
+ * @throws if `source.provider` doesn't match `providerName`.
+ */
+export function assertOwnFileSource(
+ source: ContentPartFileSource,
+ providerName: string,
+): void {
+ if (source.provider !== providerName) {
+ throw new Error(
+ `${providerName}: file source references a handle issued by ` +
+ `"${source.provider}" — a provider file handle only works with the ` +
+ `provider that created it. Upload the file with ${providerName}Files() ` +
+ `and reference that handle, or pass a data/url source instead.`,
+ )
+ }
+}
+
+/**
+ * Build the standard error a non-issuer adapter throws when it encounters a
+ * `{ type: 'file' }` source it can't consume — either because the provider has
+ * no file-handle input surface, or because the endpoint requires raw bytes
+ * (image edits, Veo) rather than a reference.
+ *
+ * @param detail Optional context appended to the message (e.g. a modality or
+ * endpoint name, or a pointer to the adapter that does support handles).
+ */
+export function unsupportedFileSourceError(
+ providerName: string,
+ detail?: string,
+): Error {
+ return new Error(
+ `${providerName} does not support provider file-handle sources ` +
+ `({ type: 'file' })${detail ? ` ${detail}` : ''}. Pass a data or url ` +
+ `source, or upload via the provider's files adapter where supported.`,
+ )
+}
diff --git a/packages/ai/src/utilities/tool-result.ts b/packages/ai/src/utilities/tool-result.ts
index 330c29be1..5a18039ec 100644
--- a/packages/ai/src/utilities/tool-result.ts
+++ b/packages/ai/src/utilities/tool-result.ts
@@ -11,7 +11,7 @@ const CONTENT_PART_TYPES = new Set([
/**
* Structural check for a single `ContentPart`. A text part must carry a string
* `content`; every other modality must carry a `source` with `type` of
- * `'url' | 'data'` and a string `value`.
+ * `'url' | 'data' | 'file'` and a string `value`.
*/
export function isContentPart(value: unknown): value is ContentPart {
if (typeof value !== 'object' || value === null) return false
@@ -30,6 +30,8 @@ export function isContentPart(value: unknown): value is ContentPart {
// sources don't. Requiring it here keeps the runtime guard consistent with
// the type and avoids emitting `data:undefined;base64,...` downstream.
if (src.type === 'data') return typeof src.mimeType === 'string'
+ // `file` sources reference a provider-issued handle and must name their issuer.
+ if (src.type === 'file') return typeof src.provider === 'string'
return src.type === 'url'
}
diff --git a/packages/ai/tests/files-source.test.ts b/packages/ai/tests/files-source.test.ts
new file mode 100644
index 000000000..d8e41cd28
--- /dev/null
+++ b/packages/ai/tests/files-source.test.ts
@@ -0,0 +1,131 @@
+import { describe, expect, it } from 'vitest'
+import {
+ assertOwnFileSource,
+ deleteFile,
+ fileSourceFromHandle,
+ getFile,
+ isContentPart,
+ isFileSource,
+ unsupportedFileSourceError,
+ uploadFile,
+} from '../src/index'
+import { normalizeFileUploadInput } from '../src/activities/files/adapter'
+import type { ContentPartSource } from '../src/types'
+import type { FileHandle, FilesAdapter } from '../src/activities/files/adapter'
+
+const fileSource: ContentPartSource = {
+ type: 'file',
+ value: 'file-abc',
+ provider: 'openai',
+}
+
+describe('file content source helpers', () => {
+ it('isFileSource narrows only the file arm', () => {
+ expect(isFileSource(fileSource)).toBe(true)
+ expect(isFileSource({ type: 'url', value: 'https://x/y' })).toBe(false)
+ expect(
+ isFileSource({ type: 'data', value: 'AAAA', mimeType: 'image/png' }),
+ ).toBe(false)
+ })
+
+ it('assertOwnFileSource passes on match and throws on mismatch', () => {
+ expect(() => assertOwnFileSource(fileSource, 'openai')).not.toThrow()
+ expect(() => assertOwnFileSource(fileSource, 'gemini')).toThrow(/openai/)
+ })
+
+ it('unsupportedFileSourceError includes provider and detail', () => {
+ const err = unsupportedFileSourceError('mistral', 'on this endpoint')
+ expect(err.message).toContain('mistral')
+ expect(err.message).toContain('on this endpoint')
+ })
+
+ it('fileSourceFromHandle prefers uri (Gemini/fal), else id (OpenAI/Anthropic)', () => {
+ const opaque: FileHandle = { id: 'file-abc', provider: 'openai' }
+ expect(fileSourceFromHandle(opaque)).toEqual({
+ type: 'file',
+ value: 'file-abc',
+ provider: 'openai',
+ })
+
+ const withUri: FileHandle = {
+ id: 'files/xyz',
+ provider: 'gemini',
+ uri: 'https://generativelanguage.googleapis.com/v1/files/xyz',
+ mimeType: 'image/png',
+ }
+ expect(fileSourceFromHandle(withUri)).toEqual({
+ type: 'file',
+ value: 'https://generativelanguage.googleapis.com/v1/files/xyz',
+ provider: 'gemini',
+ mimeType: 'image/png',
+ })
+ })
+
+ it('isContentPart accepts a valid file source and rejects one missing provider', () => {
+ expect(isContentPart({ type: 'image', source: fileSource })).toBe(true)
+ expect(
+ isContentPart({
+ type: 'image',
+ source: { type: 'file', value: 'file-abc' },
+ }),
+ ).toBe(false)
+ })
+})
+
+describe('normalizeFileUploadInput', () => {
+ it('passes a Blob through and decodes base64 input', async () => {
+ const blob = new Blob(['hi'], { type: 'text/plain' })
+ expect(normalizeFileUploadInput(blob).blob).toBe(blob)
+
+ const fromBase64 = normalizeFileUploadInput({
+ data: 'aGVsbG8=', // "hello"
+ mimeType: 'text/plain',
+ filename: 'greeting.txt',
+ })
+ expect(fromBase64.mimeType).toBe('text/plain')
+ expect(fromBase64.filename).toBe('greeting.txt')
+ expect(await fromBase64.blob.text()).toBe('hello')
+ })
+})
+
+describe('files activity dispatch', () => {
+ const uploadOnly: FilesAdapter = {
+ kind: 'files',
+ name: 'fal',
+ upload: async () => ({ id: 'https://cdn/x', provider: 'fal' }),
+ }
+ const full: FilesAdapter = {
+ kind: 'files',
+ name: 'openai',
+ upload: async () => ({ id: 'file-1', provider: 'openai' }),
+ get: async (id) => ({ id, provider: 'openai' }),
+ delete: async () => {},
+ }
+
+ it('uploadFile returns the handle', async () => {
+ const handle = await uploadFile({
+ adapter: uploadOnly,
+ input: new Blob(['x']),
+ })
+ expect(handle).toEqual({ id: 'https://cdn/x', provider: 'fal' })
+ })
+
+ it('getFile / deleteFile throw when the adapter has no lifecycle API', async () => {
+ await expect(getFile({ adapter: uploadOnly, id: 'x' })).rejects.toThrow(
+ /does not support get/,
+ )
+ await expect(deleteFile({ adapter: uploadOnly, id: 'x' })).rejects.toThrow(
+ /does not support delete/,
+ )
+ })
+
+ it('getFile / deleteFile call through when supported', async () => {
+ expect(await getFile({ adapter: full, id: 'file-1' })).toEqual({
+ id: 'file-1',
+ provider: 'openai',
+ })
+ await expect(
+ deleteFile({ adapter: full, id: 'file-1' }),
+ ).resolves.toBeUndefined()
+ })
+})
diff --git a/packages/openai-base/src/adapters/chat-completions-text.ts b/packages/openai-base/src/adapters/chat-completions-text.ts
index 72ea19ca7..68751777a 100644
--- a/packages/openai-base/src/adapters/chat-completions-text.ts
+++ b/packages/openai-base/src/adapters/chat-completions-text.ts
@@ -1,4 +1,9 @@
-import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
+import {
+ EventType,
+ isFileSource,
+ normalizeSystemPrompts,
+ unsupportedFileSourceError,
+} from '@tanstack/ai'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import {
toRunErrorPayload,
@@ -1312,6 +1317,15 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter<
| { detail?: 'auto' | 'low' | 'high' }
| undefined
+ if (isFileSource(part.source)) {
+ // The Chat Completions API references images only by URL/data URI, not
+ // by an uploaded file_id — point callers at the Responses adapter.
+ throw unsupportedFileSourceError(
+ this.name,
+ 'on the Chat Completions API — use the Responses adapter (e.g. openaiText) to reference an uploaded file by file_id',
+ )
+ }
+
// For base64 data, construct a data URI using the mimeType from source.
// Default to a generic octet-stream MIME if the source didn't provide
// one — interpolating `undefined` into the URI ("data:undefined;base64,
diff --git a/packages/openai-base/src/adapters/responses-text.ts b/packages/openai-base/src/adapters/responses-text.ts
index d48018209..8a922b7df 100644
--- a/packages/openai-base/src/adapters/responses-text.ts
+++ b/packages/openai-base/src/adapters/responses-text.ts
@@ -1,4 +1,9 @@
-import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
+import {
+ EventType,
+ assertOwnFileSource,
+ isFileSource,
+ normalizeSystemPrompts,
+} from '@tanstack/ai'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import {
toRunErrorPayload,
@@ -1796,6 +1801,14 @@ export abstract class OpenAIBaseResponsesTextAdapter<
const imageMetadata = part.metadata as
| { detail?: 'auto' | 'low' | 'high' }
| undefined
+ if (isFileSource(part.source)) {
+ assertOwnFileSource(part.source, this.name)
+ return {
+ type: 'input_image',
+ file_id: part.source.value,
+ detail: imageMetadata?.detail || 'auto',
+ }
+ }
if (part.source.type === 'url') {
return {
type: 'input_image',
@@ -1819,6 +1832,13 @@ export abstract class OpenAIBaseResponsesTextAdapter<
}
}
case 'audio': {
+ if (isFileSource(part.source)) {
+ assertOwnFileSource(part.source, this.name)
+ return {
+ type: 'input_file',
+ file_id: part.source.value,
+ }
+ }
if (part.source.type === 'url') {
return {
type: 'input_file',
@@ -1839,8 +1859,19 @@ export abstract class OpenAIBaseResponsesTextAdapter<
}
}
+ case 'document': {
+ // A document uploaded via the Files API is referenced by `file_id`;
+ // inline document bytes/URLs aren't accepted on this path.
+ if (isFileSource(part.source)) {
+ assertOwnFileSource(part.source, this.name)
+ return {
+ type: 'input_file',
+ file_id: part.source.value,
+ }
+ }
+ throw new Error(`Unsupported content part type: ${part.type}`)
+ }
case 'video':
- case 'document':
default:
// OpenAI Responses API doesn't accept native video/document parts on
// this path — surface as explicit unsupported error so callers see
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e006a80cf..1f7e799a5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -52,7 +52,7 @@ importers:
version: 0.5.0
knip:
specifier: ^5.70.2
- version: 5.73.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.3)(typescript@5.9.3)
+ version: 5.73.4(@types/node@24.10.3)(typescript@5.9.3)
markdown-link-extractor:
specifier: ^4.0.3
version: 4.0.3
@@ -828,8 +828,8 @@ importers:
specifier: ^0.561.0
version: 0.561.0(react@19.2.3)
nitro:
- specifier: 3.0.1-alpha.2
- version: 3.0.1-alpha.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(aws4fetch@1.0.20)(chokidar@5.0.0)(rolldown@1.1.3)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+ specifier: latest
+ version: 3.0.260610-beta(aws4fetch@1.0.20)(chokidar@5.0.0)(dotenv@17.2.3)(giget@2.0.0)(jiti@2.6.1)(miniflare@4.20260617.1)(rollup@4.60.1)(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(wrangler@4.103.0)
react:
specifier: ^19.2.3
version: 19.2.3
@@ -3864,18 +3864,12 @@ packages:
'@elevenlabs/types@0.9.1':
resolution: {integrity: sha512-lkWAMaFJLsGNWcblryBRHbtozMh7wHy0YsqURLwQwjhpvycuFN5qUa94CZtVU01jFwrfr5h1ca24+nYEkKf0Ew==}
- '@emnapi/core@1.10.0':
- resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
-
'@emnapi/core@1.11.1':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
'@emnapi/core@1.7.1':
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
- '@emnapi/runtime@1.10.0':
- resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
-
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
@@ -3885,9 +3879,6 @@ packages:
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
- '@emnapi/wasi-threads@1.2.1':
- resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
-
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
@@ -5511,12 +5502,6 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
- '@napi-rs/wasm-runtime@1.1.6':
- resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==}
- peerDependencies:
- '@emnapi/core': ^1.7.1
- '@emnapi/runtime': ^1.7.1
-
'@ngrok/ngrok-android-arm64@1.7.0':
resolution: {integrity: sha512-8tco3ID6noSaNy+CMS7ewqPoIkIM6XO5COCzsUp3Wv3XEbMSyn65RN6cflX2JdqLfUCHcMyD0ahr9IEiHwqmbQ==}
engines: {node: '>= 10'}
@@ -8802,9 +8787,6 @@ packages:
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
- '@tybys/wasm-util@0.10.3':
- resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
-
'@tybys/wasm-util@0.9.0':
resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==}
@@ -17797,12 +17779,6 @@ snapshots:
'@elevenlabs/types@0.9.1': {}
- '@emnapi/core@1.10.0':
- dependencies:
- '@emnapi/wasi-threads': 1.2.1
- tslib: 2.8.1
- optional: true
-
'@emnapi/core@1.11.1':
dependencies:
'@emnapi/wasi-threads': 1.2.2
@@ -17814,11 +17790,6 @@ snapshots:
'@emnapi/wasi-threads': 1.1.0
tslib: 2.8.1
- '@emnapi/runtime@1.10.0':
- dependencies:
- tslib: 2.8.1
- optional: true
-
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
@@ -17832,11 +17803,6 @@ snapshots:
dependencies:
tslib: 2.8.1
- '@emnapi/wasi-threads@1.2.1':
- dependencies:
- tslib: 2.8.1
- optional: true
-
'@emnapi/wasi-threads@1.2.2':
dependencies:
tslib: 2.8.1
@@ -19265,8 +19231,8 @@ snapshots:
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
- '@emnapi/core': 1.10.0
- '@emnapi/runtime': 1.10.0
+ '@emnapi/core': 1.11.1
+ '@emnapi/runtime': 1.11.1
'@tybys/wasm-util': 0.10.1
optional: true
@@ -19290,13 +19256,6 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
- '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
- dependencies:
- '@emnapi/core': 1.11.1
- '@emnapi/runtime': 1.11.1
- '@tybys/wasm-util': 0.10.3
- optional: true
-
'@ngrok/ngrok-android-arm64@1.7.0':
optional: true
@@ -19827,7 +19786,7 @@ snapshots:
'@oxc-resolver/binding-wasm32-wasi@11.15.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
dependencies:
- '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
+ '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
@@ -20997,7 +20956,6 @@ snapshots:
dependencies:
'@emnapi/core': 1.11.1
'@emnapi/runtime': 1.11.1
- '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)
optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53':
@@ -23064,11 +23022,6 @@ snapshots:
tslib: 2.8.1
optional: true
- '@tybys/wasm-util@0.10.3':
- dependencies:
- tslib: 2.8.1
- optional: true
-
'@tybys/wasm-util@0.9.0':
dependencies:
tslib: 2.8.1
@@ -27031,7 +26984,7 @@ snapshots:
klona@2.0.6: {}
- knip@5.73.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@24.10.3)(typescript@5.9.3):
+ knip@5.73.4(@types/node@24.10.3)(typescript@5.9.3):
dependencies:
'@nodelib/fs.walk': 1.2.8
'@types/node': 24.10.3