Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline 1c339219c1 fix(core): share web search limits with OpenCode Web Search
OpenCode Web Search, the default search provider for Console-connected
users, made its own request with no size cap and discarded the body of
failed responses. It now uses the shared response handling, and OpenCode
API error bodies ({ _tag, message }) supply the failure explanation.

Reading a failed response for its explanation is bounded to one second
so a stalled error body still fails promptly.

Also formats the earlier changes with Prettier and tidies the tests.
2026-09-23 12:57:33 -05:00
Aiden Cline a7f3ba9a2f fix(core): make web search limits non-fatal and report real errors
Oversized provider responses failed the whole search: WebSearchMcp.call
rejected bodies over 256 KB, and a single 424 KB Firecrawl result was
enough. The tool then replaced the cause with a generic message.

All providers now read at most 1 MiB, parse a cut-off body as partial
JSON, and keep the results that arrived complete. WebSearch.query caps
each result's content at 4,000 characters so one page cannot crowd out
the rest of the bounded tool output.

Web search errors now carry readable messages, including the provider's
own explanation for non-2xx responses, MCP isError results, and
JSON-RPC errors, which previously surfaced as no results.
2026-09-23 11:44:55 -05:00
99 changed files with 1022 additions and 3775 deletions
+1 -1
View File
@@ -102,7 +102,7 @@ Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `MediaProtocol.incomplete`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `MediaProtocol.decodeFrame` and raise stream-time failures with `MediaProtocol.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
Transcription uses all three kinds (OpenAI and Gemini stream, Deepgram is inline, AssemblyAI is queued): every route carries its `kind` and `TranscriptionClient` dispatches on it. `ImageRoute` is the same union; both clients dispatch through `MediaRoute.dispatch` and models compose through `composeAnyRoute`, and fal queue protocols come from `protocols/utils/fal-queue.ts`. Bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
Transcription uses all three kinds (OpenAI and Gemini stream, Deepgram is inline, AssemblyAI is queued): every route carries its `kind` and `TranscriptionClient` dispatches on it. Bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
### URL Construction
+1 -56
View File
@@ -584,61 +584,6 @@ Z.ai does not include trustworthy MIME metadata for output URLs, so generated im
`application/octet-stream` until materialized. Output URLs expire after 30 days; call `asset.materialize()` and
persist the bytes promptly if they must remain available.
### Partial images
OpenAI's GPT image models stream previews. `Image.stream` sends `stream: true` with `partialImages` (0–3, default 2)
and emits `image-partial` events before each final `image`; `Image.generate` keeps the plain JSON request.
`dall-e-*` models do not stream and fail typed:
```ts
yield *
Image.stream({
model: openai.image("gpt-image-2"),
prompt: "A lighthouse at dusk",
providerOptions: { partialImages: 2 },
}).pipe(Stream.runForEach((event) => (ImageEvent.is.imagePartial(event) ? showPreview(event.image) : Effect.void)))
```
The provider may send fewer previews than requested when the final image is ready first.
### Queued image providers
Black Forest Labs, fal, Replicate, and Stability's creative upscaler are submit-then-poll routes. `Image.generate`
and `Image.stream` poll for you (pass `{ poll }` to tune the interval and timeout); `Image.start` returns a
`Generation` whose `token` is serializable JSON for `Image.resume` in another process:
```ts
import { BlackForestLabs, Stability } from "@opencode/ai/providers"
const bfl = BlackForestLabs.configure({ apiKey: process.env.BFL_API_KEY })
const generation = yield * Image.start({ model: bfl.image("flux-2-pro"), prompt, size: "1024x768" })
persist(generation.token)
const resumed = yield * Image.resume(bfl.image("flux-2-pro"), loadToken())
const response = yield * resumed.await({ poll: { interval: "2 seconds" } })
```
- **Black Forest Labs** — results are downloaded before returning, because `result.sample` expires in 10 minutes.
- **Replicate** — inputs are model-defined, so only `prompt` lowers: sizing, count, seed, format, and files go in
`providerOptions` under the model's names, with files as `Media.Asset` (data URLs up to 256 KB, larger by URL).
Outputs are removed an hour after the prediction completes. `Prefer: wait=60` in `headers` or `http.headers` holds
the submission open so a fast prediction costs one result read.
- **Stability** — `stability.image(id)` generates inline; `stability.upscale()` is the creative upscaler, queued:
```ts
const stability = Stability.configure({ apiKey: process.env.STABILITY_API_KEY })
const upscaled =
yield *
Image.generate(
{ model: stability.upscale(), prompt: "A lighthouse", images: [yield * Media.file("./small.png")] },
{ poll: { interval: "5 seconds" } },
)
```
Imagen is not available: Google shut it down on the Gemini API, and Vertex discontinued the Imagen 4 models on
2026-06-30. `Google.image(...)` uses Gemini-native image models.
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
```ts
@@ -891,7 +836,7 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`Image.request` / `generate` / `stream` / `start` / `resume`** — images over inline, streaming (partial previews), and queued routes through a provider-neutral request and response model.
- **`Image.request` / `Image.generate` / `Image.stream`** — generate images through a provider-neutral image request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
- **`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.
+8 -14
View File
@@ -11,11 +11,11 @@ The design below is derived from a survey of the raw provider APIs (OpenAI, Gemi
## What the survey forces
1. **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.
2. **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.
2. **Output is never just bytes.** base64, signed URLs with TTLs from 10 minutes (BFL) 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.
3. **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://`).
4. **Partial streaming is modality-specific.** Images: a few whole partial frames. Audio: ordered chunks plus timestamp events. Jobs: status/progress/logs. Video: none.
5. **Usage is a union**: tokens, seconds, characters (often only in headers), credits, compute time.
6. **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).
6. **Moderation can be partial success** (Veo strips audio but returns video). Deprecations are constant (Sora API shuts down 2026-09-24, Imagen on Gemini API 2026-08-17).
## Where existing SDKs are weak and we should not be
@@ -54,7 +54,7 @@ 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`). Provider package entrypoints keep `model(modelID, settings)` per modality-specific path, e.g. `@opencode/ai/providers/openai/responses`.
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 APIs for one modality, the selectors stay explicit (`openai.chat`, a future `google.imagen`), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native since Imagen on the Gemini API shuts down 2026-08-17). Provider package entrypoints keep `model(modelID, settings)` per modality-specific path, e.g. `@opencode/ai/providers/openai/responses`.
### `Media` — the asset type
@@ -128,8 +128,6 @@ yield* Image.stream(request) // Stream<ImageEvent>
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`. `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`).
@@ -387,19 +385,15 @@ Existing facades gain per-modality selectors; the modality routes each facade pr
| 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` | |
| `OpenAI` | responses (default), chat | Images API | Sora (deprecated 2026-09-24) | ✓ | ✓ | |
| `Google` | Gemini | Gemini-native (default), `imagen` | Veo | Gemini TTS | `gemini-3.5-transcribe` | |
| `XAI` | ✓ | ✓ | ✓ | | | |
| `ElevenLabs` | | | | ✓ | Scribe | soundEffect, music |
| `Cartesia` | | | | ✓ | | |
| `Deepgram` | | | | Aura | ✓ | |
| `Fal` | | ✓ (queued) | ✓ | | | |
| `Fal` | | ✓ | ✓ | | | |
| `AssemblyAI` | | | | | ✓ (queued) | |
| `BlackForestLabs` | | ✓ (queued) | | | | |
| `Replicate` | | ✓ (queued) | | | | |
| `Stability` | | `image` (inline), `upscale()` (queued) | | | | |
| `Runway` | | | ✓ | | | |
| `Luma`, `Kling`, `MiniMax` | | per provider | | | | |
| `Replicate`, `Runway`, `Luma`, `Kling`, `MiniMax`, `BlackForestLabs`, `Stability` | | per provider | | | | |
New facades follow the existing one-file-per-provider rule. Package entrypoints are modality-specific, such as `@opencode/ai/providers/openai/images`, and return the concrete model.
@@ -444,7 +438,7 @@ Foundation + Image ship together as the reference implementation, serially. Vide
1. **Foundation** — per-modality selectors, `Media`, `Generation`, `Poll`, `Usage` union, `MediaProtocol` kinds, `@opencode/ai/promise` with `llm` + `image`. Port the five existing image protocols onto it. Unify `MediaPart` and add the `media` LLM event (fixes Gemini image output being dropped).
2. **Video** — ✅ Veo, xAI, fal, Runway shipped (`MediaProtocol.queued`, `Video.start/generate/resume/stream`, promise `ai.video`). Deferred: `Video.complete` (webhooks), Luma, Kling, MiniMax, Replicate.
3. **Speech + Transcription** — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram shipped (`MediaProtocol.stream`, `Speech.generate/stream`, promise `ai.speech`). ✅ Transcription: OpenAI, Gemini, Deepgram, AssemblyAI shipped across all three route kinds (`Transcription.generate/stream/start/resume`, promise `ai.transcription`). Pending: ElevenLabs Scribe. Deferred: `Speech.session` and `Transcription.session` (WebSocket streaming).
4. **Image queued routes and partials** — ✅ BFL, fal, Replicate, and Stability creative upscale queued; Stability generate inline; OpenAI `partial_images` streaming (`image-partial` restored). 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.
4. **Image queued routes and partials** — BFL, fal, Replicate, Stability; OpenAI `partial_images` streaming.
5. **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.
+1 -29
View File
@@ -108,7 +108,7 @@ const PROVIDERS: ReadonlyArray<Provider> = [
id: "fal",
label: "fal",
tier: "canary",
note: "fal queue image and video recorded tests",
note: "fal queue video recorded tests",
vars: [{ name: "FAL_KEY" }],
// fal has no free authenticated list endpoint; a 404 for an unknown request id proves the key was accepted.
validate: (env) =>
@@ -123,34 +123,6 @@ const PROVIDERS: ReadonlyArray<Provider> = [
return yield* responseError(response)
}),
},
{
id: "black-forest-labs",
label: "Black Forest Labs",
tier: "canary",
note: "BFL FLUX queued image recorded tests",
vars: [{ name: "BFL_API_KEY" }],
validate: (env) =>
HttpClientRequest.get("https://api.bfl.ai/v1/credits").pipe(
HttpClientRequest.setHeader("x-key", Redacted.value(Redacted.make(env.BFL_API_KEY))),
executeRequest,
),
},
{
id: "replicate",
label: "Replicate",
tier: "canary",
note: "Replicate prediction image recorded tests",
vars: [{ name: "REPLICATE_API_TOKEN" }],
validate: (env) => validateBearer("https://api.replicate.com/v1/account", Redacted.make(env.REPLICATE_API_TOKEN)),
},
{
id: "stability",
label: "Stability AI",
tier: "canary",
note: "Stability inline generate and queued upscale recorded tests",
vars: [{ name: "STABILITY_API_KEY" }],
validate: (env) => validateBearer("https://api.stability.ai/v1/user/balance", Redacted.make(env.STABILITY_API_KEY)),
},
{
id: "runway",
label: "Runway",
+8 -44
View File
@@ -1,12 +1,9 @@
import { Context, Effect, Layer, Stream } from "effect"
import type { AwaitOptions, Generation } from "./generation.js"
import { RequestExecutor } from "./route/executor.js"
import { MediaRoute } from "./route/media.js"
import type { AIError } from "./schema/index.js"
import {
responseEvents,
type ImageEvent,
type ImageModel,
type ImageOptions,
type ImageRequestFor,
type ImageResponse,
@@ -15,74 +12,43 @@ import {
export interface Interface {
readonly generate: <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
options?: AwaitOptions,
) => Effect.Effect<ImageResponse, AIError>
readonly stream: <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
options?: AwaitOptions,
) => Stream.Stream<ImageEvent, AIError>
readonly start: <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
) => Effect.Effect<Generation<ImageResponse>, AIError>
readonly resume: <Options extends ImageOptions>(
model: ImageModel<Options>,
token: unknown,
) => Effect.Effect<Generation<ImageResponse>, AIError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
export const generate = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
options?: AwaitOptions,
): Effect.Effect<ImageResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request, options)
return yield* client.generate(request)
})
export const stream = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
options?: AwaitOptions,
): Stream.Stream<ImageEvent, AIError, Service> =>
Stream.unwrap(
Effect.gen(function* () {
const client = yield* Service
return client.stream(request, options)
return client.stream(request)
}),
)
export const start = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
): Effect.Effect<Generation<ImageResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.start(request)
})
export const resume = <Options extends ImageOptions>(
model: ImageModel<Options>,
token: unknown,
): Effect.Effect<Generation<ImageResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.resume(model, token)
})
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const dispatch = MediaRoute.dispatch<ImageEvent, ImageResponse>({
modality: "image",
execute: executor.execute,
responseEvents,
})
const generate = <Options extends ImageOptions>(request: ImageRequestFor<Options>) =>
request.model.route.generate(request, executor.execute)
return Service.of({
start: (request) => dispatch.start(request.model.route, request),
resume: (model, token) => dispatch.resume(model.route, model, token),
generate: (request, options) => dispatch.generate(request.model.route, request, options),
stream: (request, options) => dispatch.stream(request.model.route, request, options),
generate,
// Inline routes have no partial frames yet; the stream is the completed response expanded into events.
stream: (request) =>
Stream.fromIterableEffect(Effect.map(generate(request), responseEvents)),
})
}),
)
@@ -92,6 +58,4 @@ export const ImageClient = {
layer,
generate,
stream,
start,
resume,
} as const
+15 -97
View File
@@ -1,7 +1,6 @@
import { Effect, Schema, Stream } from "effect"
import { Generation, ProgressEvent, QueuedEvent, type AwaitOptions } from "./generation.js"
import { Media } from "./media.js"
import { MediaModel, composeAnyRoute, tryRequest } from "./media-model.js"
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
import { MediaRoute } from "./route/media.js"
import type { MediaProtocol } from "./route/media-protocol.js"
import { AIError, HttpOptions, MediaUsage, ProviderMetadata } from "./schema/index.js"
@@ -13,9 +12,8 @@ import { ImageClient, Service } from "./image-client.js"
export type ImageOptions = Record<string, unknown>
export type ImageRoute<Options extends ImageOptions = ImageOptions> = MediaRoute.AnyRoute<
export type ImageRoute<Options extends ImageOptions = ImageOptions> = MediaRoute.Route<
ImageRequestFor<Options>,
ImageEvent,
ImageResponse
>
@@ -26,58 +24,25 @@ export class ImageModel<Options extends ImageOptions = ImageOptions> extends Med
return new ImageModel<Options>(input)
}
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
static fromRoute<Options extends ImageOptions>(
route: ImageModel.InlineRouteInput<Options>,
input: MediaRoute.ModelInput,
): ImageModel<Options>
static fromRoute<Options extends ImageOptions, Frame, State>(
route: ImageModel.StreamRouteInput<Options, Frame, State>,
input: MediaRoute.ModelInput,
): ImageModel<Options>
static fromRoute<Options extends ImageOptions, Token>(
route: ImageModel.QueuedRouteInput<Options, Token>,
input: MediaRoute.ModelInput,
): ImageModel<Options>
static fromRoute<Options extends ImageOptions, Frame, State, Token>(
route: ImageModel.RouteInput<Options, Frame, State, Token>,
/** Compose an inline image protocol with its canonical path into a model for one deployment. */
static fromRoute<Options extends ImageOptions = ImageOptions>(
route: ImageModel.RouteInput<Options>,
input: MediaRoute.ModelInput,
) {
return new ImageModel<Options>({
id: input.id,
provider: route.provider,
http: input.http,
route: composeAnyRoute(route, input, collectResponse),
route: composeRoute(MediaRoute.inline, route, input),
})
}
}
export namespace ImageModel {
export type InlineRouteInput<Options extends ImageOptions = ImageOptions> = MediaModel.RouteInput<
export type RouteInput<Options extends ImageOptions = ImageOptions> = MediaModel.RouteInput<
ImageRequestFor<Options>,
MediaProtocol.Inline<ImageRequestFor<Options>, ImageResponse>
>
export type StreamRouteInput<
Options extends ImageOptions = ImageOptions,
Frame = unknown,
State = unknown,
> = MediaModel.RouteInput<
MediaProtocol.Addressed<ImageRequestFor<Options>>,
MediaProtocol.Streamed<ImageRequestFor<Options>, ImageEvent, Frame, State>
>
export type QueuedRouteInput<Options extends ImageOptions = ImageOptions, Token = unknown> = MediaModel.RouteInput<
ImageRequestFor<Options>,
MediaProtocol.Queued<ImageRequestFor<Options>, ImageResponse, Token>
>
export type RouteInput<
Options extends ImageOptions = ImageOptions,
Frame = unknown,
State = unknown,
Token = unknown,
> = MediaModel.AnyRouteInput<ImageRequestFor<Options>, ImageEvent, ImageResponse, Frame, State, Token>
}
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
@@ -152,13 +117,6 @@ export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")
}
}
/** The `index`-th lower-fidelity preview of an image still generating; the `image` event supersedes it. */
export const ImagePartialEvent = Schema.Struct({
type: Schema.tag("image-partial"),
index: Schema.Number,
image: Media.AssetSchema,
}).annotate({ identifier: "Image.Event.Partial" })
export const ImageOutputEvent = Schema.Struct({
type: Schema.tag("image"),
index: Schema.Number,
@@ -172,24 +130,16 @@ export const ImageFinishEvent = Schema.Struct({
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "Image.Event.Finish" })
const imageEventTagged = Schema.Union([
QueuedEvent,
ProgressEvent,
ImagePartialEvent,
ImageOutputEvent,
ImageFinishEvent,
]).pipe(Schema.toTaggedUnion("type"))
const imageEventTagged = Schema.Union([ImageOutputEvent, ImageFinishEvent]).pipe(Schema.toTaggedUnion("type"))
export const ImageEvent = Object.assign(imageEventTagged, {
is: {
generationQueued: imageEventTagged.guards["generation-queued"],
generationProgress: imageEventTagged.guards["generation-progress"],
imagePartial: imageEventTagged.guards["image-partial"],
image: imageEventTagged.guards.image,
finish: imageEventTagged.guards.finish,
},
})
export type ImageEvent = Schema.Schema.Type<typeof imageEventTagged>
/** Inline routes produce every image at once; expand the response into the streaming event shape. */
export const responseEvents = (response: ImageResponse): ReadonlyArray<ImageEvent> => [
...response.images.map((image, index) => ImageOutputEvent.make({ index, image })),
ImageFinishEvent.make({
@@ -199,20 +149,6 @@ export const responseEvents = (response: ImageResponse): ReadonlyArray<ImageEven
}),
]
const collectResponse = (events: ReadonlyArray<ImageEvent>): Effect.Effect<ImageResponse> => {
const finish = events.find(ImageEvent.is.finish)
// Every image protocol's `finish` emits the terminal event or fails, so a completed stream always has one.
if (finish === undefined) return Effect.die(new Error("The image stream completed without a finish event"))
return Effect.succeed(
new ImageResponse({
images: events.filter(ImageEvent.is.image).map((event) => event.image),
usage: finish.usage,
notices: finish.notices,
providerMetadata: finish.providerMetadata,
}),
)
}
// ---------------------------------------------------------------------------
// Request-shaped call API
// ---------------------------------------------------------------------------
@@ -233,40 +169,22 @@ const requestEffect = (input: ImageRequest | ImageRequestInput) => tryRequest(()
export function generate<const Model extends ImageModel>(
input: ImageRequestInput<Model>,
options?: AwaitOptions,
): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest, options?: AwaitOptions): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest | ImageRequestInput, options?: AwaitOptions) {
return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.generate(request, options)))
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest | ImageRequestInput) {
return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.generate(request)))
}
export function stream<const Model extends ImageModel>(
input: ImageRequestInput<Model>,
options?: AwaitOptions,
): Stream.Stream<ImageEvent, AIError, Service>
export function stream(input: ImageRequest, options?: AwaitOptions): Stream.Stream<ImageEvent, AIError, Service>
export function stream(input: ImageRequest | ImageRequestInput, options?: AwaitOptions) {
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => ImageClient.stream(request, options))))
export function stream(input: ImageRequest): Stream.Stream<ImageEvent, AIError, Service>
export function stream(input: ImageRequest | ImageRequestInput) {
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => ImageClient.stream(request))))
}
/** Inline and streaming routes fail with `UnsupportedOperation`. */
export function start<const Model extends ImageModel>(
input: ImageRequestInput<Model>,
): Effect.Effect<Generation<ImageResponse>, AIError, Service>
export function start(input: ImageRequest): Effect.Effect<Generation<ImageResponse>, AIError, Service>
export function start(input: ImageRequest | ImageRequestInput) {
return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.start(request)))
}
export const resume = <Options extends ImageOptions>(
model: ImageModel<Options>,
token: unknown,
): Effect.Effect<Generation<ImageResponse>, AIError, Service> => ImageClient.resume(model, token)
export const Image = {
request,
generate,
stream,
start,
resume,
} as const
+1 -29
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { Endpoint } from "./route/endpoint.js"
import { MediaRoute } from "./route/media.js"
import type { MediaRoute } from "./route/media.js"
import type { MediaProtocol } from "./route/media-protocol.js"
import { AIError, HttpOptions, InvalidRequestError, ModelID, ProviderID } from "./schema/index.js"
@@ -42,11 +42,6 @@ export namespace MediaModel {
/** Headers the protocol requires on every call, such as a pinned API version; deployment headers win. */
readonly headers?: Record<string, string>
}
export type AnyRouteInput<Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token> =
| RouteInput<Request, MediaProtocol.Inline<Request, Response>>
| RouteInput<MediaProtocol.Addressed<Request>, MediaProtocol.Streamed<Request, Event, Frame, State>>
| RouteInput<Request, MediaProtocol.Queued<Request, Response, Token>>
}
/** Compose a protocol route input with one deployment through `MediaRoute.inline`, `queued`, or `stream`. */
@@ -65,29 +60,6 @@ export const composeRoute = <Request extends MediaRoute.MediaRequest, Protocol,
route.headers === undefined && input.headers === undefined ? undefined : { ...route.headers, ...input.headers },
})
export const composeAnyRoute = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
route: MediaModel.AnyRouteInput<Request, Event, Response, Frame, State, Token>,
input: MediaRoute.ModelInput,
collect: (events: ReadonlyArray<Event>) => Effect.Effect<Response, AIError>,
): MediaRoute.AnyRoute<Request, Event, Response> => {
if (isStreamInput(route))
return composeRoute((composition) => MediaRoute.stream({ ...composition, collect }), route, input)
if (isQueuedInput(route)) return composeRoute(MediaRoute.queued, route, input)
return composeRoute(MediaRoute.inline, route, input)
}
const isStreamInput = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
route: MediaModel.AnyRouteInput<Request, Event, Response, Frame, State, Token>,
): route is MediaModel.RouteInput<
MediaProtocol.Addressed<Request>,
MediaProtocol.Streamed<Request, Event, Frame, State>
> => route.protocol.kind === "stream"
const isQueuedInput = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
route: MediaModel.AnyRouteInput<Request, Event, Response, Frame, State, Token>,
): route is MediaModel.RouteInput<Request, MediaProtocol.Queued<Request, Response, Token>> =>
route.protocol.kind === "queued"
/** Lift a synchronous Schema-class constructor into a typed `InvalidRequest` failure. */
export const tryRequest = <A>(make: () => A): Effect.Effect<A, AIError> =>
Effect.try({
+5 -11
View File
@@ -1,6 +1,6 @@
import { Effect, Layer, ManagedRuntime, Stream } from "effect"
import type { AwaitOptions, Generation, Snapshot } from "./generation.js"
import { Image, ImageModel, ImageRequest, type ImageOptions, type ImageRequestInput } from "./image.js"
import { Image, ImageModel, ImageRequest, type ImageRequestInput } from "./image.js"
import { ImageClient } from "./image-client.js"
import { LLM } from "./index.js"
import { LLMClient } from "./route/client.js"
@@ -123,16 +123,10 @@ export const make = (options: Options = {}) => {
request: Image.request,
generate: <const Model extends ImageModel>(
input: ImageRequestInput<Model> | ImageRequest,
options?: AwaitOptions & RunOptions,
) => run(Image.generate(imageRequest(input), { poll: options?.poll }), options),
stream: <const Model extends ImageModel>(
input: ImageRequestInput<Model> | ImageRequest,
options?: AwaitOptions & RunOptions,
) => iterate(Image.stream(imageRequest(input), { poll: options?.poll }), options),
start: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) =>
run(Image.start(imageRequest(input)), options).then(handle),
resume: <Options extends ImageOptions>(model: ImageModel<Options>, token: unknown, options?: RunOptions) =>
run(Image.resume(model, token), options).then(handle),
options?: RunOptions,
) => run(Image.generate(imageRequest(input)), options),
stream: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) =>
iterate(Image.stream(imageRequest(input)), options),
},
video: {
request: Video.request,
-223
View File
@@ -1,223 +0,0 @@
import { Effect, Schema } from "effect"
import type { HttpClientResponse } from "effect/unstable/http"
import type { Status } from "../generation.js"
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
import { Media } from "../media.js"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
import { ProviderShared, optionalNull } from "./shared.js"
import { MediaInput } from "./utils/media-input.js"
const ADAPTER = "bfl-images"
const NAME = "Black Forest Labs"
const PROVIDER = ProviderID.make("black-forest-labs")
export const DEFAULT_BASE_URL = "https://api.bfl.ai"
// ---------------------------------------------------------------------------
// 1. Public model input
// ---------------------------------------------------------------------------
export type BlackForestLabsImageOptions = {
readonly safety_tolerance?: number
readonly prompt_upsampling?: boolean
readonly disable_pup?: boolean
readonly raw?: boolean
readonly guidance?: number
readonly steps?: number
} & Record<string, unknown>
export type Request = ImageRequestFor<BlackForestLabsImageOptions>
// ---------------------------------------------------------------------------
// 2. Token and response schemas
// ---------------------------------------------------------------------------
/** Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. */
export const Token = Schema.Struct({ id: Schema.String, pollingURL: Schema.String })
export type Token = Schema.Schema.Type<typeof Token>
const StartResponse = Schema.Struct({
id: Schema.String,
polling_url: Schema.String,
})
const Result = Schema.Struct({
id: Schema.String,
status: Schema.String,
result: optionalNull(
Schema.StructWithRest(
Schema.Struct({ sample: Schema.String, seed: optionalNull(Schema.Number), prompt: optionalNull(Schema.String) }),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
cost: optionalNull(Schema.Number),
})
const STATUS = {
Pending: "running",
Reasoning: "running",
Generating: "running",
Ready: "completed",
Error: "failed",
// Moderation is terminal; `decodeResult` reports it as a content-policy failure.
"Content Moderated": "failed",
"Request Moderated": "failed",
"Task not found": "expired",
} as const satisfies Record<string, Status>
const isModerated = (status: string) => status === "Content Moderated" || status === "Request Moderated"
// ---------------------------------------------------------------------------
// 5. Request body construction
// ---------------------------------------------------------------------------
interface Capabilities {
readonly sizing: "dimensions" | "aspectRatio" | "none"
/** `input_image` numbers extra references `input_image_2`…; the other fields take one image. */
readonly imageField: "input_image" | "image_prompt" | "image"
readonly maxImages: number
readonly mask: boolean
}
const capabilities = (model: string): Capabilities => {
if (model.startsWith("flux-pro-1.0-fill")) return { sizing: "none", imageField: "image", maxImages: 1, mask: true }
if (model.startsWith("flux-pro-1.0-expand")) return { sizing: "none", imageField: "image", maxImages: 1, mask: false }
if (model.startsWith("flux-kontext"))
return { sizing: "aspectRatio", imageField: "input_image", maxImages: 4, mask: false }
if (model.startsWith("flux-pro-1.1-ultra"))
return { sizing: "aspectRatio", imageField: "image_prompt", maxImages: 1, mask: false }
if (model.startsWith("flux-pro-1.1") || model.startsWith("flux-dev"))
return { sizing: "dimensions", imageField: "image_prompt", maxImages: 1, mask: false }
if (model.startsWith("flux-2-klein"))
return { sizing: "dimensions", imageField: "input_image", maxImages: 4, mask: false }
return { sizing: "dimensions", imageField: "input_image", maxImages: 8, mask: false }
}
const unsupported = (model: string, field: string, message: string) =>
ProviderShared.unsupportedOperation({
operation: `media.${field}`,
provider: PROVIDER,
route: ADAPTER,
message: `${model} ${message}`,
})
const validate = (request: Request, model: Capabilities) => {
const id = request.model.id
const images = request.images?.length ?? 0
if (request.n !== undefined && request.n > 1)
return Effect.fail(unsupported(id, "n", "generates one image per request; call it once per image"))
if (request.size !== undefined && model.sizing !== "dimensions")
return Effect.fail(unsupported(id, "size", "does not take size (width and height)"))
if (request.aspectRatio !== undefined && model.sizing !== "aspectRatio")
return Effect.fail(unsupported(id, "aspectRatio", "does not take aspectRatio"))
if (images > model.maxImages) return Effect.fail(unsupported(id, "images", `takes at most ${model.maxImages} images`))
if (request.mask !== undefined && !model.mask)
return Effect.fail(unsupported(id, "mask", "does not inpaint; use flux-pro-1.0-fill"))
return Effect.void
}
const imageInput = (asset: Media.Asset) => {
const value = asset.inline()?.base64 ?? ProviderShared.mediaUrl(asset)
if (value === undefined)
return Effect.fail(ProviderShared.invalidRequest(`${NAME} accepts inline images or https URLs`))
return Effect.succeed(value)
}
const fromRequest = Effect.fn("BlackForestLabsImages.fromRequest")(function* (request: Request) {
const model = capabilities(request.model.id)
yield* validate(request, model)
const images = yield* Effect.forEach(request.images ?? [], imageInput)
const fields = images.map((image, index) => [
index === 0 ? model.imageField : `${model.imageField}_${index + 1}`,
image,
])
return MediaProtocol.json(
mergeJsonRecords(
{
prompt: request.prompt,
...(request.size === undefined ? {} : MediaInput.dimensions(request.size)),
aspect_ratio: request.aspectRatio,
seed: request.seed,
output_format: request.format,
mask: request.mask === undefined ? undefined : yield* imageInput(request.mask),
...Object.fromEntries(fields),
},
request.providerOptions,
request.http?.body,
) ?? {},
)
})
// ---------------------------------------------------------------------------
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
token: { id: value.id, pollingURL: value.polling_url },
snapshot: { id: value.id, status: "queued" },
}))
const decodeDocument = MediaProtocol.decodeJson(ADAPTER, NAME, Result)
const decodeStatus = Effect.fn("BlackForestLabsImages.decodeStatus")(function* (
response: HttpClientResponse.HttpClientResponse,
context: MediaProtocol.PollContext<Token>,
) {
const output = yield* decodeDocument(response)
return { id: context.token.id, status: yield* MediaProtocol.status(STATUS, output.value.status, output) }
})
const decodeResult = Effect.fn("BlackForestLabsImages.decodeResult")(function* (
response: HttpClientResponse.HttpClientResponse,
context: MediaProtocol.PollContext<Token>,
) {
const output = yield* decodeDocument(response)
const document = output.value
const status = yield* MediaProtocol.status(STATUS, document.status, output)
if (isModerated(document.status)) return yield* output.contentPolicy(`${NAME} moderated the generation`)
if (status === "failed" || status === "expired")
return yield* output.ended(status, `${NAME} generation ${context.token.id} ended with ${document.status}`)
if (status !== "completed" || document.result === undefined || document.result === null)
return yield* output.invalid(`${NAME} generation ${context.token.id} has no result`)
const { sample, seed, prompt, ...rest } = document.result
return new ImageResponse({
// `sample` is a signed URL that expires 10 minutes after the result is ready, so it is downloaded now.
images: [yield* context.materialize(Media.url(sample))],
usage:
document.cost === undefined || document.cost === null ? undefined : { type: "credits", credits: document.cost },
providerMetadata: {
bfl: { id: context.token.id, seed: seed ?? undefined, prompt: prompt ?? undefined, ...rest },
},
})
})
// ---------------------------------------------------------------------------
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.queued<Request, ImageResponse, Token>({
id: ADAPTER,
name: NAME,
token: Token,
start: { body: { from: fromRequest }, decode: decodeStart },
status: { path: (token) => token.pollingURL, decode: decodeStatus },
result: { path: (token) => token.pollingURL, decode: decodeResult },
})
export const model = (input: MediaRoute.ModelInput) =>
ImageModel.fromRoute<BlackForestLabsImageOptions, Token>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
path: ({ request }) => `/v1/${request.model.id}`,
},
input,
)
export const BlackForestLabsImages = {
protocol,
model,
} as const
-168
View File
@@ -1,168 +0,0 @@
import { Effect, Schema } from "effect"
import type { HttpClientResponse } from "effect/unstable/http"
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
import { Media } from "../media.js"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
import { ProviderShared, optionalNull } from "./shared.js"
import { FalQueue } from "./utils/fal-queue.js"
import { MediaInput } from "./utils/media-input.js"
const ADAPTER = "fal-images"
const NAME = "fal Images"
const PROVIDER = ProviderID.make("fal")
// ---------------------------------------------------------------------------
// 1. Public model input
// ---------------------------------------------------------------------------
export type FalImageOptions = {
readonly image_size?:
| "square_hd"
| "square"
| "portrait_4_3"
| "portrait_16_9"
| "landscape_4_3"
| "landscape_16_9"
| (string & {})
readonly enable_safety_checker?: boolean
} & Record<string, unknown>
export type Request = ImageRequestFor<FalImageOptions>
// ---------------------------------------------------------------------------
// 2. Response schema
// ---------------------------------------------------------------------------
const QueueResult = Schema.StructWithRest(
Schema.Struct({
images: Schema.Array(
Schema.Struct({
url: Schema.String,
width: optionalNull(Schema.Number),
height: optionalNull(Schema.Number),
content_type: optionalNull(Schema.String),
}),
),
seed: optionalNull(Schema.Number),
has_nsfw_concepts: optionalNull(Schema.Array(Schema.Boolean)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
// ---------------------------------------------------------------------------
// 5. Request body construction
// ---------------------------------------------------------------------------
const sizing = (model: string) => {
if (/^fal-ai\/(nano-banana|flux-pro\/v1\.1-ultra)/.test(model)) return "aspect_ratio"
if (model.startsWith("fal-ai/flux")) return "image_size"
return undefined
}
const unsupported = (model: string, field: string, message: string) =>
ProviderShared.unsupportedOperation({
operation: `media.${field}`,
provider: PROVIDER,
route: ADAPTER,
message: `${model} ${message}`,
})
const validate = (request: Request) => {
const id = request.model.id
const field = sizing(id)
if (request.size !== undefined && request.aspectRatio !== undefined)
return Effect.fail(ProviderShared.invalidRequest(`${NAME} accepts either size or aspectRatio, not both`))
if (request.size !== undefined && field === "aspect_ratio")
return Effect.fail(unsupported(id, "size", "sizes by aspectRatio"))
if (request.aspectRatio !== undefined && field === "image_size")
return Effect.fail(unsupported(id, "aspectRatio", "sizes by size (image_size)"))
if ((request.images?.length ?? 0) > 1 && !isEdit(id))
return Effect.fail(unsupported(id, "images", "takes one image_url; use an /edit endpoint for several images"))
return Effect.void
}
// `/edit` endpoints take an `image_urls` list; image-to-image, fill, and Ultra take one `image_url` (beside `mask_url`).
const isEdit = (model: string) => model.endsWith("/edit")
const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request: Request) {
yield* validate(request)
const images = yield* Effect.forEach(request.images ?? [], (image) => FalQueue.mediaUrl(image, NAME))
const edit = isEdit(request.model.id)
return MediaProtocol.json(
mergeJsonRecords(
{
prompt: request.prompt,
num_images: request.n,
seed: request.seed,
image_size: request.size === undefined ? undefined : MediaInput.dimensions(request.size),
aspect_ratio: request.aspectRatio,
output_format: request.format,
image_urls: edit && images.length > 0 ? images : undefined,
image_url: edit ? undefined : images[0],
mask_url: request.mask === undefined ? undefined : yield* FalQueue.mediaUrl(request.mask, NAME),
},
request.providerOptions,
request.http?.body,
) ?? {},
)
})
// ---------------------------------------------------------------------------
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeQueueResult = MediaProtocol.decodeJson(ADAPTER, NAME, QueueResult)
const decodeResult = Effect.fn("FalImages.decodeResult")(function* (
response: HttpClientResponse.HttpClientResponse,
context: MediaProtocol.PollContext<FalQueue.Token>,
) {
const output = yield* decodeQueueResult(response)
const { images, seed, has_nsfw_concepts, ...rest } = output.value
if (images.length === 0) return yield* output.invalid(`${NAME} returned no images`)
// With the safety checker on, flagged images come back blacked out rather than omitted.
const flagged = (has_nsfw_concepts ?? []).flatMap((value, index) => (value ? [index] : []))
return new ImageResponse({
images: images.map((image) =>
Media.url(image.url, {
mediaType: image.content_type ?? undefined,
info: { width: image.width ?? undefined, height: image.height ?? undefined },
}),
),
notices:
flagged.length === 0
? undefined
: flagged.map((index) => ({ type: "moderated" as const, message: `${NAME} flagged image ${index} as NSFW` })),
providerMetadata: { fal: { requestId: context.token.requestID, seed: seed ?? undefined, ...rest } },
})
})
// ---------------------------------------------------------------------------
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = FalQueue.protocol<Request, ImageResponse>({
id: ADAPTER,
name: NAME,
from: fromRequest,
decodeResult,
})
export const model = (input: MediaRoute.ModelInput) =>
ImageModel.fromRoute<FalImageOptions, FalQueue.Token>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: FalQueue.DEFAULT_BASE_URL,
path: ({ request }) => `/${request.model.id}`,
},
input,
)
export const FalImages = {
protocol,
model,
} as const
+74 -11
View File
@@ -1,16 +1,17 @@
import { Effect, Schema } from "effect"
import type { HttpClientResponse } from "effect/unstable/http"
import type { Status } from "../generation.js"
import { Media } from "../media.js"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js"
import { ProviderShared, optionalNull } from "./shared.js"
import { FalQueue } from "./utils/fal-queue.js"
const ADAPTER = "fal-video"
const NAME = "fal Video"
const PROVIDER = ProviderID.make("fal")
export const DEFAULT_BASE_URL = "https://queue.fal.run"
// ---------------------------------------------------------------------------
// 1. Public model input
@@ -30,9 +31,32 @@ export type FalVideoOptions = {
export type Request = VideoRequestFor<FalVideoOptions>
// ---------------------------------------------------------------------------
// 2. Response schema
// 2. Token and response schemas
// ---------------------------------------------------------------------------
/** fal hands back absolute follow-up URLs on submit; they are authoritative for status, result, and cancel. */
export const Token = Schema.Struct({
requestID: Schema.String,
statusURL: Schema.String,
responseURL: Schema.String,
cancelURL: Schema.String,
})
export type Token = Schema.Schema.Type<typeof Token>
const StartResponse = Schema.Struct({
request_id: Schema.String,
status_url: Schema.String,
response_url: Schema.String,
cancel_url: Schema.String,
queue_position: optionalNull(Schema.Number),
})
const QueueStatus = Schema.Struct({
status: Schema.String,
queue_position: optionalNull(Schema.Number),
error: optionalNull(Schema.Unknown),
})
const QueueResult = Schema.StructWithRest(
Schema.Struct({
video: Schema.Struct({
@@ -46,10 +70,20 @@ const QueueResult = Schema.StructWithRest(
[Schema.Record(Schema.String, Schema.Unknown)],
)
const STATUS = {
IN_QUEUE: "queued",
IN_PROGRESS: "running",
COMPLETED: "completed",
} as const satisfies Record<string, Status>
// ---------------------------------------------------------------------------
// 5. Request body construction
// ---------------------------------------------------------------------------
// fal accepts public URLs and data URIs; there is no provider file handle to forward.
const mediaUrl = (asset: Media.Asset) =>
ProviderShared.mediaReference(asset, undefined, NAME).pipe(Effect.map((reference) => reference.value))
const fromRequest = Effect.fn("FalVideo.fromRequest")(function* (request: Request) {
if (request.frames?.last !== undefined)
return yield* ProviderShared.unsupportedOperation({
@@ -58,9 +92,8 @@ const fromRequest = Effect.fn("FalVideo.fromRequest")(function* (request: Reques
route: ADAPTER,
message: `${NAME} names the last frame per model; pass it through providerOptions (e.g. end_image_url) instead of frames.last`,
})
const imageUrl =
request.frames?.first === undefined ? undefined : yield* FalQueue.mediaUrl(request.frames.first, NAME)
const videoUrl = request.video === undefined ? undefined : yield* FalQueue.mediaUrl(request.video, NAME)
const imageUrl = request.frames?.first === undefined ? undefined : yield* mediaUrl(request.frames.first)
const videoUrl = request.video === undefined ? undefined : yield* mediaUrl(request.video)
return MediaProtocol.json(
mergeJsonRecords(
{
@@ -83,11 +116,38 @@ const fromRequest = Effect.fn("FalVideo.fromRequest")(function* (request: Reques
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
token: {
requestID: value.request_id,
statusURL: value.status_url,
responseURL: value.response_url,
cancelURL: value.cancel_url,
},
snapshot: { id: value.request_id, status: "queued", position: value.queue_position ?? undefined },
}))
const decodeQueueStatus = MediaProtocol.decodeJson(ADAPTER, NAME, QueueStatus)
const decodeQueueResult = MediaProtocol.decodeJson(ADAPTER, NAME, QueueResult)
const decodeStatus = Effect.fn("FalVideo.decodeStatus")(function* (
response: HttpClientResponse.HttpClientResponse,
context: MediaProtocol.PollContext<Token>,
) {
const output = yield* decodeQueueStatus(response)
const decoded = output.value
const status = yield* MediaProtocol.status(STATUS, decoded.status, output)
// fal reports request failures as COMPLETED with an `error`; the response endpoint carries the details.
const failed = status === "completed" && decoded.error !== undefined && decoded.error !== null
return {
id: context.token.requestID,
status: failed ? "failed" : status,
position: status === "queued" ? (decoded.queue_position ?? undefined) : undefined,
}
})
const decodeResult = Effect.fn("FalVideo.decodeResult")(function* (
response: HttpClientResponse.HttpClientResponse,
context: MediaProtocol.PollContext<FalQueue.Token>,
context: MediaProtocol.PollContext<Token>,
) {
const output = yield* decodeQueueResult(response)
const { video, seed, ...rest } = output.value
@@ -109,21 +169,24 @@ const decodeResult = Effect.fn("FalVideo.decodeResult")(function* (
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = FalQueue.protocol<Request, VideoResponse>({
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>({
id: ADAPTER,
name: NAME,
token: Token,
unsupported: ["n", "durationSeconds", "references"],
from: fromRequest,
decodeResult,
start: { body: { from: fromRequest }, decode: decodeStart },
status: { path: (token) => token.statusURL, decode: decodeStatus },
result: { path: (token) => token.responseURL, decode: decodeResult },
cancel: { method: "PUT", path: (token) => token.cancelURL },
})
export const model = (input: MediaRoute.ModelInput) =>
VideoModel.fromRoute<FalVideoOptions, FalQueue.Token>(
VideoModel.fromRoute<FalVideoOptions, Token>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: FalQueue.DEFAULT_BASE_URL,
baseURL: DEFAULT_BASE_URL,
path: ({ request }) => `/${request.model.id}`,
},
input,
+12 -25
View File
@@ -130,10 +130,6 @@ const OpenAIChatUserContent = Schema.Union([
type: Schema.Literal("image_url"),
image_url: Schema.Struct({ url: Schema.String }),
}),
Schema.Struct({
type: Schema.Literal("file"),
file: Schema.Struct({ filename: Schema.String, file_data: Schema.String }),
}),
])
const OpenAIChatMessage = Schema.Union([
@@ -364,15 +360,6 @@ const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChat
})
const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) {
// Chat Completions accepts PDFs, and no other documents, as inline `file` parts; file URLs are not supported.
if (part.media.mediaType.toLowerCase() === "application/pdf")
return {
type: "file" as const,
file: {
filename: part.filename ?? "document.pdf",
file_data: (yield* ProviderShared.requireInlineMedia("OpenAI Chat", part.media)).dataUrl,
},
}
if (part.media.kind !== "image")
return yield* ProviderShared.invalidRequest(`OpenAI Chat does not support media type ${part.media.mediaType}`)
const url =
@@ -502,7 +489,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
options: LoweringOptions,
) {
const messages: OpenAIChatMessage[] = []
const attachments: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
@@ -524,9 +511,9 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
cache_control: options.cacheControl?.(part.cache),
})
const files = content.filter((item) => item.type === "file")
attachments.push(...(yield* Effect.forEach(files, (item) => lowerMedia(ProviderShared.toolFileMedia(item)))))
images.push(...(yield* Effect.forEach(files, (item) => lowerMedia(ProviderShared.toolFileMedia(item)))))
}
return { messages, attachments }
return { messages, images }
})
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
@@ -587,21 +574,21 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool")
messages.push({ role: "assistant", content: "Done." })
}
const pendingAttachments: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushAttachments = () => {
if (pendingAttachments.length === 0) return
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
if (pendingImages.length === 0) return
bridgeTools()
messages.push({ role: "user", content: pendingAttachments.splice(0) })
messages.push({ role: "user", content: pendingImages.splice(0) })
}
for (const message of request.messages) {
if (message.role === "user") bridgeTools()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingAttachments.length > 0) {
if (pendingImages.length > 0) {
messages.push({
role: "user",
content: [
...pendingAttachments.splice(0),
...pendingImages.splice(0),
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
],
})
@@ -645,13 +632,13 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message, lowering)
messages.push(...lowered.messages)
pendingAttachments.push(...lowered.attachments)
pendingImages.push(...lowered.images)
continue
}
flushAttachments()
flushImages()
messages.push(...(yield* lowerMessage(message, reasoningField, requireReasoning, lowering)))
}
flushAttachments()
flushImages()
return messages
})
+45 -151
View File
@@ -1,17 +1,10 @@
import { Effect, Schema, Stream } from "effect"
import {
ImageFinishEvent,
ImageModel,
ImageOutputEvent,
ImagePartialEvent,
type ImageEvent,
type ImageRequestFor,
} from "../image.js"
import { Effect, Schema } from "effect"
import type { HttpClientResponse } from "effect/unstable/http"
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
import { Media } from "../media.js"
import { Framing } from "../route/framing.js"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, mergeJsonRecords, type MediaUsage } from "../schema/index.js"
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
import { ProviderShared } from "./shared.js"
import { MediaInput } from "./utils/media-input.js"
@@ -34,8 +27,6 @@ export type OpenAIImageOptions = {
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
readonly moderation?: OpenAIImageString<"auto" | "low">
readonly outputCompression?: number
/** Previews sent before the final image when streaming (default 2); ignored by `Image.generate`. */
readonly partialImages?: number
} & Record<string, unknown>
export type Request = ImageRequestFor<OpenAIImageOptions>
@@ -44,14 +35,6 @@ export type Request = ImageRequestFor<OpenAIImageOptions>
// 2. Response schema
// ---------------------------------------------------------------------------
const Usage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
total_tokens: Schema.optional(Schema.Number),
input_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
})
const OpenAIImageResponse = Schema.Struct({
data: Schema.Array(
Schema.Struct({
@@ -61,44 +44,17 @@ const OpenAIImageResponse = Schema.Struct({
}),
),
output_format: Schema.optional(Schema.String),
usage: Schema.optional(Usage),
usage: Schema.optional(
Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
total_tokens: Schema.optional(Schema.Number),
input_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
),
})
// ---------------------------------------------------------------------------
// 3. Streaming event schema
// ---------------------------------------------------------------------------
const StreamEvent = Schema.Union([
Schema.Struct({
type: Schema.Literals(["image_generation.partial_image", "image_edit.partial_image"]),
b64_json: Schema.String,
partial_image_index: Schema.Number,
output_format: Schema.String,
}),
Schema.Struct({
type: Schema.Literals(["image_generation.completed", "image_edit.completed"]),
b64_json: Schema.String,
output_format: Schema.String,
usage: Schema.optional(Usage),
}),
])
const decodeEvent = MediaProtocol.decodeFrame(ADAPTER, NAME, StreamEvent)
const decodeDocument = Schema.decodeUnknownEffect(Schema.fromJsonString(OpenAIImageResponse))
/** `generate` reads the whole JSON response as one frame, with the requested format for responses that omit it. */
type Frame = string | { readonly document: string; readonly requested: string | undefined }
// ---------------------------------------------------------------------------
// 4. Parser state
// ---------------------------------------------------------------------------
interface State {
readonly completed: number
readonly format?: string
readonly usage?: MediaUsage
}
// ---------------------------------------------------------------------------
// 5. Request body construction
// ---------------------------------------------------------------------------
@@ -108,49 +64,26 @@ const RESERVED_FORM_FIELDS = new Set(["model", "prompt", "image", "image[]", "im
const nativeOptions = (options: OpenAIImageOptions | undefined) => {
if (!options) return undefined
const { outputCompression, partialImages: _, ...native } = options
const { outputCompression, ...native } = options
return { output_compression: outputCompression, ...native }
}
const streamOptions = (request: MediaProtocol.Addressed<Request>) => {
if (request.mode !== "stream") return Effect.succeed(undefined)
if (request.model.id.startsWith("dall-e"))
return Effect.fail(
ProviderShared.unsupportedOperation({
operation: "media.stream",
provider: PROVIDER,
route: ADAPTER,
message: `${request.model.id} does not stream; use Image.generate or a GPT image model`,
}),
)
if (request.n !== undefined && request.n > 1)
return Effect.fail(
ProviderShared.unsupportedOperation({
operation: "media.n",
provider: PROVIDER,
route: ADAPTER,
message: `${NAME} streams one image; use Image.generate for n=${request.n}`,
}),
)
return Effect.succeed({ stream: true, partial_images: request.providerOptions?.partialImages ?? 2 })
}
const isEdit = (request: Request) => (request.images?.length ?? 0) > 0
const isInline = (asset: Media.Asset) => asset.source.type === "bytes" || asset.source.type === "base64"
const isInline = (asset: Media.Asset) => asset.inline() !== undefined
const reference = (asset: Media.Asset) =>
ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(
Effect.map((item) => (item.type === "ref" ? { file_id: item.value } : { image_url: item.value })),
)
const fromRequest = Effect.fn("OpenAIImages.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
const fromRequest = Effect.fn("OpenAIImages.fromRequest")(function* (request: Request) {
const images = request.images ?? []
const mask = request.mask
if (mask !== undefined && images.length === 0)
return yield* ProviderShared.invalidRequest("An OpenAI image mask requires at least one input image")
const fields = mergeJsonRecords(
{ n: request.n, size: request.size, output_format: request.format, ...(yield* streamOptions(request)) },
{ n: request.n, size: request.size, output_format: request.format },
nativeOptions(request.providerOptions),
request.http?.body,
)
@@ -189,7 +122,7 @@ const fromRequest = Effect.fn("OpenAIImages.fromRequest")(function* (request: Me
})
// ---------------------------------------------------------------------------
// 6. Stream parsing
// 6. Response decoding
// ---------------------------------------------------------------------------
const requestedFormat = (body: MediaProtocol.Body) => {
@@ -198,95 +131,56 @@ const requestedFormat = (body: MediaProtocol.Body) => {
return typeof value === "string" ? value : undefined
}
const usage = (value: Schema.Schema.Type<typeof Usage> | undefined): MediaUsage | undefined =>
value === undefined
? undefined
: {
type: "tokens",
input: value.input_tokens,
output: value.output_tokens,
total: value.total_tokens,
details: { openai: value },
}
const eventImage = (frame: string, label: string, data: string, format: string) =>
MediaInput.decodedAsset(
(message, cause) => MediaProtocol.frameError(ADAPTER, message, frame, cause),
label,
data,
`image/${format}`,
{ info: { format } },
)
const onEvent = Effect.fn("OpenAIImages.onEvent")(function* (state: State, frame: string) {
const event = yield* decodeEvent(frame)
const format = event.output_format
if ("partial_image_index" in event) {
const image = yield* eventImage(frame, `${NAME} partial image`, event.b64_json, format)
return [state, [ImagePartialEvent.make({ index: event.partial_image_index, image })]] as const
}
const image = yield* eventImage(frame, `${NAME} result ${state.completed}`, event.b64_json, format)
return [
{ ...state, completed: state.completed + 1, format, usage: usage(event.usage) },
[ImageOutputEvent.make({ index: state.completed, image })],
] as const
})
const onDocument = Effect.fn("OpenAIImages.onDocument")(function* (frame: Exclude<Frame, string>) {
const invalid = (message: string, cause?: unknown) =>
MediaProtocol.frameError(ADAPTER, message, frame.document, cause)
const decoded = yield* decodeDocument(frame.document).pipe(
Effect.mapError((cause) => invalid(`${NAME} returned an invalid response`, cause)),
)
const format = decoded.output_format ?? frame.requested ?? "png"
const decodeResponse = Effect.fn("OpenAIImages.decodeResponse")(function* (
response: HttpClientResponse.HttpClientResponse,
context: MediaProtocol.DecodeContext<Request>,
) {
const output = yield* MediaProtocol.decodeJson(ADAPTER, NAME, OpenAIImageResponse)(response)
const decoded = output.value
const format = decoded.output_format ?? requestedFormat(context.body) ?? "png"
const mediaType = `image/${format}`
const images = yield* Effect.forEach(decoded.data, (item, index) => {
const providerMetadata =
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } }
if (item.b64_json)
return MediaInput.decodedAsset(invalid, `${NAME} result ${index}`, item.b64_json, mediaType, {
return MediaInput.decodedAsset(output.invalid, `${NAME} result ${index}`, item.b64_json, mediaType, {
info: { format },
providerMetadata,
})
if (item.url) return Effect.succeed(Media.url(item.url, { mediaType, info: { format }, providerMetadata }))
return Effect.fail(invalid(`${NAME} result ${index} has neither image data nor a URL`))
return Effect.fail(output.invalid(`${NAME} result ${index} has neither image data nor a URL`))
})
if (images.length === 0) return yield* output.invalid(`${NAME} returned no images`)
return new ImageResponse({
images,
usage:
decoded.usage === undefined
? undefined
: {
type: "tokens",
input: decoded.usage.input_tokens,
output: decoded.usage.output_tokens,
total: decoded.usage.total_tokens,
details: { openai: decoded.usage },
},
providerMetadata: { openai: { outputFormat: format } },
})
if (images.length === 0) return yield* invalid(`${NAME} returned no images`)
const state: State = { completed: images.length, format, usage: usage(decoded.usage) }
return [state, images.map((image, index) => ImageOutputEvent.make({ index, image }))] as const
})
const step = (state: State, frame: Frame) => (typeof frame === "string" ? onEvent(state, frame) : onDocument(frame))
const finish = (state: State) => {
if (state.completed === 0) return Effect.fail(MediaProtocol.incomplete(ADAPTER))
return Effect.succeed([
ImageFinishEvent.make({ usage: state.usage, providerMetadata: { openai: { outputFormat: state.format } } }),
])
}
// ---------------------------------------------------------------------------
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, ImageEvent, Frame, State>({
export const protocol = MediaProtocol.inline<Request, ImageResponse>({
id: ADAPTER,
name: NAME,
unsupported: ["aspectRatio", "seed"],
body: { from: fromRequest },
frames: (bytes, context) =>
context.request.mode === "stream"
? Framing.sse.frame(bytes)
: Framing.document
.frame(bytes)
.pipe(Stream.map((document) => ({ document, requested: requestedFormat(context.body) }))),
initial: () => ({ completed: 0 }),
step,
finish,
response: { decode: decodeResponse },
})
export const model = (input: MediaRoute.ModelInput) =>
ImageModel.fromRoute<OpenAIImageOptions, Frame, State>(
ImageModel.fromRoute<OpenAIImageOptions>(
{
id: ADAPTER,
provider: PROVIDER,
@@ -1,184 +0,0 @@
import { Duration, Effect, Schema } from "effect"
import type { HttpClientResponse } from "effect/unstable/http"
import type { Status } from "../generation.js"
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
import { Media } from "../media.js"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, mergeJsonRecords, type AIError } from "../schema/index.js"
import { ProviderShared, optionalNull } from "./shared.js"
const ADAPTER = "replicate-images"
const NAME = "Replicate"
const PROVIDER = ProviderID.make("replicate")
export const DEFAULT_BASE_URL = "https://api.replicate.com"
const OUTPUT_RETENTION = Duration.hours(1)
const MAX_DATA_URL_BYTES = 256 * 1024
// ---------------------------------------------------------------------------
// 1. Public model input
// ---------------------------------------------------------------------------
/** Model-defined `input` fields under the model's own names; `Media.Asset` values lower to URLs or data URLs. */
export type ReplicateImageOptions = Record<string, unknown>
export type Request = ImageRequestFor<ReplicateImageOptions>
// ---------------------------------------------------------------------------
// 2. Token and response schemas
// ---------------------------------------------------------------------------
export const Token = Schema.Struct({ id: Schema.String, getURL: Schema.String, cancelURL: Schema.String })
export type Token = Schema.Schema.Type<typeof Token>
const Prediction = Schema.Struct({
id: Schema.String,
status: Schema.String,
output: optionalNull(Schema.Unknown),
error: optionalNull(Schema.Unknown),
data_removed: optionalNull(Schema.Boolean),
completed_at: optionalNull(Schema.String),
metrics: optionalNull(Schema.Struct({ predict_time: optionalNull(Schema.Number) })),
urls: Schema.Struct({ get: Schema.String, cancel: Schema.String }),
})
const Output = Schema.Union([Schema.String, Schema.Array(Schema.String)])
const isOutput = Schema.is(Output)
const STATUS = {
starting: "queued",
processing: "running",
succeeded: "completed",
failed: "failed",
canceled: "cancelled",
// The prediction hit its `Cancel-After` deadline before it started running.
aborted: "expired",
} as const satisfies Record<string, Status>
// ---------------------------------------------------------------------------
// 5. Request body construction
// ---------------------------------------------------------------------------
// Official models run by `owner/name`; anything else (`owner/name:version` or a bare version id) is a pinned version.
const isOfficial = (model: string) => /^[^/:]+\/[^/:]+$/.test(model)
const inlineSize = (source: Media.Source) => {
if (source.type === "bytes") return source.data.byteLength
if (source.type === "base64") return source.data.length * 0.75
return 0
}
const fileInput = (asset: Media.Asset) => {
if (inlineSize(asset.source) > MAX_DATA_URL_BYTES)
return Effect.fail(
ProviderShared.invalidRequest(`${NAME} data URL inputs are limited to 256 KB; pass a larger file by https URL`),
)
return ProviderShared.mediaReference(asset, undefined, NAME).pipe(Effect.map((reference) => reference.value))
}
const inputValue = (value: unknown): Effect.Effect<unknown, AIError> => {
if (value instanceof Media.Asset) return fileInput(value)
if (Array.isArray(value) && value.some((item) => item instanceof Media.Asset))
return Effect.forEach(value, (item) => (item instanceof Media.Asset ? fileInput(item) : Effect.succeed(item)))
return Effect.succeed(value)
}
const fromRequest = Effect.fn("ReplicateImages.fromRequest")(function* (request: Request) {
const native = yield* Effect.forEach(Object.entries(request.providerOptions ?? {}), ([key, value]) =>
inputValue(value).pipe(Effect.map((lowered) => [key, lowered] as const)),
)
const input = mergeJsonRecords({ prompt: request.prompt }, Object.fromEntries(native))
const model = request.model.id
return MediaProtocol.json(
mergeJsonRecords(isOfficial(model) ? { input } : { version: model, input }, request.http?.body) ?? {},
)
})
// ---------------------------------------------------------------------------
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodePrediction = MediaProtocol.decodeJson(ADAPTER, NAME, Prediction)
const decodeStart = Effect.fn("ReplicateImages.decodeStart")(function* (
response: HttpClientResponse.HttpClientResponse,
) {
const output = yield* decodePrediction(response)
const prediction = output.value
return {
token: { id: prediction.id, getURL: prediction.urls.get, cancelURL: prediction.urls.cancel },
// `Prefer: wait` can return an already-finished prediction, so `await` skips straight to the result.
snapshot: { id: prediction.id, status: yield* MediaProtocol.status(STATUS, prediction.status, output) },
}
})
const decodeStatus = Effect.fn("ReplicateImages.decodeStatus")(function* (
response: HttpClientResponse.HttpClientResponse,
context: MediaProtocol.PollContext<Token>,
) {
const output = yield* decodePrediction(response)
return { id: context.token.id, status: yield* MediaProtocol.status(STATUS, output.value.status, output) }
})
const decodeResult = Effect.fn("ReplicateImages.decodeResult")(function* (
response: HttpClientResponse.HttpClientResponse,
context: MediaProtocol.PollContext<Token>,
) {
const output = yield* decodePrediction(response)
const prediction = output.value
const status = yield* MediaProtocol.status(STATUS, prediction.status, output)
if (status === "failed" || status === "cancelled" || status === "expired")
return yield* output.ended(
status,
`${NAME} prediction ${context.token.id} ${prediction.status}${typeof prediction.error === "string" ? `: ${prediction.error}` : ""}`,
)
if (status !== "completed") return yield* output.invalid(`${NAME} prediction ${context.token.id} has not finished`)
if (prediction.data_removed === true)
return yield* output.ended("expired", `${NAME} removed the output of prediction ${context.token.id}`)
if (!isOutput(prediction.output))
return yield* output.invalid(`${NAME} prediction ${context.token.id} returned output that is not image URLs`)
const urls = typeof prediction.output === "string" ? [prediction.output] : prediction.output
if (urls.length === 0) return yield* output.invalid(`${NAME} prediction ${context.token.id} returned no images`)
const predictTime = prediction.metrics?.predict_time ?? undefined
const completedAt = prediction.completed_at ?? undefined
const expiresAt =
completedAt === undefined ? undefined : Date.parse(completedAt) + Duration.toMillis(OUTPUT_RETENTION)
return new ImageResponse({
images: urls.map((url) => Media.url(url, { expiresAt })),
usage: predictTime === undefined ? undefined : { type: "compute", seconds: predictTime },
providerMetadata: { replicate: { predictionId: prediction.id } },
})
})
// ---------------------------------------------------------------------------
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.queued<Request, ImageResponse, Token>({
id: ADAPTER,
name: NAME,
token: Token,
unsupported: ["images", "mask", "n", "size", "aspectRatio", "seed", "format"],
start: { body: { from: fromRequest }, decode: decodeStart },
status: { path: (token) => token.getURL, decode: decodeStatus },
result: { path: (token) => token.getURL, decode: decodeResult },
cancel: { method: "POST", path: (token) => token.cancelURL },
})
export const model = (input: MediaRoute.ModelInput) =>
ImageModel.fromRoute<ReplicateImageOptions, Token>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
path: ({ request }) =>
isOfficial(request.model.id) ? `/v1/models/${request.model.id}/predictions` : "/v1/predictions",
},
input,
)
export const ReplicateImages = {
protocol,
model,
} as const
+3 -6
View File
@@ -253,9 +253,9 @@ export const errorText = (error: unknown) => {
/**
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, optionally filters named events, and drops empty events and known
* keepalives that proxies send as data. `[DONE]` is dropped by default or
* retained for protocols that use it as their stream boundary. Retry control events are ignored without
* decoder, optionally filters named events, and drops empty and bare `null`
* events. `[DONE]` is dropped by default or retained for protocols that use it
* as their stream boundary. Retry control events are ignored without
* interrupting the stream. Decoder failures become provider output errors so
* the public error channel stays `AIError`.
*/
@@ -291,9 +291,6 @@ export const sseFraming = (
// `data: null`, between events or after `[DONE]`. No protocol has a
// null event, so it carries nothing and must not abort the stream.
event.data !== "null" &&
// Vertex AI partner models (e.g. `xai/grok-4.6`) send their SSE
// keepalive comment as `data: : keepalive` while reasoning.
event.data !== ": keepalive" &&
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")),
),
Stream.map((event) => event.data),
@@ -1,244 +0,0 @@
import { Effect, Schema } from "effect"
import type { HttpClientResponse } from "effect/unstable/http"
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
import { ProviderShared } from "./shared.js"
import { MediaInput } from "./utils/media-input.js"
const ADAPTER = "stability-images"
const UPSCALE_ADAPTER = "stability-upscale"
const NAME = "Stability AI"
const PROVIDER = ProviderID.make("stability")
export const DEFAULT_BASE_URL = "https://api.stability.ai"
const RESULTS_PATH = "/v2beta/results"
const UPSCALE_MODEL = "creative"
/** Base64 JSON instead of raw bytes, so the seed and finish reason arrive with the image. */
const HEADERS = { accept: "application/json" }
// ---------------------------------------------------------------------------
// 1. Public model input
// ---------------------------------------------------------------------------
export type StabilityStylePreset =
| "enhance"
| "anime"
| "photographic"
| "digital-art"
| "comic-book"
| "fantasy-art"
| "line-art"
| "analog-film"
| "neon-punk"
| "isometric"
| "low-poly"
| "origami"
| "modeling-compound"
| "cinematic"
| "3d-model"
| "pixel-art"
| "tile-texture"
| (string & {})
export type StabilityImageOptions = {
readonly negative_prompt?: string
readonly style_preset?: StabilityStylePreset
readonly strength?: number
readonly cfg_scale?: number
} & Record<string, unknown>
/** Creative upscale options; the one `images` source is the image to upscale. */
export type StabilityUpscaleOptions = {
readonly negative_prompt?: string
readonly style_preset?: StabilityStylePreset
readonly creativity?: number
} & Record<string, unknown>
export type Request = ImageRequestFor<StabilityImageOptions>
export type UpscaleRequest = ImageRequestFor<StabilityUpscaleOptions>
// ---------------------------------------------------------------------------
// 2. Token and response schemas
// ---------------------------------------------------------------------------
export const Token = Schema.Struct({ id: Schema.String })
export type Token = Schema.Schema.Type<typeof Token>
// Generate names the base64 image `image`; `/v2beta/results/{id}` names it `result`.
const ImageDocument = Schema.Struct({
image: Schema.optional(Schema.String),
result: Schema.optional(Schema.String),
seed: Schema.optional(Schema.Number),
finish_reason: Schema.optional(Schema.String),
})
const Started = Schema.Struct({ id: Schema.String })
// ---------------------------------------------------------------------------
// 5. Request body construction
// ---------------------------------------------------------------------------
/** `sd3.5-*` ids share the `sd3` endpoint and select the model with a form field. */
const endpoint = (model: string) => (model.startsWith("sd3") ? "sd3" : model)
const RESERVED_FORM_FIELDS = new Set(["image", "prompt", "mode", "model"])
const unsupported = (route: string, operation: string, message: string) =>
ProviderShared.unsupportedOperation({ operation, provider: PROVIDER, route, message })
const form = Effect.fn("StabilityImages.form")(function* (
route: string,
fields: Record<string, unknown>,
native: Record<string, unknown> | undefined,
source: Request["images"],
) {
if ((source?.length ?? 0) > 1) return yield* unsupported(route, "media.images", `${NAME} takes one source image`)
const body = new FormData()
const overlay = Object.entries(native ?? {}).filter(([key]) => !RESERVED_FORM_FIELDS.has(key))
Object.entries(mergeJsonRecords(fields, Object.fromEntries(overlay)) ?? {}).forEach(([key, value]) =>
body.append(key, typeof value === "string" ? value : ProviderShared.encodeJson(value)),
)
const image = source?.[0]
if (image !== undefined)
body.append("image", MediaInput.blob(yield* MediaInput.inlineBytes(route, image), image.mediaType), "image")
return MediaProtocol.multipart(body)
})
const fromRequest = Effect.fn("StabilityImages.fromRequest")(function* (request: Request) {
if (request.n !== undefined && request.n > 1)
return yield* unsupported(ADAPTER, "media.n", `${NAME} generates one image per request; call it once per image`)
const target = endpoint(request.model.id)
const edit = (request.images?.length ?? 0) > 0
if (edit && target === "core")
return yield* unsupported(ADAPTER, "media.images", `${NAME} core is text-to-image only; use ultra or sd3.5-*`)
return yield* form(
ADAPTER,
{
prompt: request.prompt,
aspect_ratio: request.aspectRatio,
seed: request.seed,
output_format: request.format,
model: target === "sd3" ? request.model.id : undefined,
mode: target === "sd3" && edit ? "image-to-image" : undefined,
},
mergeJsonRecords(request.providerOptions, request.http?.body),
request.images,
)
})
const fromUpscaleRequest = Effect.fn("StabilityImages.fromUpscaleRequest")(function* (request: UpscaleRequest) {
if ((request.images?.length ?? 0) === 0)
return yield* ProviderShared.invalidRequest(`${NAME} upscale requires the source image in images`)
return yield* form(
UPSCALE_ADAPTER,
{ prompt: request.prompt, seed: request.seed, output_format: request.format },
mergeJsonRecords(request.providerOptions, request.http?.body),
request.images,
)
})
// ---------------------------------------------------------------------------
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeImageDocument = (route: string) => {
const decode = MediaProtocol.decodeJson(route, NAME, ImageDocument)
return Effect.fn("StabilityImages.decodeImage")(function* (response: HttpClientResponse.HttpClientResponse) {
const output = yield* decode(response)
const document = output.value
const data = document.image ?? document.result
if (data === undefined) return yield* output.invalid(`${NAME} returned no image`)
const image = yield* MediaInput.decodedAsset(output.invalid, `${NAME} result`, data, undefined)
return new ImageResponse({
images: [image],
notices:
document.finish_reason === "CONTENT_FILTERED"
? [{ type: "moderated", message: `${NAME} blurred the image for violating its content policy` }]
: undefined,
providerMetadata: { stability: { seed: document.seed, finishReason: document.finish_reason } },
})
})
}
const decodeResponse = decodeImageDocument(ADAPTER)
const decodeUpscaleImage = decodeImageDocument(UPSCALE_ADAPTER)
const decodeStart = MediaProtocol.decodeStarted(UPSCALE_ADAPTER, NAME, Started, (value) => ({
token: { id: value.id },
snapshot: { id: value.id, status: "queued" },
}))
// `/v2beta/results/{id}` answers 202 while in progress and 200 with the finished image document.
const decodeStatus = (response: HttpClientResponse.HttpClientResponse, context: MediaProtocol.PollContext<Token>) =>
Effect.succeed({
id: context.token.id,
status: response.status === 202 ? ("running" as const) : ("completed" as const),
})
const decodeUpscaleResult = Effect.fn("StabilityImages.decodeUpscaleResult")(function* (
response: HttpClientResponse.HttpClientResponse,
context: MediaProtocol.PollContext<Token>,
) {
if (response.status === 202) {
const output = yield* MediaProtocol.text(UPSCALE_ADAPTER, NAME, response)
return yield* output.invalid(`${NAME} upscale ${context.token.id} has not finished`)
}
return yield* decodeUpscaleImage(response)
})
// ---------------------------------------------------------------------------
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.inline<Request, ImageResponse>({
id: ADAPTER,
name: NAME,
unsupported: ["size", "mask"],
body: { from: fromRequest },
response: { decode: decodeResponse },
})
export const upscaleProtocol = MediaProtocol.queued<UpscaleRequest, ImageResponse, Token>({
id: UPSCALE_ADAPTER,
name: NAME,
token: Token,
unsupported: ["n", "size", "aspectRatio", "mask"],
start: { body: { from: fromUpscaleRequest }, decode: decodeStart },
status: { path: (token) => `${RESULTS_PATH}/${token.id}`, decode: decodeStatus },
result: { path: (token) => `${RESULTS_PATH}/${token.id}`, decode: decodeUpscaleResult },
})
export const model = (input: MediaRoute.ModelInput) =>
ImageModel.fromRoute<StabilityImageOptions>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
headers: HEADERS,
path: ({ request }) => `/v2beta/stable-image/generate/${endpoint(request.model.id)}`,
},
input,
)
export const upscaleModel = (input: Omit<MediaRoute.ModelInput, "id">) =>
ImageModel.fromRoute<StabilityUpscaleOptions, Token>(
{
id: UPSCALE_ADAPTER,
provider: PROVIDER,
protocol: upscaleProtocol,
baseURL: DEFAULT_BASE_URL,
headers: HEADERS,
// Only the creative upscaler is asynchronous; fast and conservative answer inline.
path: `/v2beta/stable-image/upscale/${UPSCALE_MODEL}`,
},
{ ...input, id: UPSCALE_MODEL },
)
export const StabilityImages = {
protocol,
upscaleProtocol,
model,
upscaleModel,
} as const
@@ -1,92 +0,0 @@
import { Effect, Schema } from "effect"
import type { HttpClientResponse } from "effect/unstable/http"
import type { Status } from "../../generation.js"
import type { Media } from "../../media.js"
import { MediaProtocol } from "../../route/media-protocol.js"
import type { AIError } from "../../schema/index.js"
import { ProviderShared, optionalNull } from "../shared.js"
export const DEFAULT_BASE_URL = "https://queue.fal.run"
/** fal hands back absolute follow-up URLs on submit; they are authoritative for status, result, and cancel. */
export const Token = Schema.Struct({
requestID: Schema.String,
statusURL: Schema.String,
responseURL: Schema.String,
cancelURL: Schema.String,
})
export type Token = Schema.Schema.Type<typeof Token>
const StartResponse = Schema.Struct({
request_id: Schema.String,
status_url: Schema.String,
response_url: Schema.String,
cancel_url: Schema.String,
queue_position: optionalNull(Schema.Number),
})
const QueueStatus = Schema.Struct({
status: Schema.String,
queue_position: optionalNull(Schema.Number),
error: optionalNull(Schema.Unknown),
})
const STATUS = {
IN_QUEUE: "queued",
IN_PROGRESS: "running",
COMPLETED: "completed",
} as const satisfies Record<string, Status>
// fal accepts public URLs and data URIs; there is no provider file handle to forward.
export const mediaUrl = (asset: Media.Asset, name: string) =>
ProviderShared.mediaReference(asset, undefined, name).pipe(Effect.map((reference) => reference.value))
export const protocol = <Request, Response>(input: {
readonly id: string
readonly name: string
readonly unsupported?: ReadonlyArray<keyof Request & string>
readonly from: (request: Request) => Effect.Effect<MediaProtocol.Body, AIError>
readonly decodeResult: (
response: HttpClientResponse.HttpClientResponse,
context: MediaProtocol.PollContext<Token>,
) => Effect.Effect<Response, AIError>
}) => {
const decodeQueueStatus = MediaProtocol.decodeJson(input.id, input.name, QueueStatus)
return MediaProtocol.queued<Request, Response, Token>({
id: input.id,
name: input.name,
token: Token,
unsupported: input.unsupported,
start: {
body: { from: input.from },
decode: MediaProtocol.decodeStarted(input.id, input.name, StartResponse, (value) => ({
token: {
requestID: value.request_id,
statusURL: value.status_url,
responseURL: value.response_url,
cancelURL: value.cancel_url,
},
snapshot: { id: value.request_id, status: "queued", position: value.queue_position ?? undefined },
})),
},
status: {
path: (token) => token.statusURL,
decode: Effect.fn("FalQueue.decodeStatus")(function* (response, context) {
const output = yield* decodeQueueStatus(response)
const decoded = output.value
const status = yield* MediaProtocol.status(STATUS, decoded.status, output)
// fal reports request failures as COMPLETED with an `error`; the response endpoint carries the details.
const failed = status === "completed" && decoded.error !== undefined && decoded.error !== null
return {
id: context.token.requestID,
status: failed ? "failed" : status,
position: status === "queued" ? (decoded.queue_position ?? undefined) : undefined,
}
}),
},
result: { path: (token) => token.responseURL, decode: input.decodeResult },
cancel: { method: "PUT", path: (token) => token.cancelURL },
})
}
export * as FalQueue from "./fal-queue.js"
@@ -34,11 +34,6 @@ export const query = (route: string, values: Record<string, unknown>): Effect.Ef
)
}
export const dimensions = (size: string) => {
const [width, height] = size.split("x").map(Number)
return { width, height }
}
/** Provider file handle when the ref belongs to this provider; refs from other providers are never forwarded. */
export const refID = (asset: Media.Asset, provider: ProviderID) =>
asset.source.type === "ref" && asset.source.provider === provider ? asset.source.id : undefined
+3 -15
View File
@@ -9,24 +9,12 @@ const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
return { anyOf: projected }
}
// Moonshot rejects an `enum` without a sibling `type`. Its `type` may be one type, or one type plus "null".
const enumType = (values: ReadonlyArray<unknown>) => {
const types = [
...new Set(values.map((value) => (value === null ? "null" : Array.isArray(value) ? "array" : typeof value))),
]
if (types.length === 1) return types[0]
if (types.length === 2 && types.includes("null")) return [...types.filter((type) => type !== "null"), "null"]
return undefined
}
const moonshotNode = (schema: unknown): unknown => {
if (Array.isArray(schema)) return schema.map(moonshotNode)
if (!isRecord(schema)) return schema
if (typeof schema.$ref === "string") return { $ref: schema.$ref }
const type = schema.type === undefined && Array.isArray(schema.enum) ? enumType(schema.enum) : undefined
return Object.fromEntries([
...(type === undefined ? [] : [["type", type]]),
...Object.entries(schema).flatMap(([key, value]) => {
return Object.fromEntries(
Object.entries(schema).flatMap(([key, value]) => {
if (key === "items" && Array.isArray(value)) return [[key, tupleItemsSchema(value)]]
if (key === "prefixItems") {
if ("items" in schema) return []
@@ -35,7 +23,7 @@ const moonshotNode = (schema: unknown): unknown => {
if (key === "unevaluatedItems") return []
return [[key, moonshotNode(value)]]
}),
])
)
}
const moonshot = (schema: JsonSchema): JsonSchema => {
-3
View File
@@ -86,7 +86,6 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
// emits `image_content_policy_violation` as the native code.
const CONTENT_POLICY_CODES = new Set([
"content_filter",
"content_moderation",
"responsibleaipolicyviolation",
"content_policy_violation",
"image_content_policy_violation",
@@ -205,8 +204,6 @@ function providerCodes(value: unknown) {
const exception = isRecord(decoded.exception) ? decoded.exception : undefined
return [
decoded.code,
// Stability's `{ id, name, errors }` bodies carry the code in `name`.
Array.isArray(decoded.errors) ? decoded.name : undefined,
decoded.error_type,
error?.code,
error?.type,
@@ -1,42 +0,0 @@
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { BlackForestLabsImages, DEFAULT_BASE_URL } from "../protocols/bfl-images.js"
export type { BlackForestLabsImageOptions } from "../protocols/bfl-images.js"
export const id = ProviderID.make("black-forest-labs")
const baseURL = DEFAULT_BASE_URL
export type Config = ProviderAuthOption<"optional"> & {
/** `https://api.eu.bfl.ai` or `https://api.us.bfl.ai` pin inference to one region. */
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly http?: HttpOptions.Input
}
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
.orElse(Auth.config("BFL_API_KEY"))
.pipe(Auth.header("x-key"))
}
export const configure = (input: Config = {}) => {
const image = (modelID: string | ModelID) =>
BlackForestLabsImages.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
return {
id,
image,
configure,
}
}
export const provider = configure()
export const image = provider.image
+11 -15
View File
@@ -1,15 +1,12 @@
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { FalImages } from "../protocols/fal-images.js"
import { FalVideo } from "../protocols/fal-video.js"
import { FalQueue } from "../protocols/utils/fal-queue.js"
import { DEFAULT_BASE_URL, FalVideo } from "../protocols/fal-video.js"
export type { FalImageOptions } from "../protocols/fal-images.js"
export type { FalVideoOptions } from "../protocols/fal-video.js"
export const id = ProviderID.make("fal")
const baseURL = FalQueue.DEFAULT_BASE_URL
const baseURL = DEFAULT_BASE_URL
export type Config = ProviderAuthOption<"optional"> & {
readonly baseURL?: string
@@ -26,21 +23,20 @@ const auth = (options: ProviderAuthOption<"optional">) => {
}
export const configure = (input: Config = {}) => {
const media = (modelID: string | ModelID) => ({
id: modelID,
auth: auth(input),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
const video = (modelID: string | ModelID) =>
FalVideo.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
return {
id,
image: (modelID: string | ModelID) => FalImages.model(media(modelID)),
video: (modelID: string | ModelID) => FalVideo.model(media(modelID)),
video,
configure,
}
}
export const provider = configure()
export const image = provider.image
export const video = provider.video
-3
View File
@@ -6,7 +6,6 @@ export * as AmazonBedrockMantle from "./amazon-bedrock-mantle.js"
export * as AssemblyAI from "./assemblyai.js"
export * as Azure from "./azure.js"
export * as Baseten from "./baseten.js"
export * as BlackForestLabs from "./black-forest-labs.js"
export * as Cartesia from "./cartesia.js"
export * as Cerebras from "./cerebras.js"
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
@@ -32,9 +31,7 @@ export * as OpenAICompatible from "./openai-compatible.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
export * as OpenCodeZen from "./opencode-zen.js"
export * as OpenRouter from "./openrouter.js"
export * as Replicate from "./replicate.js"
export * as Runway from "./runway.js"
export * as Stability from "./stability.js"
export * as TogetherAI from "./togetherai.js"
export * as TypeSafeAI from "./typesafe-ai.js"
export * as VercelAIGateway from "./vercel-ai-gateway.js"
-36
View File
@@ -1,36 +0,0 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { DEFAULT_BASE_URL, ReplicateImages } from "../protocols/replicate-images.js"
export type { ReplicateImageOptions } from "../protocols/replicate-images.js"
export const id = ProviderID.make("replicate")
const baseURL = DEFAULT_BASE_URL
export type Config = ProviderAuthOption<"optional"> & {
readonly baseURL?: string
/** `{ Prefer: "wait=60" }` holds the submission open until the prediction finishes (up to 60 seconds). */
readonly headers?: Record<string, string>
readonly http?: HttpOptions.Input
}
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "REPLICATE_API_TOKEN")
export const configure = (input: Config = {}) => {
const image = (modelID: string | ModelID) =>
ReplicateImages.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
return {
id,
image,
configure,
}
}
export const provider = configure()
export const image = provider.image
-35
View File
@@ -1,35 +0,0 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { DEFAULT_BASE_URL, StabilityImages } from "../protocols/stability-images.js"
export type { StabilityImageOptions, StabilityUpscaleOptions } from "../protocols/stability-images.js"
export const id = ProviderID.make("stability")
const baseURL = DEFAULT_BASE_URL
export type Config = ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly http?: HttpOptions.Input
}
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "STABILITY_API_KEY")
export const configure = (input: Config = {}) => {
const deployment = {
auth: auth(input),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
}
return {
id,
image: (modelID: string | ModelID) => StabilityImages.model({ ...deployment, id: modelID }),
upscale: () => StabilityImages.upscaleModel(deployment),
configure,
}
}
export const provider = configure()
export const image = provider.image
export const upscale = provider.upscale
+1 -3
View File
@@ -88,13 +88,11 @@ export interface Started<Token> {
/**
* A follow-up call's inputs: the decoded token and the auth headers the route sent, so a protocol can attach them
* to output URLs that require the same credentials to download (Veo). `materialize` downloads an output through the
* route's executor, for URLs that expire too soon to hand back (BFL).
* to output URLs that require the same credentials to download (Veo).
*/
export interface PollContext<Token> {
readonly token: Token
readonly auth: Record<string, string>
readonly materialize: (asset: Media.Asset) => Effect.Effect<Media.Asset, AIError>
}
/**
+3 -68
View File
@@ -2,17 +2,10 @@ import { Effect, Schema, Stream } from "effect"
import { Headers, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
import { Auth, type AuthInput } from "./auth.js"
import { Endpoint } from "./endpoint.js"
import { Service as RequestExecutorService, type Interface } from "./executor-service.js"
import type { Interface } from "./executor-service.js"
import { RequestExecutor } from "./executor.js"
import { MediaProtocol } from "./media-protocol.js"
import {
Generation,
resultEvents,
type AwaitOptions,
type Observation,
type Route as GenerationRoute,
} from "../generation.js"
import type { Media } from "../media.js"
import { Generation, type Route as GenerationRoute } from "../generation.js"
import { ProviderShared } from "../protocols/shared.js"
import {
AIError,
@@ -79,11 +72,6 @@ export interface StreamRoute<Request extends MediaRequest, Event, Response> {
readonly generate: (request: Request, execute: Execute) => Effect.Effect<Response, AIError>
}
export type AnyRoute<Request extends MediaRequest, Event, Response> =
| Route<Request, Response>
| StreamRoute<Request, Event, Response>
| QueuedRoute<Request, Response>
export interface Composition<Request extends MediaRequest> {
readonly id: string
readonly provider: string | ProviderID
@@ -148,8 +136,6 @@ export const queued = <Request extends MediaRequest, Response, Token>(
const encodeToken = Schema.encodeSync(protocol.token)
const generationRoute = (token: Token, http: HttpOptions | undefined, execute: Execute) => {
const materialize = (asset: Media.Asset) =>
asset.materialize().pipe(Effect.provideService(RequestExecutorService, { execute }))
const poll = <A>(operation: {
readonly path: (token: Token) => string
readonly decode: (
@@ -159,7 +145,7 @@ export const queued = <Request extends MediaRequest, Response, Token>(
}) =>
transport
.call("GET", operation.path(token), http, execute)
.pipe(Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })))
.pipe(Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth })))
const cancel = protocol.cancel
const route: GenerationRoute<Response> = {
status: poll(protocol.status),
@@ -264,57 +250,6 @@ export const stream = <Request extends MediaRequest, Event, Response, Frame, Sta
}
}
export const dispatch = <Event, Response>(input: {
readonly modality: string
readonly execute: Execute
readonly responseEvents: (response: Response) => ReadonlyArray<Event>
}) => {
const notQueued = (route: { readonly provider: ProviderID; readonly id: string }, operation: string) =>
ProviderShared.unsupportedOperation({
operation: `${input.modality}.${operation}`,
provider: route.provider,
route: route.id,
message: `${route.provider}/${route.id} is not a queued route; use generate or stream`,
})
const start = <Request extends MediaRequest>(route: AnyRoute<Request, Event, Response>, request: Request) => {
if (route.kind !== "queued") return Effect.fail(notQueued(route, "start"))
return route.start(request, input.execute)
}
return {
start,
resume: <Request extends MediaRequest>(
route: AnyRoute<Request, Event, Response>,
model: MediaRequest["model"],
token: unknown,
) => {
if (route.kind !== "queued") return Effect.fail(notQueued(route, "resume"))
return route.resume(model, token, input.execute)
},
generate: <Request extends MediaRequest>(
route: AnyRoute<Request, Event, Response>,
request: Request,
options?: AwaitOptions,
) => {
if (route.kind !== "queued") return route.generate(request, input.execute)
return start(route, request).pipe(Effect.flatMap((generation) => generation.await(options)))
},
stream: <Request extends MediaRequest>(
route: AnyRoute<Request, Event, Response>,
request: Request,
options?: AwaitOptions,
): Stream.Stream<Event | Observation, AIError> => {
if (route.kind === "stream") return route.stream(request, input.execute)
if (route.kind === "queued")
return Stream.unwrap(
start(route, request).pipe(
Effect.map((generation) => resultEvents(generation, input.responseEvents, options)),
),
)
return Stream.fromIterableEffect(Effect.map(route.generate(request, input.execute), input.responseEvents))
},
}
}
// ---------------------------------------------------------------------------
// Transport plumbing shared by every kind
// ---------------------------------------------------------------------------
+36 -11
View File
@@ -1,7 +1,7 @@
import { Context, Effect, Layer, Stream } from "effect"
import type { AwaitOptions, Generation } from "./generation.js"
import { resultEvents, type AwaitOptions, type Generation } from "./generation.js"
import { ProviderShared } from "./protocols/shared.js"
import { RequestExecutor } from "./route/executor.js"
import { MediaRoute } from "./route/media.js"
import type { AIError } from "./schema/index.js"
import {
responseEvents,
@@ -10,6 +10,7 @@ import {
type TranscriptionOptions,
type TranscriptionRequestFor,
type TranscriptionResponse,
type TranscriptionRoute,
} from "./transcription.js"
export interface Interface {
@@ -69,20 +70,44 @@ export const resume = <Options extends TranscriptionOptions>(
return yield* client.resume(model, token)
})
const notQueued = <Options extends TranscriptionOptions>(route: TranscriptionRoute<Options>, operation: string) =>
ProviderShared.unsupportedOperation({
operation: `transcription.${operation}`,
provider: route.provider,
route: route.id,
message: `${route.provider}/${route.id} is not a queued route; use Transcription.generate or Transcription.stream`,
})
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const dispatch = MediaRoute.dispatch<TranscriptionEvent, TranscriptionResponse>({
modality: "transcription",
execute: executor.execute,
responseEvents,
})
const start = <Options extends TranscriptionOptions>(request: TranscriptionRequestFor<Options>) => {
const route = request.model.route
if (route.kind !== "queued") return Effect.fail(notQueued(route, "start"))
return route.start(request, executor.execute)
}
return Service.of({
start: (request) => dispatch.start(request.model.route, request),
resume: (model, token) => dispatch.resume(model.route, model, token),
generate: (request, options) => dispatch.generate(request.model.route, request, options),
stream: (request, options) => dispatch.stream(request.model.route, request, options),
start,
resume: (model, token) => {
const route = model.route
if (route.kind !== "queued") return Effect.fail(notQueued(route, "resume"))
return route.resume(model, token, executor.execute)
},
generate: (request, options) => {
const route = request.model.route
if (route.kind !== "queued") return route.generate(request, executor.execute)
return start(request).pipe(Effect.flatMap((generation) => generation.await(options)))
},
stream: (request, options) => {
const route = request.model.route
if (route.kind === "stream") return route.stream(request, executor.execute)
if (route.kind === "queued")
return Stream.unwrap(
start(request).pipe(Effect.map((generation) => resultEvents(generation, responseEvents, options))),
)
return Stream.fromIterableEffect(Effect.map(route.generate(request, executor.execute), responseEvents))
},
})
}),
)
+20 -15
View File
@@ -1,7 +1,7 @@
import { Effect, Schema, Stream } from "effect"
import { Generation, ProgressEvent, QueuedEvent, type AwaitOptions } from "./generation.js"
import { Media } from "./media.js"
import { MediaModel, composeAnyRoute, tryRequest } from "./media-model.js"
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
import { MediaRoute } from "./route/media.js"
import type { MediaProtocol } from "./route/media-protocol.js"
import { AIError, HttpOptions, MediaUsage, ProviderMetadata } from "./schema/index.js"
@@ -13,11 +13,10 @@ import { TranscriptionClient, Service } from "./transcription-client.js"
export type TranscriptionOptions = Record<string, unknown>
export type TranscriptionRoute<Options extends TranscriptionOptions = TranscriptionOptions> = MediaRoute.AnyRoute<
TranscriptionRequestFor<Options>,
TranscriptionEvent,
TranscriptionResponse
>
export type TranscriptionRoute<Options extends TranscriptionOptions = TranscriptionOptions> =
| MediaRoute.Route<TranscriptionRequestFor<Options>, TranscriptionResponse>
| MediaRoute.StreamRoute<TranscriptionRequestFor<Options>, TranscriptionEvent, TranscriptionResponse>
| MediaRoute.QueuedRoute<TranscriptionRequestFor<Options>, TranscriptionResponse>
export class TranscriptionModel<Options extends TranscriptionOptions = TranscriptionOptions> extends MediaModel<
TranscriptionRoute<Options>,
@@ -48,11 +47,16 @@ export class TranscriptionModel<Options extends TranscriptionOptions = Transcrip
route: TranscriptionModel.RouteInput<Options, Frame, State, Token>,
input: MediaRoute.ModelInput,
) {
const composed: TranscriptionRoute<Options> = isStreamInput(route)
? composeRoute((composition) => MediaRoute.stream({ ...composition, collect: collectResponse }), route, input)
: isQueuedInput(route)
? composeRoute(MediaRoute.queued, route, input)
: composeRoute(MediaRoute.inline, route, input)
return new TranscriptionModel<Options>({
id: input.id,
provider: route.provider,
http: input.http,
route: composeAnyRoute(route, input, collectResponse),
route: composed,
})
}
}
@@ -85,16 +89,17 @@ export namespace TranscriptionModel {
Frame = unknown,
State = unknown,
Token = unknown,
> = MediaModel.AnyRouteInput<
TranscriptionRequestFor<Options>,
TranscriptionEvent,
TranscriptionResponse,
Frame,
State,
Token
>
> = InlineRouteInput<Options> | StreamRouteInput<Options, Frame, State> | QueuedRouteInput<Options, Token>
}
const isStreamInput = <Options extends TranscriptionOptions, Frame, State, Token>(
route: TranscriptionModel.RouteInput<Options, Frame, State, Token>,
): route is TranscriptionModel.StreamRouteInput<Options, Frame, State> => route.protocol.kind === "stream"
const isQueuedInput = <Options extends TranscriptionOptions, Frame, State, Token>(
route: TranscriptionModel.RouteInput<Options, Frame, State, Token>,
): route is TranscriptionModel.QueuedRouteInput<Options, Token> => route.protocol.kind === "queued"
export const TranscriptionModelSchema = Schema.declare(
(value): value is TranscriptionModel => value instanceof TranscriptionModel,
{ expected: "Transcription.Model" },
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,65 +0,0 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:replicate-images",
"provider:replicate",
"protocol:replicate-images"
],
"name": "replicate-images/waits-for-a-prediction-and-reads-its-output",
"recordedAt": "2026-09-23T18:42:06.871Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.replicate.com/v1/models/black-forest-labs/flux-schnell/predictions",
"headers": {
"content-type": "application/json"
},
"body": "{\"input\":{\"prompt\":\"A simple flat black circle centered on a plain white background.\",\"aspect_ratio\":\"1:1\",\"output_format\":\"webp\",\"megapixels\":\"0.25\"}}"
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json; charset=utf-8"
},
"body": "{\"id\":\"mde2assdssrp40d0sy9tf0p6a8\",\"model\":\"black-forest-labs/flux-schnell\",\"version\":\"hidden\",\"input\":{\"aspect_ratio\":\"1:1\",\"megapixels\":\"0.25\",\"output_format\":\"webp\",\"prompt\":\"A simple flat black circle centered on a plain white background.\"},\"logs\":\"running quantized prediction\\nUsing seed: 6062557\\n 0%| | 0/4 [00:00<?, ?it/s]\\n100%|██████████| 4/4 [00:00<00:00, 57.07it/s]\\nTotal safe images: 1 out of 1\\n\",\"output\":[\"https://replicate.delivery/xezq/r1LEsqIqPxYAH9qg7cV5mGQBxCuO98VnnE3py6f7AyTe9cQXA/out-0.webp\"],\"data_removed\":false,\"error\":null,\"source\":\"api\",\"status\":\"succeeded\",\"created_at\":\"2026-09-23T18:42:04.366Z\",\"started_at\":\"2026-09-23T18:42:04.374610941Z\",\"completed_at\":\"2026-09-23T18:42:04.540732474Z\",\"urls\":{\"cancel\":\"https://api.replicate.com/v1/predictions/mde2assdssrp40d0sy9tf0p6a8/cancel\",\"get\":\"https://api.replicate.com/v1/predictions/mde2assdssrp40d0sy9tf0p6a8\",\"stream\":\"https://stream.replicate.com/v1/files/uwhq-pppjocryviy6634d5p2ypvoge6a4sdagsyjscmwfycwk4s5mzfya\",\"web\":\"https://replicate.com/p/mde2assdssrp40d0sy9tf0p6a8\"},\"metrics\":{\"image_count\":1,\"predict_time\":0.166121534,\"total_time\":0.174732474}}"
}
},
{
"transport": "http",
"request": {
"method": "GET",
"url": "https://api.replicate.com/v1/predictions/mde2assdssrp40d0sy9tf0p6a8",
"headers": {},
"body": ""
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json; charset=utf-8"
},
"body": "{\"id\":\"mde2assdssrp40d0sy9tf0p6a8\",\"model\":\"black-forest-labs/flux-schnell\",\"version\":\"hidden\",\"input\":{\"aspect_ratio\":\"1:1\",\"megapixels\":\"0.25\",\"output_format\":\"webp\",\"prompt\":\"A simple flat black circle centered on a plain white background.\"},\"logs\":\"running quantized prediction\\nUsing seed: 6062557\\n 0%| | 0/4 [00:00\\u003c?, ?it/s]\\n100%|██████████| 4/4 [00:00\\u003c00:00, 57.07it/s]\\nTotal safe images: 1 out of 1\\n\",\"output\":[\"https://replicate.delivery/xezq/r1LEsqIqPxYAH9qg7cV5mGQBxCuO98VnnE3py6f7AyTe9cQXA/out-0.webp\"],\"data_removed\":false,\"error\":null,\"source\":\"api\",\"status\":\"succeeded\",\"created_at\":\"2026-09-23T18:42:04.366Z\",\"started_at\":\"2026-09-23T18:42:04.374610941Z\",\"completed_at\":\"2026-09-23T18:42:04.540732474Z\",\"urls\":{\"cancel\":\"https://api.replicate.com/v1/predictions/mde2assdssrp40d0sy9tf0p6a8/cancel\",\"get\":\"https://api.replicate.com/v1/predictions/mde2assdssrp40d0sy9tf0p6a8\",\"stream\":\"https://stream.replicate.com/v1/files/uwhq-pppjocryviy6634d5p2ypvoge6a4sdagsyjscmwfycwk4s5mzfya\",\"web\":\"https://replicate.com/p/mde2assdssrp40d0sy9tf0p6a8\"},\"metrics\":{\"image_count\":1,\"predict_time\":0.166121534,\"total_time\":0.174732474}}\n"
}
},
{
"transport": "http",
"request": {
"method": "GET",
"url": "https://replicate.delivery/xezq/r1LEsqIqPxYAH9qg7cV5mGQBxCuO98VnnE3py6f7AyTe9cQXA/out-0.webp",
"headers": {},
"body": ""
},
"response": {
"status": 200,
"headers": {
"content-type": "image/webp"
},
"body": "UklGRqoHAABXRUJQVlA4IJ4HAAAwVwCdASoAAgACPm02m0mkIyKhIZRYEIANiWlu4XJwxZLjbB5U/WL+x9qP99sjv3F/ZcJe1t/nt8/AB9YOIDSiZAPQP+kfPL9Rewp+ufV4/bj2Qv2RBp9cdE96SBAgQIECBAgQIECBAgQIECBAgQIECBAgYGq4SZoxUi0AvBTNzvUYC1IL8Tony3/cMBakF+J0T3pIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgPjkCconhIWPUL5Xo7aIszZOJuVIL8TonvSQIEB8IfkD7YkBBYPELVVnMEQwMwgLd5Es/h/cMBakF+J0T2aLO3m0GdsX4aL0CN5CKd/I3ojE96SBAgQIEBIdQDRI7TUSgOzphkwsYG5xOie9JAgQHw2bVdGWeQ6S3XI0lHX1OYf3DAWpBfic90Li0k5gaWqyuqN7OOKc5IN1wwFqQX4jo1ih59LIM7stRlYNAowFqQX4nROmRC4X6AMR8j39bYuvr+4YC1IL8TedTyvtc6+YLQxAUXWlfYT/cMBakF+I6fhv4OT5r0mTXCaIrg9QSfnRPekgQICRNQ1eBEz/cLigA0zw+cU8TonvSQIEB8OHaIgz6O7cpPqpaBGdm2s5myW64YC1IL8To5Ud8hgA5Wyl91NPzFoGwnJr6QK/pSyXzlOXLly5cuXK3owgv6gBckgZgIkkQYtGlHyryK1ny1422ie9JAgQIECAoRmPiQoiJ70kCBAgQIEB8fu813inhgLUgvxOie9JAgWgKEYC1IL8TonvSQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAgQIECBAG1Cn+4YC1IL8TonvSQID4AAP7/MKAAAN0g+U+ImcfgCz2aKy4tNvylZV3Cv/wwUni2BNEBjnTHPGqscmeShAAAAAAAAAAAADYj+y+z0NN7R6a8RbDtfusZMx4etW8gtyViZe34vq3W3NjOiOV0qRDklCsSVvOrcF5V9WC0L4zbxWLXVhIbqnl80h/HDhw74+xFxxmtsYX5cC3w4m79qsrVjZ37egibdk2Ge6Qu68EslcdUf6rog27AFCm99fBcOsq4fOSy0iGr5r5f8I63WS67KRWyrZ27vV1uEXysWJdWkEhXemFO6vNoGF9/9zunp+A0MWM3weOtuGaFDpkxdlJOR7uIigJtU1cJkAA9R8aToMqOX4rQIdqjDnETQBX33kvzRVW1csWjjxBU9Yf6rE07KNQOy04qetnkZ3CdSXTxmpaOn9T8PlOD1L9vLrjbmnf07z60lNFprW3AfLBLgnrOsZ8VoOveR1HliiAEo4QJg33k1KYSbPXNyN+wyZBsuddhix7aZYt2Isz2l0Z6DnQIRUaqFR3Yu7Bc9xyFNLbUifeFjXdzhboteWMO/UTEeQWtcLTyLyaUOlopJYUU7Zzz/bdgx/ad4cKWPkRd1JP6UAzr+/JAy4dhTw4DhduXAc/NUTd7USztOlHPlq/aGQVT3A/6v597iQf0PlaSdvE1BTU2Z05kiWAka8N6SKr+Aw+x8QzxYOoEHA+CiDWwtW54ScLGC+cXIexAb65A3XyQ1JTmgTmXVBa5FFTvCI98pm//ueu+4OPajrgFaZb+Od7g/sCw5KiGnFlI5gu3KQzAkDJlevAInyOP/WJ4koNusg4FuFuknXI/Enhju9Vk2hPmfzLzeXNP7E1RRXMIQJqjDMHovfeyNtF/q+c8wGOYJ3yOmxg7HUoHIoztGO5UDtwM81B7muLfhrJ0KTwCuLyQ1r+v6CikUIK7ZyiXNBhwkz44EYO5uKk63W1TqIsYeRgkPPZjsqRbafuddHvf3MjxeZ/RICg3eswECK6A7S1lWwKTcSiitUOWhyC6QW7jsDIQT49rrnT+K+EoryGdhsN51UAmBQ+uYHhVrBvVQX9GneRRAMVHBCleZ3Iynm+KABdh15vySt7UNzltzo1iySExQgU10YykYowoG3oE4125RiGiOakea8W1W+giqu+AOebuB7UvEvFJcEEIPY3AAX49W/Vrlhsbltrh8DN5p+E8h7CS6DlkQTnWF/4iFndcv5LDOTT+lmqlPRdzxiA4N0wL4px6MG5f8qvgdfp3gyg0+mYJLN+FQ/lhnvb19guhXb+fOcCDzwIjViWtoO38UrltJEsaT4w1TGNCEgsKAtuPD5U47DU3NDVqRxAOqIgsFNEJsmT+U9gxZWKZ09pku5T5EBrcRciU4M2LH1uyE/IXEoKM4aCfZhwSnaKE9+lFcvdqpbJn16TA5n+v9HhBvwfjGV2S8qNnLx7ek57DEEYceTzp2rIoFbj68/EJ1Ji4j34Aq97vcKQGiuaioY1sfP0+xxZxvusGf3nz4ETKoYlRyhgTebEiHBGo/eBNUdsGgWE/a7WklCM5ouTocXNl/VJ9XpVUNMq+mIW1UOjiEHyX8Tp1EKQl/Z/1LsOsCku3gZHOqsHU6Se3AeghZ4DaSxFbgs8I9dG0FONqEH/d45QKPO8WFAAAAAAAAAAAAAA=",
"bodyEncoding": "base64"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4 -87
View File
@@ -1,13 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { Effect, Layer } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { Image, ImageClient, Media } from "../src/index.js"
import { BlackForestLabs, Fal, Google, OpenAI, Replicate, Stability, XAI, ZAI } from "../src/providers.js"
import { Google, OpenAI, XAI, ZAI } from "../src/providers.js"
import { it } from "./lib/effect.js"
import { dynamicResponse, json } from "./lib/http.js"
const layer = (handler: Parameters<typeof dynamicResponse>[0]) =>
ImageClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
import { dynamicResponse } from "./lib/http.js"
describe("Image", () => {
for (const provider of [OpenAI, Google, XAI, ZAI]) {
@@ -72,11 +69,7 @@ describe("Image", () => {
expect(response.images).toHaveLength(2)
expect(response.image.mediaType).toBe("image/webp")
expect(response.image.source).toEqual({
type: "bytes",
data: Uint8Array.from([1, 2, 3]),
mediaType: "image/webp",
})
expect(response.image.source).toEqual({ type: "bytes", data: Uint8Array.from([1, 2, 3]), mediaType: "image/webp" })
expect(yield* response.image.bytes()).toEqual(Uint8Array.from([1, 2, 3]))
expect(response.image.providerMetadata).toEqual({ openai: { revisedPrompt: "A precise robot" } })
expect(response.usage).toMatchObject({ type: "tokens", total: 12 })
@@ -699,80 +692,4 @@ describe("Image", () => {
),
),
)
it.effect("rejects what a route cannot honor before sending anything", () =>
Effect.gen(function* () {
const openai = OpenAI.configure({ apiKey: "test" })
const replicate = Replicate.configure({ apiKey: "test" }).image("black-forest-labs/flux-schnell")
const prompt = "A lighthouse"
const errors = yield* Effect.all(
[
Image.start({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt }),
Image.start({
model: BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"),
prompt,
aspectRatio: "16:9",
}),
Image.start({
model: Fal.configure({ apiKey: "test" }).image("fal-ai/nano-banana-2"),
prompt,
size: "512x512",
}),
Stream.runCollect(Image.stream({ model: openai.image("dall-e-3"), prompt })),
Stream.runCollect(Image.stream({ model: openai.image("gpt-image-2"), prompt, n: 2 })),
Image.start({ model: replicate, prompt, seed: 7 }),
Image.start({
model: replicate,
prompt,
providerOptions: { image: Media.bytes(new Uint8Array(300 * 1024), "image/png") },
}),
Image.start({ model: Stability.configure({ apiKey: "test" }).upscale(), prompt }),
].map((effect) => Effect.flip(effect)),
)
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
[
["UnsupportedOperation", "image.start"],
["UnsupportedOperation", "media.aspectRatio"],
["UnsupportedOperation", "media.size"],
["UnsupportedOperation", "media.stream"],
["UnsupportedOperation", "media.n"],
["UnsupportedOperation", "media.seed"],
["InvalidRequest", false],
["InvalidRequest", false],
],
)
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
)
const moderated = { id: "req_1", status: "Content Moderated" }
const prediction = {
id: "p_1",
status: "succeeded",
output: { text: "not an image" },
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
}
it.effect("classifies terminal outcomes the recordings never saw", () =>
Effect.gen(function* () {
const bfl = yield* Image.resume(BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"), {
id: "req_1",
pollingURL: "https://bfl.test/v1/get_result?id=req_1",
}).pipe(
Effect.flatMap((generation) => generation.await()),
Effect.flip,
)
const replicate = yield* Image.generate({
model: Replicate.configure({ apiKey: "test", baseURL: "https://replicate.test" }).image("owner/model"),
prompt: "A lighthouse",
}).pipe(Effect.flip)
expect(bfl.reason).toMatchObject({ _tag: "ContentPolicy", body: JSON.stringify(moderated) })
expect(replicate.reason).toMatchObject({ _tag: "InvalidProviderOutput", body: JSON.stringify(prediction) })
}).pipe(
Effect.provide(
layer((input) =>
Effect.succeed(json(input, input.request.url.startsWith("https://bfl.test") ? moderated : prediction)),
),
),
),
)
})
+1 -9
View File
@@ -10,7 +10,7 @@ import {
type ImageRoute,
} from "../src/index.js"
import type { Service } from "../src/image-client.js"
import { Anthropic, BlackForestLabs, Google, OpenAI, Stability, XAI, ZAI } from "../src/providers.js"
import { Anthropic, Google, OpenAI, XAI, ZAI } from "../src/providers.js"
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
type Equal<A, B> = [A, B] extends [B, A] ? true : false
@@ -95,14 +95,6 @@ Image.generate({ model: openai, prompt: "A lighthouse", providerOptions: { nativ
Image.generate({ model: openai, prompt: "A lighthouse", providerOptions: { quality: 1 } })
// @ts-expect-error Known OpenAI numeric options retain their value kind.
Image.generate({ model: openai, prompt: "A lighthouse", providerOptions: { outputCompression: "80" } })
// @ts-expect-error Partial image counts are numeric.
Image.stream({ model: openai, prompt: "A lighthouse", providerOptions: { partialImages: "1" } })
const bfl = BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro")
// @ts-expect-error Known BFL numeric options retain their value kind.
Image.start({ model: bfl, prompt: "A lighthouse", providerOptions: { safety_tolerance: "2" } })
const stability = Stability.configure({ apiKey: "test" })
// @ts-expect-error Only the creative upscaler is queued, so the selector takes no model id.
stability.upscale("fast")
OpenAI.imageGeneration({ action: "future-action", quality: "future-quality", size: "2048x2048" })
// @ts-expect-error Hosted image generation numeric options retain their value kind.
OpenAI.imageGeneration({ partialImages: "2" })
+1 -29
View File
@@ -1,5 +1,4 @@
import { Effect, Fiber, Layer, Ref } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLMClient, RequestExecutor } from "../../src/route.js"
import type { Service as LLMClientService } from "../../src/route/client.js"
@@ -18,33 +17,6 @@ export type Handler = (
input: HandlerInput,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>
export interface Call {
readonly method: string
readonly url: string
readonly headers: Headers
readonly body: string
}
/** Record every request and tell the handler how many times this exact method+URL has been seen (1-based). */
export const observe = (calls: Array<Call>, input: HandlerInput) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
const call = { method: web.method, url: web.url, headers: web.headers, body: input.text }
calls.push(call)
return { call, nth: calls.filter((seen) => seen.method === call.method && seen.url === call.url).length }
})
export const json = (input: HandlerInput, value: unknown, init?: ResponseInit) =>
input.respond(JSON.stringify(value), { ...init, headers: { "content-type": "application/json", ...init?.headers } })
/** Fork the polling program, let the test clock cover `seconds` of polling, and join. */
export const settle = <A, E, R>(program: Effect.Effect<A, E, R>, seconds: number) =>
Effect.gen(function* () {
const fiber = yield* Effect.forkChild(program)
yield* TestClock.adjust(`${seconds} seconds`)
return yield* Fiber.join(fiber)
})
export const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
Layer.succeed(
HttpClient.HttpClient,
+6 -32
View File
@@ -4,8 +4,8 @@ import { HttpClientRequest } from "effect/unstable/http"
import { AIError, LLMEvent, Media, SpeechEvent, TranscriptionEvent } from "../src/index.js"
import { RequestExecutor } from "../src/route.js"
import { AI } from "../src/promise.js"
import { AssemblyAI, OpenAI, Replicate, Runway } from "../src/providers.js"
import { handlerLayer, json } from "./lib/http.js"
import { AssemblyAI, OpenAI, Runway } from "../src/providers.js"
import { handlerLayer } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
const openai = OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" })
@@ -29,28 +29,8 @@ const executor = (seen: Array<string>) =>
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
seen.push(web.url)
if (web.url.endsWith("/images/generations"))
return JSON.parse(input.text).stream === true
? input.respond(
sseEvents(
{
type: "image_generation.partial_image",
b64_json: "AQ==",
partial_image_index: 0,
output_format: "png",
},
{ type: "image_generation.completed", b64_json: "AQID", output_format: "png" },
),
{ headers: { "content-type": "text/event-stream" } },
)
: input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }], output_format: "png" }), {
headers: { "content-type": "application/json" },
})
if (web.url.startsWith("https://replicate.test"))
return json(input, {
id: "p_1",
status: "succeeded",
output: "https://replicate.test/a.webp",
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
return input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }], output_format: "png" }), {
headers: { "content-type": "application/json" },
})
if (web.url.endsWith("/chat/completions"))
return input.respond(chatBody, { headers: { "content-type": "text/event-stream" } })
@@ -109,7 +89,7 @@ const executor = (seen: Array<string>) =>
)
describe("AI promise client", () => {
test("generates text, images, and streams over one managed runtime, and queues images", async () => {
test("generates text, images, and streams over one managed runtime", async () => {
const seen: Array<string> = []
const ai = AI.make({ layer: executor(seen) })
@@ -131,7 +111,7 @@ describe("AI promise client", () => {
for await (const event of ai.image.stream({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })) {
imageEvents.push(event.type)
}
expect(imageEvents).toEqual(["image-partial", "image", "finish"])
expect(imageEvents).toEqual(["image", "finish"])
expect(seen).toEqual([
"https://openai.test/v1/chat/completions",
@@ -139,12 +119,6 @@ describe("AI promise client", () => {
"https://openai.test/v1/chat/completions",
"https://openai.test/v1/images/generations",
])
const generation = await ai.image.start({
model: Replicate.configure({ apiKey: "test", baseURL: "https://replicate.test" }).image("owner/model"),
prompt: "A lighthouse",
})
expect((await generation.await()).image.source).toMatchObject({ url: "https://replicate.test/a.webp" })
await ai.dispose()
})
-4
View File
@@ -196,10 +196,6 @@ describe("provider error classification", () => {
classifyProviderFailure({ message: messages.error.message, rawBody: JSON.stringify(messages) }),
].map((failure) => failure._tag),
).toEqual(["ContentPolicy", "ContentPolicy", "ContentPolicy"])
const stability = { id: "a1b2", name: "content_moderation", errors: ["Your request was flagged"] }
expect(
classifyProviderFailure({ message: "Forbidden", status: 403, rawBody: JSON.stringify(stability) })._tag,
).toBe("ContentPolicy")
})
test("recovers policy codes that OpenCode Zen preserves only in its message label", () => {
@@ -1,39 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Image } from "../../src/index.js"
import { BlackForestLabs } from "../../src/providers.js"
import { dimensions } from "../lib/image.js"
import { recordedTests } from "../recorded-test.js"
import { queuedPoll } from "./queued-recording.js"
const model = BlackForestLabs.configure({
apiKey: process.env.BFL_API_KEY ?? "fixture",
}).image("flux-2-klein-4b")
const recorded = recordedTests({
prefix: "bfl-images",
provider: "black-forest-labs",
protocol: "bfl-images",
requires: ["BFL_API_KEY"],
})
describe("Black Forest Labs Images recorded", () => {
recorded.effect(
"submits, resumes, and polls an image",
() =>
Effect.gen(function* () {
const started = yield* Image.start({
model,
prompt: "A simple flat black circle centered on a plain white background.",
size: "512x512",
format: "jpeg",
})
const generation = yield* Image.resume(model, JSON.parse(JSON.stringify(started.token)))
const response = yield* generation.await({ poll: queuedPoll })
expect(response.image.source.type).toBe("bytes")
expect(dimensions(yield* response.image.bytes())).toEqual({ width: 512, height: 512 })
}),
{ timeout: 15 * 60 * 1000 },
)
})
@@ -1,41 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Image } from "../../src/index.js"
import { Fal } from "../../src/providers.js"
import { dimensions } from "../lib/image.js"
import { recordedTests } from "../recorded-test.js"
import { queuedPoll } from "./queued-recording.js"
const model = Fal.configure({
apiKey: process.env.FAL_KEY ?? "fixture",
}).image("fal-ai/flux/schnell")
const recorded = recordedTests({
prefix: "fal-images",
provider: "fal",
protocol: "fal-images",
requires: ["FAL_KEY"],
})
describe("fal Images recorded", () => {
recorded.effect(
"generates an image",
() =>
Effect.gen(function* () {
const response = yield* Image.generate(
{
model,
prompt: "A simple flat black circle centered on a plain white background.",
size: "512x512",
format: "jpeg",
},
{ poll: queuedPoll },
)
expect(response.images).toHaveLength(1)
expect(response.image.info).toEqual({ width: 512, height: 512 })
expect(dimensions(yield* response.image.bytes())).toEqual({ width: 512, height: 512 })
}),
{ timeout: 15 * 60 * 1000 },
)
})
@@ -343,34 +343,6 @@ describe("Google Vertex providers", () => {
}),
)
// Captured from xai/grok-4.6 on Vertex: one keepalive every 15s until the first token.
it.effect("ignores keepalives sent as data while a partner model reasons", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: GoogleVertexChat.configure({
accessToken: "vertex-token",
location: "global",
project: "vertex-project",
}).model("xai/grok-4.6"),
prompt: "Say hello.",
}),
).pipe(
Effect.provide(
fixedResponse(
`data: : keepalive\n\ndata: : keepalive\n\n${sseEvents(
deltaChunk({ role: "assistant", content: "Hello." }),
finishChunk("stop"),
)}`,
),
),
)
expect(response.text).toBe("Hello.")
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
}),
)
it.effect("sends Grok requests through Vertex Responses", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -791,89 +791,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("lowers inline PDFs as file parts", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([
{ type: "text", text: "Summarize these." },
{ type: "media", media: Media.base64("JVBERi0=", "application/pdf"), filename: "report.pdf" },
{ type: "media", media: Media.fromDataUrl("data:application/pdf;base64,JVBERi0=") },
]),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [
{ type: "text", text: "Summarize these." },
{ type: "file", file: { filename: "report.pdf", file_data: "data:application/pdf;base64,JVBERi0=" } },
{ type: "file", file: { filename: "document.pdf", file_data: "data:application/pdf;base64,JVBERi0=" } },
],
},
])
}),
)
it.effect("moves PDFs from tool results into a follow-up user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Read the report."),
Message.assistant([ToolCallPart.make({ id: "call_pdf", name: "read", input: {} })]),
Message.tool({
id: "call_pdf",
name: "read",
resultType: "content",
result: [
{ type: "text", text: "PDF read successfully" },
{
type: "file",
mime: "application/pdf",
uri: "data:application/pdf;base64,JVBERi0=",
name: "report.pdf",
},
],
}),
],
}),
)
expect(prepared.body.messages).toContainEqual({
role: "tool",
tool_call_id: "call_pdf",
content: "PDF read successfully",
})
expect(prepared.body.messages.at(-1)).toEqual({
role: "user",
content: [
{ type: "file", file: { filename: "report.pdf", file_data: "data:application/pdf;base64,JVBERi0=" } },
],
})
}),
)
it.effect("requires inline data for PDF files", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user({
type: "media",
media: Media.url("https://example.com/report.pdf", { mediaType: "application/pdf" }),
}),
],
}),
).pipe(Effect.flip)
expect(error.message).toContain("OpenAI Chat requires inline media")
}),
)
it.effect("prepares raw and data URL image media as vision input", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { Image, ImageEvent, Media } from "../../src/index.js"
import { Effect } from "effect"
import { Image, Media } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js"
import { dimensions } from "../lib/image.js"
import { recordedTests } from "../recorded-test.js"
@@ -60,24 +60,4 @@ describe("OpenAI Images recorded", () => {
expect(dimensions(yield* response.image.bytes())).toEqual({ width: 1024, height: 1024 })
}),
)
recorded.effect("streams a partial image before the final image", () =>
Effect.gen(function* () {
const events = Array.from(
yield* Image.stream({
model,
prompt: "A simple flat black circle centered on a plain white background.",
size: "1024x1024",
format: "jpeg",
providerOptions: { quality: "low", outputCompression: 10, partialImages: 1 },
}).pipe(Stream.runCollect),
)
expect(events.map((event) => event.type)).toEqual(["image-partial", "image", "finish"])
const image = events.find(ImageEvent.is.image)
expect(image?.image.mediaType).toBe("image/jpeg")
expect(dimensions(yield* image!.image.bytes())).toEqual({ width: 1024, height: 1024 })
expect(events.find(ImageEvent.is.finish)?.usage).toMatchObject({ type: "tokens" })
}),
)
})
@@ -1,41 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Image } from "../../src/index.js"
import { Replicate } from "../../src/providers.js"
import { recordedTests } from "../recorded-test.js"
import { queuedPoll } from "./queued-recording.js"
const model = Replicate.configure({
apiKey: process.env.REPLICATE_API_TOKEN ?? "fixture",
}).image("black-forest-labs/flux-schnell")
const recorded = recordedTests({
prefix: "replicate-images",
provider: "replicate",
protocol: "replicate-images",
requires: ["REPLICATE_API_TOKEN"],
})
describe("Replicate Images recorded", () => {
recorded.effect(
"waits for a prediction and reads its output",
() =>
Effect.gen(function* () {
const response = yield* Image.generate(
{
model,
prompt: "A simple flat black circle centered on a plain white background.",
providerOptions: { aspect_ratio: "1:1", output_format: "webp", megapixels: "0.25" },
http: { headers: { Prefer: "wait=60" } },
},
{ poll: queuedPoll },
)
expect(response.images).toHaveLength(1)
expect(response.image.source.type).toBe("url")
expect(response.usage).toMatchObject({ type: "compute" })
expect((yield* response.image.bytes()).length).toBeGreaterThan(0)
}),
{ timeout: 15 * 60 * 1000 },
)
})
@@ -1,58 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Image, Media } from "../../src/index.js"
import { Stability } from "../../src/providers.js"
import { dimensions } from "../lib/image.js"
import { recordedTests } from "../recorded-test.js"
import { queuedPoll } from "./queued-recording.js"
const stability = Stability.configure({ apiKey: process.env.STABILITY_API_KEY ?? "fixture" })
const recorded = recordedTests({
prefix: "stability-images",
provider: "stability",
protocol: "stability-images",
requires: ["STABILITY_API_KEY"],
// Multipart boundaries change on every request, so bodies cannot be matched.
options: { match: (incoming, recorded) => incoming.method === recorded.method && incoming.url === recorded.url },
})
describe("Stability Images recorded", () => {
recorded.effect("generates an image inline", () =>
Effect.gen(function* () {
const response = yield* Image.generate({
model: stability.image("core"),
prompt: "A simple flat black circle centered on a plain white background.",
aspectRatio: "1:1",
format: "jpeg",
})
expect(response.image.mediaType).toBe("image/jpeg")
expect(response.providerMetadata).toMatchObject({ stability: { finishReason: "SUCCESS" } })
expect(dimensions(yield* response.image.bytes()).width).toBeGreaterThan(0)
}),
)
recorded.effect(
"upscales an image through the results queue",
() =>
Effect.gen(function* () {
const generation = yield* Image.start({
model: stability.upscale(),
prompt: "A simple flat black circle on a white background.",
images: [
Media.bytes(
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
"image/jpeg",
),
],
format: "jpeg",
})
const response = yield* generation.await({ poll: queuedPoll })
expect(response.image.mediaType).toBe("image/jpeg")
expect(dimensions(yield* response.image.bytes()).width).toBeGreaterThan(1024)
}),
{ timeout: 15 * 60 * 1000 },
)
})
-32
View File
@@ -158,38 +158,6 @@ it.effect("ZAI lowers effort using the selected native API without inventing thi
}),
)
it.effect("ZAI Coding Chat sends PDFs from tool results as file parts", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: ZAICodingPlan.configure({ apiKey: "fixture" }).chat("glm-5.3-flash"),
messages: [
Message.user("Read the report."),
Message.assistant({ type: "tool-call", id: "call_pdf", name: "read", input: {} }),
Message.tool({
id: "call_pdf",
name: "read",
resultType: "content",
result: [
{
type: "file",
mime: "application/pdf",
uri: "data:application/pdf;base64,JVBERi0=",
name: "report.pdf",
},
],
}),
],
}),
)
expect(prepared.body.messages.at(-1)).toEqual({
role: "user",
content: [{ type: "file", file: { filename: "report.pdf", file_data: "data:application/pdf;base64,JVBERi0=" } }],
})
}),
)
it.effect("ZAI rejects invalid typed thinking options before execution", () =>
Effect.gen(function* () {
const model = ZAI.configure({ apiKey: "fixture" })
-14
View File
@@ -192,20 +192,6 @@ describe("AI.Usage", () => {
}),
)
it.effect("sseFraming drops keepalive comments sent as data and keeps other payloads", () =>
Effect.gen(function* () {
const frames = yield* ProviderShared.sseFraming(
Stream.make(
new TextEncoder().encode(
'data: {"first":true}\n\ndata: : keepalive\n\n: keepalive\n\ndata: : ping\n\ndata: {"second":true}\n\n',
),
),
).pipe(Stream.runCollect)
expect(Array.from(frames)).toEqual(['{"first":true}', ": ping", '{"second":true}'])
}),
)
test("visibleOutputTokens clamps reasoning > output to zero", () => {
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
@@ -28,38 +28,6 @@ describe("tool schema projections", () => {
})
})
test("moonshot derives a type for untyped enums", () => {
expect(
ToolSchemaProjection.moonshot({
type: "object",
properties: {
kind: { description: "The kind of flag", enum: ["boolean", "string"] },
level: { enum: [1, 2.5] },
optional: { enum: [null, "a"] },
choice: { anyOf: [{ enum: [true, false] }, { type: "null" }] },
list: { type: "array", items: { enum: ["x"] } },
map: { type: "object", additionalProperties: { enum: ["y"] } },
typed: { type: "string", enum: ["a", null] },
mixed: { enum: ["a", 1] },
},
$defs: { Mode: { enum: ["fast"] } },
}),
).toEqual({
type: "object",
properties: {
kind: { type: "string", description: "The kind of flag", enum: ["boolean", "string"] },
level: { type: "number", enum: [1, 2.5] },
optional: { type: ["string", "null"], enum: [null, "a"] },
choice: { anyOf: [{ type: "boolean", enum: [true, false] }, { type: "null" }] },
list: { type: "array", items: { type: "string", enum: ["x"] } },
map: { type: "object", additionalProperties: { type: "string", enum: ["y"] } },
typed: { type: "string", enum: ["a", null] },
mixed: { enum: ["a", 1] },
},
$defs: { Mode: { type: "string", enum: ["fast"] } },
})
})
test("gemini handles numeric enums, dangling required fields, untyped arrays, and scalar object keys", () => {
expect(
ToolSchemaProjection.gemini({
+31 -2
View File
@@ -1,13 +1,42 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { Effect, Fiber, Layer, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { HttpClientRequest } from "effect/unstable/http"
import { Media, Video, VideoClient, type GenerationEvent } from "../src/index.js"
import { Fal, Google, Runway, XAI } from "../src/providers.js"
import { it } from "./lib/effect.js"
import { dynamicResponse, json, observe, settle, type Call } from "./lib/http.js"
import { dynamicResponse, type HandlerInput } from "./lib/http.js"
interface Call {
readonly method: string
readonly url: string
readonly headers: Headers
readonly body: string
}
/** Record every request and tell the handler how many times this exact method+URL has been seen (1-based). */
const observe = (calls: Array<Call>, input: HandlerInput) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
const call = { method: web.method, url: web.url, headers: web.headers, body: input.text }
calls.push(call)
return { call, nth: calls.filter((seen) => seen.method === call.method && seen.url === call.url).length }
})
const json = (input: HandlerInput, value: unknown, init?: ResponseInit) =>
input.respond(JSON.stringify(value), { ...init, headers: { "content-type": "application/json", ...init?.headers } })
const layer = (handler: Parameters<typeof dynamicResponse>[0]) =>
VideoClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
/** Fork the polling program, let the test clock cover `seconds` of polling, and join. */
const settle = <A, E, R>(program: Effect.Effect<A, E, R>, seconds: number) =>
Effect.gen(function* () {
const fiber = yield* Effect.forkChild(program)
yield* TestClock.adjust(`${seconds} seconds`)
return yield* Fiber.join(fiber)
})
const DAY = 24 * 60 * 60 * 1000
// ---------------------------------------------------------------------------
+27
View File
@@ -164,6 +164,32 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
},
})),
)
const resources = createMemo(() =>
(data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map((resource) => ({
id: `resource:${resource.server}:${resource.uri}`,
kind: "resource" as const,
label: `@${resource.name}`,
path: resource.uri,
description: resource.description,
mention: {
type: "file" as const,
path: resource.uri,
content: `@${resource.name}`,
start: 0,
end: 0,
mime: resource.mimeType ?? "text/plain",
filename: resource.name,
url: resource.uri,
source: {
type: "resource" as const,
text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 },
clientName: resource.server,
uri: resource.uri,
},
},
resource,
})),
)
const skills = createMemo(() => data.location.skill.list({ directory: sdk().directory }) ?? [])
const context = createMemo<ComposerSuggestion[]>(() => [
...references(),
@@ -190,6 +216,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
label: `@${agent.name}`,
mention: { type: "agent" as const, name: agent.name, content: `@${agent.name}`, start: 0, end: 0 },
})),
...resources(),
...recent().map((path) => ({
id: `file:${path}`,
kind: "file" as const,
@@ -5,7 +5,6 @@ import { Service } from "@opencode/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { redactConfig } from "./redact"
export default Runtime.handler(
Commands.commands.debug.commands.config,
@@ -13,6 +12,6 @@ export default Runtime.handler(
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
process.stdout.write(JSON.stringify(redactConfig(entries), null, 2) + EOL)
process.stdout.write(JSON.stringify(entries, null, 2) + EOL)
}),
)
@@ -1,20 +0,0 @@
// Redact only the debug output; the resolved configuration must remain usable by providers.
const secretName = /(?:api.?key|secret|password|token$|authorization$|cookie$|credential|private.?key)/i
export function redactConfig(value: unknown, headers = false): unknown {
if (Array.isArray(value)) return value.map((item) => redactConfig(item, headers))
if (value === null || typeof value !== "object") return value
return Object.fromEntries(
Object.entries(value).map(([key, item]) => {
if (typeof item === "string" && (headers || secretName.test(key))) return [key, "***"]
if (typeof item === "string" && /^https?:\/\//i.test(item)) {
if (!URL.canParse(item)) return [key, "***"]
const url = new URL(item)
if (url.username || url.password || [...url.searchParams.keys()].some((name) => secretName.test(name)))
return [key, "***"]
}
return [key, redactConfig(item, headers || key.toLowerCase() === "headers")]
}),
)
}
+1 -42
View File
@@ -3,7 +3,6 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"
import { redactConfig } from "../src/commands/handlers/debug/redact"
describe("debug config command", () => {
test("is included in troubleshooting help", async () => {
@@ -15,7 +14,6 @@ describe("debug config command", () => {
expect(config.exitCode).toBe(0)
expect(config.stdout).toContain("opencode debug config [flags]")
expect(config.stdout).toContain("List configuration sources")
expect(config.stdout).not.toContain("--reveal-secrets")
})
test("prints config entries from the invoking directory without reordering permissions", async () => {
@@ -31,14 +29,6 @@ describe("debug config command", () => {
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
providers: {
example: {
settings: { apiKey: "sk-example", timeout: 1200 },
headers: { Authorization: "Bearer example", "X-API-Key": "key", "X-Custom": "opaque" },
models: { demo: { request: { headers: { "x-auth-token": "model-secret" } } } },
},
},
mcp: { remote: { oauth: { client_secret: "oauth-secret", client_id: "public" } } },
},
},
]
@@ -73,18 +63,7 @@ describe("debug config command", () => {
const result = await cli(["debug", "config"], project, { XDG_STATE_HOME: path.join(root, "state") })
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(JSON.parse(result.stdout)).toEqual(redactConfig(entries))
expect(JSON.parse(result.stdout)[0].info.providers.example).toEqual({
settings: { apiKey: "***", timeout: 1200 },
headers: { Authorization: "***", "X-API-Key": "***", "X-Custom": "***" },
models: { demo: { request: { headers: { "x-auth-token": "***" } } } },
})
expect(JSON.parse(result.stdout)[0].info.mcp.remote.oauth).toEqual({
client_secret: "***",
client_id: "public",
})
expect(result.stdout).not.toContain("sk-example")
expect(result.stdout).not.toContain("Bearer example")
expect(JSON.parse(result.stdout)).toEqual(entries)
expect(requested?.pathname).toBe("/api/config")
expect(requested?.searchParams.get("location[directory]")).toBe(project)
expect(authorization).toEqual([`Basic ${btoa("opencode:secret")}`])
@@ -96,26 +75,6 @@ describe("debug config command", () => {
})
})
test("redacts URL credentials and credential environment variables without mutating config", () => {
const input = {
mcp: {
local: { environment: { SERVICE_TOKEN: "token-value", PATH: "/usr/bin" } },
remote: { url: "https://user:pass@example.com/mcp", headers: { "x-custom": "opaque" } },
},
url: "https://example.com/api?api_key=secret&mode=debug",
max_tokens: 100,
}
expect(redactConfig(input)).toEqual({
mcp: {
local: { environment: { SERVICE_TOKEN: "***", PATH: "/usr/bin" } },
remote: { url: "***", headers: { "x-custom": "***" } },
},
url: "***",
max_tokens: 100,
})
expect(input.mcp.remote.url).toBe("https://user:pass@example.com/mcp")
})
async function cli(args: string[], cwd = path.join(import.meta.dir, ".."), env?: Record<string, string>) {
const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], {
cwd,
+1 -1
View File
@@ -221,7 +221,7 @@ describe("mini command", () => {
expect(JSON.parse(result.stdout)).toMatchObject({
type: "error",
sessionID: "",
error: { type: "unknown", message: "UnexpectedStatus: 500" },
error: { type: "unknown", message: "UnexpectedStatus" },
})
} finally {
server.stop(true)
@@ -9,9 +9,8 @@ export class ClientError extends Error {
override readonly name = "ClientError"
constructor(
readonly reason: ClientErrorReason,
options?: ErrorOptions & { readonly detail?: string | null },
options?: ErrorOptions,
) {
const detail = options?.detail ?? (options?.cause instanceof Error ? options.cause.message : undefined)
super(detail ? `${reason}: ${detail}` : reason, options)
super(reason, options)
}
}
@@ -335,7 +335,7 @@ export function make(options: ClientOptions) {
try {
await response.body?.cancel()
} catch {}
throw new ClientError("UnexpectedStatus", { cause: { status: response.status }, detail: String(response.status) })
throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })
}
const request = async <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise<A> => {
@@ -359,7 +359,7 @@ export function make(options: ClientOptions) {
try {
await response.body?.cancel()
} catch {}
throw new ClientError("UnsupportedContentType", { detail: response.headers.get("content-type") })
throw new ClientError("UnsupportedContentType")
}
if (response.body === null) throw new ClientError("MalformedResponse")
const reader = response.body.getReader()
@@ -2185,7 +2185,7 @@ async function json(response: Response): Promise<unknown> {
try {
await response.body?.cancel()
} catch {}
throw new ClientError("UnsupportedContentType", { detail: response.headers.get("content-type") })
throw new ClientError("UnsupportedContentType")
}
let text: string
try {
-21
View File
@@ -191,27 +191,6 @@ test("declared errors with a data envelope read the nested message", async () =>
expect(error).toMatchObject({ name: "WorktreeError", data: { message: "Worktree directory unavailable" } })
})
test("client errors keep the reason and describe the failure in the message", async () => {
const failure = (fetch: () => Promise<Response>) =>
OpenCode.make({ baseUrl: "http://localhost:3000", fetch })
.session.list()
.catch((cause: unknown) => cause)
expect(await failure(() => Promise.reject(new TypeError("Unable to connect")))).toMatchObject({
reason: "Transport",
message: "Transport: Unable to connect",
})
expect(await failure(async () => new Response("", { status: 500 }))).toMatchObject({
reason: "UnexpectedStatus",
message: "UnexpectedStatus: 500",
})
expect(await failure(async () => new Response("<html>", { headers: { "content-type": "text/html" } }))).toMatchObject(
{
reason: "UnsupportedContentType",
message: "UnsupportedContentType: text/html",
},
)
})
test("project.update uses the global project contract", async () => {
let request: Request | undefined
const project = {
+50 -72
View File
@@ -8,7 +8,6 @@ import { createHash } from "node:crypto"
import { isDeepStrictEqual } from "node:util"
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Semaphore, Stream, Types } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { EffectFlock } from "@opencode/util/effect-flock"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
@@ -121,11 +120,10 @@ export interface Interface extends State.Transformable<Editor> {
readonly args?: Record<string, string>
}) => Effect.Effect<PromptResult | undefined, NotFoundError>
readonly resourceCatalog: () => Effect.Effect<ResourceCatalog>
readonly resources: (input: { readonly server: ServerName | string }) => Effect.Effect<ResourceCatalog, Error>
readonly readResource: (input: {
readonly server: ServerName | string
readonly uri: string
}) => Effect.Effect<ResourceContent | undefined, Error>
}) => Effect.Effect<ResourceContent | undefined, NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/MCP") {}
@@ -150,7 +148,6 @@ export const layer = (options?: Options) =>
const forms = yield* Form.Service
const integration = yield* Integration.Service
const credentials = yield* Credential.Service
const flock = yield* EffectFlock.Service
const root = yield* Effect.scope
const fork = yield* FiberSet.makeRuntime<never, void, never>()
@@ -216,7 +213,6 @@ export const layer = (options?: Options) =>
const { McpOAuth } = yield* Effect.promise(() => import("./oauth.js"))
return yield* McpOAuth.connectProvider({ config: entry.config, integrationID: entry.integrationID }).pipe(
Effect.provideService(Credential.Service, credentials),
Effect.provideService(EffectFlock.Service, flock),
)
})
@@ -356,41 +352,6 @@ export const layer = (options?: Options) =>
),
)
const loadCatalog = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
recovering(name, entry, connection, (connection) =>
Effect.all(
{
resources: connection.resources(),
// Some servers declare resources without implementing template listing.
templates: connection.resourceTemplates().pipe(Effect.orElseSucceed(() => [])),
},
{ concurrency: "unbounded" },
),
).pipe(
Effect.map((catalog) =>
ResourceCatalog.make({
resources: catalog.resources.map((resource) =>
Resource.make({
server: name,
name: resource.name,
uri: resource.uri,
description: resource.description,
mimeType: resource.mimeType,
}),
),
templates: catalog.templates.map((template) =>
ResourceTemplate.make({
server: name,
name: template.name,
uriTemplate: template.uriTemplate,
description: template.description,
mimeType: template.mimeType,
}),
),
}),
),
)
const watch = (name: ServerName, entry: ServerEntry, connection: McpClient.Connection) => {
const live = whenLive(name, entry, connection)
connection.onClose(() =>
@@ -701,22 +662,57 @@ export const layer = (options?: Options) =>
return { ...result, server: target.name, name: input.name }
}),
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
const empty = ResourceCatalog.make({ resources: [], templates: [] })
const catalogs = yield* Effect.forEach(
Array.from(entries),
([name, entry]) =>
entry.client
? loadCatalog(name, entry, entry.client).pipe(Effect.orElseSucceed(() => empty))
: Effect.succeed(empty),
([name, entry]) => {
if (!entry.client) return Effect.succeed({ resources: [], templates: [] })
return Effect.all(
{
resources: entry.client.resources().pipe(Effect.orElseSucceed(() => [])),
templates: entry.client.resourceTemplates().pipe(Effect.orElseSucceed(() => [])),
},
{ concurrency: "unbounded" },
).pipe(
Effect.map((catalog) => ({
resources: catalog.resources.map((resource) =>
Resource.make({
server: name,
name: resource.name,
uri: resource.uri,
description: resource.description,
mimeType: resource.mimeType,
}),
),
templates: catalog.templates.map((template) =>
ResourceTemplate.make({
server: name,
name: template.name,
uriTemplate: template.uriTemplate,
description: template.description,
mimeType: template.mimeType,
}),
),
})),
)
},
{ concurrency: "unbounded" },
)
return mergeCatalogs(catalogs)
}),
resources: Effect.fn("MCP.resources")(function* (input) {
const target = yield* requireServer(input.server)
yield* target.entry.startup.await
if (!target.entry.client) return ResourceCatalog.make({ resources: [], templates: [] })
return mergeCatalogs([yield* loadCatalog(target.name, target.entry, target.entry.client)])
return ResourceCatalog.make({
resources: catalogs
.flatMap((catalog) => catalog.resources)
.toSorted(
(a, b) =>
a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri),
),
templates: catalogs
.flatMap((catalog) => catalog.templates)
.toSorted(
(a, b) =>
a.server.localeCompare(b.server) ||
a.name.localeCompare(b.name) ||
a.uriTemplate.localeCompare(b.uriTemplate),
),
})
}),
readResource: Effect.fn("MCP.readResource")(function* (input) {
const target = yield* requireServer(input.server)
@@ -724,7 +720,7 @@ export const layer = (options?: Options) =>
if (!target.entry.client) return undefined
const result = yield* recovering(target.name, target.entry, target.entry.client, (connection) =>
connection.readResource({ uri: input.uri }),
)
).pipe(Effect.orElseSucceed(() => undefined))
if (!result) return undefined
return ResourceContent.make({
server: target.name,
@@ -740,29 +736,11 @@ export const layer = (options?: Options) =>
}),
)
function mergeCatalogs(catalogs: ReadonlyArray<ResourceCatalog>) {
return ResourceCatalog.make({
resources: catalogs
.flatMap((catalog) => catalog.resources)
.toSorted(
(a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name) || a.uri.localeCompare(b.uri),
),
templates: catalogs
.flatMap((catalog) => catalog.templates)
.toSorted(
(a, b) =>
a.server.localeCompare(b.server) ||
a.name.localeCompare(b.name) ||
a.uriTemplate.localeCompare(b.uriTemplate),
),
})
}
export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Location.node, Environment.node, Bus.node, Form.node, Integration.node, Credential.node, EffectFlock.node],
deps: [Location.node, Environment.node, Bus.node, Form.node, Integration.node, Credential.node],
})
}
-8
View File
@@ -18,7 +18,6 @@ import {
import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema } from "@modelcontextprotocol/core"
import { Cause, Deferred, Effect } from "effect"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import { EffectFlock } from "@opencode/util/effect-flock"
import { Credential } from "../credential.js"
import { OauthCallbackPage } from "../oauth/page.js"
import type { Integration } from "../integration.js"
@@ -142,7 +141,6 @@ export interface Options {
readonly clientMetadataUrl?: string
readonly discovery?: OAuthDiscoveryState
readonly invalidate?: OAuthClientProvider["invalidateCredentials"]
readonly refreshLock?: OAuthClientProvider["withRefreshLock"]
}
export const provider = (options: Options): OAuthClientProvider => {
@@ -202,7 +200,6 @@ export const provider = (options: Options): OAuthClientProvider => {
return redirect.open(url)
},
...(options.invalidate ? { invalidateCredentials: options.invalidate } : {}),
...(options.refreshLock ? { withRefreshLock: options.refreshLock } : {}),
saveCodeVerifier: (verifier) => options.store.saveCodeVerifier(verifier),
codeVerifier: async () => {
const verifier = await options.store.codeVerifier()
@@ -274,7 +271,6 @@ export const connectProvider = Effect.fnUntraced(function* (input: {
readonly integrationID: Integration.ID
}) {
const credentials = yield* Credential.Service
const flock = yield* EffectFlock.Service
const run = Effect.runPromiseWith(yield* Effect.context())
const found = (yield* credentials.list(input.integrationID)).at(-1)
if (!found || found.value.type !== "oauth") return provider({ config: input.config, store: memoryStore() })
@@ -295,10 +291,6 @@ export const connectProvider = Effect.fnUntraced(function* (input: {
await run(Effect.logWarning("mcp oauth credential invalidated", { credentialID: id, scope }))
await run(credentials.remove(id))
},
// Other processes share the row too. A refresh that waited here re-reads the token the previous
// holder saved instead of replaying the one it replaced, which rotating servers reject or revoke.
refreshLock: (refresh) =>
run(flock.withLock(Effect.tryPromise({ try: refresh, catch: (error) => error }), `mcp-oauth-refresh:${id}`)),
store: {
tokens: async () => {
const oauth = await read()
-2
View File
@@ -71,7 +71,6 @@ import { PatchTool } from "../tool/plugin/patch.js"
import { EditTool } from "../tool/plugin/edit.js"
import { GlobTool } from "../tool/plugin/glob.js"
import { GrepTool } from "../tool/plugin/grep.js"
import { McpResourceTools } from "../tool/plugin/mcp-resource.js"
import { OpenCodeTools } from "../tool/plugin/opencode.js"
import { QuestionTool } from "../tool/plugin/question.js"
import { ReadToolFileSystem } from "../tool/read-filesystem.js"
@@ -235,7 +234,6 @@ const pre = [
GlobTool.Plugin,
GrepTool.Plugin,
OpenCodeTools.Plugin,
McpResourceTools.Plugin,
QuestionTool.Plugin,
ReadTool.Plugin,
ShellTool.Plugin,
+21 -15
View File
@@ -1,4 +1,4 @@
import { Duration, Effect, Equal, Schema, Semaphore, Stream } from "effect"
import { Duration, Effect, Equal, Option, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
import { define } from "@opencode/plugin/effect/plugin"
@@ -11,6 +11,7 @@ import { IntegrationConnection } from "../../integration/connection.js"
import { ManagedPolicy } from "../../managed-policy.js"
import { Provider } from "../../provider.js"
import { WebSearch } from "../../websearch.js"
import { WebSearchResponse } from "../websearch/response.js"
import { ConfigPolicy } from "@opencode/schema/config/policy"
import { ConfigProvider } from "@opencode/schema/config/provider"
import { Money } from "@opencode/schema/money"
@@ -45,6 +46,9 @@ const TokenPending = Schema.Struct({ error: Schema.String })
const DeviceToken = Schema.Union([Token, TokenPending])
const User = Schema.Struct({ id: Schema.String, email: Schema.String })
const Org = Schema.Struct({ id: Schema.String, name: Schema.String })
const decodeWebSearchResponse = Schema.decodeUnknownOption(
Schema.Struct({ providerID: WebSearch.ID, results: Schema.Array(Schema.Unknown) }),
)
function oauth(http: HttpClient.HttpClient) {
return {
@@ -313,25 +317,27 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
providerID: descriptor.providerID,
}),
)
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http))
.execute(request)
.pipe(
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
Effect.flatMap(HttpClientResponse.schemaBodyJson(WebSearch.Response)),
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("OpenCode web search request timed out")),
}),
)
if (response.providerID !== descriptor.providerID) {
const body = yield* WebSearchResponse.execute(http, request).pipe(
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("OpenCode web search request timed out")),
}),
)
const response = WebSearchResponse.json(body.text, body.truncated).pipe(
Option.flatMap(decodeWebSearchResponse),
)
if (Option.isNone(response))
return yield* Effect.fail(new Error("OpenCode web search returned an invalid response"))
if (response.value.providerID !== descriptor.providerID) {
return yield* Effect.fail(
new Error(
`OpenCode web search returned provider ${response.providerID} instead of ${descriptor.providerID}`,
`OpenCode web search returned provider ${response.value.providerID} instead of ${descriptor.providerID}`,
),
)
}
return response.results
return WebSearchResponse.items(WebSearch.Result, response.value.results, body.truncated)
}),
})
editor.default.set(descriptor.providerID)
+7 -5
View File
@@ -47,23 +47,25 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const url = new URL(endpoint)
if (credential?.type === "key") url.searchParams.set("exaApiKey", credential.key)
const result = yield* WebSearchMcp.call(
const response = yield* WebSearchMcp.call(
http,
url.toString(),
"web_search_exa",
{ input: McpInput, output: McpOutput },
{ query: input.query, numResults: 8 },
)
const content = result?.content.find((item) => item.text)
return content ? parseResults(content.text) : []
const content = response.result?.content.find((item) => item.text)
return content ? parseResults(content.text, response.truncated) : []
}),
})
})
}),
})
function parseResults(text: string) {
return text.split(/\n\n---\n\n/).flatMap((block) => {
function parseResults(text: string, truncated: boolean) {
const blocks = text.split(/\n\n---\n\n/)
// The final block of a truncated response may end mid-line, including inside its URL.
return (truncated ? blocks.slice(0, -1) : blocks).flatMap((block) => {
const url = block.match(/^URL:\s*(.+)$/m)?.[1]?.trim()
if (!url) return []
const title = block.match(/^Title:\s*(.+)$/m)?.[1]?.trim()
+22 -25
View File
@@ -5,6 +5,7 @@ import { Effect, Option, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app.js"
import { WebSearchMcp } from "./mcp.js"
import { WebSearchResponse } from "./response.js"
export const endpoint = "https://mcp.firecrawl.dev/v2/mcp"
@@ -17,21 +18,15 @@ const McpOutput = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
})
const SearchResponse = Schema.fromJsonString(
Schema.Struct({
success: Schema.Boolean,
data: Schema.Struct({
web: Schema.Array(
Schema.Struct({
url: Schema.String,
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
description: Schema.NullOr(Schema.String).pipe(Schema.optional),
}),
),
}),
}),
const decodeSearchResponse = Schema.decodeUnknownOption(
Schema.Struct({ data: Schema.Struct({ web: Schema.Array(Schema.Unknown) }) }),
)
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)
const SearchResult = Schema.Struct({
url: Schema.String,
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
description: Schema.NullOr(Schema.String).pipe(Schema.optional),
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.firecrawl",
@@ -56,7 +51,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("firecrawl")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
const response = yield* WebSearchMcp.call(
http,
endpoint,
"firecrawl_search",
@@ -67,16 +62,18 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
const content = result?.content.find((item) => item.text)
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
return (
response?.data.web.map((item) => ({
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.description ? { content: item.description } : {}),
time: {},
})) ?? []
)
const content = response.result?.content.find((item) => item.text)
const search = content
? Option.getOrUndefined(
WebSearchResponse.json(content.text, response.truncated).pipe(Option.flatMap(decodeSearchResponse)),
)
: undefined
return WebSearchResponse.items(SearchResult, search?.data.web ?? [], response.truncated).map((item) => ({
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.description ? { content: item.description } : {}),
time: {},
}))
}),
})
})
+16 -25
View File
@@ -1,26 +1,22 @@
export * as WebSearchMcp from "./mcp.js"
import { Duration, Effect, Schema } from "effect"
import { Duration, Effect, Option, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { collectBoundedResponseBody } from "../../tool/http-body.js"
import { WebSearchResponse } from "./response.js"
export const MAX_RESPONSE_BYTES = 256 * 1024
export const parseResponse = <F extends Schema.Struct.Fields>(body: string, result: Schema.Struct<F>) => {
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ result })))
const parse = (payload: string) => {
const trimmed = payload.trim()
if (!trimmed.startsWith("{")) return Effect.undefined
return decode(trimmed).pipe(Effect.map((response) => response.result))
}
export const parseResponse = <F extends Schema.Struct.Fields>(
body: { readonly text: string; readonly truncated: boolean },
tool: string,
result: Schema.Struct<F>,
) => {
const decode = Schema.decodeUnknownEffect(Schema.Struct({ result }))
return Effect.gen(function* () {
const trimmed = body.trim()
const direct = trimmed ? yield* parse(trimmed) : undefined
if (direct) return direct
for (const line of body.split("\n")) {
if (!line.startsWith("data: ")) continue
const data = yield* parse(line.substring(6))
if (data) return data
for (const payload of WebSearchResponse.payloads(body.text)) {
const message = WebSearchResponse.json(payload, body.truncated)
if (Option.isNone(message)) return yield* Effect.fail(new Error(`${tool} returned invalid JSON`))
const failure = WebSearchResponse.failure(message.value)
if (failure !== undefined) return yield* Effect.fail(new Error(failure))
return (yield* decode(message.value)).result
}
})
}
@@ -52,13 +48,8 @@ export const call = <F extends Schema.Struct.Fields, R extends Schema.Struct.Fie
}),
)
return yield* Effect.gen(function* () {
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http)).execute(request)
const body = yield* collectBoundedResponseBody(
response,
MAX_RESPONSE_BYTES,
() => new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`),
)
return yield* parseResponse(body.toString("utf8"), schema.output)
const body = yield* WebSearchResponse.execute(http, request)
return { result: yield* parseResponse(body, tool, schema.output), truncated: body.truncated }
}).pipe(
Effect.scoped,
Effect.timeoutOrElse({
+31 -42
View File
@@ -1,10 +1,11 @@
export * as WebSearchParallel from "./parallel.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { Effect, Option, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app.js"
import { WebSearchMcp } from "./mcp.js"
import { WebSearchResponse } from "./response.js"
export const endpoint = "https://search.parallel.ai/mcp"
@@ -14,38 +15,19 @@ const McpInput = Schema.Struct({
model_name: Schema.String.check(Schema.isMaxLength(100)).pipe(Schema.optional),
})
const SearchResponse = Schema.Struct({
search_id: Schema.String,
results: Schema.Array(
Schema.Struct({
url: Schema.String,
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
publish_date: Schema.NullOr(Schema.String).pipe(Schema.optional),
excerpts: Schema.Array(Schema.String),
}),
),
warnings: Schema.NullOr(
Schema.Array(
Schema.Struct({
type: Schema.Literals(["spec_validation_warning", "input_validation_warning", "warning"]),
message: Schema.String,
detail: Schema.NullOr(Schema.Record(Schema.String, Schema.Json)).pipe(Schema.optional),
}),
),
).pipe(Schema.optional),
usage: Schema.NullOr(
Schema.Array(
Schema.Struct({
name: Schema.String,
count: Schema.Int,
}),
),
).pipe(Schema.optional),
session_id: Schema.String,
})
const McpOutput = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
structuredContent: SearchResponse,
// Parallel sends this after a text copy of the same JSON, so a truncated response can lose it.
structuredContent: Schema.Unknown.pipe(Schema.optional),
})
const decodeSearchResponse = Schema.decodeUnknownOption(Schema.Struct({ results: Schema.Array(Schema.Unknown) }))
const SearchResult = Schema.Struct({
url: Schema.String,
title: Schema.NullOr(Schema.String).pipe(Schema.optional),
publish_date: Schema.NullOr(Schema.String).pipe(Schema.optional),
excerpts: Schema.Array(Schema.String),
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
@@ -71,7 +53,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("parallel")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
const response = yield* WebSearchMcp.call(
http,
endpoint,
"web_search",
@@ -85,17 +67,24 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
...(credential?.type === "key" ? { Authorization: `Bearer ${credential.key}` } : {}),
},
)
return (
result?.structuredContent.results.map((item) => {
const published = item.publish_date ? Date.parse(item.publish_date) : undefined
return {
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.excerpts.length ? { content: item.excerpts.join("\n\n") } : {}),
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
}
}) ?? []
const content = response.result?.content.find((item) => item.text)
const search = Option.getOrUndefined(
decodeSearchResponse(
response.result?.structuredContent ??
(content
? Option.getOrUndefined(WebSearchResponse.json(content.text, response.truncated))
: undefined),
),
)
return WebSearchResponse.items(SearchResult, search?.results ?? [], response.truncated).map((item) => {
const published = item.publish_date ? Date.parse(item.publish_date) : undefined
return {
url: item.url,
...(item.title ? { title: item.title } : {}),
...(item.excerpts.length ? { content: item.excerpts.join("\n\n") } : {}),
time: { ...(published !== undefined && Number.isFinite(published) ? { published } : {}) },
}
})
}),
})
})
@@ -0,0 +1,88 @@
export * as WebSearchResponse from "./response.js"
import { parseJSON } from "@opencode/ai/protocols/utils/partial-json"
import { Duration, Effect, Option, Schema, Stream } from "effect"
import { HttpClient, HttpClientError, type HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
export const MAX_BYTES = 1024 * 1024
// Keeps the status for rate-limit failover and adds the provider's own explanation of the failure.
export const execute = (http: HttpClient.HttpClient, request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const response = yield* HttpClient.withScope(http).execute(request)
if (response.status >= 200 && response.status < 300) return yield* read(response)
return yield* new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({
request,
response,
description: yield* read(response).pipe(
Effect.map((body) =>
payloads(body.text)
.flatMap((payload) => Option.toArray(json(payload, body.truncated)))
.map(failure)
.find((message) => message !== undefined),
),
// The explanation is optional; a slow or broken body must not delay the failure.
Effect.timeoutOrElse({ duration: Duration.seconds(1), orElse: () => Effect.undefined }),
Effect.orElseSucceed(() => undefined),
),
}),
})
})
// Stops reading at MAX_BYTES instead of failing; parsers keep the results that arrived complete.
const read = (response: HttpClientResponse.HttpClientResponse) =>
Effect.gen(function* () {
let size = 0
const chunks = yield* response.stream.pipe(
Stream.takeUntil((chunk) => (size += chunk.byteLength) > MAX_BYTES),
Stream.runCollect,
)
return { text: Buffer.concat(chunks).subarray(0, MAX_BYTES).toString("utf8"), truncated: size > MAX_BYTES }
})
// A JSON body, or the data lines of a server-sent event stream.
export const payloads = (text: string) =>
[text, ...text.split("\n").flatMap((line) => (line.startsWith("data: ") ? [line.slice(6)] : []))]
.map((payload) => payload.trim())
.filter((payload) => payload.startsWith("{"))
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const decodePartialJson = Option.liftThrowable(parseJSON)
export const json = (text: string, truncated: boolean) => (truncated ? decodePartialJson(text) : decodeJson(text))
const decodeFailure = Schema.decodeUnknownOption(
Schema.Union([
Schema.Struct({ error: Schema.Struct({ message: Schema.String }) }),
Schema.Struct({
result: Schema.Struct({
isError: Schema.Literal(true),
content: Schema.Array(Schema.Struct({ text: Schema.String })),
}),
}),
Schema.Struct({ detail: Schema.Struct({ error: Schema.String }) }),
Schema.Struct({ _tag: Schema.String, message: Schema.String }),
]),
)
// JSON-RPC errors, MCP tool errors, Tavily's error detail, and OpenCode API errors.
export const failure = (value: unknown) =>
Option.getOrUndefined(
Option.map(decodeFailure(value), (decoded) => {
if ("error" in decoded) return decoded.error.message
if ("result" in decoded) return decoded.result.content.map((item) => item.text).join("\n")
if ("detail" in decoded) return decoded.detail.error
return decoded.message
}),
)
// The last item of a truncated response may be cut mid-value, so it is dropped rather than trusted.
export const items = <S extends Schema.ConstraintDecoder<unknown>>(
schema: S,
values: readonly unknown[],
truncated: boolean,
) => {
const decode = Schema.decodeUnknownOption(schema)
return (truncated ? values.slice(0, -1) : values).flatMap((value) => Option.toArray(decode(value)))
}
+19 -21
View File
@@ -1,9 +1,10 @@
export * as WebSearchTavily from "./tavily.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Duration, Effect, Schema, Scope } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Duration, Effect, Option, Schema, Scope } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { App } from "../../app.js"
import { WebSearchResponse } from "./response.js"
export const endpoint = "https://api.tavily.com/search"
@@ -14,14 +15,12 @@ const SearchRequest = Schema.Struct({
max_results: Schema.Number,
})
const SearchResponse = Schema.Struct({
results: Schema.Array(
Schema.Struct({
title: Schema.String,
url: Schema.String,
content: Schema.String,
}),
),
const decodeSearchResponse = Schema.decodeUnknownOption(Schema.Struct({ results: Schema.Array(Schema.Unknown) }))
const SearchResult = Schema.Struct({
title: Schema.String,
url: Schema.String,
content: Schema.String,
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
@@ -63,17 +62,16 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
max_results: 8,
}),
)
const response = yield* HttpClient.withScope(HttpClient.filterStatusOk(http))
.execute(request)
.pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(SearchResponse)),
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
}),
)
return response.results.map((item) => ({
const body = yield* WebSearchResponse.execute(http, request).pipe(
Effect.scoped,
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
}),
)
const search = WebSearchResponse.json(body.text, body.truncated).pipe(Option.flatMap(decodeSearchResponse))
if (Option.isNone(search)) return yield* Effect.fail(new Error("Tavily returned an invalid response"))
return WebSearchResponse.items(SearchResult, search.value.results, body.truncated).map((item) => ({
url: item.url,
title: item.title,
...(item.content ? { content: item.content } : {}),
+21 -23
View File
@@ -5,6 +5,7 @@ import { Effect, Option, Schema, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { App } from "../../app.js"
import { WebSearchMcp } from "./mcp.js"
import { WebSearchResponse } from "./response.js"
export const endpoint = "https://agent.tinyfish.ai/mcp"
@@ -16,18 +17,13 @@ const McpOutput = Schema.Struct({
content: Schema.Array(Schema.Struct({ type: Schema.Literal("text"), text: Schema.String })),
})
const SearchResponse = Schema.fromJsonString(
Schema.Struct({
results: Schema.Array(
Schema.Struct({
url: Schema.String,
title: Schema.String,
snippet: Schema.String,
}),
),
}),
)
const decodeSearchResponse = Schema.decodeUnknownOption(SearchResponse)
const decodeSearchResponse = Schema.decodeUnknownOption(Schema.Struct({ results: Schema.Array(Schema.Unknown) }))
const SearchResult = Schema.Struct({
url: Schema.String,
title: Schema.String,
snippet: Schema.String,
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.tinyfish",
@@ -52,7 +48,7 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("tinyfish")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const result = yield* WebSearchMcp.call(
const response = yield* WebSearchMcp.call(
http,
endpoint,
"search",
@@ -65,16 +61,18 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
: { "X-TinyFish-Access-Mode": "keyless" }),
},
)
const content = result?.content.find((item) => item.text)
const response = content ? Option.getOrUndefined(decodeSearchResponse(content.text)) : undefined
return (
response?.results.map((item) => ({
url: item.url,
title: item.title,
...(item.snippet ? { content: item.snippet } : {}),
time: {},
})) ?? []
)
const content = response.result?.content.find((item) => item.text)
const search = content
? Option.getOrUndefined(
WebSearchResponse.json(content.text, response.truncated).pipe(Option.flatMap(decodeSearchResponse)),
)
: undefined
return WebSearchResponse.items(SearchResult, search?.results ?? [], response.truncated).map((item) => ({
url: item.url,
title: item.title,
...(item.snippet ? { content: item.snippet } : {}),
time: {},
}))
}),
})
})
@@ -1,101 +0,0 @@
export * as McpResourceTools from "./mcp-resource.js"
import { ToolFailure } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import { Effect, Schema } from "effect"
import { Mcp } from "../../mcp/index.js"
import { Permission } from "../../permission.js"
export const Plugin = {
id: "opencode.tools.mcp-resources",
effect: Effect.fn("McpResourceTools.Plugin")(function* (ctx: Context) {
const mcp = yield* Mcp.Service
const permission = yield* Permission.Service
yield* ctx.tool
.transform((editor) => {
editor.add({
name: "list_mcp_resources",
options: { namespace: "opencode", codemode: true },
description:
"List documents, records, and other data exposed by MCP servers. Use this when the user refers to something that is not a local file, such as a URI with a custom scheme, then load a match with read_mcp_resource. Each entry names the server to read it from. Templates are resources addressed by a parameter such as a record ID; fill in the uriTemplate placeholders before reading.",
input: Schema.Struct({
server: Schema.optionalKey(
Schema.String.annotate({ description: "MCP server name as configured. Omit to list every server." }),
),
}),
output: Schema.Struct({
resources: Schema.Array(Mcp.Resource),
templates: Schema.Array(Mcp.ResourceTemplate),
}),
execute: (input, context) =>
Effect.gen(function* () {
// Listing every server checks each server name so per-server rules still apply.
const servers =
input.server === undefined ? (yield* mcp.servers()).map((server) => server.name) : [input.server]
yield* permission.assert({
action: "opencode_list_mcp_resources",
resources: servers,
save: servers,
metadata: {},
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
if (input.server === undefined) return { output: yield* mcp.resourceCatalog() }
return { output: yield* mcp.resources({ server: input.server }) }
}).pipe(Effect.mapError((error) => new ToolFailure({ message: error.message, error }))),
})
editor.add({
name: "read_mcp_resource",
options: { namespace: "opencode", codemode: true },
description:
"Read one MCP resource by server and URI. Not for local files. Always return the full contents rather than slicing or filtering them; oversized output is truncated automatically and the full content is saved to a file you can read. Images and PDFs are shown to you directly.",
input: Schema.Struct({
server: Schema.String.annotate({
description: "The server field of the discovered resource.",
}),
uri: Schema.String.annotate({
description: "Exact resource URI from discovery, an expanded uriTemplate, or the user.",
}),
}),
output: Mcp.ResourceContent,
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
action: "opencode_read_mcp_resource",
resources: [`${input.server}:${input.uri}`],
save: [`${input.server}:*`],
metadata: { server: input.server, uri: input.uri },
sessionID: context.sessionID,
agent: context.agent,
source: { type: "tool", messageID: context.messageID, id: context.id },
})
const resource = yield* mcp.readResource(input)
if (!resource)
return yield* new ToolFailure({
message: `MCP server "${input.server}" is not connected or does not expose resources`,
})
return {
output: resource,
content: resource.contents.flatMap((part) =>
part.type === "blob" && (part.mimeType?.startsWith("image/") || part.mimeType === "application/pdf")
? [{ type: "file" as const, uri: `data:${part.mimeType};base64,${part.blob}`, mime: part.mimeType }]
: [],
),
}
}).pipe(
Effect.mapError((error) =>
error instanceof ToolFailure
? error
: new ToolFailure({
message: `Unable to read MCP resource ${input.server}:${input.uri}: ${error.message}`,
error,
}),
),
),
})
})
.pipe(Effect.orDie)
}),
}
+1 -1
View File
@@ -84,7 +84,7 @@ export const Plugin = {
draft.namespace({
name: "opencode",
description:
"Tools for managing OpenCode itself, such as working with sessions, searching the available models, and reading MCP resources.",
"Tools for managing OpenCode itself, such as working with sessions and searching the available models.",
})
draft.add({
name: "session_rename",
+2 -7
View File
@@ -135,14 +135,9 @@ export const Plugin = {
.pipe(Effect.orDie)
const alternatePath = Effect.fn("ReadTool.alternatePath")(function* (absolute: string) {
const canonical = (name: string) =>
name
.normalize("NFC")
.replace(/[\u00a0\u202f]/g, " ")
.replace(/[\u2018\u2019]/g, "'")
const base = canonical(basename(absolute))
const base = basename(absolute).replace(/[\u00a0\u202f]/g, " ")
const matches = (yield* reader.list(AbsolutePath.make(dirname(absolute)))).filter(
(entry) => entry.type === "file" && canonical(entry.name) === base,
(entry) => entry.type === "file" && entry.name.replace(/[\u00a0\u202f]/g, " ") === base,
)
if (matches.length !== 1) return
return join(dirname(absolute), matches[0].name)
+1 -12
View File
@@ -3,7 +3,7 @@ export * as SubagentTool from "./subagent.js"
import { ToolFailure } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Effect, Predicate, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { Job } from "../../job.js"
@@ -268,17 +268,6 @@ export const Plugin = {
)
.pipe(Effect.orDie)
yield* ctx.tool.hook("execute.before", (event) =>
Effect.sync(() => {
if (event.tool !== name || !Predicate.isObject(event.input)) return
if (event.input.model !== "" && event.input.sessionID !== "") return
const input = { ...event.input }
if (input.model === "") delete input.model
if (input.sessionID === "") delete input.sessionID
event.input = input
}),
)
const hook = (event: SessionHooks["context"]) =>
Effect.gen(function* () {
const tool = event.tools[name]
+15 -16
View File
@@ -4,7 +4,6 @@ import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { ToolFailure } from "@opencode/ai"
import { Effect, Schema, Semaphore } from "effect"
import { HttpClientError } from "effect/unstable/http"
import { Form } from "../../form.js"
import { Permission } from "../../permission.js"
import { WebSearch } from "../../websearch.js"
@@ -12,10 +11,6 @@ import { WebSearch } from "../../websearch.js"
export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query."
const providerSelectionLock = Semaphore.makeUnsafe(1)
const httpErrors = new Map([
[429, "Web search rate limited (HTTP 429)"],
[401, "Web search authentication failed (HTTP 401)"],
])
export const description = `Search the web using the user's selected search integration. Use this for current information beyond knowledge cutoff.
@@ -164,17 +159,21 @@ export const Plugin = {
return { output, content, metadata: { provider: output.provider } }
}).pipe(
Effect.mapError((error) => {
const fallback = `Unable to search the web for ${input.query}`
if (!Schema.is(WebSearch.RequestError)(error)) return new ToolFailure({ message: fallback, error })
const status = HttpClientError.isHttpClientError(error.cause) ? error.cause.response?.status : undefined
return new ToolFailure({
message:
status === undefined
? fallback
: (httpErrors.get(status) ?? `Web search request failed (HTTP ${status})`),
error,
metadata: { provider: error.providerID },
})
const message = `Unable to search the web for ${input.query}`
// A cause becomes the model-visible error, so web search failures are described here instead;
// other causes, such as permission errors, keep their own session error.
if (error instanceof WebSearch.RequestError)
return new ToolFailure({
message: `${message} (${error.providerID}): ${error.message}`,
metadata: { provider: error.providerID },
})
if (
error instanceof WebSearch.ProviderRequiredError ||
error instanceof WebSearch.ProviderNotFoundError ||
error instanceof WebSearch.DisabledError
)
return new ToolFailure({ message: `${message}: ${error.message}` })
return new ToolFailure({ message, error })
}),
),
}),
+48 -5
View File
@@ -29,6 +29,8 @@ export const Response = WebSearch.Response
export type Response = WebSearch.Response
export const ProviderKey = "websearch:provider"
// Keeps one oversized page from crowding the other results out of the bounded tool output.
export const MAX_RESULT_CONTENT_LENGTH = 4_000
export const Selection = Schema.Union([ID, Schema.Literal("random"), Schema.Literal(false)])
export type Selection = typeof Selection.Type
@@ -39,18 +41,45 @@ export interface ProviderImplementation extends Provider {
export class ProviderRequiredError extends Schema.TaggedError<ProviderRequiredError>()(
"WebSearch.ProviderRequired",
{},
) {}
) {
override get message() {
return "No web search provider is selected"
}
}
export class ProviderNotFoundError extends Schema.TaggedError<ProviderNotFoundError>()("WebSearch.ProviderNotFound", {
providerID: ID,
}) {}
}) {
override get message() {
return `Web search provider not found: ${this.providerID}`
}
}
export class DisabledError extends Schema.TaggedError<DisabledError>()("WebSearch.Disabled", {}) {}
export class DisabledError extends Schema.TaggedError<DisabledError>()("WebSearch.Disabled", {}) {
override get message() {
return "Web search is disabled"
}
}
export class RequestError extends Schema.TaggedError<RequestError>()("WebSearch.Request", {
providerID: ID,
cause: Schema.Defect(),
}) {}
}) {
// HTTP client messages include the request URL, which can carry a provider credential.
override get message() {
const cause = this.cause
if (!HttpClientError.isHttpClientError(cause))
return cause instanceof Error && cause.message ? cause.message : "Request failed"
const status = cause.response?.status
if (status !== undefined && cause.reason.description) return `HTTP ${status}: ${cause.reason.description}`
if (status === 429) return "Rate limited (HTTP 429)"
if (status === 401) return "Authentication failed (HTTP 401)"
if (status !== undefined) return `Request failed (HTTP ${status})`
return cause.cause instanceof Error && cause.cause.message
? `Request failed: ${cause.cause.message}`
: "Request failed"
}
}
export type Error = ProviderRequiredError | ProviderNotFoundError | DisabledError | RequestError
@@ -195,7 +224,13 @@ const layer = Layer.effect(
const result = yield* provider
.execute({ query: input.query })
.pipe(Effect.flatMap(decodeResults), Effect.result)
if (result._tag === "Success") return new Response({ providerID: provider.id, results: result.success })
if (result._tag === "Success")
return new Response({
providerID: provider.id,
results: result.success.map((item) =>
item.content === undefined ? item : { ...item, content: limitContent(item.content) },
),
})
const cause = result.failure
const error = new RequestError({ providerID: provider.id, cause })
if (choice !== "random" || !HttpClientError.isHttpClientError(cause) || cause.response?.status !== 429)
@@ -212,6 +247,14 @@ const layer = Layer.effect(
}),
)
function limitContent(content: string) {
if (content.length <= MAX_RESULT_CONTENT_LENGTH) return content
const code = content.charCodeAt(MAX_RESULT_CONTENT_LENGTH - 1)
// Do not split a surrogate pair; a lone surrogate is invalid text for model requests.
const end = code >= 0xd800 && code <= 0xdbff ? MAX_RESULT_CONTENT_LENGTH - 1 : MAX_RESULT_CONTENT_LENGTH
return `${content.slice(0, end)}\n[truncated]`
}
function cooldownMillis(value: string | undefined, now: number) {
if (!value?.trim()) return 60_000
const seconds = Number(value)
-1
View File
@@ -21,7 +21,6 @@ export const emptyMcpLayer = Layer.succeed(
prompts: () => Effect.succeed([]),
prompt: () => Effect.undefined,
resourceCatalog: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })),
resources: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })),
readResource: () => Effect.undefined,
}),
)
+1 -2
View File
@@ -60,7 +60,6 @@ export const registerToolPlugin = <R>(
readonly effect: (context: Context) => Effect.Effect<void, never, R>
},
overrides: Parameters<typeof host>[0] = {},
hook?: Context["tool"]["hook"],
): Effect.Effect<void, never, R | Tool.Service | Scope.Scope> =>
Effect.gen(function* () {
const tools = yield* Tool.Service
@@ -74,7 +73,7 @@ export const registerToolPlugin = <R>(
transform: tools.transform,
reload: tools.reload,
list: tools.list,
hook: hook ?? (() => Effect.die("registerToolPlugin does not support tool hooks")),
hook: () => Effect.die("registerToolPlugin does not support tool hooks"),
},
})
yield* plugin.effect(context)
+2 -47
View File
@@ -5,24 +5,11 @@ import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
import { McpClient } from "@opencode/core/mcp/client"
import { McpOAuth } from "@opencode/core/mcp/oauth"
import { EffectFlock } from "@opencode/util/effect-flock"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { Cause, Effect, Exit } from "effect"
import { hostEnvironmentLayer } from "./fixture/environment"
import { tmpdir } from "./fixture/tmpdir"
const authServer = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) })
const state = await tmpdir()
afterAll(async () => {
authServer.stop(true)
await state[Symbol.asyncDispose]()
})
// Every provider locks in one directory, as separate processes on the same machine do.
const flock = LayerNode.compile(EffectFlock.node, {
replacements: [Global.node.replace(Global.layerWith({ state: state.path }))],
})
afterAll(() => authServer.stop(true))
const integrationID = Integration.ID.make("mcp_test")
const methodID = Integration.MethodID.make("oauth")
@@ -67,10 +54,7 @@ const memoryCredentials = (initial: Credential.Info[]) => {
const connectProvider = (config: typeof ConfigMCP.Remote.Type, store: ReturnType<typeof memoryCredentials>) =>
Effect.runPromise(
McpOAuth.connectProvider({ config, integrationID }).pipe(
Effect.provideService(Credential.Service, store.service),
Effect.provide(flock),
),
McpOAuth.connectProvider({ config, integrationID }).pipe(Effect.provideService(Credential.Service, store.service)),
)
// Serves authorization server metadata with the given capabilities and records DCR + token requests.
@@ -291,35 +275,6 @@ describe("MCP OAuth", () => {
])
})
test("serializes refreshes of a credential shared across processes", async () => {
const presented: string[] = []
const server = Bun.serve({
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (request.method !== "POST" || url.pathname !== "/token") return new Response(null, { status: 404 })
const token = new URLSearchParams(await request.text()).get("refresh_token") ?? ""
presented.push(token)
// Hold the response so a refresher that did not wait would present the same token.
await Bun.sleep(50)
return Response.json({ access_token: "next", token_type: "Bearer", refresh_token: `${token}+` })
},
})
const url = server.url.href
const store = memoryCredentials([credential({ access: "expired", refresh: "r", url })])
const providers = await Promise.all([connectProvider(remote(url), store), connectProvider(remote(url), store)])
// Plain fetch: processes share no in-memory request, so only the lock can order the refreshes.
const results = await Promise.all(
providers.map((provider) => auth(provider, { serverUrl: url, fetchFn: (input, init) => fetch(input, init) })),
).finally(() => server.stop(true))
expect(results).toEqual(["AUTHORIZED", "AUTHORIZED"])
expect(presented).toEqual(["r", "r+"])
const stored = store.rows.get(Credential.ID.make("cred_test"))?.value
expect(stored?.type === "oauth" && stored.refresh).toBe("r++")
})
test("generates a loopback redirect URL when none is configured", async () => {
expect(await authorize()).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/)
})
+3 -176
View File
@@ -17,7 +17,6 @@ import { ConfigMcpPlugin } from "@opencode/core/config/plugin/mcp"
import { Credential } from "@opencode/core/credential"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { EffectFlock } from "@opencode/util/effect-flock"
import { Bus } from "@opencode/core/bus"
import { ID, type Payload } from "@opencode/schema/event"
import { Form } from "@opencode/core/form"
@@ -33,9 +32,7 @@ import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { State } from "@opencode/core/state"
import { McpTool } from "@opencode/core/tool/mcp"
import { McpResourceTools } from "@opencode/core/tool/plugin/mcp-resource"
import { Tool } from "@opencode/core/tool"
import { ToolOutput } from "@opencode/core/tool-output"
import {
Context,
Deferred,
@@ -61,14 +58,7 @@ import { imagePassthrough } from "./lib/image"
import { location } from "./fixture/location"
import { tmpdirScoped } from "./fixture/tmpdir"
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
import {
codeModeListings,
executeTool,
registerToolPlugin,
toolDefinitions,
toolIdentity,
waitForTool,
} from "./lib/tool"
import { codeModeListings, executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
let decision: Effect.Effect<void, Permission.Error> = Effect.void
@@ -108,9 +98,6 @@ function resourceServer(
{ uri: "docs://logo", blob: "aGVsbG8=", mimeType: "image/png" },
] as Array<{ uri: string; text: string; mimeType?: string } | { uri: string; blob: string; mimeType?: string }>,
resourceLists: 0,
resourceReads: [] as string[],
templatesUnsupported: false,
missing: [] as string[],
templateLists: 0,
toolLists: 0,
toolCalls: [] as Array<{
@@ -207,15 +194,10 @@ function resourceServer(
})
protocol.setRequestHandler("resources/templates/list", (request) => {
state.templateLists += 1
if (state.templatesUnsupported) return Promise.reject(new Error("Method not found"))
const page = state.templatePages?.[request.params?.cursor ?? "initial"]
return Promise.resolve({ resourceTemplates: page?.items ?? state.templates, nextCursor: page?.nextCursor })
})
protocol.setRequestHandler("resources/read", (request) => {
state.resourceReads.push(request.params.uri)
if (state.missing.includes(request.params.uri)) return Promise.reject(new Error("Resource not found"))
return Promise.resolve({ contents: state.contents })
})
protocol.setRequestHandler("resources/read", () => Promise.resolve({ contents: state.contents }))
}
return protocol
}
@@ -348,7 +330,6 @@ function resourceMcpLayer(
},
}),
Layer.mock(Credential.Service, {}),
Layer.mock(EffectFlock.Service, {}),
overrides?.environment ?? hostEnvironmentLayer,
),
),
@@ -1411,152 +1392,6 @@ test("loads and reads MCP resources", async () => {
)
})
it.live("discovers and reads MCP resources through Code Mode", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const server = yield* resourceServer()
server.state.resourcePages = {
initial: { items: [{ name: "Readme", uri: "docs://readme" }], nextCursor: "resources-2" },
"resources-2": { items: [{ name: "Guide", uri: "docs://guide" }] },
}
server.state.templatePages = {
initial: { items: [{ name: "File", uriTemplate: "docs://{path}" }], nextCursor: "templates-2" },
"templates-2": { items: [{ name: "Issue", uriTemplate: "issue://{id}" }] },
}
yield* Effect.gen(function* () {
const mcp = yield* Mcp.Service
yield* settled(mcp)
yield* registerToolPlugin(McpResourceTools.Plugin).pipe(Effect.provide(permissions))
const tools = yield* Tool.Service
const snapshot = yield* tools.snapshot()
const sessionID = Session.ID.make("ses_resource_tools")
const run = (code: string) =>
snapshot.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call_resource", name: "execute", input: { code } },
})
// The SDK walks every page, so one call returns the full catalog.
const listed = yield* run('return await tools.opencode.list_mcp_resources({ server: "resources" })')
expect(JSON.parse(listed.output.output)).toEqual({
resources: [
{ server: "resources", name: "Guide", uri: "docs://guide" },
{ server: "resources", name: "Readme", uri: "docs://readme" },
],
templates: [
{ server: "resources", name: "File", uriTemplate: "docs://{path}" },
{ server: "resources", name: "Issue", uriTemplate: "issue://{id}" },
],
})
expect(server.state.resourceReads).toEqual([])
// Omitting the server lists every server, so the model can find which one owns a URI.
assertion = yield* Deferred.make<Permission.AssertInput>()
const everywhere = yield* run("return await tools.opencode.list_mcp_resources({})")
expect(yield* Deferred.await(assertion)).toMatchObject({
action: "opencode_list_mcp_resources",
resources: ["resources"],
save: ["resources"],
})
expect(JSON.parse(everywhere.output.output)).toEqual({
resources: [
{ server: "resources", name: "Guide", uri: "docs://guide" },
{ server: "resources", name: "Readme", uri: "docs://readme" },
],
templates: [
{ server: "resources", name: "File", uriTemplate: "docs://{path}" },
{ server: "resources", name: "Issue", uriTemplate: "issue://{id}" },
],
})
// A server may declare resources without implementing template listing.
server.state.templatesUnsupported = true
const untemplated = yield* run('return await tools.opencode.list_mcp_resources({ server: "resources" })')
expect(JSON.parse(untemplated.output.output)).toEqual({
resources: [
{ server: "resources", name: "Guide", uri: "docs://guide" },
{ server: "resources", name: "Readme", uri: "docs://readme" },
],
templates: [],
})
server.state.templatesUnsupported = false
assertion = yield* Deferred.make<Permission.AssertInput>()
const read = yield* run(
'const resource = await tools.opencode.read_mcp_resource({ server: "resources", uri: "docs://readme" }); return resource.contents.filter(part => part.type === "text").map(part => part.text).join("\\n")',
)
expect(read.content).toEqual([
{ type: "text", text: "hello" },
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png" },
])
expect(read.metadata?.toolCalls).toMatchObject([
{
tool: "opencode.read_mcp_resource",
status: "completed",
input: { server: "resources", uri: "docs://readme" },
},
])
expect(server.state.resourceReads).toEqual(["docs://readme"])
expect(yield* Deferred.await(assertion)).toEqual({
action: "opencode_read_mcp_resource",
resources: ["resources:docs://readme"],
save: ["resources:*"],
metadata: { server: "resources", uri: "docs://readme" },
sessionID,
agent: toolIdentity.agent,
source: { type: "tool", messageID: toolIdentity.messageID, id: "call_resource" },
})
server.state.contents = [{ uri: "docs://readme", text: "line\n".repeat(20_000), mimeType: "text/plain" }]
const large = yield* run(
'const resource = await tools.opencode.read_mcp_resource({ server: "resources", uri: "docs://readme" }); return resource.contents[0].text',
)
const bounded = yield* ToolOutput.Service.use((output) => output.truncate(large)).pipe(
Effect.provide(AppNodeBuilder.build(ToolOutput.node)),
)
expect(bounded.metadata?.truncated).toBe(true)
expect(bounded.content[0]).toMatchObject({ type: "text" })
const outputPath = bounded.metadata?.outputPath
expect(typeof outputPath).toBe("string")
if (typeof outputPath !== "string") throw new Error("Missing full resource output")
expect(yield* Effect.promise(() => Bun.file(outputPath).text())).toBe("line\n".repeat(20_000))
// An empty contents array means the resource exists without content, not that it is missing.
server.state.contents = []
const empty = yield* run(
'return await tools.opencode.read_mcp_resource({ server: "resources", uri: "docs://empty" })',
)
expect(empty.metadata?.error).toBeUndefined()
expect(JSON.parse(empty.output.output)).toEqual({ server: "resources", uri: "docs://empty", contents: [] })
server.state.missing = ["docs://gone"]
const gone = yield* run(
'return await tools.opencode.read_mcp_resource({ server: "resources", uri: "docs://gone" })',
)
expect(gone.metadata?.error).toBe(true)
expect(gone.output.output).toContain("Unable to read MCP resource resources:docs://gone")
expect(gone.output.output).toContain("Resource not found")
const missing = yield* run(
'return await tools.opencode.read_mcp_resource({ server: "missing", uri: "docs://readme" })',
)
expect(missing.metadata?.error).toBe(true)
expect(missing.output.output).toContain("MCP server not found: missing")
const reads = server.state.resourceReads.length
decision = Effect.fail(
new Permission.BlockedError({ rules: [], permission: "opencode_read_mcp_resource", resources: ["*"] }),
)
const denied = yield* run(
'return await tools.opencode.read_mcp_resource({ server: "resources", uri: "docs://denied" })',
)
expect(denied.metadata?.error).toBe(true)
expect(server.state.resourceReads).toHaveLength(reads)
}).pipe(Effect.provide(resourceMcpLayer(server.url)))
}),
)
test("adds, disconnects, and reconnects MCP servers at runtime", async () => {
const published: string[] = []
await Effect.runPromise(
@@ -1990,15 +1825,7 @@ testEffect(Layer.empty).live("keeps MCP config snapshots stable during an in-fli
const shutdownIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Bus.node,
Integration.node,
Credential.node,
EffectFlock.node,
Form.node,
Environment.node,
Location.node,
]),
LayerNode.group([Bus.node, Integration.node, Credential.node, Form.node, Environment.node, Location.node]),
[
Location.node.replace(
Layer.succeed(
@@ -17,6 +17,7 @@ import { ModelResolver } from "@opencode/core/model-resolver"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
import { WebSearchResponse } from "@opencode/core/plugin/websearch/response"
import { Provider } from "@opencode/core/provider"
import { WebSearch } from "@opencode/core/websearch"
import { withEnv } from "../fixture/env"
@@ -805,7 +806,13 @@ describe("OpencodePlugin", () => {
body?: unknown
}> = []
const gate = Promise.withResolvers<void>()
const state = { advertised: true, providerID: "opencode", waitForConfig: false }
const state = {
advertised: true,
providerID: "opencode",
waitForConfig: false,
unavailable: false,
oversized: false,
}
const server = Bun.serve({
port: 0,
fetch: async (request) => {
@@ -832,6 +839,11 @@ describe("OpencodePlugin", () => {
})
}
if (path === "/api/websearch" || path === "/other/api/websearch") {
if (state.unavailable)
return Response.json(
{ _tag: "ServiceUnavailableError", message: "Web search request failed: firecrawl" },
{ status: 503 },
)
return Response.json({
providerID: state.providerID,
results: [
@@ -841,6 +853,16 @@ describe("OpencodePlugin", () => {
content: "Open source AI coding agent.",
time: { published: 1_700_000_000_000 },
},
...(state.oversized
? [
{
url: "https://huge.example.com",
content: "x".repeat(2 * WebSearchResponse.MAX_BYTES),
time: {},
},
{ url: "https://after.example.com", content: "after", time: {} },
]
: []),
],
})
}
@@ -927,6 +949,18 @@ describe("OpencodePlugin", () => {
value: account("replacement"),
})
state.oversized = true
expect((yield* websearch.query({ query: "oversized" })).results.map((result) => result.url)).toEqual([
"https://github.com/anomalyco/opencode",
])
state.oversized = false
state.unavailable = true
expect((yield* websearch.query({ query: "unavailable" }).pipe(Effect.flip)).message).toBe(
"HTTP 503: Web search request failed: firecrawl",
)
state.unavailable = false
state.providerID = "unexpected"
expect((yield* websearch.query({ query: "wrong provider" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request")
+195
View File
@@ -5,6 +5,7 @@ import { WebSearch } from "@opencode/core/websearch"
import { WebSearchExa } from "@opencode/core/plugin/websearch/exa"
import { WebSearchFirecrawl } from "@opencode/core/plugin/websearch/firecrawl"
import { WebSearchParallel } from "@opencode/core/plugin/websearch/parallel"
import { WebSearchResponse } from "@opencode/core/plugin/websearch/response"
import { WebSearchTavily } from "@opencode/core/plugin/websearch/tavily"
import { WebSearchTinyFish } from "@opencode/core/plugin/websearch/tinyfish"
import { host, integrationHost, webSearchHost } from "./host"
@@ -29,6 +30,8 @@ beforeEach(() => {
})
const it = webSearchIntegrationTest
const sseMessage = (message: object) =>
`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: 1, ...message })}\n\n`
describe("built-in web search providers", () => {
;[
@@ -93,6 +96,198 @@ describe("built-in web search providers", () => {
}),
)
describe("responses larger than the size cap", () => {
const huge = "x".repeat(2 * WebSearchResponse.MAX_BYTES)
const mcpText = (text: string) => sseMessage({ result: { content: [{ type: "text", text }] } })
const exaBlock = (url: string, title: string, content: string) =>
`Title: ${title}\nURL: ${url}\nPublished: N/A\nAuthor: N/A\nHighlights:\n${content}`
const parallelSearch = {
search_id: "search_1",
results: [
{ url: "https://effect.website", title: "Effect", publish_date: null, excerpts: ["Effect documentation"] },
{ url: "https://huge.example.com", title: "Huge", publish_date: null, excerpts: [huge] },
{ url: "https://after.example.com", title: "After", publish_date: null, excerpts: ["after"] },
],
session_id: "ses_parallel",
}
;[
{
plugin: WebSearchExa.Plugin,
providerID: WebSearch.ID.make("exa"),
body: mcpText(
[
exaBlock("https://effect.website", "Effect", "Effect documentation"),
exaBlock("https://huge.example.com", "Huge", huge),
exaBlock("https://after.example.com", "After", "after"),
].join("\n\n---\n\n"),
),
},
{
plugin: WebSearchFirecrawl.Plugin,
providerID: WebSearch.ID.make("firecrawl"),
body: mcpText(
JSON.stringify({
success: true,
data: {
web: [
{ url: "https://effect.website", title: "Effect", description: "Effect documentation" },
{ url: "https://huge.example.com", title: "Huge", description: huge },
{ url: "https://after.example.com", title: "After", description: "after" },
],
},
}),
),
},
{
plugin: WebSearchParallel.Plugin,
providerID: WebSearch.ID.make("parallel"),
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
content: [{ type: "text", text: JSON.stringify(parallelSearch) }],
structuredContent: parallelSearch,
},
}),
},
{
plugin: WebSearchTavily.Plugin,
providerID: WebSearch.ID.make("tavily"),
body: JSON.stringify({
results: [
{ title: "Effect", url: "https://effect.website", content: "Effect documentation" },
{ title: "Huge", url: "https://huge.example.com", content: huge },
{ title: "After", url: "https://after.example.com", content: "after" },
],
}),
},
{
plugin: WebSearchTinyFish.Plugin,
providerID: WebSearch.ID.make("tinyfish"),
body: mcpText(
JSON.stringify({
results: [
{ title: "Effect", url: "https://effect.website", snippet: "Effect documentation" },
{ title: "Huge", url: "https://huge.example.com", snippet: huge },
{ title: "After", url: "https://after.example.com", snippet: "after" },
],
}),
),
},
].forEach((provider) => {
it.effect(`keeps the complete results from ${provider.plugin.id}`, () =>
Effect.gen(function* () {
resetWebSearchFixture(provider.body)
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* provider.plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
expect(yield* websearch.query({ query: "effect", providerID: provider.providerID })).toEqual(
new WebSearch.Response({
providerID: provider.providerID,
results: [{ url: "https://effect.website", title: "Effect", content: "Effect documentation", time: {} }],
}),
)
}),
)
})
})
it.effect("reports MCP tool errors instead of returning no results", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* WebSearchFirecrawl.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
const search = websearch.query({ query: "effect", providerID: WebSearch.ID.make("firecrawl") }).pipe(Effect.flip)
resetWebSearchFixture(
sseMessage({ result: { isError: true, content: [{ type: "text", text: "Rate limit exceeded" }] } }),
)
expect((yield* search).message).toBe("Rate limit exceeded")
resetWebSearchFixture(sseMessage({ error: { code: -32602, message: "Invalid arguments" } }))
expect((yield* search).message).toBe("Invalid arguments")
}),
)
it.effect("reports the provider's explanation for HTTP failures", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* WebSearchTinyFish.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
yield* WebSearchTavily.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
const quota =
"Free daily Search quota used (50/50). Sign up for continued access: https://agent.tinyfish.ai/sign-up"
resetWebSearchFixture(JSON.stringify({ jsonrpc: "2.0", error: { code: -31001, message: quota }, id: 1 }), 401)
const tinyfish = yield* websearch
.query({ query: "effect", providerID: WebSearch.ID.make("tinyfish") })
.pipe(Effect.flip)
expect(tinyfish.message).toBe(`HTTP 401: ${quota}`)
resetWebSearchFixture(JSON.stringify({ detail: { error: "Unauthorized: missing or invalid API key." } }), 401)
const tavily = yield* websearch
.query({ query: "effect", providerID: WebSearch.ID.make("tavily") })
.pipe(Effect.flip)
expect(tavily.message).toBe("HTTP 401: Unauthorized: missing or invalid API key.")
}),
)
it.effect("limits oversized Firecrawl results instead of failing the search", () =>
Effect.gen(function* () {
const thread = "comment ".repeat(64 * 1024)
resetWebSearchFixture(
`event: message\ndata: ${JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: {
content: [
{
type: "text",
text: JSON.stringify({
success: true,
data: {
web: [
{ url: "https://news.ycombinator.com/item?id=1", title: "Thread", description: thread },
{ url: "https://effect.website", title: "Effect", description: "Effect documentation" },
],
},
}),
},
],
},
})}\n\n`,
)
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* WebSearchFirecrawl.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
expect(yield* websearch.query({ query: "opencode", providerID: WebSearch.ID.make("firecrawl") })).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("firecrawl"),
results: [
{
url: "https://news.ycombinator.com/item?id=1",
title: "Thread",
content: `${thread.slice(0, WebSearch.MAX_RESULT_CONTENT_LENGTH)}\n[truncated]`,
time: {},
},
{ url: "https://effect.website", title: "Effect", content: "Effect documentation", time: {} },
],
}),
)
}),
)
it.effect("registers Exa with its MCP schema", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+4 -33
View File
@@ -30,7 +30,6 @@ import { SessionMessage } from "@opencode/core/session/message"
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
import { SessionStore } from "@opencode/core/session/store"
import { Plugin } from "@opencode/core/plugin"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { PluginSupervisor } from "@opencode/core/plugin/supervisor"
import { Permission } from "@opencode/core/permission"
import { SubagentTool } from "@opencode/core/tool/plugin/subagent"
@@ -113,13 +112,8 @@ const executionNode = makeGlobalNode({
const subagentPluginSupervisor = makeLocationNode({
name: "test/subagent-plugins",
layer: Layer.effectDiscard(
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
yield* registerToolPlugin(SubagentTool.Plugin, {}, (name, callback) => hooks.register("tool", name, callback))
}),
),
deps: [Agent.node, Config.node, Model.node, Permission.node, Session.node, Job.node, Tool.node, PluginHooks.node],
layer: Layer.effectDiscard(registerToolPlugin(SubagentTool.Plugin)),
deps: [Agent.node, Config.node, Model.node, Permission.node, Session.node, Job.node, Tool.node],
})
const nodes = LayerNode.group([
@@ -413,7 +407,7 @@ describe("SubagentTool", () => {
type: "tool-call",
id: "call-subagent",
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "review this", model: "", sessionID: "" },
input: { agent: "reviewer", description: "review", prompt: "review this" },
},
})
@@ -490,7 +484,6 @@ describe("SubagentTool", () => {
description: "follow up",
prompt: "continue this",
sessionID: childID,
model: "",
},
},
})
@@ -607,28 +600,6 @@ describe("SubagentTool", () => {
message: `Subagent session not found: ${missing}`,
},
})
expect(
yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-malformed-child-id",
name: SubagentTool.name,
input: { agent: "reviewer", description: "follow up", prompt: "continue", sessionID: "placeholder" },
},
}),
).toMatchObject({
status: "error",
error: { message: expect.stringContaining('sessionID: Expected a string starting with "ses"') },
})
expect(yield* call(parent.id, "call-parent-id")).toEqual({
status: "error",
error: {
type: "tool.execution",
message: `Session ${parent.id} is not a child of the current session`,
},
})
expect(yield* call(unrelated.id, "call-unrelated-child")).toEqual({
status: "error",
error: {
@@ -684,7 +655,7 @@ describe("SubagentTool", () => {
})
// The requested model beats the agent's configured model.
const spawned = yield* call("call-override", { model: "test/override#fast", sessionID: "" })
const spawned = yield* call("call-override", { model: "test/override#fast" })
expect(spawned).toMatchObject({ status: "completed", metadata: { status: "completed" } })
const child = yield* sessions.get(outputSessionID(spawned.metadata))
expect(child).toMatchObject({ agent: "reviewer", model: overrideModel })
+61 -7
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Context, Effect, Layer } from "effect"
import type { HttpClientError } from "effect/unstable/http"
import { HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Permission } from "@opencode/core/permission"
@@ -44,7 +44,7 @@ class Fixture {
formResponse: Form.TerminalState = { status: "cancelled" }
formResponses: Form.TerminalState[] = []
formWait = Effect.void
error: HttpClientError.HttpClientError | undefined
error: HttpClientError.HttpClientError | Error | undefined
results: readonly WebSearch.Result[] = [
{ url: "https://example.com", title: "Search results", content: "search results", time: {} },
]
@@ -62,7 +62,7 @@ const setup = Effect.gen(function* () {
execute: () =>
Effect.gen(function* () {
fixture.events.push("query")
if (fixture.error) return yield* fixture.error
if (fixture.error) return yield* Effect.fail(fixture.error)
return fixture.results
}),
}),
@@ -418,7 +418,7 @@ describe("WebSearchTool registration", () => {
.pipe(Effect.flip)
expect(toSessionError(error)).toEqual({
type: "tool.execution",
message: "Web search rate limited (HTTP 429)",
message: `Unable to search the web for ${query} (${error.metadata?.provider}): Rate limited (HTTP 429)`,
})
expect(error.metadata).toMatchObject({ provider: expect.stringMatching(/^(exa|parallel)$/) })
}),
@@ -437,9 +437,9 @@ describe("WebSearchTool registration", () => {
yield* Effect.forEach(
[
{ status: 403, message: "Web search request failed (HTTP 403)" },
{ status: 429, message: "Web search rate limited (HTTP 429)" },
{ status: 401, message: "Web search authentication failed (HTTP 401)" },
{ status: 403, message: "Unable to search the web for effect (exa): Request failed (HTTP 403)" },
{ status: 429, message: "Unable to search the web for effect (exa): Rate limited (HTTP 429)" },
{ status: 401, message: "Unable to search the web for effect (exa): Authentication failed (HTTP 401)" },
],
({ status, message }, index) =>
Effect.gen(function* () {
@@ -469,4 +469,58 @@ describe("WebSearchTool registration", () => {
)
}),
)
it.effect("reports the underlying reason for provider failures", () =>
Effect.gen(function* () {
const fixture = yield* setup
const tools = yield* fixture.registry.snapshot()
yield* fixture.websearch.select(WebSearch.ID.make("exa"))
yield* Effect.forEach(
[
{
error: new Error("web_search_exa request timed out"),
message: "Unable to search the web for effect (exa): web_search_exa request timed out",
},
{
error: new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: HttpClientRequest.post("https://mcp.exa.ai/mcp?exaApiKey=secret"),
cause: new TypeError("Unable to connect. Is the computer able to access the url?"),
}),
}),
message:
"Unable to search the web for effect (exa): Request failed: Unable to connect. Is the computer able to access the url?",
},
],
(item, index) =>
Effect.gen(function* () {
fixture.error = item.error
const error = yield* tools
.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: `call-reason-${index}`, name: "websearch", input: { query: "effect" } },
})
.pipe(Effect.flip)
expect(toSessionError(error)).toEqual({ type: "tool.execution", message: item.message })
expect(error.metadata).toEqual({ provider: "exa" })
}),
{ discard: true },
)
yield* fixture.websearch.select(false)
const disabled = yield* tools
.execute({
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-disabled", name: "websearch", input: { query: "effect" } },
})
.pipe(Effect.flip)
expect(toSessionError(disabled)).toEqual({
type: "tool.execution",
message: "Unable to search the web for effect: Web search is disabled",
})
}),
)
})
+28
View File
@@ -86,6 +86,34 @@ describe("WebSearch", () => {
}),
)
it.effect("limits each result's content for every provider", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const providerID = WebSearch.ID.make("large")
const limit = WebSearch.MAX_RESULT_CONTENT_LENGTH
yield* websearch.transform((editor) => {
editor.add({
id: providerID,
name: "Large",
execute: () =>
Effect.succeed([
{ url: "https://large.example.com", content: "b".repeat(limit + 1), time: {} },
{ url: "https://emoji.example.com", content: `${"a".repeat(limit - 1)}😀 after the limit`, time: {} },
{ url: "https://exact.example.com", content: "c".repeat(limit), time: {} },
{ url: "https://empty.example.com", time: {} },
]),
})
})
expect((yield* websearch.query({ query: "large", providerID })).results).toEqual([
{ url: "https://large.example.com", content: `${"b".repeat(limit)}\n[truncated]`, time: {} },
{ url: "https://emoji.example.com", content: `${"a".repeat(limit - 1)}\n[truncated]`, time: {} },
{ url: "https://exact.example.com", content: "c".repeat(limit), time: {} },
{ url: "https://empty.example.com", time: {} },
])
}),
)
it.effect("requires a provider when no default is set", () =>
Effect.gen(function* () {
yield* register("exa")
File diff suppressed because one or more lines are too long
+1 -5
View File
@@ -199,11 +199,7 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
inArray(geoStat.dataset, scope.datasets),
inArray(geoStat.client, scope.clients),
inArray(geoStat.source, scope.sources),
or(
inArray(geoStat.provider, RETIRED_STAT_PROVIDERS),
inArray(geoStat.model, RETIRED_STAT_MODELS),
and(eq(geoStat.provider, "unknown"), eq(geoStat.model, "hy4-preview")),
),
or(inArray(geoStat.provider, RETIRED_STAT_PROVIDERS), inArray(geoStat.model, RETIRED_STAT_MODELS)),
),
),
catch: (cause) => DatabaseError.make({ cause }),
@@ -38,17 +38,6 @@ describe("inference stat normalization", () => {
expect(statProvider("unknown", "", "custom-provider")).toBe("custom-provider")
})
test("attributes hy4 preview traffic to Tencent instead of the unknown provider", () => {
expect(modelAuthor("hy4-preview")).toBe("tencent")
expect(toModelAggregate(aggregate("hy4-preview", "opencode"))).toMatchObject([
{ model: "hy4-preview", provider: "tencent" },
])
expect(toProviderAggregate(aggregate("hy4-preview", "opencode"))).toMatchObject([{ provider: "tencent" }])
expect(toGeoAggregate({ ...aggregate("hy4-preview", "opencode"), country: "US" })).toMatchObject([
{ model: "hy4-preview", provider: "tencent" },
])
})
test("merges union alpha routes without exposing the provider", () => {
;["opencode-go/union-alpha", "opencode/union-alpha"].forEach((model) => {
expect(statModel(model, "")).toBe("union-alpha")
@@ -6,7 +6,6 @@ export const MODEL_AUTHOR_RULES = [
{ match: "gpt", author: "openai" },
{ match: "grok", author: "xai" },
{ match: "hy3", author: "tencent" },
{ match: "hy4", author: "tencent" },
{ match: "kimi", author: "moonshot" },
{ match: "mimo", author: "xiaomi" },
{ match: "minimax", author: "minimax" },
-1
View File
@@ -186,7 +186,6 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
or(
inArray(modelStat.provider, RETIRED_STAT_PROVIDERS),
inArray(modelStat.model, RETIRED_STAT_MODELS),
and(eq(modelStat.provider, "unknown"), eq(modelStat.model, "hy4-preview")),
),
),
),
+6 -18
View File
@@ -120,7 +120,6 @@ import type { SessionEntry } from "./grouping/session"
import { SessionGroupView } from "./group-view"
import { useEntryAnchor } from "./anchor-view"
import { containsAnchor, createTimelineAnchors } from "./anchors"
import { rowsAfter, rowsBefore, rowWeight } from "./mount-budget"
export { InlineToolRow } from "./message-parts"
export { toolDisplay } from "./message-parts"
@@ -130,7 +129,6 @@ addDefaultParsers(parsers.parsers)
const NAVIGATION_SLACK_ID = "session-navigation-slack"
const BACKGROUND_TOOL_HINT_DELAY = 3_000
// Budgets count rendered entries (see mount-budget.ts); a collapsed group costs one.
// The tail comfortably overfills a tall viewport; older rows mount as the reader approaches them.
const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60
@@ -392,17 +390,7 @@ export function Session(props: {
// at the bottom the hidden span follows appends; leaving the bottom pins it to preserve the viewport.
const [hiddenRows, setHiddenRows] = createSignal<number>()
const [visibleRowsEnd, setVisibleRowsEnd] = createSignal<number>()
const weights = createMemo(() =>
rows.map((row) =>
rowWeight(row, {
expanded: (groupID) => sessionTabs.groupExpanded(sessionID, groupID) ?? false,
grouped: (kind) => (kind === "reasoning" ? thinkingMode() === "hide" : groupExploration()),
}),
),
)
const hidden = createMemo(() =>
Math.min(hiddenRows() ?? Infinity, rowsBefore(weights(), rows.length, TRANSCRIPT_TAIL_ROWS)),
)
const hidden = createMemo(() => Math.max(0, Math.min(hiddenRows() ?? Infinity, rows.length - TRANSCRIPT_TAIL_ROWS)))
const visibleEnd = createMemo(() => Math.max(hidden(), Math.min(visibleRowsEnd() ?? rows.length, rows.length)))
const visibleRows = createMemo(() => rows.slice(hidden(), visibleEnd()))
const prependHistory = createHistoryPrepend({
@@ -434,7 +422,7 @@ export function Session(props: {
revealingOlderRows = true
const before = scroll.scrollHeight
scroll.stickyScroll = false
setHiddenRows(rowsBefore(weights(), current, TRANSCRIPT_BACKFILL_CHUNK))
setHiddenRows(Math.max(0, current - TRANSCRIPT_BACKFILL_CHUNK))
afterLayout(() => {
scroll.scrollBy(scroll.scrollHeight - before + scrollBy)
scroll.stickyScroll = !navigationMessage()
@@ -454,7 +442,7 @@ export function Session(props: {
)
return false
revealingNewerRows = true
const next = rowsAfter(weights(), current, TRANSCRIPT_BACKFILL_CHUNK)
const next = Math.min(rows.length, current + TRANSCRIPT_BACKFILL_CHUNK)
setVisibleRowsEnd(next === rows.length ? undefined : next)
afterLayout(() => {
revealingNewerRows = false
@@ -549,8 +537,8 @@ export function Session(props: {
setAwayFromBottom(false)
return
}
setHiddenRows(rowsBefore(weights(), index, TRANSCRIPT_BACKFILL_CHUNK))
const end = rowsAfter(weights(), index, TRANSCRIPT_BACKFILL_CHUNK)
setHiddenRows(Math.max(0, index - TRANSCRIPT_BACKFILL_CHUNK))
const end = Math.min(rows.length, index + TRANSCRIPT_BACKFILL_CHUNK)
setVisibleRowsEnd(end === rows.length ? undefined : end)
scroll.stickyScroll = false
const restore = () =>
@@ -567,7 +555,7 @@ export function Session(props: {
const target = contentY - anchor.screenY
const maximum = Math.max(0, scroll.scrollHeight - scroll.viewport.height)
if (target > maximum && visibleEnd() < rows.length) {
const next = rowsAfter(weights(), visibleEnd(), TRANSCRIPT_BACKFILL_CHUNK)
const next = Math.min(rows.length, visibleEnd() + TRANSCRIPT_BACKFILL_CHUNK)
setVisibleRowsEnd(next === rows.length ? undefined : next)
restore()
return
@@ -1,38 +0,0 @@
import { groupID } from "./anchors"
import type { GroupKind, SessionNode, SessionRow } from "./grouping/session"
/**
* Transcript mounting is budgeted by rendered entries, not rows. A collapsed group
* renders one header however many parts it holds; an expanded or ungrouped one
* renders its children. With every group collapsed each row costs one, which is
* the former row-count budget.
*/
export function rowWeight(
row: SessionRow,
input: { expanded: (groupID: string) => boolean; grouped: (kind: GroupKind) => boolean },
) {
if (row.type !== "group") return 1
const visit = (node: Extract<SessionNode, { type: "group" }>, level: number): number => {
if (!input.grouped(node.kind)) return node.size
const id = groupID(node, level)
if (!id || !input.expanded(id)) return 1
return node.children.reduce((total, child) => total + (child.type === "group" ? visit(child, level + 1) : 1), 1)
}
return visit(row, 0)
}
/** First row index such that rows [index, end) spend at least `budget`. */
export function rowsBefore(weights: readonly number[], end: number, budget: number) {
let index = end
let spent = 0
while (index > 0 && spent < budget) spent += weights[--index]
return index
}
/** End row index such that rows [start, index) spend at least `budget`. */
export function rowsAfter(weights: readonly number[], start: number, budget: number) {
let index = start
let spent = 0
while (index < weights.length && spent < budget) spent += weights[index++]
return index
}
+4
View File
@@ -68,6 +68,10 @@ export function cliErrorMessage(input: unknown): string | undefined {
}
if (tagged(input, "UICancelledError") || named(input, "UICancelledError")) return ""
if (isRecord(input) && named(input, "MCPFailed")) {
const name = isRecord(input.data) ? field(input.data, "name") : undefined
return `MCP server "${name}" failed. Note, opencode does not support MCP authentication yet.`
}
return undefined
}
@@ -1,74 +0,0 @@
import { expect, test } from "bun:test"
import { groupID } from "../../../src/routes/session/anchors"
import type { SessionEntry, SessionRow } from "../../../src/routes/session/grouping/session"
import { groupEntries } from "../../../src/routes/session/grouping/tree"
import { rowsAfter, rowsBefore, rowWeight } from "../../../src/routes/session/mount-budget"
const read = (index: number): SessionEntry => ({
type: "part",
ref: { messageID: `m${index}`, partID: `read-${index}` },
})
function group(size: number, path: readonly ("exploration" | "reasoning")[] = ["exploration"]): SessionRow {
const [node] = groupEntries(
Array.from({ length: size }, (_, index) => read(index)),
() => path,
)
if (node.type !== "group") throw new Error("Expected group")
return node.kind === "reasoning"
? { ...node, kind: "reasoning", completed: true }
: { ...node, kind: "exploration", pending: [], completed: true }
}
const collapsed = { expanded: () => false, grouped: () => true }
test("with every group collapsed each row costs one, matching the former row-count budget", () => {
const rows: SessionRow[] = [
{ type: "message", messageID: "u" },
group(200),
group(3, ["reasoning"]),
read(1),
{ type: "assistant-footer", messageID: "a" },
]
const weights = rows.map((row) => rowWeight(row, collapsed))
expect(weights).toEqual([1, 1, 1, 1, 1])
for (const length of [0, 1, 39, 40, 41, 150]) {
const ones = Array.from({ length }, () => 1)
expect(rowsBefore(ones, length, 40)).toBe(Math.max(0, length - 40))
for (const index of [0, Math.floor(length / 2), length]) {
expect(rowsBefore(ones, index, 60)).toBe(Math.max(0, index - 60))
expect(rowsAfter(ones, index, 60)).toBe(Math.min(length, index + 60))
}
}
})
test("an expanded group costs its header plus rendered children", () => {
const row = group(200)
if (row.type !== "group") throw new Error("Expected group")
const id = groupID(row, 0)
expect(rowWeight(row, { expanded: (key) => key === id, grouped: () => true })).toBe(201)
})
test("a collapsed inner group costs one even when its parent is expanded", () => {
const row = group(50, ["exploration", "exploration"])
if (row.type !== "group" || row.children[0].type !== "group") throw new Error("Expected nested group")
const outer = groupID(row, 0)
const inner = groupID(row.children[0], 1)
expect(rowWeight(row, { expanded: (key) => key === outer, grouped: () => true })).toBe(2)
expect(rowWeight(row, { expanded: (key) => key === outer || key === inner, grouped: () => true })).toBe(52)
// A saved expanded inner group under a collapsed parent mounts nothing.
expect(rowWeight(row, { expanded: (key) => key === inner, grouped: () => true })).toBe(1)
})
test("an ungrouped kind renders every leaf", () => {
expect(rowWeight(group(12, ["reasoning"]), { expanded: () => false, grouped: (kind) => kind !== "reasoning" })).toBe(
12,
)
})
test("a heavy tail group leaves fewer older rows mounted", () => {
const weights = [...Array.from({ length: 100 }, () => 1), 201, 1, 1]
// The expanded group alone exceeds the tail budget.
expect(rowsBefore(weights, weights.length, 40)).toBe(100)
expect(rowsAfter(weights, 90, 60)).toBe(101)
})
@@ -1,135 +0,0 @@
import { expect, test } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { ScrollBoxRenderable, type Renderable } from "@opentui/core"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode/util/global"
import type { SessionMessageInfo } from "@opencode/client"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test("an expanded group spends the mounting budget that older rows used", async () => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const session = {
id: "ses_budget",
title: "Budget",
projectID: "proj_test",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const model = { providerID: "fixture", id: "fixture" }
// 30 plain turns (3 rows each), then one turn whose single step makes 100 reads.
const messages: SessionMessageInfo[] = [
...Array.from({ length: 30 }, (_, index) => [
{ type: "user" as const, id: `user-${index}`, text: `Turn ${index}`, time: { created: index * 10 } },
{
type: "assistant" as const,
id: `answer-${index}`,
agent: "build",
model,
finish: "stop" as const,
time: { created: index * 10 + 1, completed: index * 10 + 2 },
content: [{ type: "text" as const, text: `Answer ${index}` }],
},
]).flat(),
{ type: "user", id: "user-explore", text: "Explore", time: { created: 1000 } },
{
type: "assistant",
id: "answer-explore",
agent: "build",
model,
finish: "stop",
time: { created: 1001, completed: 1002 },
content: Array.from({ length: 100 }, (_, index) => ({
type: "tool" as const,
id: `read-${index}`,
name: "read",
time: { created: 1001, completed: 1001 },
state: {
status: "completed" as const,
input: { path: `${index}.ts` },
content: [{ type: "text" as const, text: "ok" }] as [{ type: "text"; text: string }],
metadata: {},
},
})),
},
]
const calls = createFetch((url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: messages.toReversed(), cursor: {} })
if (url.pathname === `/api/session/${session.id}/inbox` || url.pathname === `/api/session/${session.id}/permission`)
return json({ data: [] })
}, createEventStream())
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({
animations: false,
tabs: { enabled: false },
keybinds: { "session.messages_last_user": "ctrl+shift+u", "session.last": "ctrl+shift+l" },
}),
update: async () => ({}),
},
packages: { prepare: async () => ({ directory: "" }) },
args: { sessionID: session.id },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
const find = (node: Renderable): ScrollBoxRenderable | undefined =>
node instanceof ScrollBoxRenderable && node.getRenderable("user-explore")
? node
: node.getChildren().map(find).find(Boolean)
try {
await setup.waitForFrame((frame) => frame.includes("Explored — 100 reads"))
await setup.waitForVisualIdle({ quietFrames: 3 })
const scroll = find(setup.renderer.root)
if (!scroll) throw new Error("Missing transcript scrollbox")
// Booleans keep a failure message from serializing the renderable graph.
const mounted = (id: string) => scroll.getRenderable(id) !== undefined
// Collapsed: 40 rows, the same as the former row budget. Turn 18 onward is mounted.
expect(mounted("user-18")).toBe(true)
expect(mounted("user-17")).toBe(false)
const header = setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Explored — 100 reads"))
await setup.mockMouse.click(6, header)
await setup.waitForFrame((frame) => frame.includes("Read 99.ts"))
await setup.waitForVisualIdle({ quietFrames: 3 })
// Expanded: the group's 101 rendered entries exceed the 40-entry tail on their own.
expect(mounted("user-29")).toBe(false)
expect(mounted("user-explore")).toBe(false)
// Navigate back to the header (the view stayed pinned to the bottom), collapse, return.
setup.mockInput.pressKey("u", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => frame.includes("Explored — 100 reads"))
await setup.waitForVisualIdle({ quietFrames: 3 })
await setup.mockMouse.click(
6,
setup
.captureCharFrame()
.split("\n")
.findIndex((line) => line.includes("Explored — 100 reads")),
)
await setup.waitForVisualIdle({ quietFrames: 3 })
setup.mockInput.pressKey("l", { ctrl: true, shift: true })
await setup.waitForFrame((frame) => !frame.includes("Read 99.ts") && frame.includes("Explored — 100 reads"))
await setup.waitForVisualIdle({ quietFrames: 3 })
expect(mounted("user-18")).toBe(true)
expect(mounted("user-17")).toBe(false)
} finally {
setup.renderer.destroy()
await task
await server.stop()
}
}, 20000)
@@ -1,32 +1,8 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..a2a7fc2a1b567ee3633392d6c596734d1d00fb90 100644
index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..5eaa7b451c8c7e12e51f61c2a835ff6f7a8ea0ae 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -754,6 +754,7 @@ async function authInternal(provider, { serverUrl, authorizationCode, iss, scope
}, infoCtx);
return "AUTHORIZED";
}
+ const refreshed = await (provider.withRefreshLock ?? ((refresh) => refresh()))(async () => {
let tokens = discardIfIssuerMismatch(await provider.tokens(infoCtx), issuer);
if (tokens && tokens.issuer === void 0) {
tokens = {
@@ -775,11 +776,14 @@ async function authInternal(provider, { serverUrl, authorizationCode, iss, scope
...newTokens,
issuer
}, infoCtx);
- return "AUTHORIZED";
+ return true;
} catch (error) {
if (error instanceof InsecureTokenEndpointError) throw error;
if (!(error instanceof require_src.OAuthError) || error.code === require_src.OAuthErrorCode.ServerError) {} else throw error;
}
+ return false;
+ });
+ if (refreshed) return "AUTHORIZED";
const state = provider.state ? await provider.state() : void 0;
const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, {
metadata,
@@ -977,7 +981,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o
@@ -977,7 +977,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o
else {
const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname);
url = new URL(wellKnownPath, opts?.metadataServerUrl ?? issuer);
@@ -34,7 +10,7 @@ index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..a2a7fc2a1b567ee3633392d6c596734d
}
let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) response = await tryMetadataDiscovery(new URL(`/.well-known/${wellKnownType}`, issuer), protocolVersion, fetchFn);
@@ -1158,7 +1161,6 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
@@ -1158,7 +1157,6 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
if (state) authorizationUrl.searchParams.set("state", state);
if (scope) authorizationUrl.searchParams.set("scope", scope);
@@ -42,69 +18,11 @@ index 635f1c0134274c89e7efbcae2adf3d9ecba575ee..a2a7fc2a1b567ee3633392d6c596734d
if (resource) authorizationUrl.searchParams.set("resource", resource.href);
return {
authorizationUrl,
diff --git a/dist/index.d.cts b/dist/index.d.cts
index 2d74180d5402a97f7460305726d8dc7da6105dcf..97a30ccce75a8135827654375f90d9034f9fb434 100644
--- a/dist/index.d.cts
+++ b/dist/index.d.cts
@@ -335,6 +335,12 @@ interface OAuthClientProvider {
* This avoids requiring the user to intervene manually.
*/
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise<void>;
+ /**
+ * If implemented, wraps reading the stored tokens, refreshing them and saving the result.
+ * Providers whose credentials are shared across processes can hold a lock here, so a
+ * waiting refresher reads the rotated token instead of replaying the one it replaced.
+ */
+ withRefreshLock?<T>(refresh: () => Promise<T>): Promise<T>;
/**
* Prepares grant-specific parameters for a token request.
*
diff --git a/dist/index.d.mts b/dist/index.d.mts
index d54447a08f0aa9876dacfe37f50ec1a48a77c132..e26e4dadb4867b80904ae7011cf0d121645a61ae 100644
--- a/dist/index.d.mts
+++ b/dist/index.d.mts
@@ -335,6 +335,12 @@ interface OAuthClientProvider {
* This avoids requiring the user to intervene manually.
*/
invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void | Promise<void>;
+ /**
+ * If implemented, wraps reading the stored tokens, refreshing them and saving the result.
+ * Providers whose credentials are shared across processes can hold a lock here, so a
+ * waiting refresher reads the rotated token instead of replaying the one it replaced.
+ */
+ withRefreshLock?<T>(refresh: () => Promise<T>): Promise<T>;
/**
* Prepares grant-specific parameters for a token request.
*
diff --git a/dist/index.mjs b/dist/index.mjs
index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..5136555235c5b1a30cff4708d396dccfbf8cee00 100644
index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..73a93a066c3540e131fa0770a74b4a7e8d9343a0 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -751,6 +751,7 @@ async function authInternal(provider, { serverUrl, authorizationCode, iss, scope
}, infoCtx);
return "AUTHORIZED";
}
+ const refreshed = await (provider.withRefreshLock ?? ((refresh) => refresh()))(async () => {
let tokens = discardIfIssuerMismatch(await provider.tokens(infoCtx), issuer);
if (tokens && tokens.issuer === void 0) {
tokens = {
@@ -772,11 +773,14 @@ async function authInternal(provider, { serverUrl, authorizationCode, iss, scope
...newTokens,
issuer
}, infoCtx);
- return "AUTHORIZED";
+ return true;
} catch (error) {
if (error instanceof InsecureTokenEndpointError) throw error;
if (!(error instanceof OAuthError) || error.code === OAuthErrorCode.ServerError) {} else throw error;
}
+ return false;
+ });
+ if (refreshed) return "AUTHORIZED";
const state = provider.state ? await provider.state() : void 0;
const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, {
metadata,
@@ -974,7 +978,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o
@@ -974,7 +974,6 @@ async function discoverMetadataWithFallback(serverUrl, wellKnownType, fetchFn, o
else {
const wellKnownPath = buildWellKnownPath(wellKnownType, issuer.pathname);
url = new URL(wellKnownPath, opts?.metadataServerUrl ?? issuer);
@@ -112,7 +30,7 @@ index f02ce3ca394e826fc27c9848dbe9a214bf6ba7a9..5136555235c5b1a30cff4708d396dccf
}
let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn);
if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) response = await tryMetadataDiscovery(new URL(`/.well-known/${wellKnownType}`, issuer), protocolVersion, fetchFn);
@@ -1155,7 +1158,6 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
@@ -1155,7 +1154,6 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
authorizationUrl.searchParams.set("redirect_uri", String(redirectUrl));
if (state) authorizationUrl.searchParams.set("state", state);
if (scope) authorizationUrl.searchParams.set("scope", scope);
+1 -15
View File
@@ -34,7 +34,6 @@ The current list of models includes:
- **GLM-5.3**
- **GLM-5.2**
- **GLM-5.1**
- **GPT 6 Luna**
- **GPT 5.6 Luna**
- **Kimi K3**
- **Kimi K2.7 Code**
@@ -59,7 +58,6 @@ The current list of models includes:
- **DeepSeek V4 Flash Vision Exp**
- **Hy4 preview**
- **Hy3**
- **Space Bunny Free** (limited time)
The list of models may change as we test and add new ones.
@@ -159,20 +157,15 @@ Token prices are per 1M tokens.
| DeepSeek V4 Flash Vision Exp (Peak) | $0.30 | $1.20 | $0.006 | - | **$15** |
| Hy4 preview | $0.834 | $2.501 | $0.042 | - | **$30** |
| Hy3 | $0.14 | $0.58 | $0.035 | - | **$60** |
| Space Bunny Free | Free | Free | Free | - | **Unlimited**<br /><small>limited time</small> |
| Grok 4.7 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$15** |
| Grok 4.7 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$15** |
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$15** |
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$15** |
| GPT 6 Luna (≤ 272K tokens) | $0.10 | $0.50 | $0.01 | $0.125 | **$15** |
| GPT 6 Luna (> 272K tokens) | $0.20 | $0.75 | $0.02 | $0.25 | **$15** |
| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | **$15** |
| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 | **$15** |
</div>
**Space Bunny Free:** Free for a limited time.
**DeepSeek V4.1 Flash / V4 Pro / V4 Flash Vision Exp:** Peak hours are 01:00-04:00 and 06:00-10:00 UTC, Monday through Friday; all other hours, including weekends, are Off-Peak. [Learn more](https://api-docs.deepseek.com/quick_start/pricing/).
**DeepSeek V4 Flash Vision Exp:** Images are converted into tokens based on their dimensions and billed as input tokens alongside text tokens. [Learn more](https://api-docs.deepseek.com/quick_start/pricing/).
@@ -212,10 +205,8 @@ The table below provides an estimated request count based on typical Go usage pa
| DeepSeek V4 Flash Vision Exp | 6,500 | 16,250 | 32,500 |
| Hy4 preview | 1,350 | 3,380 | 6,770 |
| Hy3 | 4,300 | 10,750 | 21,500 |
| Space Bunny Free | Unlimited | Unlimited | Unlimited |
| Grok 4.7 | 169 | 423 | 845 |
| Grok 4.6 | 169 | 423 | 845 |
| GPT 6 Luna | 4,230 | 10,560 | 21,130 |
| GPT 5.6 Luna | 2,050 | 5,100 | 10,250 |
</div>
@@ -225,7 +216,6 @@ The estimates use the following token counts per request; actual usage varies.
- Grok 4.7/4.6 — 390 input, 32,500 cached, 120 output tokens per request
- GLM-5.3-Flash — 1,000 input, 55,000 cached, 200 output tokens per request
- GLM-5.3/5.2/5.1 — 700 input, 52,000 cached, 150 output tokens per request
- GPT 6 Luna — 1,000 input, 50,000 cached, 220 output tokens per request
- GPT 5.6 Luna — 1,000 input, 50,000 cached, 220 output tokens per request
- Kimi K3 — 1,050 input, 76,500 cached, 300 output tokens per request
- Kimi K2.7/K2.6 — 870 input, 55,000 cached, 200 output tokens per request
@@ -284,7 +274,6 @@ You can also access Go models through the following API endpoints.
| ---------------------------- | ---------------------------- | ------------------------------------------------ | --------------------------- |
| Grok 4.7 | grok-4.7 | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` |
| Grok 4.6 | grok-4.6 | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` |
| GPT 6 Luna | gpt-6-luna | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` |
| GPT 5.6 Luna | gpt-5.6-luna | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` |
| GLM-5.3-Flash | glm-5.3-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| GLM-5.3 | glm-5.3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -314,7 +303,6 @@ You can also access Go models through the following API endpoints.
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
| Hy4 preview | hy4-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Space Bunny Free | space-bunny-free | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
</div>
@@ -342,7 +330,6 @@ curl https://opencode.ai/zen/go/v1/models
| ---------------------------- | -------------- | -------------- |
| Grok 4.7 | Not used | 30 days |
| Grok 4.6 | Not used | 30 days |
| GPT 6 Luna | Not used | 30 days |
| GPT 5.6 Luna | Not used | 30 days |
| GLM-5.3-Flash | Not used | 0 days |
| GLM-5.3 | Not used | 0 days |
@@ -371,10 +358,9 @@ curl https://opencode.ai/zen/go/v1/models
| DeepSeek V4 Flash Vision Exp | Not used | 0 days\* |
| Hy4 preview | Not used | 0 days |
| Hy3 | Not used | 0 days |
| Space Bunny Free | Not used | 0 days |
- **Grok 4.7/4.6:** ZDR disables important API features that depend on stored data, including the stateful Responses API, Files and Collections, and the Batch API. [Learn more](https://docs.x.ai/developers/faq/security#what-is-zero-data-retention-zdr).
- **GPT 6 Luna / GPT 5.6 Luna:** Abuse monitoring logs are generated for all API feature usage and retained for up to 30 days. [Learn more](https://developers.openai.com/api/docs/guides/your-data#data-retention-controls-for-abuse-monitoring).
- **GPT 5.6 Luna:** Abuse monitoring logs are generated for all API feature usage and retained for up to 30 days. [Learn more](https://developers.openai.com/api/docs/guides/your-data#data-retention-controls-for-abuse-monitoring).
- **Muse Spark 1.3 Contributor:** Heavily discounted token pricing in exchange for permission to use your prompts and completions to train future Meta models. Availability is limited to regions permitted by Meta's [Geographic Use Policy](https://ai.developer.meta.com/legal/geographic-use-policy). [Learn more](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier).
- **Muse Spark 1.2 Contributor:** Heavily discounted token pricing in exchange for permission to use your prompts and completions to train future Meta models. Availability is limited to regions permitted by Meta's [Geographic Use Policy](https://ai.developer.meta.com/legal/geographic-use-policy). [Learn more](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier).
- **DeepSeek:** ZDR agreement is renewed monthly. The current agreement is valid through September 30, 2026.
@@ -106,7 +106,6 @@ You can also access the models directly through the following API endpoints.
| Jev 1.13 | jev-1.13 | `https://opencode.ai/zen/v1/systemone` | - |
| Jev 1.13 Free | jev-1.13-free | `https://opencode.ai/zen/v1/systemone` | - |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Space Bunny Free | space-bunny-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.6-Flash Free | mimo-v2.6-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Ling 3.0 Flash Fin Free | ling-3.0-flash-fin-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -195,7 +194,6 @@ Console uses pay-as-you-go pricing. Below are the prices **per 1M tokens**.
| Model | Input | Output | Cached Read | Cached Write |
| --------------------------------- | ------ | ------- | ----------- | ------------ |
| Big Pickle | Free | Free | Free | - |
| Space Bunny Free | Free | Free | Free | - |
| MiMo-V2.6-Flash Free | Free | Free | Free | - |
| MiMo-V2.5 Free | Free | Free | Free | - |
| Ling 3.0 Flash Fin Free | Free | Free | Free | - |
@@ -306,7 +304,6 @@ models to generate session titles.
- Nemotron 3 Ultra Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Nemotron 3.5 Lightning Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Space Bunny Free is a stealth model that's free on OpenCode for a limited time. Its provider follows a zero-retention policy and does not use your data for model training.
- Muse Spark 1.3 Contributor Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Jev 1.13 Free is available on OpenCode for a limited time.
+3 -7
View File
@@ -40,8 +40,8 @@
--surface-hover: #181818;
--link: #4d8eff;
--header-height: 3.875rem;
--frame-width: 96rem;
--content-width: 60rem;
--frame-width: 76rem;
--content-width: 44rem;
--gutter: 1.5rem;
}
@@ -199,16 +199,12 @@ a:hover {
width: min(100%, var(--frame-width));
min-height: calc(100vh - var(--header-height));
margin-inline: auto;
grid-template-columns: 15rem minmax(0, 1fr) 16rem;
grid-template-columns: 15rem minmax(0, var(--content-width)) minmax(12rem, 1fr);
}
.sidebar-column {
position: sticky;
top: var(--header-height);
height: calc(100vh - var(--header-height));
padding: 2.25rem 2rem 3rem var(--gutter);
border-right: 1px solid var(--border);
overflow-y: auto;
}
.docs-sidebar section + section {