@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
97 lines (90 loc) • 3.16 kB
text/typescript
import type { ContentPart } from '../types'
const CONTENT_PART_TYPES = new Set([
'text',
'image',
'audio',
'video',
'document',
])
/**
* Structural check for a single `ContentPart`. A text part must carry a string
* `content`. Every other part carries a source with a string `value`; a file
* source's `value` is a non-empty opaque handle, and its optional `provider`
* is a string.
*/
export function isContentPart(value: unknown): value is ContentPart {
if (typeof value !== 'object' || value === null) return false
const part = value as Record<string, unknown>
if (typeof part.type !== 'string' || !CONTENT_PART_TYPES.has(part.type)) {
return false
}
if (part.type === 'text') {
return typeof part.content === 'string'
}
const source = part.source
if (typeof source !== 'object' || source === null) return false
const src = source as Record<string, unknown>
if (typeof src.value !== 'string') return false
// `file` sources carry an opaque handle in `value`; `provider`, when set,
// names the issuer.
if (src.type === 'file') {
return (
src.value.length > 0 &&
(src.provider === undefined || typeof src.provider === 'string')
)
}
// `data` sources require a mimeType (matches ContentPartDataSource); `url`
// 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'
return src.type === 'url'
}
/**
* True iff `value` is a NON-EMPTY array whose every element is a valid
* `ContentPart`. Empty arrays and mixed arrays return false so they continue
* to be treated as ordinary (stringified) data — this keeps the auto-detection
* footgun narrow.
*/
export function isContentPartArray(
value: unknown,
): value is Array<ContentPart> {
return Array.isArray(value) && value.length > 0 && value.every(isContentPart)
}
/**
* Error text for a failed tool result: `output.error` when it is a string,
* else the output itself when it is a string, else a generic message.
* `StreamProcessor` and `chat()` history share it, so a reload shows the
* same text as the live stream.
*/
export function toolResultErrorText(output: unknown): string {
if (
output &&
typeof output === 'object' &&
'error' in output &&
typeof output.error === 'string'
) {
return output.error
}
return typeof output === 'string' ? output : 'Tool execution failed'
}
/** Parse tool result content as JSON. Plain text stays a string. */
export function parseToolOutput(content: string): unknown {
try {
return JSON.parse(content)
} catch {
return content
}
}
/**
* Normalize a tool's return value for transport:
* - string → unchanged
* - ContentPart array → unchanged (multimodal, passed through to the adapter)
* - anything else → `JSON.stringify`
*/
export function normalizeToolResult(
result: unknown,
): string | Array<ContentPart> {
if (typeof result === 'string') return result
if (isContentPartArray(result)) return result
return JSON.stringify(result)
}