ai
Version:
AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.
183 lines (170 loc) • 6 kB
text/typescript
import type {
FilesV4,
FilesV4UploadFileCallOptions,
ProviderV4,
} from '@ai-sdk/provider';
import {
convertBase64ToUint8Array,
detectMediaType,
} from '@ai-sdk/provider-utils';
import type { ProviderMetadata } from '../types/provider-metadata';
import type { ProviderReference } from '../types/provider-reference';
import type { Warning } from '../types/warning';
import type { UploadFileResult } from './upload-file-result';
/**
* Uploads a file using a files API interface.
*
* @param api - The Files API interface to use for uploading.
* @param data - The file data to upload (tagged `{ type: 'data' | 'text' | 'stream' }`).
* Stream data is sent without buffering by providers that support streaming
* uploads (others reject with `UnsupportedFunctionalityError`); the provider
* consumes the stream — any failed upload, including validation failures
* before a request is made, cancels it. Do not reuse it.
* @param mediaType - Optional IANA media type. Auto-detected from file bytes
* when omitted (falls back to `text/plain` for the `text` variant and
* `application/octet-stream` for the `stream` variant, which cannot be sniffed).
* @param filename - Optional filename for the uploaded file. Multipart-based
* providers default it to `"blob"` when omitted.
* @param abortSignal - Optional signal to cancel the upload.
* @param headers - Optional additional HTTP headers for the request.
* @param providerOptions - Additional provider-specific options.
*
* @returns A result object containing the provider reference, optional
* metadata, and — when reported by the provider — `byteSize`, `createdAt`,
* and `expiresAt` (the provider-applied retention expiry).
*/
export async function uploadFile({
api,
data: dataArg,
mediaType: mediaTypeArg,
filename,
abortSignal,
headers,
providerOptions,
}: {
/**
* The files API interface to use for uploading.
* Can be a `FilesV4` instance or a `ProviderV4` instance with a `files()` method.
*/
api: FilesV4 | ProviderV4;
} & Omit<FilesV4UploadFileCallOptions, 'mediaType' | 'data'> & {
/**
* The file data. Accepts the tagged `{ type: 'data' | 'text' }` shapes, or
* the shorthand `Uint8Array | string` (treated as `{ type: 'data', data }`).
*/
data: FilesV4UploadFileCallOptions['data'] | Uint8Array | string;
/**
* Optional IANA media type of the file. Auto-detected from file bytes when
* omitted; falls back to `text/plain` for the `text` variant.
*/
mediaType?: string;
}): Promise<UploadFileResult> {
const data: FilesV4UploadFileCallOptions['data'] =
dataArg instanceof Uint8Array || typeof dataArg === 'string'
? { type: 'data', data: dataArg }
: dataArg;
// stream data cannot be sniffed without consuming it
const mediaType =
mediaTypeArg ??
(data.type === 'text'
? 'text/plain'
: data.type === 'stream'
? 'application/octet-stream'
: (detectMediaType({ data: data.data }) ??
(isLikelyText(data.data)
? 'text/plain'
: 'application/octet-stream')));
let result;
try {
const filesApi: FilesV4 =
'uploadFile' in api
? api
: typeof api.files === 'function'
? api.files()
: (() => {
throw new Error(
'The provider does not support file uploads. Make sure it exposes a files() method.',
);
})();
result = await filesApi.uploadFile({
data,
mediaType,
filename,
abortSignal,
headers,
providerOptions,
});
} catch (error) {
// ownership guarantee: a failed upload releases the stream, even when
// the provider rejected before (or without) consuming it
if (data.type === 'stream') {
await data.stream.cancel(error).catch(() => {});
}
throw error;
}
return new DefaultUploadFileResult({
providerReference: result.providerReference,
mediaType: result.mediaType,
filename: result.filename,
byteSize: result.byteSize,
createdAt: result.createdAt,
expiresAt: result.expiresAt,
providerMetadata: result.providerMetadata,
warnings: result.warnings,
});
}
class DefaultUploadFileResult implements UploadFileResult {
readonly providerReference: ProviderReference;
readonly mediaType?: string;
readonly filename?: string;
readonly byteSize?: number;
readonly createdAt?: Date;
readonly expiresAt?: Date;
readonly providerMetadata?: ProviderMetadata;
readonly warnings: Array<Warning>;
constructor(options: {
providerReference: ProviderReference;
mediaType?: string;
filename?: string;
byteSize?: number;
createdAt?: Date;
expiresAt?: Date;
providerMetadata?: ProviderMetadata;
warnings: Array<Warning>;
}) {
this.providerReference = options.providerReference;
this.mediaType = options.mediaType;
this.filename = options.filename;
this.byteSize = options.byteSize;
this.createdAt = options.createdAt;
this.expiresAt = options.expiresAt;
this.providerMetadata = options.providerMetadata;
this.warnings = options.warnings;
}
}
function isLikelyText(data: Uint8Array | string): boolean {
/*
* Limit checks to 512 bytes for performance.
* 4 base64 characters represent 3 bytes, and we use a small margin of 4 bytes just to be safe.
*/
const CHECK_LENGTH = 512;
const BASE64_CHECK_LENGTH = Math.ceil((CHECK_LENGTH + 4) / 3) * 4;
const bytes =
typeof data === 'string'
? convertBase64ToUint8Array(
data.substring(0, Math.min(data.length, BASE64_CHECK_LENGTH)),
)
: data;
const checkLength = Math.min(bytes.length, CHECK_LENGTH);
if (checkLength === 0) return false;
for (let i = 0; i < checkLength; i++) {
const byte = bytes[i];
if (
byte === 0x00 ||
(byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d)
) {
return false;
}
}
return true;
}