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