The BytePlus adapter connects TanStack AI to BytePlus, ByteDance's international model platform, across four generation kinds:
npm install @tanstack/ai-byteplus
# or
pnpm add @tanstack/ai-byteplus
# or
yarn add @tanstack/ai-byteplusBytePlus splits these models across two products, and they do not share credentials:
| Adapters | Product | Env var | Auth header |
|---|---|---|---|
| byteplusText, byteplusVideo, byteplusImage | ModelArk (Ark) | ARK_API_KEY (falls back to BYTEPLUS_API_KEY) | Authorization: Bearer |
| byteplusSpeech, byteplusTranscription | Seed Speech | BYTEPLUS_VOICE_API_KEY | X-Api-Key |
# ModelArk: chat, Seedance video, Seedream image
ARK_API_KEY=...
# Seed Speech: TTS and transcription — a separate product key
BYTEPLUS_VOICE_API_KEY=...Passing an Ark key to the speech adapters fails with 45000010 Invalid X-Api-Key.
Ark keys are also region-isolated: the default base URL is the Asia-Pacific south-east endpoint (https://ark.ap-southeast.bytepluses.com/api/v3), and a key issued for one region will not authenticate against another. Point the adapter elsewhere with baseURL:
import { createBytePlusText } from '@tanstack/ai-byteplus'
import { arkApiKey } from './config'
const adapter = createBytePlusText('dola-seed-2-1-turbo-260628', arkApiKey, {
baseURL: 'https://ark.eu-west.bytepluses.com/api/v3',
})Per the BytePlus docs the EU endpoint serves chat and image only — Seedance video is Asia-Pacific only.
The adapter carries the model, so there is no separate model option.
Server — an endpoint that streams the reply back over SSE:
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { byteplusText } from '@tanstack/ai-byteplus'
export async function POST(request: Request) {
const { messages } = await request.json()
const stream = chat({
adapter: byteplusText('dola-seed-2-1-turbo-260628'),
messages,
})
return toServerSentEventsResponse(stream)
}Client — the same useChat hook as every other provider:
import { useState } from 'react'
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
export function Chat() {
const [input, setInput] = useState('')
const { messages, sendMessage, isLoading } = useChat({
connection: fetchServerSentEvents('/api/chat'),
})
return (
<div>
{messages.map((message) => (
<div key={message.id}>
<strong>{message.role}</strong>
{message.parts.map((part, index) =>
part.type === 'text' ? <p key={index}>{part.content}</p> : null,
)}
</div>
))}
<form
onSubmit={(event) => {
event.preventDefault()
if (!input.trim() || isLoading) return
sendMessage(input)
setInput('')
}}
>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button type="submit" disabled={isLoading}>
Send
</button>
</form>
</div>
)
}Ark's chat endpoint is OpenAI-compatible, so sampling parameters keep their OpenAI snake_case names and live in modelOptions. thinking, reasoning_effort, repetition_penalty and service_tier are the Ark-only additions:
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { byteplusText } from '@tanstack/ai-byteplus'
export async function POST(request: Request) {
const { messages } = await request.json()
const stream = chat({
adapter: byteplusText('dola-seed-2-1-turbo-260628'),
messages,
modelOptions: {
temperature: 0.7,
top_p: 0.9,
max_tokens: 2048,
thinking: { type: 'enabled' },
reasoning_effort: 'medium',
},
})
return toServerSentEventsResponse(stream)
}Two constraints the type system can't express, both live-verified as 400s:
service_tier: 'flex' routes the request to the cheaper offline batch queue with no latency guarantee.
Seed models reason by default. Reasoning arrives as its own stream of reasoning_content deltas and is surfaced as reasoning content rather than answer text, so useChat renders it separately from the reply. Turn it off per request:
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { byteplusText } from '@tanstack/ai-byteplus'
export async function POST(request: Request) {
const { messages } = await request.json()
const stream = chat({
adapter: byteplusText('dola-seed-2-1-turbo-260628'),
messages,
modelOptions: { thinking: { type: 'disabled' } },
})
return toServerSentEventsResponse(stream)
}disabled works everywhere; auto is accepted only by gpt-oss-120b-250805. deepseek-v3-2-251201 is the one model that defaults to reasoning off.
The four "thinking summary" models — dola-seed-2-1-turbo-260628, seed-2-0-lite-260428, seed-2-0-mini-260428 and seed-2-0-pro-260328 — also emit an opaque encrypted_content blob alongside the reasoning trace. It is a signature over that trace, and BytePlus's docs ask for it back verbatim on the assistant message in the next turn.
The adapter round-trips it for you, over the same seam Anthropic's thinking signatures use: the blob is captured off the stream and attached to the reasoning step as its signature, which lands on the thinking part of the assistant message and is echoed back on the next request. Two consequences worth knowing:
Ten of the eighteen chat models accept response_format: { type: 'json_schema' }:
dola-seed-2-1-turbo-260628, seed-2-0-pro-260328, seed-2-0-lite-260228, seed-2-0-mini-260215, seed-1-8-251228, seed-1-6-250915, seed-1-6-250615, seed-1-6-flash-250715, seed-1-6-flash-250615, glm-5-2-260617.
import { chat } from '@tanstack/ai'
import { byteplusText } from '@tanstack/ai-byteplus'
import { z } from 'zod'
const RecipeSchema = z.object({
name: z.string().meta({ description: 'Name of the dish' }),
minutes: z.number().meta({ description: 'Total cooking time in minutes' }),
ingredients: z.array(z.string()),
})
const recipe = await chat({
adapter: byteplusText('dola-seed-2-1-turbo-260628'),
messages: [{ role: 'user', content: 'Give me a recipe for carbonara' }],
outputSchema: RecipeSchema,
})
console.log(recipe.name, recipe.minutes)On the other eight models the adapter fails loudly rather than degrading: chat({ outputSchema }) throws, and the streaming form emits a RUN_ERROR naming the models that do work. There is no JSON-mode fallback to fall back to — Ark rejects response_format: { type: 'json_object' } on every one of them too.
Two traps worth knowing, both found by probing the live API rather than reading the tables:
BYTEPLUS_STRUCTURED_OUTPUT_CHAT_MODELS is exported if you want to gate a model picker on it.
Video generation is an experimental feature.
Seedance is an asynchronous task API: generateVideo() opens the job and returns a jobId, and the video URL arrives with the terminal status.
import { generateVideo, getVideoJobStatus } from '@tanstack/ai'
import { byteplusVideo } from '@tanstack/ai-byteplus'
const adapter = byteplusVideo('dreamina-seedance-2-0-260128')
const { jobId } = await generateVideo({
adapter,
prompt: 'a guitar being played in a store',
size: '16:9_720p',
duration: 5,
})
let status = await getVideoJobStatus({ adapter, jobId })
while (status.status === 'pending' || status.status === 'processing') {
await new Promise((resolve) => setTimeout(resolve, 5000))
status = await getVideoJobStatus({ adapter, jobId })
}
console.log(status.status === 'completed' ? status.url : status.error)Generated video URLs expire 24 hours after the task completes (the task record itself is kept for seven days), so download anything you intend to keep — see Keeping generated files.
size is a ratio or ratio_resolution template ('16:9', '16:9_720p'). Ratios are 16:9, 9:16, 4:3, 3:4, 1:1, 21:9 and adaptive.
Server — hand the polling to the core with stream: true and pipe the chunks out:
import { generateVideo, toServerSentEventsResponse } from '@tanstack/ai'
import { byteplusVideo } from '@tanstack/ai-byteplus'
export async function POST(request: Request) {
const { prompt } = await request.json()
const stream = generateVideo({
adapter: byteplusVideo('dreamina-seedance-2-0-260128'),
prompt,
size: '16:9_720p',
duration: 5,
stream: true,
pollingInterval: 5000,
})
return toServerSentEventsResponse(stream)
}Client — useGenerateVideo tracks the job for you:
import { fetchServerSentEvents, useGenerateVideo } from '@tanstack/ai-react'
export function SeedanceGenerator() {
const { generate, result, videoStatus, isLoading, error } = useGenerateVideo({
connection: fetchServerSentEvents('/api/generate/video'),
})
return (
<div>
<button
onClick={() => generate({ prompt: 'a guitar being played in a store' })}
disabled={isLoading}
>
{isLoading ? 'Generating…' : 'Generate video'}
</button>
{isLoading && <p>Status: {videoStatus?.status ?? 'starting…'}</p>}
{error && <p>Error: {error.message}</p>}
{result && <video src={result.url} controls width={640} />}
</div>
)
}Seedance options are model-specific, and Ark rejects an inapplicable field with a 400 rather than ignoring it — "the specified parameter draft is not supported for model seedance-1-0-pro in t2v, must be empty". The applicable set:
| Option | Models that accept it |
|---|---|
| service_tier ('default' | 'flex') | Seedance 1.x only — Seedance 2.5 and the 2.0 family reject it |
| camera_fixed | Seedance 1.x only |
| frames (fractional-second output, 25 + 4n in [29, 289]) | seedance-1-0-pro-250528, seedance-1-0-pro-fast-251015 |
| draft (cheap low-fidelity preview) | seedance-1-5-pro-251215 only |
| priority (0–9) | Seedance 2.5 and the dreamina-seedance-2-0-* family |
| duration: -1 (model picks the length) | Seedance 2.5, 2.0 and seedance-1-5-pro-251215 |
| output_format ('mp4' | 'mov') | Seedance 2.5 (default mp4) |
| seed, watermark, generate_audio, return_last_frame, callback_url | every model |
watermark defaults to false for video (the opposite of Seedream images). generate_audio is accepted everywhere but only Seedance 2.5, 2.0 and 1.5-pro actually produce an audio track.
Resolutions are per model too: there is no 2K tier on any Seedance model, 4k exists only on dreamina-seedance-2-0-260128, Seedance 2.5 is 480p/720p only, and seedance-1-0-pro-fast-251015 does accept 1080p despite older prose listing it as 480p/720p only. An unsupported combination is caught locally with a clear error before the request goes out.
Reference media follows the shared role hints — start_frame, end_frame and reference. Seedance 2.5 and the 2.0 family take full multimodal references (reference images, video and audio); Seedance 2.5 additionally accepts audio-only reference input and up to 30 reference images / 10 videos / 10 audio clips (2.0 is 9 / 3 / 3). The 1.x models take start/end frames only, and seedance-1-0-pro-fast-251015 takes a start frame only.
Seedance 2.5 (dreamina-seedance-2-5-260628) is a first-class model in this package. It is the current multimodal flagship for longer clips (up to 30 seconds), audio-only reference input, and output_format: 'mov'. Like the 2.0 series it may still require model activation / a resource pack in the Ark Console before the account can call it (404 ModelNotOpen until then).
import { generateVideo } from '@tanstack/ai'
import { byteplusVideo } from '@tanstack/ai-byteplus'
const { jobId } = await generateVideo({
adapter: byteplusVideo('dreamina-seedance-2-5-260628'),
prompt: 'a guitar being played in a store',
size: '16:9_720p',
duration: 10,
modelOptions: {
generate_audio: true,
priority: 5,
output_format: 'mp4',
},
})| Capability | Seedance 2.5 |
|---|---|
| Duration | 4–30s, or -1 (model chooses; required for video-editing tasks) |
| Resolution | 480p, 720p (default 720p) — no 1080p / 4k |
| Reference media | images 1–30, videos 0–10, audio 0–10; audio-only allowed |
| First + last frame | yes |
| priority / generate_audio | yes |
| service_tier / camera_fixed / frames / draft | no |
byteplusVideo() still accepts any string for ids BytePlus ships between package releases. An unknown id relaxes local guards so Ark validates the request; known ids (including 2.5) keep their documented narrowing.
Seedance is also reachable through @tanstack/ai-fal, which proxies it alongside hundreds of other hosted models. This adapter talks to BytePlus directly, which means BytePlus billing and rate limits, model ids in BytePlus's own naming, and the first-class Seedance request fields above (camera_fixed, draft, priority, reference-media roles) rather than fal's normalized subset. Pick whichever matches the account you already have; there is no reason to install both just for Seedance.
import { generateImage } from '@tanstack/ai'
import { byteplusImage } from '@tanstack/ai-byteplus'
const result = await generateImage({
adapter: byteplusImage('dola-seedream-5-0-pro-260628'),
prompt: 'a guitar being played in a store',
size: '2K',
modelOptions: { watermark: false },
})
console.log(result.images[0]?.url)size takes either a token (1K, 2K, 4K) or explicit pixels (2048x2048) — never a mix of the two. Pass image parts in the prompt array to edit or condition on existing images (up to 14 references, 10 on dola-seedream-5-0-pro-260628):
import { generateImage } from '@tanstack/ai'
import { byteplusImage } from '@tanstack/ai-byteplus'
const result = await generateImage({
adapter: byteplusImage('seedream-5-0-260128'),
prompt: [
{ type: 'text', content: 'Put this guitar on a concert stage' },
{
type: 'image',
source: { type: 'url', value: 'https://example.com/guitar.png' },
},
],
})
console.log(result.images.length)Two behaviours surprise people:
Result URLs expire after 24 hours. Pass response_format: 'b64_json' in modelOptions to get the bytes inline instead.
Seed Speech uses BYTEPLUS_VOICE_API_KEY, not the Ark key.
import { generateSpeech } from '@tanstack/ai'
import { byteplusSpeech } from '@tanstack/ai-byteplus'
const result = await generateSpeech({
adapter: byteplusSpeech('seed-audio-1.0'),
text: 'welcome to the guitar store',
voice: 'en_female_stokie_uranus_bigtts',
format: 'mp3',
})
console.log(result.contentType, result.audio.length)Seed Speech has no top-level speaker field — the voice travels inside a references entry, so the adapter turns voice into references: [{ speaker: 'en_female_stokie_uranus_bigtts' }] for you. The consequence: modelOptions.references replaces that entry rather than merging with it, so a request that passes references for voice cloning silently drops voice. Include a speaker member yourself if you still want a stock voice alongside your clips.
The suffix on a voice id tells you which generation you are asking for:
The full roster lives in the BytePlus voice list and changes far more often than this package ships, so any string is accepted.
Output formats are wav, mp3, pcm and ogg_opus. Reach for modelOptions for ogg_opus, for an explicit sample_rate, for references (voice cloning — up to three 30-second audio clips, addressed from the text as @Audio1–@Audio3), for watermark, or for word-level timings:
import { generateSpeech } from '@tanstack/ai'
import { byteplusSpeech, type BytePlusTTSResult } from '@tanstack/ai-byteplus'
const result: BytePlusTTSResult = await generateSpeech({
adapter: byteplusSpeech('seed-audio-1.0'),
text: 'welcome to the guitar store',
modelOptions: {
format: 'ogg_opus',
sample_rate: 48000,
speech_rate: 20,
enable_subtitle: true,
},
})
for (const sentence of result.subtitle?.sentences ?? []) {
console.log(sentence.text, sentence.start_time)
}Three things to plan around:
The client half is the standard useGenerateSpeech hook; nothing about it is BytePlus-specific.
Also on the voice key. The endpoint is synchronous — audio in, transcript out, no polling:
import { generateTranscription } from '@tanstack/ai'
import { byteplusTranscription } from '@tanstack/ai-byteplus'
import { audioFile } from './audio'
const result = await generateTranscription({
adapter: byteplusTranscription('seed-asr'),
audio: audioFile,
modelOptions: { enable_punc: true, enable_speaker_info: true },
})
console.log(result.text)
for (const segment of result.segments ?? []) {
console.log(segment.speaker, segment.text)
}Audio can be a File, base64, a data URL or a public URL, up to two hours and 100 MB. enable_itn renders spoken numbers and dates as digits, enable_punc inserts punctuation, enable_ddc strips fillers, and enable_speaker_info attaches speaker labels that surface as segment.speaker.
BytePlus retires model ids aggressively and its published lists include ids that no longer resolve, so this package ships only dated ids that answered a live request. BYTEPLUS_CHAT_MODELS, BYTEPLUS_VIDEO_MODELS, BYTEPLUS_IMAGE_MODELS, BYTEPLUS_TTS_MODELS and BYTEPLUS_TRANSCRIPTION_MODELS are the authoritative lists and are exported for building model pickers.
Every factory has an environment-variable form and an explicit-key form.
Chat adapter for the Seed, GLM, DeepSeek and gpt-oss models. Reads ARK_API_KEY (or BYTEPLUS_API_KEY). config.baseURL overrides the region endpoint.
Seedance video adapter (experimental). Reads ARK_API_KEY.
Seedream image adapter. Reads ARK_API_KEY.
Seed Speech TTS adapter. Reads BYTEPLUS_VOICE_API_KEY.
Seed Speech transcription adapter. Reads BYTEPLUS_VOICE_API_KEY.
BytePlus does not expose provider-specific tool factories. Define your own with toolDefinition() from @tanstack/ai — Ark's tool calling is the standard OpenAI shape and works with the usual tools flow.