mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-19 11:47:02 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c339219c1 | |||
| a7f3ba9a2f |
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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" },
|
||||
|
||||
-81
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-32
File diff suppressed because one or more lines are too long
-65
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-33
File diff suppressed because one or more lines are too long
-141
File diff suppressed because one or more lines are too long
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
})
|
||||
@@ -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" })
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")]
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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: {},
|
||||
}))
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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$/)
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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")),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user