UNPKG

@ai-sdk/provider-utils

Version:
269 lines (237 loc) • 8.07 kB
import { APICallError } from '@ai-sdk/provider'; import { convertAsyncIteratorToReadableStream } from './convert-async-iterator-to-readable-stream'; import { extractResponseHeaders } from './extract-response-headers'; import type { FetchFunction } from './fetch-function'; import { generateId } from './generate-id'; import { getRuntimeEnvironmentUserAgent } from './get-runtime-environment-user-agent'; import { handleFetchError } from './handle-fetch-error'; import { isAbortError } from './is-abort-error'; import type { ResponseHandler } from './response-handler'; import { VERSION } from './version'; import { withUserAgentSuffix } from './with-user-agent-suffix'; // use function to allow for mocking in tests: const getOriginalFetch = () => globalThis.fetch; /** * A part of a streaming multipart/form-data request body. * * Parts are emitted in array order, which providers may depend on * (e.g. xAI requires expiry fields to precede the file part). */ export type MultipartStreamPart = | { type: 'field'; name: string; value: string } | { type: 'file'; name: string; filename?: string; mediaType?: string; content: ReadableStream<Uint8Array> | Uint8Array; }; // header parameter values must not smuggle CR/LF or break the quoted-string function escapeMultipartHeaderValue(value: string): string { return value .replace(/[\r\n]/g, '') .replace(/\\/g, '\\\\') .replace(/"/g, '\\"'); } interface MultipartBody { stream: ReadableStream<Uint8Array>; /** * Deterministically releases every stream part after a failed or abandoned * request — fetch implementations do not reliably cancel streaming request * bodies on rejection/abort. Safe to call multiple times. */ dispose: (reason?: unknown) => Promise<void>; } function createMultipartBody( parts: Array<MultipartStreamPart>, boundary: string, ): MultipartBody { const encoder = new TextEncoder(); let disposed = false; let activeReader: ReadableStreamDefaultReader<Uint8Array> | undefined; const enteredStreams = new Set<ReadableStream<Uint8Array>>(); async function* emitParts(): AsyncGenerator<Uint8Array> { for (const part of parts) { if (disposed) return; const disposition = `--${boundary}\r\nContent-Disposition: form-data; name="${escapeMultipartHeaderValue(part.name)}"`; if (part.type === 'field') { yield encoder.encode(`${disposition}\r\n\r\n${part.value}\r\n`); continue; } // a filename parameter is always emitted: without it, multipart // parsers treat the part as a scalar field (FormData defaults to // "blob" as well) const filenameParameter = `; filename="${escapeMultipartHeaderValue( part.filename ?? 'blob', )}"`; const mediaType = (part.mediaType ?? 'application/octet-stream').replace( /[\r\n]/g, '', ); yield encoder.encode( `${disposition}${filenameParameter}\r\nContent-Type: ${mediaType}\r\n\r\n`, ); if (part.content instanceof Uint8Array) { yield part.content; } else { // synchronous handoff (no awaits) so dispose() sees either the // active reader or an un-entered stream, never neither const reader = part.content.getReader(); activeReader = reader; enteredStreams.add(part.content); let finished = false; try { while (true) { const { done, value } = await reader.read(); if (done || disposed) { finished = done; break; } yield value; } } finally { activeReader = undefined; // early teardown (dispose/cancel): release the source stream if (!finished) { await reader.cancel().catch(() => {}); } reader.releaseLock(); } if (disposed) return; } yield encoder.encode('\r\n'); } yield encoder.encode(`--${boundary}--\r\n`); } return { stream: convertAsyncIteratorToReadableStream(emitParts()), async dispose(reason?: unknown) { if (disposed) return; disposed = true; // an active reader holds the lock; cancelling it resolves a pending // read() and propagates cancellation to the underlying source const reader = activeReader; if (reader != null) { await reader.cancel(reason).catch(() => {}); } // streams the generator never entered are still unlocked for (const part of parts) { if ( part.type === 'file' && !(part.content instanceof Uint8Array) && !enteredStreams.has(part.content) ) { await part.content.cancel(reason).catch(() => {}); } } }, }; } /** * POSTs a multipart/form-data body as a request stream, so file parts backed * by a `ReadableStream` are sent without buffering the full file in memory. * * Requires a fetch implementation that supports streaming request bodies * (`duplex: 'half'`). Callers with fully buffered payloads can keep using * `postFormDataToApi`. */ export const postMultipartStreamToApi = async <T>({ url, headers = {}, parts, failedResponseHandler, successfulResponseHandler, abortSignal, fetch = getOriginalFetch(), }: { url: string; headers?: Record<string, string | undefined>; parts: Array<MultipartStreamPart>; failedResponseHandler: ResponseHandler<Error>; successfulResponseHandler: ResponseHandler<T>; abortSignal?: AbortSignal; fetch?: FetchFunction; }) => { const boundary = `ai-sdk-multipart-${generateId()}`; // stream contents cannot be replayed for error reporting; expose part // names, field values, and file placeholders only const requestBodyValues = Object.fromEntries( parts.map(part => [ part.name, part.type === 'field' ? part.value : `<file:${part.filename ?? part.name}>`, ]), ); const body = createMultipartBody(parts, boundary); try { const requestInit: RequestInit & { duplex: 'half' } = { method: 'POST', headers: withUserAgentSuffix( { ...headers, 'Content-Type': `multipart/form-data; boundary=${boundary}`, }, `ai-sdk-provider-utils/${VERSION}`, getRuntimeEnvironmentUserAgent(), ), body: body.stream, duplex: 'half', signal: abortSignal, }; const response = await fetch(url, requestInit); const responseHeaders = extractResponseHeaders(response); if (!response.ok) { let errorInformation: { value: Error; responseHeaders?: Record<string, string> | undefined; }; try { errorInformation = await failedResponseHandler({ response, url, requestBodyValues, }); } catch (error) { if (isAbortError(error) || APICallError.isInstance(error)) { throw error; } throw new APICallError({ message: 'Failed to process error response', cause: error, statusCode: response.status, url, responseHeaders, requestBodyValues, }); } throw errorInformation.value; } try { return await successfulResponseHandler({ response, url, requestBodyValues, }); } catch (error) { if (error instanceof Error) { if (isAbortError(error) || APICallError.isInstance(error)) { throw error; } } throw new APICallError({ message: 'Failed to process successful response', cause: error, statusCode: response.status, url, responseHeaders, requestBodyValues, }); } } catch (error) { // fetch implementations do not reliably cancel streaming request bodies // on rejection/abort — release every source part deterministically await body.dispose(error); throw handleFetchError({ error, url, requestBodyValues }); } };