@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
139 lines (130 loc) • 5.03 kB
text/typescript
import type { ContentPartFileSource, ContentPartSource } from '../types'
/**
* Narrow a {@link ContentPartSource} to the provider-file-reference arm.
*
* Issuer adapters use this to route a file source to their native wire field;
* everyone else is protected by the core preflight (see
* {@link assertMessagesFileSourceSupport}) plus a defensive throw at their own
* mapping site.
*/
export function isFileSource(
source: ContentPartSource,
): source is ContentPartFileSource {
return source.type === 'file'
}
/**
* Resolve the handle `providerName` should send for a file source.
*
* A file source carries one opaque handle (`value`) and, optionally, the
* provider that issued it. An adapter always knows which provider it talks
* to, so a source that names no provider is taken as-is.
*
* @throws when the source names a different issuing provider. A handle only
* resolves at the provider that minted it.
*/
export function fileReferenceFor(
source: ContentPartFileSource,
providerName: string,
): string {
if (source.provider !== undefined && source.provider !== providerName) {
throw new Error(
`${providerName}: file source was issued by ${source.provider}. ` +
`A provider file handle only works with the provider that issued ` +
`it. Upload the file with ${providerName}Files(), or pass a data or ` +
`url source instead.`,
)
}
return source.value
}
/**
* 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-reference 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 references).
* When provided it replaces the generic remediation tail, so a site-specific
* hint ("pass inline bytes") is never contradicted by generic advice.
*/
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.`),
)
}
/**
* The slice of an adapter the file-source preflight reads. Adapters that can
* consume `{ type: 'file' }` sources declare `supportsFileSources: true`;
* everything else — including adapters written before this arm existed —
* fails closed at the activity layer instead of falling through to a
* URL/data branch and silently mis-mapping the reference.
*/
export interface FileSourceCapable {
name: string
supportsFileSources?: boolean
}
/** True when a content-part-like value carries a `{ type: 'file' }` source. */
function partHasFileSource(part: unknown): boolean {
if (typeof part !== 'object' || part === null) return false
const source = (part as { source?: unknown }).source
return (
typeof source === 'object' &&
source !== null &&
(source as { type?: unknown }).type === 'file'
)
}
/**
* True when `value` is a content part with a file source, or an array
* (possibly nested — fused embedding items) that contains one.
*/
function inputHasFileSource(value: unknown): boolean {
if (Array.isArray(value)) return value.some(inputHasFileSource)
return partHasFileSource(value)
}
/**
* Fail-closed preflight for media prompts and embedding inputs
* (`generateImage` / `generateVideo` / `embed`): throws when the input
* carries a `{ type: 'file' }` source and the adapter hasn't declared
* `supportsFileSources`. Runs in the activity dispatcher — the same layer
* that validates modality — so an adapter that predates the file arm can
* never receive one. Walks a single part, an array of parts, and nested
* arrays (fused embedding items).
*/
export function assertPromptFileSourceSupport(
adapter: FileSourceCapable,
prompt: unknown,
): void {
if (adapter.supportsFileSources === true) return
if (inputHasFileSource(prompt)) {
throw unsupportedFileSourceError(adapter.name)
}
}
/**
* Fail-closed preflight for chat messages: throws when any message content
* part carries a `{ type: 'file' }` source and the adapter hasn't declared
* `supportsFileSources`. See {@link assertPromptFileSourceSupport}.
*/
export function assertMessagesFileSourceSupport(
adapter: FileSourceCapable,
messages: ReadonlyArray<unknown>,
): void {
if (adapter.supportsFileSources === true) return
for (const message of messages) {
if (typeof message !== 'object' || message === null) continue
const content = (message as { content?: unknown }).content
if (!Array.isArray(content)) continue
for (const part of content) {
if (partHasFileSource(part)) {
throw unsupportedFileSourceError(adapter.name)
}
}
}
}