Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 75 additions & 87 deletions packages/ai/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# @opencode/ai

Schema-first APIs for text, images, decisions, and embeddings, built with Effect. Video, speech, transcription, extraction, and
realtime voice are [experimental](#experimental).
Schema-first APIs for text, images, video, decisions, and embeddings, built with Effect. Speech, transcription,
extraction, and realtime voice are [experimental](#experimental).

`@opencode/ai` is the Promise API: one managed runtime behind the LLM and media methods, plus asset helpers. `ai.file`
and `ai.write` load `node:fs/promises` on first use, so no Effect `FileSystem` is needed:
Expand Down Expand Up @@ -211,7 +211,7 @@ Raw usage is preserved in `usage.providerMetadata.google`. `inputTokens` follows
`contextTokens` uses its full `raw_prompt_token` count when supplied. These can differ substantially with server-side
continuation. Explicit caches, hosted tools, and generated media are not supported by this initial protocol.

The same configured facade names image models (and the experimental video, speech, and transcription models).
The same configured facade names image and video models (and the experimental speech and transcription models).
`ai.image.generate` resolves the provider's image route from the model and returns `Media.Asset`s with lazily decoded bytes:

```ts
Expand Down Expand Up @@ -1031,6 +1031,72 @@ const program = Effect.gen(function* () {

The hosted result is represented as a provider-executed tool call and a tool result whose content carries the generated image as a file. Gemini image-capable models instead emit a first-class `media` `LLMEvent` for inline image output (`response.message` then carries a `media` part). Retaining `response.message` preserves the generated image for continuation on both routes.

## Video generation

Video generation is a job: `start` submits it and returns at once, and the caller decides when to read its `status`,
fetch its `result`, or `cancel` it. Nothing polls for you. Every call after `start` takes the model and the job's
`token`, plain JSON you can store and read back from another process, so recovering after a restart is the same call
as a normal status read. Models come from `.video(...)` selectors on the `XAI` facade.

```ts
import { Effect } from "effect"
import { ai, Media } from "@opencode/ai/effect"
import { XAI } from "@opencode/ai/providers"

const model = XAI.configure({ apiKey: process.env.XAI_API_KEY }).video("grok-imagine-video-1.5")

const program = Effect.gen(function* () {
const job = yield* ai.video.start({
model,
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
start: "https://example.com/first-frame.png", // exact first frame; `end` pins the last one
references: [], // images, videos or audio that guide subject or style
duration: "8 seconds",
aspect: "16:9", // or "auto" to follow the start image
resolution: "720p", // 480p | 720p | 1080p | 4k, or any provider value
sound: false,
})
job.token // `{ requestID }` for xAI: store it with the provider and model ID

const status = yield* ai.video.status(model, job.token)
status.status // "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
status.progress // 0..1, only when the provider reports it

const response = yield* ai.video.result(model, job.token)
yield* Media.write(response.video, "./kitten.mp4") // streams the download to disk
})
```

The shared fields are `prompt`, `start`, `end`, `references`, `duration`, `aspect`, `resolution` and `sound`;
everything else is provider-specific and goes under `providerOptions`, typed from the selected model. A request only
fails before sending when its protocol has no way to express it, such as a reference video on xAI. Values the provider
may reject, like an unsupported duration or too many references, go to the provider, and its error comes back typed.

`status` is one read; `result` fails with an `InvalidRequest` reason when the job hasn't finished, and with the
provider's reason (and terminal document on `reason.body`) when it failed, was cancelled or expired. Moderation
outcomes (xAI `respect_moderation`) fail with a `ContentPolicy` reason when no video comes back. Status and result reads
retry transient failures (rate limits, provider 5xx, network errors) with backoff that honors `retry-after`; `start`
and `cancel` never retry. `cancel` fails with an `UnsupportedOperation` reason on providers without a cancel endpoint,
such as xAI, where the job keeps running and billing. Finished videos are usually URLs that expire (`expiresAt` when the
provider documents it): download them with `Media.write`, `asset.stream()` or `materialize()` before then.

Provider notes:

- **xAI** maps `start` to `image`, `end` to `last_frame`, image references to `reference_images`, audio references to
`reference_audios`, and `sound` to `generate_audio`, and leaves `aspect: "auto"` out when a start frame sets the
shape. It has no reference video input. `providerOptions.mode: "edit" | "extend"` sends a native `video: { url }`
source to `/videos/edits` or `/videos/extensions`.

The promise client has the same calls, each accepting `{ signal }`:

```ts
import { ai } from "@opencode/ai"

const job = await ai.video.start({ model, prompt })
const status = await ai.video.status(model, job.token)
if (status.status === "completed") await ai.write((await ai.video.result(model, job.token)).video, "./kite.mp4")
```

## Public API

Everything below is exported from `@opencode/ai/effect` unless noted.
Expand All @@ -1044,108 +1110,30 @@ Everything below is exported from `@opencode/ai/effect` unless noted.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`ai.image.request` / `generate` / `stream` / `start` / `resume`** — images over inline, streaming (partial previews), and queued routes through a provider-neutral request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
- **`ai.video.request` / `start` / `status` / `result` / `cancel`** — video generation as a job addressed by a stored token; see [Video generation](#video-generation). `VideoClient` is its Effect service and layer.
- **`Media`** — the shared asset type (`Media.Asset`, `Media.Source`) and constructors used by messages, tool results, and media requests.
- **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes.
- **`ai.decision.request` / `ai.decision.generate`** — typed predicate, choice, and score questions over text, JSON, and media input; `DecisionClient` is its Effect service and layer.
- **`ai.embedding.request` / `ai.embedding.generate`** — one vector per text or media value, with optional `dimensions` and `inputType`; `EmbeddingClient` is its Effect service and layer.
- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
- **`@opencode/ai`** (Promise root) — a default `ai` with the text calls (`request`, `generate`, `stream`, `loop`, `compact`, `canCompact`) at the top level plus `image`, `decision` and `embedding` (plus `experimental.video`, `experimental.speech`, and `experimental.transcription`) as Promise / `AsyncIterable` APIs, `run(effect)` for any package Effect, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets. `AI.make({ layer? })` builds a client over a custom executor. The same `ai` from `@opencode/ai/effect` returns `Effect` and `Stream`.
- **`@opencode/ai/experimental`** — APIs that may change without compatibility guarantees: `Video`, `Speech`, `Transcription`, `Extraction`, and `Realtime`. See [Experimental](#experimental).
- **`@opencode/ai`** (Promise root) — a default `ai` with the text calls (`request`, `generate`, `stream`, `loop`, `compact`, `canCompact`) at the top level plus `image`, `decision`, `embedding` and `video` (plus `experimental.speech` and `experimental.transcription`) as Promise / `AsyncIterable` APIs, `run(effect)` for any package Effect, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets. `AI.make({ layer? })` builds a client over a custom executor. The same `ai` from `@opencode/ai/effect` returns `Effect` and `Stream`.
- **`@opencode/ai/experimental`** — APIs that may change without compatibility guarantees: `Speech`, `Transcription`, `Extraction`, and `Realtime`. See [Experimental](#experimental).

## Experimental

These APIs may change without compatibility guarantees. The Effect API is exported from `@opencode/ai/experimental`,
and the Promise client exposes it under `ai.experimental`.

- **`Video`**, **`Speech`**, and **`Transcription`** — media generation and speech-to-text, with `VideoClient`,
`SpeechClient`, and `TranscriptionClient` as their Effect services. Promise: `ai.experimental.video`, `.speech`, and
`.transcription`.
- **`Speech`** and **`Transcription`** — speech generation and speech-to-text, with `SpeechClient` and
`TranscriptionClient` as their Effect services. Promise: `ai.experimental.speech` and `.transcription`.
- **`Extraction`** — typed values read out of text.
- **`Realtime`** — `Realtime.connect` starts an OpenAI, xAI, or Gemini voice session and returns a WebRTC answer SDP or
a short-lived WebSocket URL and token.

The provider selectors for these modalities (`.video(...)`, `.speech(...)`, `.transcription(...)`,
The provider selectors for these modalities (`.speech(...)`, `.transcription(...)`,
`.experimental.extraction(...)`) and their protocol entrypoints (such as `@opencode/ai/protocols/openai-speech`) are
part of the same experimental surface.

### Video generation

Video mirrors `Image` with one difference: every provider is asynchronous, so the route is a submit-then-poll
`Generation`. Models come from `.video(...)` selectors on the `XAI` facade.
Common fields (`frames`, `references`, `video`, `durationSeconds`, `aspect`, `resolution`, `audio`, `n`, `seed`,
`negativePrompt`) lower natively or fail with a typed `AIError` before any network call; provider-native controls live
under `providerOptions`, inferred from the selected model.

```ts
import { Video } from "@opencode/ai/experimental"
import { XAI } from "@opencode/ai/providers"

const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY })

// Simple: submit and wait.
const program = Effect.gen(function* () {
const response = yield* Video.generate(
{
model: xai.video("grok-imagine-video-1.5"),
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
aspect: "16:9",
resolution: "720p",
durationSeconds: 10,
},
{ poll: { interval: "10 seconds", timeout: "10 minutes" } },
)
// xAI returns a temporary URL: materialize before persisting, or the persisted URL may stop working.
return yield* response.video.materialize()
})

// Explicit control: keep the handle, persist the token, resume elsewhere.
const controlled = Effect.gen(function* () {
const generation = yield* Video.start({ model: xai.video("grok-imagine-video-1.5"), prompt })
generation.id // provider request id
generation.status // "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
generation.token // route-owned JSON, `{ requestID }` for xAI
// The token carries no route identity: persist the provider and model ID alongside it, since `resume` needs the model.
const saved = JSON.stringify(generation.token)

const resumed = yield* Video.resume(xai.video("grok-imagine-video-1.5"), JSON.parse(saved))
return yield* resumed.await({ poll: { interval: "10 seconds" } })
})

// Progress as a stream: generation-queued | generation-progress | video | finish.
const events = Video.stream({ model: xai.video("grok-imagine-video-1.5"), prompt }, { poll })
```

Status polls, result fetches, and cancels all run through the same request executor with the route's
auth. `Generation.await` and `Generation.events` fail with a
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Status polls and result fetches retry transient
failures (rate limits, provider 5xx, network errors) with backoff that honors `retry-after`, always within
`poll.timeout`; submits and cancels never retry. Interrupting a wait (or aborting its `signal`) does not cancel the
provider job, which keeps running and billing: call `cancel()` to stop it on providers with a cancel endpoint (xAI has
none, so `cancel()` sends nothing there). Failed, cancelled, and expired generations fail typed with the provider's
terminal document on `reason.body`; moderation outcomes (xAI `respect_moderation`) fail with a `ContentPolicy` reason
when no video comes back; when one still does, the raw outcome stays in `providerMetadata`.

Provider notes:

- **xAI** sends a `video` input to `/videos/edits`, or `/videos/extensions` with `providerOptions.mode: "extend"`.
`seed` and `negativePrompt` are not supported.

The promise client exposes the same surface: `ai.experimental.video.start(...)` resolves to a handle with `await`, `events`,
`result`, `refresh`, `cancel`, and `token`; `ai.experimental.video.generate`, `ai.experimental.video.resume(model, token)`, and
`ai.experimental.video.stream` mirror the Effect API. The handle's `status` and `progress` are a snapshot from when it was
created; `refresh()` resolves to a new handle. Every promise method and stream accepts `{ signal }`: like `fetch`,
aborting rejects the Promise or throws from the `for await` loop with `signal.reason` (an `AbortError` `DOMException`
unless `abort(reason)` passed one), while `break` stops a stream without throwing.

```ts
import { ai } from "@opencode/ai"

