@opencode/ai 0.0.0-dev-20115 → 0.0.0-dev-20116

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,10 @@
1
1
  # @opencode/ai
2
2
 
3
- Schema-first language model and image-generation APIs built with Effect.
3
+ Schema-first APIs for text, images, video, speech, and transcription, built with Effect.
4
4
 
5
5
  ```ts
6
- import { Effect, Layer } from "effect"
7
- import { LLM, LLMClient } from "@opencode/ai"
8
- import { RequestExecutor } from "@opencode/ai/route"
6
+ import { Effect } from "effect"
7
+ import { AIClient, LLM } from "@opencode/ai"
9
8
  import { OpenAI } from "@opencode/ai/providers"
10
9
 
11
10
  const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
@@ -18,23 +17,23 @@ const request = LLM.request({
18
17
  })
19
18
 
20
19
  const program = Effect.gen(function* () {
21
- const response = yield* LLMClient.generate(request)
20
+ const response = yield* LLM.generate(request)
22
21
  console.log(response.text)
23
22
  })
24
23
 
25
- const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
26
-
27
- await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
24
+ // Every modality client plus the HTTP request executor; `AIClient.layerWith(executor)` swaps the executor.
25
+ await Effect.runPromise(program.pipe(Effect.provide(AIClient.layer)))
28
26
  ```
29
27
 
30
- Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
28
+ Run `LLM.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses,
29
+ Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
31
30
 
32
- The same configured facade names image models. `Image.request` resolves the provider's image route from the ref and
33
- returns `Media.Asset`s with lazily decoded bytes:
31
+ The same configured facade names image, video, speech, and transcription models. `Image.generate` resolves the
32
+ provider's image route from the model and returns `Media.Asset`s with lazily decoded bytes:
34
33
 
35
34
  ```ts
36
35
  import { NodeFileSystem } from "@effect/platform-node"
37
- import { Image, ImageClient, Media } from "@opencode/ai"
36
+ import { Image, Media } from "@opencode/ai"
38
37
 
