34 KiB
Media generation in @opencode/ai — public API direction
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
Goal
@opencode/ai becomes the one package you reach for to generate anything: text, images, video, speech, transcripts, and later music and realtime. The LLM surface already exists and is shaped by three constraints: Effect-first, used by OpenCode Core, usable externally. Media has a different priority order: external DX first, Effect and Promise as peers, Core as one consumer among many.
The design below is derived from a survey of the raw provider APIs (OpenAI, Gemini/Veo/Imagen, xAI, Stability, BFL, fal, Replicate, Runway, Luma, Kling, MiniMax, ElevenLabs, Deepgram, Cartesia, AssemblyAI, Lyria) and of existing multi-provider SDKs.
What the survey forces
- Three execution shapes, everywhere. Inline sync (OpenAI images, all TTS, Gemini), async job with polling or webhook (every video provider, BFL, fal, Replicate, AssemblyAI), and bidirectional streams (ElevenLabs/Cartesia/Deepgram WS, realtime). Video has no sync provider at all.
- Output is never just bytes. base64, signed URLs with TTLs from 10 minutes (BFL, so its route downloads before returning via
PollContext.materialize) to 2 days (Veo), URLs that need auth plus redirect (Veo), separate download endpoints (Sora/content?variant=), raw bodies (Stability, TTS). Multi-output is the norm. - Inputs have roles. First/last frame, mask, style/subject reference, source video for edit/extend, reference audio, prior generation id, provider-side file handles (
file_id,gs://,runway://,mm_file://). - Partial streaming is modality-specific. Images: a few whole partial frames. Audio: ordered chunks plus timestamp events. Jobs: status/progress/logs. Video: none.
- Usage is a union: tokens, seconds, characters (often only in headers), credits, compute time.
- Moderation can be partial success (Veo strips audio but returns video). Deprecations are constant (Sora API shuts down 2026-09-24; Imagen is shut down on the Gemini API and past its 2026-06-30 discontinuation date on Vertex).
Where existing SDKs are weak and we should not be
- No streaming TTS.
- Video handles are experimental start/status pairs; the polling loop lives inside the generate call.
- Unsupported inputs become silent warnings arrays, so a request can succeed while dropping your mask.
nis fanned out into hidden parallel calls, which obscures cost and idempotency.- Each modality has its own bespoke result type; the file abstraction is a lazy base64/bytes pair with no URL, expiry, or provider ref.
- Effect's own
unstable/aihas no media generation. Nothing in the Effect ecosystem owns this.
Design principles
- Same shape as LLM.
X.request(...)→ Schema class;X.generate(request)/X.stream(request);XClient.Service+layer; typedAIError. If you knowLLM, you knowVideo. - Execution shape is route policy, not API shape.
Image.generatereturns an image whether the provider is inline or queued. Job control is available uniformly when you want it. - Errors, not warnings. Unsupported common fields fail at the protocol boundary with a typed
AIError, as the LLM routes do today. Provider-side partial results (filtered audio, moderated sample) surface asnoticeson the response, never as silent drops. - One asset type in, one asset type out, shared with LLM messages and tool results.
- Typed per-model options, no hidden fan-out, no implicit retries that spend money.
- Promise API is one mechanism for the whole package, not a media-only wrapper.
- One construction path per model. Media models come from per-modality selectors on the configured facade (
openai.image("gpt-image-2")), the same shape asopenai.responses("gpt-5").
Public API
Model selection
A model value is built as OpenAI.configure({ apiKey }).responses("gpt-5") or .image("gpt-image-2"): configure fixes credentials, endpoint, and defaults; the selector fixes which of the provider's APIs to hit and binds the typed providerOptions generic. Media follows the same shape with one selector per modality — openai.image(id) today, .video(id) / .speech(id) / .transcription(id) as those modalities land — mirroring openai.responses(id). Image.request accepts ImageModel only, exactly as LLM.request accepts LanguageModel.
import { OpenAI, Google } from "@opencode/ai/providers"
const openai = OpenAI.configure({ apiKey }) // OpenAI(...) alone uses env auth (OPENAI_API_KEY)
LLM.request({ model: openai.responses("gpt-5"), prompt })
Image.request({ model: openai.image("gpt-image-2"), prompt })
Video.request({ model: google.video("veo-3.1-generate-preview"), prompt })
Speech.request({ model: openai.speech("gpt-4o-mini-tts"), text })
Transcription.request({ model: openai.transcription("gpt-4o-transcribe"), audio })
The request namespace and the selector share one word (Image.request + .image(...)). That redundancy is accepted: a callable facade returning a lazily resolved ref would be a second way to construct the same model, and the type machinery to infer providerOptions through it is not worth one word. Where a provider has two routes for one modality, the selectors stay explicit (openai.chat, stability.image inline vs stability.upscale() queued), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native; Imagen is shut down, so there is no google.imagen). The facade selector (openai.image(id)) is the public path for media models. Modality-specific package entrypoints (model(modelID, settings) beside today's LLM paths such as @opencode/ai/providers/openai/responses) are deferred until Core has a modality-aware model resolver; Core's resolver accepts only LanguageModel today.
Media — the asset type
Replaces MediaPart.data: string | Uint8Array, ImageInput, GeneratedImage, and aligns Tool.FileContent.
import { Media } from "@opencode/ai"
Media.Source =
| { type: "bytes"; data: Uint8Array; mediaType: string }
| { type: "base64"; data: string; mediaType: string }
| { type: "url"; url: string; mediaType?: string; expiresAt?: number }
| { type: "ref"; provider: ProviderID; id: string; mediaType?: string } // file_id, gs://, runway://, prior generation
class Media.Asset {
readonly source: Media.Source
readonly mediaType: string // always resolved (sniffed when the provider omits it)
readonly kind: "image" | "video" | "audio" | "document" | "other"
readonly info?: { width?; height?; durationSeconds?; sampleRate?; channels?; encoding?; format? }
readonly expiresAt?: number
readonly providerMetadata?: ProviderMetadata
readonly headers?: Record<string, string> // transient download credentials (Veo); never in source/JSON
bytes(): Effect<Uint8Array, AIError, RequestExecutor.Service> // downloads/decodes lazily, cached
base64(): Effect<string, AIError, RequestExecutor.Service>
dataUrl(): Effect<string, AIError, RequestExecutor.Service>
materialize(): Effect<Media.Asset, AIError, RequestExecutor.Service> // url/ref → bytes, before the URL dies
}
Media.bytes(data, mediaType?) Media.base64(data, mediaType?)
Media.url(url, options?) Media.ref(provider, id)
Media.file(path) // Effect<Asset, AIError, FileSystem>: reads + sniffs
Media.write(asset, path) // Effect<void, AIError, FileSystem | RequestExecutor.Service>
Media.file and Media.write stay Effect-only: bring your platform's FileSystem layer. The Promise client owns the
runtime path: ai.file(path) and ai.write(asset, path) read and write through node:fs/promises (loaded on first
use) with the same media-type sniffing and InvalidRequest failures, and ai.bytes, ai.base64, and
ai.materialize run the asset methods in its runtime.
A ref source is accepted as input only by routes whose provider issues file handles. No shipped route produces one
yet, so bytes() and materialize() on a ref fail by design until a producer exists.
Raw-PCM outputs (Gemini TTS, Cartesia raw, Deepgram WS) carry info.encoding/sampleRate/channels because there is no container header.
Modality namespaces
Each namespace mirrors LLM exactly.
import { Image, Video, Speech, Transcription } from "@opencode/ai"
import { OpenAI, Google, ElevenLabs, Fal } from "@opencode/ai/providers"
Image
Effect.gen(function* () {
const request = Image.request({
model: openai.image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
images: [yield* Media.file("./ref.png")], // references / edit sources
mask: yield* Media.file("./mask.png"),
n: 2,
size: "1536x1024", // OpenAI sizes by pixels; Gemini/xAI take aspectRatio instead
format: "webp",
providerOptions: { quality: "high", background: "transparent" }, // typed per model
})
const response = yield* Image.generate(request) // ImageResponse
response.image // Media.Asset (first)
response.images // Media.Asset[]
response.usage // Usage union (see below)
response.notices // moderation / partial-result notices
Image.stream(request) // Stream<ImageEvent>
// ImageEvent: generation-queued | generation-progress | image-partial { index, image } | image { index, image } | finish { usage }
})
size and aspectRatio are not interchangeable; each route rejects fields it cannot lower — see the README's Image
portability matrix.
Editing is not a separate function; images/mask on the request select the edit path in the route (OpenAI /images/edits, Gemini multimodal parts, xAI /images/edits). Routes that cannot honor mask fail with Unsupported.
ImageRoute is the inline | stream | queued union, dispatched on route.kind, like every modality route. Image.stream on a streaming route emits image-partial previews before each image; on a queued route it emits generation-queued / generation-progress observations, then the result's image and finish events.
Video
Shipped in phase 2 (src/video.ts, src/video-client.ts, protocols google-video, xai-video, fal-video, runway-video).
Effect.gen(function* () {
const request = Video.request({
model: google.video("veo-3.1-generate-preview"),
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
frames: { first: yield* Media.file("./start.png"), last: yield* Media.file("./end.png") },
references: [yield* Media.file("./style.png")],
video: Media.bytes(previous, "video/mp4"), // edit / extend source
durationSeconds: 8,
aspectRatio: "16:9",
resolution: "1080p",
audio: true,
n: 1,
seed: 7,
negativePrompt: "text, watermark", // common, not provider-native
providerOptions: { personGeneration: "allow_adult" },
})
// Simple: wait for it.
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
response.usage // credits on Runway; the other three report none
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
yield* response.video.materialize() // pull bytes before the URL expires
// Explicit generation control.
const generation = yield* Video.start(request) // Generation<VideoResponse>
generation.id; generation.status; generation.progress; generation.position; generation.token
yield* generation.await({ poll }) // VideoResponse
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
// Resume from another process. The token is validated against the route's codec and refreshed once. It carries no
// route identity, so persist the provider and model ID alongside it: `resume` needs the model.
const resumed = yield* Video.resume(model, JSON.parse(saved))
// Progress as a stream.
Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
})
Tokens are route-owned JSON: Veo { operation }, xAI { requestID }, Runway { taskID }, fal
{ requestID, statusURL, responseURL, cancelURL } (fal's follow-up URLs are authoritative and absolute). Common-field
lowering per provider: Veo takes inline media only and rejects audio: false and n > 1; xAI rejects seed and
negativePrompt and routes a video input to edits or (providerOptions.mode: "extend") extensions; fal rejects
durationSeconds, references, and frames.last because the field names and enums differ per model; Runway passes
aspectRatio through as its pixel ratio and rejects n.
Deferred: Video.complete(model, token, webhook) (finish from a webhook payload without polling) and provider poll
hints (none of the four providers emit one). Later providers: Luma, Kling, MiniMax, Replicate.
Speech (TTS)
Shipped in phase 3 (src/speech.ts, src/speech-client.ts, protocols openai-speech, google-speech,
elevenlabs-speech, cartesia-speech, deepgram-speech; new ElevenLabs, Cartesia, and Deepgram facades).
const request = Speech.request({
model: elevenlabs.speech("eleven_flash_v2_5"),
text: "Hello from OpenCode.",
voice: "JBFqnCBsd6RMkjVDRZzb", // provider-native identifier, or { id }
format: "mp3", // mp3 | wav | pcm | opus | aac | flac | (string & {})
speed: 1.0,
language: "en",
instructions: "Warm, unhurried.", // only OpenAI; elsewhere fails typed
timestamps: true, // request alignment; routes without it fail typed
providerOptions: { voice_settings: { stability: 0.5 } },
})
const response = yield* Speech.generate(request) // SpeechResponse: audio: Media.Asset, timestamps?, usage?, providerMetadata?
yield* Speech.stream(request) // Stream<SpeechEvent>: generation-queued | generation-progress | audio-delta { chunk } | timestamps { items } | finish { audio, usage? }
Execution is MediaProtocol.stream for every provider: one request whose body is framed and folded by a step
state machine, with generate running the same stream and collecting it. The route submits the request with its
mode ("generate" | "stream"), which lets one provider stay one protocol — OpenAI adds stream_format: "sse" (except tts-1/tts-1-hd, which stream raw bytes), ElevenLabs appends
/stream, Cartesia switches /tts/bytes to /tts/sse, Gemini switches generateContent to
streamGenerateContent. The terminal finish event carries the assembled asset (every provider's stream is
concatenable chunks), so stream consumers also get the whole file and generate is just "take finish, gather
timestamps". The cost is memory: a stream holds every chunk until finish, so even a consumer that only plays deltas
keeps the whole clip in memory. That is bounded by the providers' input text limits (a few minutes of audio); a
long-form or session API would need an opt-out.
Voice. voice?: string | { id: string }. A string is passed through as the provider's native identifier — a
name on OpenAI and Gemini, a voice id on ElevenLabs (path segment) and Cartesia. { id } selects an OpenAI custom
voice and is treated as the plain string on routes that do not distinguish custom from built-in. Deepgram's voice is
the model id (aura-2-thalia-en), so voice is unsupported there. There is no cross-provider voice catalog or
name→id resolution. Multi-speaker (Gemini speechConfig.multiSpeakerVoiceConfig) and per-voice settings
(ElevenLabs voice_settings) go through providerOptions.
Format and PCM. format is container-level; provider sample rates and bitrates live under providerOptions
(ElevenLabs outputFormat, Cartesia sampleRate/bitRate/encoding, Deepgram encoding/container/sampleRate/
bitRate). Each protocol maps format to its native value (ElevenLabs mp3_44100_128/pcm_24000/wav_24000/
opus_48000_64, Cartesia { container, encoding, sample_rate }, Deepgram encoding+container) and declares the
asset's media type rather than sniffing, because headerless PCM can look like an MPEG frame sync. Headerless PCM
always carries info.encoding, info.sampleRate, and info.channels; its media type is the provider's declaration
(Gemini audio/L16;codec=pcm;rate=24000, Deepgram's content-type) or audio/pcm. Gemini returns PCM only, so any
other format is rejected rather than wrapped as WAV by the route. Every format value a route cannot produce (unknown
to it, a container on Cartesia SSE, WAV on an ElevenLabs stream, anything but PCM on Gemini) fails the same way as an
unsupported field: UnsupportedOperation with operation: "media.format".
Timestamps. timestamps: true on the request asks for alignment. ElevenLabs selects the with-timestamps
endpoints (character-level, NDJSON when streaming); Cartesia sets add_timestamps on /tts/sse (word-level; a
generate with timestamps collects the SSE stream). OpenAI, Gemini, and Deepgram reject it.
Common-field lowering per provider:
| Provider | voice |
speed |
language |
instructions |
timestamps |
Usage |
|---|---|---|---|---|---|---|
| OpenAI | voice (name or { id }) |
speed |
unsupported | instructions |
unsupported | tokens from SSE speech.audio.done only |
| Gemini | prebuiltVoiceConfig.voiceName |
unsupported | speechConfig.languageCode |
unsupported (direct in text) | unsupported | tokens from usageMetadata |
| ElevenLabs | path voice id (required) | voice_settings.speed |
language_code |
unsupported | with-timestamps |
credits from character-cost header |
| Cartesia | voice (required) |
generation_config.speed |
language |
unsupported | add_timestamps |
none |
| Deepgram | unsupported (voice is the model) | speed query |
unsupported | unsupported | unsupported | characters from dg-char-count header |
Deferred: Speech.session(...) — input-streaming TTS where text arrives incrementally over a WebSocket (ElevenLabs
stream-input, Cartesia WebSocket contexts, Deepgram WebSocket speak) — is a separate scoped resource, not part of
generate/stream, and ships with the realtime work in phase 5.
Transcription (STT)
Shipped as the second half of phase 3 (src/transcription.ts, src/transcription-client.ts, protocols
openai-transcription, google-transcription, deepgram-transcription, assemblyai-transcription; new AssemblyAI
facade).
const request = Transcription.request({
model: openai.transcription("gpt-4o-transcribe-diarize"),
audio: yield* Media.file("./call.wav"),
language: "en", // provider-native passthrough
timestamps: "segment", // none | segment | word
diarize: true,
speakers: 2, // expected count, hint only (AssemblyAI)
providerOptions: { known_speaker_names: ["agent"] },
})
const response = yield* Transcription.generate(request)
response.text; response.segments; response.words; response.language; response.durationSeconds; response.usage
yield* Transcription.stream(request) // Stream<TranscriptionEvent>: generation-queued | generation-progress | text-delta | segment | finish
const generation = yield* Transcription.start(request) // queued routes only
yield* Transcription.resume(model, token)
Transcription is the first modality whose providers span all three protocol kinds, and it needed no fourth kind.
Every MediaRoute now carries its kind; TranscriptionRoute is the union of the inline, stream, and queued routes;
TranscriptionModel.fromRoute is overloaded per protocol kind (arity picks the overload: <Options>,
<Options, Frame, State>, <Options, Token>) and composes through MediaRoute.inline / stream / queued; and
TranscriptionClient dispatches on route.kind. generate on a queued route is start then await; stream on an
inline route is the response as a single finish, and on a queued route it is the status observations followed by
finish. start / resume on a non-queued route fail with UnsupportedOperation (transcription.start). The
finish event carries the whole transcript (text, segments, words, language, duration, usage), so the stream route's
collect is just "take finish".
The route layer gained a binary body with array-valued query (Deepgram) and Queued.start.prepare (AssemblyAI's
upload); packages/ai/AGENTS.md (Media Routes) describes both.
Settled rules:
- Timestamps. A granularity the selected route or model cannot produce fails as
UnsupportedOperation(media.timestamps), following Speech; a route that returns more than asked (Deepgram and AssemblyAI always return words) is not stripped. Segments always carry start and end times: Gemini times each transcription part from its word offsets, so segment timestamps and diarization also request word offsets there. - Diarization.
diarizemeans segments (and words, where the provider labels them) carryspeaker. Labels are provider-native strings — OpenAIAor a known speaker name, Deepgram0, Geminispk:0, AssemblyAIA— with no cross-provider speaker model.speakersis a hint; only AssemblyAI (speakers_expected) accepts it. - Language is passed through (
language, OpenAIgpt-transcribelanguages[], GeminilanguageCodes, AssemblyAIlanguage_code).response.languageis the provider's own value, lowercased but not normalized: an ISO code on most routes,englishfrom whisper-1,en_usfrom AssemblyAI. Deepgram and AssemblyAI assume English unless asked to detect, so a missinglanguageenables their detection. - Gemini requires a transcribe model; other model ids fail with
UnsupportedOperationbefore the call, because general models ignoreaudioTranscriptionConfigand answer conversationally. Streamed chunks carry whole speaker turns (one part per turn), which join with a space. - Streaming inline providers emit only
finish; deltas are never faked. - Units. AssemblyAI milliseconds and Gemini protobuf durations (
"0.400s") are normalized to seconds at the protocol boundary.
| Provider | Kind | Audio input | timestamps |
diarize |
Unsupported | Usage |
|---|---|---|---|---|---|---|
| OpenAI | stream (stream: true in stream mode) |
multipart file (inline only) |
whisper-1 (verbose_json); diarize model: segment |
gpt-4o-transcribe-diarize (diarized_json) |
speakers; prompt on the diarize model; streaming on whisper-1 |
tokens or seconds |
| Gemini | stream (generateContent / streamGenerateContent) |
inlineData or Gemini Files fileData |
audioTranscriptionConfig.wordTimestamp |
audioTranscriptionConfig.diarization |
prompt, speakers |
tokens |
| Deepgram | inline | raw body, or JSON { url } |
words always; segment → utterances |
diarize_model=latest + utterances |
prompt, speakers |
seconds (metadata.duration) |
| AssemblyAI | queued (upload → submit → poll) | /v2/upload then audio_url, or a URL |
words always; segment → speaker_labels |
speaker_labels |
— | seconds (audio_duration) |
Deferred: Transcription.session(...) — realtime STT over WebSocket (Deepgram live, AssemblyAI streaming, ElevenLabs
realtime, OpenAI realtime transcription) — is the same future scoped session shape as input-streaming TTS and ships
with the realtime work in phase 5. ElevenLabs Scribe is not implemented yet.
Generation — shared async execution
class Generation<Response> {
readonly id: string
readonly route: GenerationRoute<Response> // token-free: { status, result, cancel?: Effect } closed over the decoded token
readonly token: unknown // route-owned serializable JSON
readonly status: "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
readonly progress?: number // 0..1, normalized
readonly position?: number
refresh(): Effect<Generation<Response>, AIError>
result(): Effect<Response, AIError>
await(options?: GenerationAwaitOptions): Effect<Response, AIError>
cancel(): Effect<void, AIError>
events(options?: GenerationAwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
}
GenerationAwaitOptions = { poll?: Poll }
Poll = { interval?: Duration; timeout?: Duration }
Generation is not video-specific. Image routes on BFL, fal, and Replicate are queued; Image.start exists for them. A route declares itself inline or queued; generate on a queued route is start then await.
Usage
Usage =
| { type: "tokens"; input; output; total; details? }
| { type: "seconds"; seconds }
| { type: "characters"; characters }
| { type: "credits"; credits }
| { type: "compute"; seconds }
Header-only usage (ElevenLabs character-cost, Deepgram dg-char-count) is lifted into usage by the route.
Promise API — @opencode/ai/promise
Mirrors the packages/plugin/src/effect and packages/plugin/src/promise split that already exists in this repo. One mechanism for LLM and media.
import { AI } from "@opencode/ai/promise"
const ai = AI.make() // ManagedRuntime over RequestExecutor.fetchLayer + all clients
// AI.make({ layer }) to inject a custom executor / recorder / middleware
const image = await ai.image.generate({ model, prompt })
await ai.bytes(image.image) // also ai.base64, ai.materialize, ai.write(asset, path)
const reference = await ai.file("./ref.png")
for await (const event of ai.speech.stream({ model, text, voice })) { … }
const generation = await ai.video.start({ model, prompt }) // snapshot handle; refresh() returns a new one
for await (const event of generation.events({ poll: { interval: 10_000 } })) { … }
const video = await generation.await({ poll: { interval: 10_000 }, signal })
const resumed = await ai.video.resume(model, JSON.parse(saved)) // persist provider + model ID with the token
const request = ai.llm.request({ model, prompt })
const text = await ai.llm.generate(request)
for await (const event of ai.llm.stream(request)) { … }
await ai.dispose()
Streams become AsyncIterable via Stream.toAsyncIterable. AIError is thrown as-is. AbortSignal maps to interruption. Nothing in src/* except this entrypoint knows about promises.
Providers
Existing facades gain per-modality selectors; the modality routes each facade provides:
| Facade | llm | image | video | speech | transcription | other |
|---|---|---|---|---|---|---|
OpenAI |
responses (default), chat | Images API (stream) | Sora (deprecated 2026-09-24) | ✓ | ✓ | |
Google |
Gemini | Gemini-native | Veo | Gemini TTS | gemini-3.5-transcribe |
|
XAI |
✓ | ✓ | ✓ | |||
ElevenLabs |
✓ | Scribe | soundEffect, music | |||
Cartesia |
✓ | |||||
Deepgram |
Aura | ✓ | ||||
Fal |
✓ (queued) | ✓ | ||||
AssemblyAI |
✓ (queued) | |||||
BlackForestLabs |
✓ (queued) | |||||
Replicate |
✓ (queued) | |||||
Stability |
image (inline), upscale() (queued) |
|||||
Runway |
✓ | |||||
Luma, Kling, MiniMax |
per provider |
New facades follow the existing one-file-per-provider rule. The facade selector is the public path for media models; modality-specific package entrypoints (for example @opencode/ai/providers/openai/images) are deferred until Core has a modality-aware model resolver.
ImageModel<Options> gives typed providerOptions per model; VideoModel, SpeechModel, and TranscriptionModel follow the same generic. As with LanguageModel, the route type does not carry Options, so ImageModel<OpenAIImageOptions> is an ImageModel and client methods take plain ImageRequestFor. They share an internal MediaModel base class (ids, route, http overlays) that is not part of the public exports; Generation and the promise client work with the concrete modality models.
Routes and protocols
Media does not fit the LLM four-axis route (SSE frames → event state machine) except for streaming TTS/STT. Reuse Endpoint, Auth, Framing, RequestExecutor, and add media protocol kinds:
MediaProtocol.inline—body.from(request)(JSON, multipart, or query),response.decode(response)(JSON, or binary body →Media.Asset).MediaProtocol.queued—start(body + decode to{ token, snapshot }),status,result, optionalcancel, and atokencodec.resultis always a separate GET (against the status document for Veo/xAI/Runway, fal'sresponse_urlotherwise) soawaitafterstartand afterresumeshare one path.PollContext.authhands the auth headers the route sent to the protocol for output URLs that need them (Veo downloads); they become transientMedia.Asset.headers, never part ofsource. There is no separatedownloadstep:Media.Asset.bytes()downloads through the executor with those headers.MediaRoute.inline(...)/MediaRoute.queued(...)compose each kind with endpoint and auth; the queued route decodes the token once and handsGenerationa token-free{ status, result, cancel? }.MediaProtocol.stream—body.from(request)over the request plus itsmode,frames(a function that picks the framing for the call:Framing.sse,lines,document, or the raw bytes), fresh per-responseinitial()state,stepemitting modality events, andfinish(state, context)— with the observed response for header-only usage — emitting exactly one terminal event or failing as an incomplete stream. The route fillsreason.httpon stream errors.MediaRoute.stream(...)exposesstreamandgenerate(the same stream folded by the modality'scollect).
MediaRoute.inline / MediaRoute.queued / MediaRoute.stream compose one protocol kind with endpoint/auth and tag the route with its kind; ImageModel/VideoModel/SpeechModel/TranscriptionModel share the MediaModel base (src/media-model.ts).
LLM integration
MediaPartbecomes{ type: "media"; media: Media.Asset; … }so protocols branch onkindand can passurl/refsources through natively (OpenAIimage_url, GeminifileData).- New
LLMEvents:media { media: Media.Asset }so Gemini inline image output is first-class instead of dropped. OpenAI Responsesimage_generation_callkeeps its single carrier — the provider-executedtool-resultwithfilecontent — because Core consumes hosted tool-result content today and has nomediaevent handling yet; it switches to themediacarrier when Core adopts the event, so the image is never emitted twice. Message.assistant([...])accepts media parts; Gemini multi-turn image editing replays them.Tool.FileContentaligns withMedia.Source.
Decisions
All settled:
- Per-modality selectors (
openai.image(id),.video,.speech,.transcription) name media models, mirroringopenai.responses(id). The one-word overlap with the request namespace is accepted over a callable-facadeModelRefas a second construction path. providerOptionseverywhere (rename currentImage.options) for consistency with LLM.- No hidden
nfan-out.nlowers natively; routes that cannot don > 1fail typed. Callers useEffect.all/Promise.allexplicitly. - Errors over warnings for unsupported common fields;
noticesfor provider-side partial results only. Media.Assetis a class (lazy bytes, cached) withMedia.Sourceas the serializable Schema for wire/persistence.Asset.from(source)/asset.sourceround-trip losslessly. Same pattern asLanguageModeltoday.- Promise entrypoint:
@opencode/ai/promiseexportingAI.make(options?: { layer? })plus a module-level defaultaifor scripts, covering LLM too. - Modality set for v1:
Image,Video,Speech,Transcription.Music/SoundEffectandsession(bidirectional WS, realtime) are designed-for but deferred. - Sora is skipped (API shuts down 2026-09-24). Video launches with Veo, xAI, fal, Runway.
Build order
Foundation + Image ship together as the reference implementation, serially. Video, Speech, and Transcription then proceed in parallel on separate branches. Image jobs and partial streaming come last, after Video has hardened Generation.
Phasing
- Foundation — per-modality selectors,
Media,Generation,Poll,Usageunion,MediaProtocolkinds,@opencode/ai/promisewithllm+image. Port the five existing image protocols onto it. UnifyMediaPartand add themediaLLM event (fixes Gemini image output being dropped). - Video — ✅ Veo, xAI, fal, Runway shipped (
MediaProtocol.queued,Video.start/generate/resume/stream, promiseai.video). Deferred:Video.complete(webhooks), Luma, Kling, MiniMax, Replicate. - Speech + Transcription — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram shipped (
MediaProtocol.stream,Speech.generate/stream, promiseai.speech). ✅ Transcription: OpenAI, Gemini, Deepgram, AssemblyAI shipped across all three route kinds (Transcription.generate/stream/start/resume, promiseai.transcription). Pending: ElevenLabs Scribe. Deferred:Speech.sessionandTranscription.session(WebSocket streaming). - Image queued routes and partials — ✅ BFL, fal, Replicate, and Stability creative upscale queued; Stability generate inline; OpenAI
partial_imagesstreaming (image-partialrestored). Imagen dropped: shut down on the Gemini API and discontinued on Vertex (2026-06-30). Deferred: Stability's synchronous edit and fast/conservative upscale endpoints. - Later — ElevenLabs music/SFX, Lyria,
Speech.session/Transcription.session, realtime.
Core adoption (session attachments beyond png/jpeg/gif/webp/pdf, image-generation tool, TUI rendering) comes after phase 1 and is a Core concern.