const generation = await ai.experimental.video.start({ model, prompt })
for await (const event of generation.events({ poll: { interval: 10_000 } })) console.log(event.type)
const video = await generation.result({ signal })
await ai.write(video.video, "./kite.mp4")
```

### Speech generation

Speech (text-to-speech) is one request whose response is parsed incrementally, so every route supports both
Expand Down
3 changes: 2 additions & 1 deletion packages/ai/src/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,18 @@ export const ai = {
image: Image,
decision: Decision,
embedding: Embedding,
video: Video,
/** Experimental: may change without compatibility guarantees. */
experimental: {
speech: Speech,
transcription: Transcription,
video: Video,
},
} as const

export { AIClient } from "./ai-client.js"
export { LLMClient } from "./route/client.js"
export { ImageClient } from "./image-client.js"
export { VideoClient } from "./video-client.js"
export { Auth } from "./route/auth.js"
export { ProviderPackage } from "./provider-package.js"
export type { LLMClientService } from "./route/client.js"
Expand Down
11 changes: 0 additions & 11 deletions packages/ai/src/experimental.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,3 @@
export { VideoClient } from "./video-client.js"
export { VideoAspectRatio, VideoEvent, VideoFrames, VideoModel, VideoModelSchema, VideoResponse } from "./video.js"
export type {
VideoModelOptions,
VideoOptions,
VideoRequest,
VideoRequestInput,
VideoResolution,
VideoRoute,
} from "./video.js"
export { Video } from "./video.js"
export { SpeechClient } from "./speech-client.js"
export { SpeechEvent, SpeechModel, SpeechModelSchema, SpeechResponse, SpeechTimestamp, SpeechVoice } from "./speech.js"
export type {
Expand Down
55 changes: 27 additions & 28 deletions packages/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,20 +204,23 @@ export const make = (options: Options = {}) => {
options,
)
},
/** Write an asset's bytes like `Media.write`, downloading `url` sources through the executor. */
/** Write an asset's bytes like `Media.write`: streamed through a temporary file that replaces `path` on success. */
write: async (asset: Media.Asset, path: string, options?: RunOptions) => {
const { writeFile } = await import("node:fs/promises")
return run(
asset.bytes().pipe(
Effect.flatMap((data) =>
Effect.tryPromise({
try: (signal) => writeFile(path, data, { signal }),
catch: (cause) => fileError(`Failed to write media file ${path}`, cause),
}),
),
),
options,
)
const { open, rename, rm } = await import("node:fs/promises")
const temporary = `${path}.${crypto.randomUUID()}.tmp`
try {
const file = await open(temporary, "w")
try {
for await (const chunk of iterate(asset.stream(), options)) await file.write(chunk)
} finally {
await file.close()
}
await rename(temporary, path)
} catch (cause) {
await rm(temporary, { force: true })
if (cause instanceof AIError || cause === options?.signal?.reason) throw cause
throw fileError(`Failed to write media file ${path}`, cause)
}
},
request: LLM.request,
generate,
Expand Down Expand Up @@ -255,6 +258,17 @@ export const make = (options: Options = {}) => {
generate: <const Model extends EmbeddingModel>(input: EmbeddingRequestInput<Model>, options?: RunOptions) =>
run(Embedding.generate(input), options),
},
video: {
request: Video.request,
/** Submit a generation and return its `{ id, token }` at once; nothing polls. */
start: <const Model extends VideoModel>(input: VideoRequestInput<Model> | VideoRequest, options?: RunOptions) =>
run(Video.start(input), options),
/** One status read for a stored token. */
status: (model: VideoModel, token: unknown, options?: RunOptions) => run(Video.status(model, token), options),
/** The finished videos; fails while the job is still running. */
result: (model: VideoModel, token: unknown, options?: RunOptions) => run(Video.result(model, token), options),
cancel: (model: VideoModel, token: unknown, options?: RunOptions) => run(Video.cancel(model, token), options),
},
/** Experimental: may change without compatibility guarantees. */
experimental: {
speech: {
Expand Down Expand Up @@ -285,21 +299,6 @@ export const make = (options: Options = {}) => {
resume: (model: TranscriptionModel, token: unknown, options?: RunOptions) =>
run(Transcription.resume(model, token), options).then(handle),
},
video: {
request: Video.request,
start: <const Model extends VideoModel>(input: VideoRequestInput<Model> | VideoRequest, options?: RunOptions) =>
run(Video.start(input), options).then(handle),
generate: <const Model extends VideoModel>(
input: VideoRequestInput<Model> | VideoRequest,
options?: AwaitOptions & RunOptions,
) => run(Video.generate(input, { poll: options?.poll }), options),
resume: (model: VideoModel, token: unknown, options?: RunOptions) =>
run(Video.resume(model, token), options).then(handle),
stream: <const Model extends VideoModel>(
input: VideoRequestInput<Model> | VideoRequest,
options?: AwaitOptions & RunOptions,
) => iterate(Video.stream(input, { poll: options?.poll }), options),
},
},
dispose: () => runtime.dispose(),
}
Expand Down
8 changes: 7 additions & 1 deletion packages/ai/src/media-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,16 @@ export const composeRoute = <Request extends MediaRoute.MediaRequest, Event, Res
collect: (events: ReadonlyArray<Event>) => Effect.Effect<Response, AIError>,
): MediaRoute.AnyRoute<Request, Event, Response> => {
if (isStreamInput(route)) return MediaRoute.stream({ ...composition(route, input), collect })
if (isQueuedInput(route)) return MediaRoute.queued(composition(route, input))
if (isQueuedInput(route)) return composeQueuedRoute(route, input)
return MediaRoute.inline(composition(route, input))
}

/** Compose a queued protocol route input with one deployment through `MediaRoute.queued`. */
export const composeQueuedRoute = <Request extends MediaRoute.MediaRequest, Response, Token>(
route: MediaModel.QueuedRouteInput<Request, Response, Token>,
input: MediaRoute.ModelInput,
) => MediaRoute.queued(composition(route, input))

const composition = <Request extends MediaRoute.MediaRequest, Protocol>(
route: MediaModel.RouteInput<Request, Protocol>,
input: MediaRoute.ModelInput,
Expand Down
Loading
Loading