39
38
  const image = Effect.gen(function* () {
40
39
  const response = yield* Image.generate({
@@ -46,13 +45,28 @@ const image = Effect.gen(function* () {
46
45
  yield* Media.write(response.image, "./garden.png")
47
46
  })
48
47
 
49
- // `asset.bytes()` / `Media.write` also need the executor, so merge it into the environment instead of hiding it.
50
- const imageLayer = ImageClient.layer.pipe(Layer.provideMerge(RequestExecutor.fetchLayer))
48
+ // `Media.file` / `Media.write` use the Effect `FileSystem` service; provide your platform's layer.
49
+ await Effect.runPromise(image.pipe(Effect.provide(AIClient.layer), Effect.provide(NodeFileSystem.layer)))
50
+ ```
51
+
52
+ Advanced: each client also has its own `layer`, which requires `RequestExecutor.Service`. Compose client layers with
53
+ `Layer.provideMerge`, not `Layer.provide`: `asset.bytes()`, `Media.write`, and Gemini's `media` output parts need the
54
+ executor too, and hiding it fails type-checking with `RequestExecutorService` left in the requirements.
55
+
56
+ To share a policy such as logging across every client, wrap the executor once with `RequestExecutor.middleware`:
51
57
 
52
- await Effect.runPromise(image.pipe(Effect.provide(imageLayer), Effect.provide(NodeFileSystem.layer)))
58
+ ```ts
59
+ import { RequestExecutor } from "@opencode/ai/route"
60
+
61
+ const logged = RequestExecutor.middleware((request, next) =>
62
+ Effect.log(`${request.method} ${request.url}`).pipe(Effect.andThen(next(request))),
63
+ )
64
+
65
+ const everything = AIClient.layerWith(logged) // or AI.make({ layer: logged })
53
66
  ```
54
67
 
55
- Prefer promises? `@opencode/ai/promise` exposes the same LLM and image APIs over one managed runtime:
68
+ Prefer promises? `@opencode/ai/promise` exposes the same LLM and media APIs over one managed runtime, plus asset
69
+ helpers; `ai.file` and `ai.write` load `node:fs/promises` on first use, so no Effect `FileSystem` is needed:
56
70
 
57
71
  ```ts
58
72
  import { AI } from "@opencode/ai/promise"
@@ -60,6 +74,7 @@ import { AI } from "@opencode/ai/promise"
60
74
  const ai = AI.make()
61
75
  const text = await ai.llm.generate({ model: openai.responses("gpt-4o-mini"), prompt: "Say hello." })
62
76
  const generated = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })
77
+ await ai.write(generated.image, "./lighthouse.png") // also ai.file(path), ai.bytes(asset), ai.base64(asset), ai.materialize(asset)
63
78
  for await (const event of ai.llm.stream({ model: openai.responses("gpt-4o-mini"), prompt: "Stream hello." })) {
64
79
  // LLMEvent
65
80
  }
@@ -322,10 +337,9 @@ and `moonshot/responses`; each exports `model(modelID, settings)`.
322
337
  MiniMax defaults to its Messages API and reads `MINIMAX_API_KEY` when `apiKey` is omitted:
323
338
 
324
339
  ```ts
325
- import { Effect, Layer } from "effect"
326
- import { LLM, LLMClient } from "@opencode/ai"
340
+ import { Effect } from "effect"
341
+ import { AIClient, LLM } from "@opencode/ai"
327
342
  import { MiniMax } from "@opencode/ai/providers"
328
- import { RequestExecutor } from "@opencode/ai/route"
329
343
 
330
344
  const minimax = MiniMax.configure({ apiKey: process.env.MINIMAX_API_KEY })
331
345
  const request = LLM.request({
@@ -335,8 +349,7 @@ const request = LLM.request({
335
349
  generation: { maxTokens: 1536 },
336
350
  })
337
351
 
338
- const layer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
339
- const response = await Effect.runPromise(LLMClient.generate(request).pipe(Effect.provide(layer)))
352
+ const response = await Effect.runPromise(LLM.generate(request).pipe(Effect.provide(AIClient.layer)))
340
353
  console.log(response.text)
341
354
  ```
342
355
 
@@ -405,14 +418,14 @@ Use `Image.generate` for one-off generation or editing:
405
418
  import { Image, Media } from "@opencode/ai"
406
419
 
407
420
  const generation = Image.generate({
408
- model: meta("muse-image-1.0"),
421
+ model: meta.image("muse-image-1.0"),
409
422
  prompt: "A flat black square on a white background.",
410
423
  n: 1,
411
424
  providerOptions: { reasoningStrength: "low" },
412
425
  })
413
426
 
414
427
  const edit = Image.generate({
415
- model: meta("muse-image-1.0"),
428
+ model: meta.image("muse-image-1.0"),
416
429
  prompt: "Make the square purple.",
417
430
  images: [Media.bytes(imageBytes, "image/webp")],
418
431
  format: "png",
@@ -459,6 +472,25 @@ const program = Effect.gen(function* () {
459
472
  })
460
473
  ```
461
474
 
475
+ Common fields are portable in shape, not in support. Unsupported fields fail with a typed `AIError` before any network
476
+ call rather than being dropped, so check this table before swapping only the `model`:
477
+
478
+ | Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
479
+ | --------------------- | --- | --------- | ------------- | ------ | -------- | ------------------------- | ------------------- |
480
+ | OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
481
+ | Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
482
+ | xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
483
+ | Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
484
+ | Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
485
+ | Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
486
+ | fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`) | ✓ |
487
+ | Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
488
+ | Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
489
+ | Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
490
+
491
+ ✓ lowers natively; ✗ fails whenever the field is set (including `n: 1`); `1` means `n > 1` fails. ¹ `Image.stream` on OpenAI generates one image. fal
492
+ rejects `size` and `aspectRatio` together; which one a fal or BFL model takes depends on the model.
493
+
462
494
  `Media.Asset` is the one asset type shared by image requests, image responses, LLM messages, and tool results.
463
495
  `asset.source` is the serializable `Media.Source` (`bytes`, `base64`, `url`, or `ref`); `asset.bytes()`,
464
496
  `asset.base64()`, and `asset.dataUrl()` decode or download lazily and cache; `asset.materialize()` pulls a `url`
@@ -468,9 +500,8 @@ asset into owned bytes before the provider URL expires. Construct assets with `M
468
500
  Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
469
501
 
470
502
  ```ts
471
- const response =
472
- yield *
473
- Image.generate({
503
+ const composed = Effect.gen(function* () {
504
+ const response = yield* Image.generate({
474
505
  model,
475
506
  prompt: "Combine these product photos into one studio scene",
476
507
  images: [
@@ -481,23 +512,25 @@ const response =
481
512
  providerOptions,
482
513
  http,
483
514
  })
515
+ return response.images
516
+ })
484
517
  ```
485
518
 
486
519
  `Media.ref(provider, id)` represents provider file handles such as OpenAI file IDs or Gemini Files URIs; routes
487
- only forward refs that belong to their own provider. Raw strings are not accepted as image inputs, avoiding
488
- ambiguity between base64, URLs, and provider IDs. Empty or omitted `images` uses text-to-image generation; a
489
- non-empty array selects the provider's edit behavior without enforcing provider image-count limits locally. OpenAI
520
+ only forward refs that belong to their own provider (OpenAI, xAI, and Gemini images accept them). No shipped route
521
+ returns a ref yet, and `asset.bytes()` / `materialize()` on a ref fail by design. Raw strings are not accepted as
522
+ image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images` uses text-to-image generation; a
523
+ non-empty array selects the provider's edit behavior (see the table above for routes that limit the count). OpenAI
490
524
  uses multipart for byte/data-URL edits and its JSON reference body for URL or file-ID edits. The common `mask`
491
525
  field selects inpainting; routes that cannot honor it fail with `UnsupportedOperation`:
492
526
 
493
527
  ```ts
494
- yield *
495
- Image.generate({
496
- model: openai.image("gpt-image-2"),
497
- prompt,
498
- images: [Media.bytes(sourceBytes, "image/png")],
499
- mask: Media.bytes(maskBytes, "image/png"),
500
- })
528
+ const inpainted = Image.generate({
529
+ model: openai.image("gpt-image-2"),
530
+ prompt,
531
+ images: [Media.bytes(sourceBytes, "image/png")],
532
+ mask: Media.bytes(maskBytes, "image/png"),
533
+ })
501
534
  ```
502
535
 
503
536
  On multipart requests, `http.body` can override option fields but not structural `model`, `prompt`, `image[]`,
@@ -508,31 +541,31 @@ not accept image inputs. These cases fail with a typed `AIError` before network
508
541
  Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
509
542
 
510
543
  ```ts
511
- yield *
512
- Image.generate({
513
- model: openai.image("gpt-image-2"),
514
- prompt,
515
- providerOptions: { quality: "medium" },
516
- http,
517
- })
544
+ const medium = Image.generate({
545
+ model: openai.image("gpt-image-2"),
546
+ prompt,
547
+ providerOptions: { quality: "medium" },
548
+ http,
549
+ })
518
550
  ```
519
551
 
520
552
  xAI image models use the same request API with xAI-native controls:
521
553
 
522
554
  ```ts
523
- yield *
524
- Image.generate({
525
- model: XAI.configure({ apiKey })("any-model-id"),
526
- prompt,
527
- n: 2,
528
- aspectRatio: "16:9",
529
- providerOptions: {
530
- resolution: "1k",
531
- responseFormat: "b64_json",
532
- future_option: true,
533
- },
534
- http,
535
- })
555
+ import { XAI } from "@opencode/ai/providers"
556
+
557
+ const xai = Image.generate({
558
+ model: XAI.configure({ apiKey }).image("any-model-id"),
559
+ prompt,
560
+ n: 2,
561
+ aspectRatio: "16:9",
562
+ providerOptions: {
563
+ resolution: "1k",
564
+ responseFormat: "b64_json",
565
+ future_option: true,
566
+ },
567
+ http,
568
+ })
536
569
  ```
537
570
 
538
571
  Google's current Gemini image models use the same direct API:
@@ -542,7 +575,7 @@ import { Google } from "@opencode/ai/providers"
542
575
 
543
576
  const googleProgram = Effect.gen(function* () {
544
577
  const response = yield* Image.generate({
545
- model: Google.configure({ apiKey })("any-model-id"),
578
+ model: Google.configure({ apiKey }).image("any-model-id"),
546
579
  prompt: "A robot tending a rooftop garden",
547
580
  aspectRatio: "16:9",
548
581
  seed: 42,
@@ -567,17 +600,18 @@ their mapped aliases, and `http.body` is the final deep overlay. The selected mo
567
600
  Z.ai image models infer open Z.ai-native options from the selected model:
568
601
 
569
602
  ```ts
570
- yield *
571
- Image.generate({
572
- model: ZAI.configure({ apiKey })("any-model-id"),
573
- prompt,
574
- providerOptions: {
575
- quality: "hd",
576
- userID: "user-123",
577
- future_option: true,
578
- },
579
- http,
580
- })
603
+ import { ZAI } from "@opencode/ai/providers"
604
+
605
+ const zai = Image.generate({
606
+ model: ZAI.configure({ apiKey }).image("any-model-id"),
607
+ prompt,
608
+ providerOptions: {
609
+ quality: "hd",
610
+ userID: "user-123",
611
+ future_option: true,
612
+ },
613
+ http,
614
+ })
581
615
  ```
582
616
 
583
617
  Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
@@ -591,12 +625,14 @@ and emits `image-partial` events before each final `image`; `Image.generate` kee
591
625
  `dall-e-*` models do not stream and fail typed:
592
626
 
593
627
  ```ts
594
- yield *
595
- Image.stream({
596
- model: openai.image("gpt-image-2"),
597
- prompt: "A lighthouse at dusk",
598
- providerOptions: { partialImages: 2 },
599
- }).pipe(Stream.runForEach((event) => (ImageEvent.is.imagePartial(event) ? showPreview(event.image) : Effect.void)))
628
+ import { Stream } from "effect"
629
+ import { ImageEvent } from "@opencode/ai"
630
+
631
+ const previews = Image.stream({
632
+ model: openai.image("gpt-image-2"),
633
+ prompt: "A lighthouse at dusk",
634
+ providerOptions: { partialImages: 2 },
635
+ }).pipe(Stream.runForEach((event) => (ImageEvent.is.imagePartial(event) ? showPreview(event.image) : Effect.void)))
600
636
  ```
601
637
 
602
638
  The provider may send fewer previews than requested when the final image is ready first.
@@ -612,13 +648,20 @@ import { BlackForestLabs, Stability } from "@opencode/ai/providers"
612
648
 
613
649
  const bfl = BlackForestLabs.configure({ apiKey: process.env.BFL_API_KEY })
614
650
 
615
- const generation = yield * Image.start({ model: bfl.image("flux-2-pro"), prompt, size: "1024x768" })
616
- persist(generation.token)
651
+ const submit = Effect.gen(function* () {
652
+ const generation = yield* Image.start({ model: bfl.image("flux-2-pro"), prompt, size: "1024x768" })
653
+ persist({ provider: "black-forest-labs", modelID: "flux-2-pro", token: generation.token })
654
+ })
617
655
 
618
- const resumed = yield * Image.resume(bfl.image("flux-2-pro"), loadToken())
619
- const response = yield * resumed.await({ poll: { interval: "2 seconds" } })
656
+ const finish = Effect.gen(function* () {
657
+ const saved = load()
658
+ const resumed = yield* Image.resume(bfl.image(saved.modelID), saved.token)
659
+ return yield* resumed.await({ poll: { interval: "2 seconds" } })
660
+ })
620
661
  ```
621
662
 
663
+ The token carries no route identity, so persist the provider and model ID alongside it: `resume` needs the model.
664
+
622
665
  - **Black Forest Labs** — results are downloaded before returning, because `result.sample` expires in 10 minutes.
623
666
  - **Replicate** — inputs are model-defined, so only `prompt` lowers: sizing, count, seed, format, and files go in
624
667
  `providerOptions` under the model's names, with files as `Media.Asset` (data URLs up to 256 KB, larger by URL).
@@ -628,12 +671,13 @@ const response = yield * resumed.await({ poll: { interval: "2 seconds" } })
628
671
 
629
672
  ```ts
630
673
  const stability = Stability.configure({ apiKey: process.env.STABILITY_API_KEY })
631
- const upscaled =
632
- yield *
633
- Image.generate(
634
- { model: stability.upscale(), prompt: "A lighthouse", images: [yield * Media.file("./small.png")] },
674
+ const upscaled = Effect.gen(function* () {
675
+ const small = yield* Media.file("./small.png")
676
+ return yield* Image.generate(
677
+ { model: stability.upscale(), prompt: "A lighthouse", images: [small] },
635
678
  { poll: { interval: "5 seconds" } },
636
679
  )
680
+ })
637
681
  ```
638
682
 
639
683
  Imagen is not available: Google shut it down on the Gemini API, and Vertex discontinued the Imagen 4 models on
@@ -666,8 +710,8 @@ Common fields (`frames`, `references`, `video`, `durationSeconds`, `aspectRatio`
666
710
  under `providerOptions`, inferred from the selected model.
667
711
 
668
712
  ```ts
669
- import { Video, VideoClient } from "@opencode/ai"
670
- import { Google } from "@opencode/ai/providers"
713
+ import { Video } from "@opencode/ai"
714
+ import { Google, Runway } from "@opencode/ai/providers"
671
715
 
672
716
  const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY })
673
717
 
@@ -696,6 +740,7 @@ const controlled = Effect.gen(function* () {
696
740
  generation.id // provider operation / task / request id
697
741
  generation.status // "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
698
742
  generation.token // route-owned JSON: `{ operation }`, `{ requestID }`, `{ taskID }`, or fal's follow-up URLs
743
+ // The token carries no route identity: persist the provider and model ID alongside it, since `resume` needs the model.
699
744
  const saved = JSON.stringify(generation.token)
700
745
 
701
746
  const resumed = yield* Video.resume(google.video("veo-3.1-generate-preview"), JSON.parse(saved))
@@ -706,8 +751,8 @@ const controlled = Effect.gen(function* () {
706
751
  const events = Video.stream({ model: Runway.configure({ apiKey }).video("gen4.5"), prompt }, { poll })
707
752
  ```
708
753
 
709
- `VideoClient.layer` needs `RequestExecutor.Service`, and status polls, result fetches, cancels, and asset downloads
710
- all run through the same executor with the route's auth. `Generation.await` and `Generation.events` fail with a
754
+ Status polls, result fetches, cancels, and asset downloads all run through the same request executor with the route's
755
+ auth. `Generation.await` and `Generation.events` fail with a
711
756
  `Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Failed,
712
757
  cancelled, and expired generations fail typed with the provider's terminal document on `reason.body`; moderation
713
758
  outcomes (Veo `raiMediaFilteredReasons`, xAI `respect_moderation`, Runway `SAFETY.*` codes) surface as `notices` when
@@ -726,15 +771,18 @@ Provider notes:
726
771
  - **Runway** expects pixel ratios in `aspectRatio` for most models (`"1280:720"`), pins `X-Runway-Version`, reports
727
772
  `usage: { type: "credits" }`, and its output URLs expire after 24–48 hours.
728
773
 
729
- The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `refresh`,
730
- `cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and `ai.video.stream` mirror the Effect
731
- API.
774
+ The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `events`,
775
+ `result`, `refresh`, `cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and
776
+ `ai.video.stream` mirror the Effect API. The handle's `status` and `progress` are a snapshot from when it was
777
+ created; `refresh()` resolves to a new handle.
732
778
 
733
779
  ```ts
734
780
  import { ai } from "@opencode/ai/promise"
735
781
 
736
782
  const generation = await ai.video.start({ model, prompt })
737
- const video = await generation.await({ poll: { interval: 10_000 }, signal })
783
+ for await (const event of generation.events({ poll: { interval: 10_000 } })) console.log(event.type)
784
+ const video = await generation.result({ signal })
785
+ await ai.write(video.video, "./kite.mp4")
738
786
  ```
739
787
 
740
788
  ## Speech generation
@@ -786,7 +834,6 @@ ElevenLabs and Cartesia. `{ id }` selects an OpenAI custom voice (`{ id: "voice_
786
834
  plain string elsewhere. There is no cross-provider voice catalog. `format` is the container-level word (`mp3`, `wav`,
787
835
  `pcm`, `opus`, `aac`, `flac`); sample rates and bitrates live under `providerOptions`, and a value the route cannot
788
836
  produce fails as `UnsupportedOperation`. Streams buffer every chunk so `finish` can carry the whole clip.
789
- `SpeechClient.layer` needs `RequestExecutor.Service`.
790
837
 
791
838
  Provider notes:
792
839
 
@@ -814,7 +861,7 @@ The promise client mirrors the Effect API; `ai.speech.stream` is an `AsyncIterab
814
861
  import { ai } from "@opencode/ai/promise"
815
862
 
816
863
  const response = await ai.speech.generate({ model, text: "Hello from OpenCode.", voice: "coral" })
817
- await Bun.write("hello.mp3", await ai.run(response.audio.bytes()))
864
+ await ai.write(response.audio, "hello.mp3")
818
865
 
819
866
  for await (const event of ai.speech.stream({ model, text: "Hello from OpenCode.", voice: "coral" })) {
820
867
  if (event.type === "audio-delta") player.write(event.chunk)
@@ -831,6 +878,7 @@ facades. Common fields (`language`, `prompt`, `timestamps: "none" | "segment" |
831
878
  natively or fail with a typed `AIError` before any network call; a route may return more than asked.
832
879
 
833
880
  ```ts
881
+ import { Console, Effect, Stream } from "effect"
834
882
  import { Media, Transcription, TranscriptionEvent } from "@opencode/ai"
835
883
  import { AssemblyAI, Deepgram, OpenAI } from "@opencode/ai/providers"
836
884
 
@@ -857,7 +905,7 @@ const program = Effect.gen(function* () {
857
905
  Stream.runDrain,
858
906
  )
859
907
 
860
- // Queued: persist the token, resume from another process, and await.
908
+ // Queued: persist the token with the provider and model ID (the token alone cannot pick the model), resume, and await.
861
909
  const model = AssemblyAI.configure({ apiKey }).transcription("universal-3-5-pro")
862
910
  const generation = yield* Transcription.start({ model, audio })
863
911
  const resumed = yield* Transcription.resume(model, JSON.parse(JSON.stringify(generation.token)))
@@ -866,7 +914,7 @@ const program = Effect.gen(function* () {
866
914
  ```
867
915
 
868
916
  Inline routes emit only `finish` from `stream` (no faked deltas); queued routes emit `generation-queued` /
869
- `generation-progress` before it. `TranscriptionClient.layer` needs `RequestExecutor.Service`.
917
+ `generation-progress` before it.
870
918
 
871
919
  Provider notes:
872
920
 
@@ -878,6 +926,7 @@ Provider notes:
878
926
  The promise client mirrors the Effect API:
879
927
 
880
928
  ```ts
929
+ const audio = await ai.file("./call.mp3")
881
930
  const text = (await ai.transcription.generate({ model, audio })).text
882
931
  for await (const event of ai.transcription.stream({ model, audio })) if (event.type === "text-delta") write(event.delta)
883
932
  const generation = await ai.transcription.start({ model: assemblyai, audio })
@@ -897,7 +946,8 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
897
946
  - **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes.
898
947
  - **`Speech.request` / `Speech.generate` / `Speech.stream`** — text-to-speech through a provider-neutral request; `SpeechClient` is its Effect service and layer.
899
948
  - **`Transcription.request` / `generate` / `stream` / `start` / `resume`** — speech-to-text over inline, streaming, and queued routes; `TranscriptionClient` is its Effect service and layer.
900
- - **`@opencode/ai/promise`** — `AI.make({ layer? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, and `transcription` as Promise / `AsyncIterable` APIs.
949
+ - **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
950
+ - **`@opencode/ai/promise`** — `AI.make({ layer? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, and `transcription` as Promise / `AsyncIterable` APIs, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets.
901
951
 
902
952
  ## Testing
903
953
 
@@ -960,11 +1010,13 @@ This is different from prompt caching, server-side history storage, or truncatio
960
1010
  Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
961
1011
 
962
1012
  ```ts
963
- const result = yield * LLMClient.compact(request)
964
- const next = LLMRequest.update(request, {
965
- messages: result.replacement,
1013
+ const compacted = Effect.gen(function* () {
1014
+ const result = yield* LLMClient.compact(request)
1015
+ const next = LLMRequest.update(request, {
1016
+ messages: result.replacement,
1017
+ })
1018
+ return yield* LLMClient.generate(next)
966
1019
  })
967
- const response = yield * LLMClient.generate(next)
968
1020
  ```
969
1021
 
970
1022
  `replacement` replaces the complete input window. Do not append it to the original transcript or extract only the encrypted item: the provider may retain additional messages in its output. Retained user and assistant messages remain ordinary messages with typed text, media, or reasoning parts, in their original order. Provider-specific message IDs, status, and phase use `providerMetadata`, not a raw output array hidden in an assistant message. Unsupported returned item types fail explicitly.
@@ -980,16 +1032,16 @@ The input must still fit the model's context window. Explicit compaction is not
980
1032
  OpenAI Responses also exposes a separate, explicitly selected mechanism:
981
1033
 
982
1034
  ```ts
983
- const result =
984
- yield *
985
- LLMClient.compact(request, {
1035
+ const checkpoint = Effect.gen(function* () {
1036
+ const result = yield* LLMClient.compact(request, {
986
1037
  mechanism: "trigger",
987
1038
  webSocket, // Optional: without it, the request uses HTTP/SSE.
988
1039
  })
989
1040
 
990
- result.checkpoint // Successful encrypted CompactionPart.
991
- result.responseID
992
- result.usage
1041
+ result.checkpoint // Successful encrypted CompactionPart.
1042
+ result.responseID
1043
+ result.usage
1044
+ })
993
1045
  ```
994
1046
 
995
1047
  This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request, with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Request metadata, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
@@ -1003,9 +1055,11 @@ The supplied WebSocket executor can reuse a compatible append baseline for the c
1003
1055
  Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
1004
1056
 
1005
1057
  ```ts
1006
- if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
1007
- const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
1008
- }
1058
+ const narrowed = Effect.gen(function* () {
1059
+ if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
1060
+ const result = yield* LLMClient.compact(request, { mechanism: "trigger" })
1061
+ }
1062
+ })
1009
1063
  ```
1010
1064
 
1011
1065
  This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
@@ -1014,9 +1068,10 @@ This capability describes protocol implementation, **not universal availability
1014
1068
 
1015
1069
  `providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
1016
1070
 
1017
- Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
1071
+ Enable OpenAI compaction with typed provider options:
1018
1072
 
1019
1073
  ```ts
1074
+ import { Effect } from "effect"
1020
1075
  import { LLM, LLMClient, LLMRequest, Message } from "@opencode/ai"
1021
1076
  import { OpenAI } from "@opencode/ai/providers"
1022
1077
 
@@ -1027,9 +1082,11 @@ const request = LLM.request({
1027
1082
  contextManagement: [{ type: "compaction", compactThreshold: 200_000 }],
1028
1083
  },
1029
1084
  })
1030
- const response = yield * LLMClient.generate(request)
1031
- const next = LLMRequest.update(request, {
1032
- messages: [...request.messages, response.message, Message.user("Continue")],
1085
+ const continued = Effect.gen(function* () {
1086
+ const response = yield* LLMClient.generate(request)
1087
+ return LLMRequest.update(request, {
1088
+ messages: [...request.messages, response.message, Message.user("Continue")],
1089
+ })
1033
1090
  })
1034
1091
  ```
1035
1092
 
@@ -1257,7 +1314,7 @@ Compose a route with `Route.make({ protocol, endpoint, auth, framing, ... })`. T
1257
1314
 
1258
1315
  ## Effect
1259
1316
 
1260
- This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for LLM dispatch and `ImageClient.layer` for image dispatch, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
1317
+ This package is built on Effect. Public methods return `Effect` or `Stream`; provide `AIClient.layer` (or `AIClient.layerWith(executor)`) for every modality, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
1261
1318
 
1262
1319
  ## See also
1263
1320
 
@@ -0,0 +1,8 @@
1
+ import { Layer } from "effect";
2
+ import { RequestExecutor } from "./route/executor.js";
3
+ /** Every modality client over `executor`, which stays in the output so `asset.bytes()` and `Media.write` resolve. */
4
+ export declare const layerWith: <E, R>(executor: Layer.Layer<RequestExecutor.Service, E, R>) => Layer.Layer<import("./route/executor-service.js").RequestExecutorService | import("./route/client.js").LLMClientService | import("./image-client.js").ImageClientService | import("./speech-client.js").SpeechClientService | import("./transcription-client.js").TranscriptionClientService | import("./video-client.js").VideoClientService, E, R>;
5
+ /** Every modality client plus the executor over `RequestExecutor.fetchLayer`: the one layer most programs need. */
6
+ export declare const layer: Layer.Layer<import("./route/executor-service.js").RequestExecutorService | import("./route/client.js").LLMClientService | import("./image-client.js").ImageClientService | import("./speech-client.js").SpeechClientService | import("./transcription-client.js").TranscriptionClientService | import("./video-client.js").VideoClientService, never, never>;
7
+ export type Services = Layer.Success<typeof layer>;
8
+ export * as AIClient from "./ai-client.js";
@@ -0,0 +1,12 @@
1
+ import { Layer } from "effect";
2
+ import { ImageClient } from "./image-client.js";
3
+ import { LLMClient } from "./route/client.js";
4
+ import { RequestExecutor } from "./route/executor.js";
5
+ import { SpeechClient } from "./speech-client.js";
6
+ import { TranscriptionClient } from "./transcription-client.js";
7
+ import { VideoClient } from "./video-client.js";
8
+ /** Every modality client over `executor`, which stays in the output so `asset.bytes()` and `Media.write` resolve. */
9
+ export const layerWith = (executor) => Layer.mergeAll(LLMClient.layer, ImageClient.layer, VideoClient.layer, SpeechClient.layer, TranscriptionClient.layer).pipe(Layer.provideMerge(executor));
10
+ /** Every modality client plus the executor over `RequestExecutor.fetchLayer`: the one layer most programs need. */
11
+ export const layer = layerWith(RequestExecutor.fetchLayer);
12
+ export * as AIClient from "./ai-client.js";
@@ -11,11 +11,11 @@ export declare class Service extends Service_base {
11
11
  }
12
12
  export declare const evaluate: <Options extends EvaluationOptions, const Questions extends EvaluationQuestions>(request: EvaluationRequestFor<Options, Questions>) => Effect.Effect<EvaluationResponseFor<Questions>, AIError, Service>;
13
13
  export declare const layer: Layer.Layer<Service, never, RequestExecutor.Service>;
14
- export declare const fetchLayer: Layer.Layer<Service, never, never>;
14
+ export declare const fetchLayer: Layer.Layer<import("../route/executor-service.js").RequestExecutorService | Service, never, never>;
15
15
  export declare const EvaluationClient: {
16
16
  readonly Service: typeof Service;
17
- readonly layer: Layer.Layer<Service, never, RequestExecutor.Service>;
18
- readonly fetchLayer: Layer.Layer<Service, never, never>;
17
+ readonly layer: Layer.Layer<Service, never, import("../route/executor-service.js").RequestExecutorService>;
18
+ readonly fetchLayer: Layer.Layer<import("../route/executor-service.js").RequestExecutorService | Service, never, never>;
19
19
  readonly evaluate: <Options extends EvaluationOptions, const Questions extends EvaluationQuestions>(request: EvaluationRequestFor<Options, Questions>) => Effect.Effect<EvaluationResponseFor<Questions>, AIError, Service>;
20
20
  };
21
21
  export {};
@@ -55,7 +55,7 @@ export const layer = Layer.effect(Service, Effect.gen(function* () {
55
55
  })),
56
56
  });
57
57
  }));
58
- export const fetchLayer = layer.pipe(Layer.provide(RequestExecutor.fetchLayer));
58
+ export const fetchLayer = layer.pipe(Layer.provideMerge(RequestExecutor.fetchLayer));
59
59
  export const EvaluationClient = {
60
60
  Service,
61
61
  layer,
@@ -9,17 +9,19 @@ export interface Interface {
9
9
  readonly start: <Options extends ImageOptions>(request: ImageRequestFor<Options>) => Effect.Effect<Generation<ImageResponse>, AIError>;
10
10
  readonly resume: <Options extends ImageOptions>(model: ImageModel<Options>, token: unknown) => Effect.Effect<Generation<ImageResponse>, AIError>;
11
11
  }
12
- declare const Service_base: Context.ServiceClass<Service, "@opencode/ImageClient", Interface>;
13
- export declare class Service extends Service_base {
12
+ declare const ImageClientService_base: Context.ServiceClass<ImageClientService, "@opencode/ImageClient", Interface>;
13
+ export declare class ImageClientService extends ImageClientService_base {
14
14
  }
15
+ export declare const Service: typeof ImageClientService;
16
+ export type Service = ImageClientService;
15
17
  export declare const generate: <Options extends ImageOptions>(request: ImageRequestFor<Options>, options?: AwaitOptions) => Effect.Effect<ImageResponse, AIError, Service>;
16
18
  export declare const stream: <Options extends ImageOptions>(request: ImageRequestFor<Options>, options?: AwaitOptions) => Stream.Stream<ImageEvent, AIError, Service>;
17
19
  export declare const start: <Options extends ImageOptions>(request: ImageRequestFor<Options>) => Effect.Effect<Generation<ImageResponse>, AIError, Service>;
18
20
  export declare const resume: <Options extends ImageOptions>(model: ImageModel<Options>, token: unknown) => Effect.Effect<Generation<ImageResponse>, AIError, Service>;
19
21
  export declare const layer: Layer.Layer<Service, never, RequestExecutor.Service>;
20
22
  export declare const ImageClient: {
21
- readonly Service: typeof Service;
22
- readonly layer: Layer.Layer<Service, never, RequestExecutor.Service>;
23
+ readonly Service: typeof ImageClientService;
24
+ readonly layer: Layer.Layer<ImageClientService, never, import("./route/executor-service.js").RequestExecutorService>;
23
25
  readonly generate: <Options extends ImageOptions>(request: ImageRequestFor<Options>, options?: AwaitOptions) => Effect.Effect<ImageResponse, AIError, Service>;
24
26
  readonly stream: <Options extends ImageOptions>(request: ImageRequestFor<Options>, options?: AwaitOptions) => Stream.Stream<ImageEvent, AIError, Service>;
25
27
  readonly start: <Options extends ImageOptions>(request: ImageRequestFor<Options>) => Effect.Effect<Generation<ImageResponse>, AIError, Service>;