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 — the 2.0 family rejects 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) | the dreamina-seedance-2-0-* family only |
| duration: -1 (model picks the length) | Seedance 2.0 and seedance-1-5-pro-251215 |
| 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.0 and 1.5-pro actually produce an audio track.
Resolutions are per model too, and the shipped table comes from live probes rather than the published docs: there is no 2K tier on any Seedance model, 4k exists only on dreamina-seedance-2-0-260128, and seedance-1-0-pro-fast-251015 does accept 1080p despite being documented 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. The Seedance 2.0 family takes full multimodal references (reference images, video and audio); the 1.x models take start/end frames only, and seedance-1-0-pro-fast-251015 takes a start frame only.
Seedance 2.5 was announced on 2026-07-31, initially on BytePlus's consumer surfaces. Its Ark id — dreamina-seedance-2-5-260628 — is real and reachable, but it is activation-gated per account: until the model is switched on in the Ark Console, Ark answers 404 ModelNotOpen.
Because its capabilities could not be probed from a non-activated account, 2.5 is deliberately absent from the typed model tables above. It is still usable today — byteplusVideo()'s model parameter accepts any string, so an id BytePlus publishes after this release works without upgrading the package:
import { generateVideo } from '@tanstack/ai'
import { byteplusVideo } from '@tanstack/ai-byteplus'
// Activate Seedance 2.5 in the Ark Console first, or this 404s.
const { jobId } = await generateVideo({
adapter: byteplusVideo('dreamina-seedance-2-5-260628'),
prompt: 'a guitar being played in a store',
size: '16:9_720p',
duration: 5,
})An unknown id relaxes both halves of the adapter: size widens to any string, provider options are ungated, and the local runtime guards that encode per-model capabilities — resolution tiers, closing-frame and reference-media support, frame cardinality and mode exclusivity, duration snapping — stand down so Ark validates the request instead. Known ids keep their probe-verified narrowing. Typed narrowing for 2.5 follows in a package update once its capabilities can be verified.
The quickest way to try a newly-released id is the Seedance Studio example: its Advanced: custom model id field (placeholder dreamina-seedance-2-5-260628) takes an arbitrary id, switches the studio into unknown-model mode with every option enabled, and spells the activation caveat out in the UI — so a ModelNotOpen response reads as expected rather than broken.
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.