Compare commits

..
9 Commits
126 changed files with 1930 additions and 1758 deletions
+3 -3
View File
@@ -96,11 +96,11 @@ When a provider supports multiple physical transports, selection remains executi
### Media Routes
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.make(...)` (`src/route/media.ts`) composes a `MediaProtocol` kind with `Endpoint` and `Auth` and owns the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; use `MediaProtocol.decodeJson` / `text` / `bytes` so decode failures retain the raw body and HTTP context. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`, `pollHint`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through `MediaInput.rejectUnsupported`.
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.inline(...)` / `queued(...)` / `stream(...)` (`src/route/media.ts`) compose a `MediaProtocol` kind with `Endpoint` and `Auth` and own the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; each protocol declares `const route = MediaProtocol.identity({ id, name, provider })` once and decodes through `route.decodeJson` / `route.text` / `route.decodeStarted` so decode failures retain the raw body and HTTP context, raising `route.unsupported(operation, message)` for requests it cannot lower, and passes `route` as the first argument to `MediaProtocol.inline` / `queued` / `stream`. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through the protocol's `unsupported` list.
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel`, each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel`, each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
`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.
`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 `route.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 `route.decodeFrame` and raise stream-time failures with `route.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`).
+3 -4
View File
@@ -337,12 +337,11 @@ with the realtime work in phase 5. ElevenLabs Scribe is not implemented yet.
```ts
class Generation<Response> {
readonly id: string
readonly route: GenerationRoute<Response> // token-free: { status, result, cancel?: Effect; pollHint? } closed over the decoded token
readonly route: GenerationRoute<Response> // token-free: { status, result, cancel?: Effect } closed over the decoded token
readonly token: unknown // route-owned serializable JSON
readonly status: "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
readonly progress?: number // 0..1, normalized
readonly position?: number
readonly expiresAt?: number
refresh(): Effect<Generation<Response>, AIError>
result(): Effect<Response, AIError>
await(options?: GenerationAwaitOptions): Effect<Response, AIError>
@@ -351,7 +350,7 @@ class Generation<Response> {
}
GenerationAwaitOptions = { poll?: Poll }
Poll = { interval?: Duration; timeout?: Duration; schedule?: Schedule } // route may override from provider hints (`openai-poll-after-ms`)
Poll = { interval?: Duration; timeout?: Duration }
```
`Generation` is not video-specific. Image routes on BFL, fal, and Replicate are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
@@ -427,7 +426,7 @@ New facades follow the existing one-file-per-provider rule. The facade selector
Media does not fit the LLM four-axis route (SSE frames → event state machine) except for streaming TTS/STT. Reuse `Endpoint`, `Auth`, `Framing`, `RequestExecutor`, and add media protocol kinds:
- `MediaProtocol.inline` — `body.from(request)` (JSON, multipart, or query), `response.decode(response)` (JSON, or binary body → `Media.Asset`).
- `MediaProtocol.queued` — `start` (body + decode to `{ token, snapshot }`), `status`, `result`, optional `cancel`, `pollHint`, and a `token` codec. `result` is always a separate GET (against the status document for Veo/xAI/Runway, fal's `response_url` otherwise) so `await` after `start` and after `resume` share one path. `PollContext.auth` hands the auth headers the route sent to the protocol for output URLs that need them (Veo downloads); they become transient `Media.Asset.headers`, never part of `source`. There is no separate `download` step: `Media.Asset.bytes()` downloads through the executor with those headers. `MediaRoute.inline(...)` / `MediaRoute.queued(...)` compose each kind with endpoint and auth; the queued route decodes the token once and hands `Generation` a token-free `{ status, result, cancel? }`.
- `MediaProtocol.queued` — `start` (body + decode to `{ token, snapshot }`), `status`, `result`, optional `cancel`, and a `token` codec. `result` is always a separate GET (against the status document for Veo/xAI/Runway, fal's `response_url` otherwise) so `await` after `start` and after `resume` share one path. `PollContext.auth` hands the auth headers the route sent to the protocol for output URLs that need them (Veo downloads); they become transient `Media.Asset.headers`, never part of `source`. There is no separate `download` step: `Media.Asset.bytes()` downloads through the executor with those headers. `MediaRoute.inline(...)` / `MediaRoute.queued(...)` compose each kind with endpoint and auth; the queued route decodes the token once and hands `Generation` a token-free `{ status, result, cancel? }`.
- `MediaProtocol.stream` — `body.from(request)` over the request plus its `mode`, `frames` (a function that picks the framing for the call: `Framing.sse`, `lines`, `document`, or the raw bytes), fresh per-response `initial()` state, `step` emitting modality events, and `finish(state, context)` — with the observed response for header-only usage — emitting exactly one terminal event or failing as an incomplete stream. The route fills `reason.http` on stream errors. `MediaRoute.stream(...)` exposes `stream` and `generate` (the same stream folded by the modality's `collect`).
`MediaRoute.inline` / `MediaRoute.queued` / `MediaRoute.stream` compose one protocol kind with endpoint/auth and tag the route with its `kind`; `ImageModel`/`VideoModel`/`SpeechModel`/`TranscriptionModel` share the `MediaModel` base (`src/media-model.ts`).
+1 -1
View File
@@ -214,7 +214,7 @@ export function request(input: EvaluationRequest | EvaluationRequestInput) {
return new EvaluationRequest({
...input,
model: input.model as unknown as EvaluationModel,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
http: HttpOptions.make(input.http),
})
}
+2 -17
View File
@@ -11,7 +11,6 @@ export interface Snapshot {
/** Normalized 0..1 when the provider reports progress. */
readonly progress?: number
readonly position?: number
readonly expiresAt?: number
}
/**
@@ -22,15 +21,11 @@ export interface Route<Response> {
readonly status: Effect.Effect<Snapshot, AIError>
readonly result: Effect.Effect<Response, AIError>
readonly cancel?: Effect.Effect<void, AIError>
/** Provider polling hint (e.g. `openai-poll-after-ms`) that overrides the default interval for the next poll. */
readonly pollHint?: (snapshot: Snapshot) => Duration.Duration | undefined
}
export interface Poll {
readonly interval?: Duration.Input
readonly timeout?: Duration.Input
/** Full override of the polling schedule; `interval` and `pollHint` are ignored when supplied. */
readonly schedule?: Schedule.Schedule<unknown, Snapshot>
}
export interface AwaitOptions {
@@ -63,7 +58,6 @@ export class Generation<Response> {
readonly status: Status
readonly progress?: number
readonly position?: number
readonly expiresAt?: number
constructor(
readonly route: Route<Response>,
@@ -75,7 +69,6 @@ export class Generation<Response> {
this.status = snapshot.status
this.progress = snapshot.progress
this.position = snapshot.position
this.expiresAt = snapshot.expiresAt
}
get snapshot(): Snapshot {
@@ -84,7 +77,6 @@ export class Generation<Response> {
status: this.status,
progress: this.progress,
position: this.position,
expiresAt: this.expiresAt,
}
}
@@ -168,15 +160,8 @@ export class Generation<Response> {
)
}
private schedule(poll: Poll | undefined): Schedule.Schedule<unknown, Generation<Response>> {
if (poll?.schedule) return poll.schedule.pipe(Schedule.setInputType<Generation<Response>>())
const interval = poll?.interval ?? DEFAULT_POLL_INTERVAL
const pollHint = this.route.pollHint
const spaced = Schedule.spaced(interval).pipe(Schedule.setInputType<Generation<Response>>())
if (!pollHint) return spaced
return spaced.pipe(
Schedule.modifyDelay((metadata) => Effect.succeed(pollHint(metadata.input.snapshot) ?? interval)),
)
private schedule(poll: Poll | undefined) {
return Schedule.spaced(poll?.interval ?? DEFAULT_POLL_INTERVAL)
}
}
+4 -4
View File
@@ -4,7 +4,7 @@ import { Media } from "./media.js"
import { MediaModel, composeAnyRoute, 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"
import { AIError, HttpOptions, MediaUsage, ProviderMetadata, type OpenString } from "./schema/index.js"
import { ImageClient, Service } from "./image-client.js"
// ---------------------------------------------------------------------------
@@ -45,7 +45,7 @@ export class ImageModel<Options extends ImageOptions = ImageOptions> extends Med
) {
return new ImageModel<Options>({
id: input.id,
provider: route.provider,
provider: route.protocol.provider,
http: input.http,
route: composeAnyRoute(route, input, collectResponse),
})
@@ -97,7 +97,7 @@ export const ImageSize = Schema.declare<ImageSize>(
export type ImageAspectRatio = Media.AspectRatio
export const ImageAspectRatio = Media.AspectRatio
export type ImageFormat = "png" | "jpeg" | "webp" | (string & {})
export type ImageFormat = OpenString<"png" | "jpeg" | "webp">
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
model: ImageModelSchema,
@@ -225,7 +225,7 @@ export function request(input: ImageRequest | ImageRequestInput) {
if (input instanceof ImageRequest) return input
return new ImageRequest({
...input,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
http: HttpOptions.make(input.http),
})
}
+1 -1
View File
@@ -61,7 +61,7 @@ export const request = <const SelectedLanguageModel extends LanguageModel>(
toolChoice: requestToolChoice ? ToolChoice.make(requestToolChoice) : undefined,
generation: requestGeneration === undefined ? undefined : GenerationOptions.make(requestGeneration),
providerOptions: requestProviderOptions,
http: requestHttp === undefined ? undefined : HttpOptions.make(requestHttp),
http: HttpOptions.make(requestHttp),
})
}
-4
View File
@@ -34,8 +34,6 @@ export namespace MediaModel {
/** A protocol plus its canonical start path; `ModelInput.baseURL` overrides `baseURL` per deployment. */
export interface RouteInput<Request extends MediaRoute.MediaRequest, Protocol> {
readonly id: string
readonly provider: string | ProviderID
readonly protocol: Protocol
readonly path: Endpoint.EndpointPart<MediaProtocol.Body, Request>
readonly baseURL?: string
@@ -56,8 +54,6 @@ export const composeRoute = <Request extends MediaRoute.MediaRequest, Protocol,
input: MediaRoute.ModelInput,
): Route =>
compose({
id: route.id,
provider: route.provider,
protocol: route.protocol,
endpoint: Endpoint.path(route.path, { baseURL: input.baseURL ?? route.baseURL }),
auth: input.auth,
@@ -4,14 +4,12 @@ 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 { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { TranscriptionModel, TranscriptionResponse, type TranscriptionRequestFor } from "../transcription.js"
import { ProviderShared, optionalNull } from "./shared.js"
import { MediaInput } from "./utils/media-input.js"
const ADAPTER = "assemblyai-transcription"
const NAME = "AssemblyAI"
const PROVIDER = ProviderID.make("assemblyai")
const route = MediaProtocol.identity({ id: "assemblyai-transcription", name: "AssemblyAI", provider: "assemblyai" })
export const DEFAULT_BASE_URL = "https://api.assemblyai.com"
export const PATH = "/v2/transcript"
export const UPLOAD_PATH = "/v2/upload"
@@ -33,7 +31,7 @@ export type AssemblyAITranscriptionOptions = {
readonly fallback_language?: string
readonly code_switching?: boolean
}
readonly speech_models?: ReadonlyArray<"universal-3-5-pro" | "universal-2" | (string & {})>
readonly speech_models?: ReadonlyArray<OpenString<"universal-3-5-pro" | "universal-2">>
} & Record<string, unknown>
export type Request = TranscriptionRequestFor<AssemblyAITranscriptionOptions>
@@ -90,12 +88,12 @@ const STATUS = {
// 5. Request body construction
// ---------------------------------------------------------------------------
const decodeUpload = MediaProtocol.decodeJson(ADAPTER, NAME, Upload)
const decodeUpload = route.decodeJson(Upload)
/** `/v2/transcript` only takes a URL, so inline audio is uploaded to `/v2/upload` first. */
const prepare = Effect.fn("AssemblyAITranscription.prepare")(function* (request: Request, send: MediaProtocol.Send) {
if (request.audio.source.type !== "bytes" && request.audio.source.type !== "base64") return request
const audio = yield* MediaInput.inlineBytes(ADAPTER, request.audio)
const audio = yield* MediaInput.inlineBytes(route.id, request.audio)
const uploaded = yield* send(UPLOAD_PATH, MediaProtocol.binary(audio, "application/octet-stream")).pipe(
Effect.flatMap(decodeUpload),
)
@@ -103,7 +101,7 @@ const prepare = Effect.fn("AssemblyAITranscription.prepare")(function* (request:
})
const fromRequest = Effect.fn("AssemblyAITranscription.fromRequest")(function* (request: Request) {
const audio = yield* ProviderShared.mediaReference(request.audio, PROVIDER, NAME)
const audio = yield* ProviderShared.mediaReference(request.audio, route.provider, route.name)
return MediaProtocol.json(
mergeJsonRecords(
{
@@ -126,7 +124,7 @@ const fromRequest = Effect.fn("AssemblyAITranscription.fromRequest")(function* (
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeTranscript = MediaProtocol.decodeJson(ADAPTER, NAME, Transcript)
const decodeTranscript = route.decodeJson(Transcript)
const decodeStart = Effect.fn("AssemblyAITranscription.decodeStart")(function* (
response: HttpClientResponse.HttpClientResponse,
@@ -156,9 +154,9 @@ const decodeResult = Effect.fn("AssemblyAITranscription.decodeResult")(function*
const status = yield* MediaProtocol.status(STATUS, transcript.status, output)
const error = transcript.error ?? undefined
if (status === "failed")
return yield* output.ended("failed", `${NAME} transcription failed${error === undefined ? "" : `: ${error}`}`)
return yield* output.ended("failed", `${route.name} transcription failed${error === undefined ? "" : `: ${error}`}`)
if (status !== "completed")
return yield* output.invalid(`${NAME} transcript ${context.token.transcriptID} has not finished`)
return yield* output.invalid(`${route.name} transcript ${context.token.transcriptID} has not finished`)
const duration = transcript.audio_duration ?? undefined
return new TranscriptionResponse({
text: transcript.text ?? "",
@@ -190,9 +188,7 @@ const decodeResult = Effect.fn("AssemblyAITranscription.decodeResult")(function*
const transcriptPath = (token: Token) => `${PATH}/${token.transcriptID}`
export const protocol = MediaProtocol.queued<Request, TranscriptionResponse, Token>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.queued<Request, TranscriptionResponse, Token>(route, {
token: Token,
start: { prepare, body: { from: fromRequest }, decode: decodeStart },
status: { path: transcriptPath, decode: decodeStatus },
@@ -201,7 +197,7 @@ export const protocol = MediaProtocol.queued<Request, TranscriptionResponse, Tok
export const model = (input: MediaRoute.ModelInput) =>
TranscriptionModel.fromRoute<AssemblyAITranscriptionOptions, Token>(
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
{ protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
input,
)
+15 -2
View File
@@ -430,10 +430,23 @@ const lowerSystem = (breakpoints: BedrockCache.Breakpoints, system: ReadonlyArra
return content.length === 0 ? undefined : content
}
// Nova 2 rejects `maxTokens` at high reasoning effort, where its output can exceed the field's maximum. Other models
// that take `reasoningConfig`, such as Grok on Bedrock, accept it.
const isNova2 = (model: LanguageModel) => /\bamazon\.nova-2-/.test(model.id)
const isHighReasoningEffort = Schema.is(
Schema.Struct({
additionalModelRequestFields: Schema.Struct({
reasoningConfig: Schema.Struct({ maxReasoningEffort: Schema.Literal("high") }),
}),
}),
)
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const flattened = ProviderShared.flattenToolRequest(request)
const generation = request.generation
const maxTokens =
isNova2(request.model) && isHighReasoningEffort(request.http?.body) ? undefined : generation?.maxTokens
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints(request.model.id)
@@ -455,14 +468,14 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
}
const inferenceConfig = (() => {
if (
generation?.maxTokens === undefined &&
maxTokens === undefined &&
generation?.temperature === undefined &&
generation?.topP === undefined &&
(generation?.stop === undefined || generation.stop.length === 0)
)
return undefined
return {
maxTokens: generation?.maxTokens,
maxTokens,
temperature: generation?.temperature,
topP: generation?.topP,
stopSequences: generation?.stop,
+16 -33
View File
@@ -5,13 +5,11 @@ 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 { 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")
const route = MediaProtocol.identity({ id: "bfl-images", name: "Black Forest Labs", provider: "black-forest-labs" })
export const DEFAULT_BASE_URL = "https://api.bfl.ai"
// ---------------------------------------------------------------------------
@@ -94,33 +92,26 @@ const capabilities = (model: string): Capabilities => {
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"))
return Effect.fail(route.unsupported("media.n", `${id} 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)"))
return Effect.fail(route.unsupported("media.size", `${id} 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`))
return Effect.fail(route.unsupported("media.aspectRatio", `${id} does not take aspectRatio`))
if (images > model.maxImages)
return Effect.fail(route.unsupported("media.images", `${id} 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.fail(route.unsupported("media.mask", `${id} 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.fail(ProviderShared.invalidRequest(`${route.name} accepts inline images or https URLs`))
return Effect.succeed(value)
}
@@ -153,12 +144,12 @@ const fromRequest = Effect.fn("BlackForestLabsImages.fromRequest")(function* (re
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
const decodeStart = route.decodeStarted(StartResponse, (value) => ({
token: { id: value.id, pollingURL: value.polling_url },
snapshot: { id: value.id, status: "queued" },
}))
const decodeDocument = MediaProtocol.decodeJson(ADAPTER, NAME, Result)
const decodeDocument = route.decodeJson(Result)
const decodeStatus = Effect.fn("BlackForestLabsImages.decodeStatus")(function* (
response: HttpClientResponse.HttpClientResponse,
@@ -175,11 +166,11 @@ const decodeResult = Effect.fn("BlackForestLabsImages.decodeResult")(function* (
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 (isModerated(document.status)) return yield* output.contentPolicy(`${route.name} moderated the generation`)
if (status === "failed" || status === "expired")
return yield* output.ended(status, `${NAME} generation ${context.token.id} ended with ${document.status}`)
return yield* output.ended(status, `${route.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`)
return yield* output.invalid(`${route.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.
@@ -196,9 +187,7 @@ const decodeResult = Effect.fn("BlackForestLabsImages.decodeResult")(function* (
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.queued<Request, ImageResponse, Token>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.queued<Request, ImageResponse, Token>(route, {
token: Token,
start: { body: { from: fromRequest }, decode: decodeStart },
status: { path: (token) => token.pollingURL, decode: decodeStatus },
@@ -207,13 +196,7 @@ export const protocol = MediaProtocol.queued<Request, ImageResponse, Token>({
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}`,
},
{ protocol, baseURL: DEFAULT_BASE_URL, path: ({ request }) => `/v1/${request.model.id}` },
input,
)
+15 -25
View File
@@ -3,14 +3,12 @@ import { classifyProviderFailure } from "../provider-error.js"
import { Framing } from "../route/framing.js"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { AIError, ProviderID, mergeJsonRecords } from "../schema/index.js"
import { AIError, mergeJsonRecords, type OpenString } from "../schema/index.js"
import { SpeechModel, type SpeechEvent, type SpeechRequestFor } from "../speech.js"
import { ProviderShared, optionalNull } from "./shared.js"
import { SpeechStream } from "./utils/speech-stream.js"
const ADAPTER = "cartesia-speech"
const NAME = "Cartesia"
const PROVIDER = ProviderID.make("cartesia")
const route = MediaProtocol.identity({ id: "cartesia-speech", name: "Cartesia", provider: "cartesia" })
export const DEFAULT_BASE_URL = "https://api.cartesia.ai"
export const API_VERSION = "2026-08-14"
export const BYTES_PATH = "/tts/bytes"
@@ -22,8 +20,6 @@ const DEFAULT_BIT_RATE = 128000
// 1. Public model input
// ---------------------------------------------------------------------------
export type CartesiaSpeechString<Known extends string> = Known | (string & {})
export type CartesiaEncoding = SpeechStream.PcmEncoding
export type CartesiaSpeechOptions = {
@@ -32,7 +28,7 @@ export type CartesiaSpeechOptions = {
readonly encoding?: CartesiaEncoding
readonly generation_config?: {
readonly volume?: number
readonly emotion?: CartesiaSpeechString<"neutral" | "calm" | "angry" | "content" | "sad" | "scared">
readonly emotion?: OpenString<"neutral" | "calm" | "angry" | "content" | "sad" | "scared">
}
readonly pronunciation_dict_id?: string
} & Record<string, unknown>
@@ -60,7 +56,7 @@ const SseEvent = Schema.Struct({
error_code: optionalNull(Schema.String),
})
const decodeEvent = MediaProtocol.decodeFrame(ADAPTER, NAME, SseEvent)
const decodeEvent = route.decodeFrame(SseEvent)
// ---------------------------------------------------------------------------
// 4. Parser state
@@ -84,16 +80,14 @@ const outputFormat = Effect.fn("CartesiaSpeech.outputFormat")(function* (request
const format = request.format ?? (sse ? "pcm" : "mp3")
const container = CONTAINERS[format]
if (container === undefined)
return yield* SpeechStream.unsupportedFormat(
PROVIDER,
ADAPTER,
`${NAME} supports the pcm, wav, and mp3 formats, not "${format}"`,
return yield* route.unsupported(
"media.format",
`${route.name} supports the pcm, wav, and mp3 formats, not "${format}"`,
)
if (sse && container !== "raw")
return yield* SpeechStream.unsupportedFormat(
PROVIDER,
ADAPTER,
`${NAME} streams and timestamps only raw PCM; request format "pcm" instead of "${format}"`,
return yield* route.unsupported(
"media.format",
`${route.name} streams and timestamps only raw PCM; request format "pcm" instead of "${format}"`,
)
const sampleRate = request.providerOptions?.sampleRate ?? DEFAULT_SAMPLE_RATE
if (container === "mp3")
@@ -104,7 +98,7 @@ const outputFormat = Effect.fn("CartesiaSpeech.outputFormat")(function* (request
const fromRequest = Effect.fn("CartesiaSpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
const voice = SpeechStream.voiceID(request.voice)
if (voice === undefined)
return yield* ProviderShared.invalidRequest(`${NAME} requires a voice id; pass it as \`voice\``)
return yield* ProviderShared.invalidRequest(`${route.name} requires a voice id; pass it as \`voice\``)
const { sampleRate: _sampleRate, bitRate: _bitRate, encoding: _encoding, ...native } = request.providerOptions ?? {}
return MediaProtocol.json(
mergeJsonRecords(
@@ -138,7 +132,7 @@ const onEvent = Effect.fn("CartesiaSpeech.onEvent")(function* (state: State, fra
if (event.type === "error")
return yield* new AIError({
reason: classifyProviderFailure({
message: `${NAME} stream failed${event.title === undefined ? "" : ` (${event.title})`}: ${event.message ?? "unknown error"}`,
message: `${route.name} stream failed${event.title === undefined ? "" : ` (${event.title})`}: ${event.message ?? "unknown error"}`,
status: event.status_code,
rawBody: frame,
}),
@@ -150,10 +144,10 @@ const finish = Effect.fn("CartesiaSpeech.finish")(function* (
state: State,
context: MediaProtocol.ResponseContext<Request>,
) {
if (usesSse(context.request) && !state.done) return yield* MediaProtocol.incomplete(ADAPTER)
if (usesSse(context.request) && !state.done) return yield* route.incomplete()
const format = yield* outputFormat(context.request)
return yield* SpeechStream.finish(
ADAPTER,
route,
state,
format.container === "raw"
? SpeechStream.pcm(format.encoding, format.sample_rate)
@@ -165,9 +159,7 @@ const finish = Effect.fn("CartesiaSpeech.finish")(function* (
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint8Array, State>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint8Array, State>(route, {
unsupported: ["instructions"],
body: { from: fromRequest },
frames: (bytes, context) => (usesSse(context.request) ? Framing.sse.frame(bytes) : bytes),
@@ -179,8 +171,6 @@ export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint
export const model = (input: MediaRoute.ModelInput) =>
SpeechModel.fromRoute<CartesiaSpeechOptions, string | Uint8Array, State>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
headers: { "Cartesia-Version": API_VERSION },
+11 -18
View File
@@ -1,14 +1,12 @@
import { Effect } from "effect"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
import { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { SpeechModel, type SpeechEvent, type SpeechRequestFor } from "../speech.js"
import { MediaInput } from "./utils/media-input.js"
import { SpeechStream } from "./utils/speech-stream.js"
const ADAPTER = "deepgram-speech"
const NAME = "Deepgram"
const PROVIDER = ProviderID.make("deepgram")
const route = MediaProtocol.identity({ id: "deepgram-speech", name: "Deepgram", provider: "deepgram" })
export const DEFAULT_BASE_URL = "https://api.deepgram.com"
export const PATH = "/v1/speak"
@@ -16,13 +14,11 @@ export const PATH = "/v1/speak"
// 1. Public model input
// ---------------------------------------------------------------------------
export type DeepgramSpeechString<Known extends string> = Known | (string & {})
export type DeepgramEncoding = DeepgramSpeechString<"linear16" | "mulaw" | "alaw" | "mp3" | "opus" | "flac" | "aac">
export type DeepgramEncoding = OpenString<"linear16" | "mulaw" | "alaw" | "mp3" | "opus" | "flac" | "aac">
export type DeepgramSpeechOptions = {
readonly encoding?: DeepgramEncoding
readonly container?: DeepgramSpeechString<"wav" | "ogg" | "none">
readonly container?: OpenString<"wav" | "ogg" | "none">
readonly sampleRate?: number
readonly bitRate?: number
readonly mip_opt_out?: boolean
@@ -60,7 +56,7 @@ const audioFormat = (request: Request) => {
const queryParameters = (request: Request) => {
const { encoding: _encoding, container: _container, sampleRate, bitRate, ...native } = request.providerOptions ?? {}
return MediaInput.query(ADAPTER, {
return MediaInput.query(route.id, {
...native,
model: request.model.id,
...audioFormat(request),
@@ -76,10 +72,9 @@ const fromRequest = Effect.fn("DeepgramSpeech.fromRequest")(function* (request:
FORMATS[request.format] === undefined &&
request.providerOptions?.encoding === undefined
)
return yield* SpeechStream.unsupportedFormat(
PROVIDER,
ADAPTER,
`${NAME} has no encoding for format "${request.format}"; pass providerOptions.encoding`,
return yield* route.unsupported(
"media.format",
`${route.name} has no encoding for format "${request.format}"; pass providerOptions.encoding`,
)
return MediaProtocol.json(
mergeJsonRecords({ text: request.text }, request.http?.body) ?? {},
@@ -104,7 +99,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
const encoding = HEADERLESS_ENCODINGS[format.encoding ?? ""]
const requestID = headers["dg-request-id"]
const modelName = headers["dg-model-name"]
return SpeechStream.finish(ADAPTER, state, {
return SpeechStream.finish(route, state, {
...(format.container === "none" && encoding !== undefined
? SpeechStream.pcm(encoding, SpeechStream.sampleRate(mediaType), mediaType)
: // Deepgram's default encoding is MP3; WAV is a container around any encoding.
@@ -121,9 +116,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, SpeechEvent, Uint8Array, State>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.stream<Request, SpeechEvent, Uint8Array, State>(route, {
unsupported: ["voice", "language", "instructions", "timestamps"],
body: { from: fromRequest },
frames: (bytes) => bytes,
@@ -134,7 +127,7 @@ export const protocol = MediaProtocol.stream<Request, SpeechEvent, Uint8Array, S
export const model = (input: MediaRoute.ModelInput) =>
SpeechModel.fromRoute<DeepgramSpeechOptions, Uint8Array, State>(
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
{ protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
input,
)
@@ -2,14 +2,12 @@ import { Effect, Schema } from "effect"
import type { HttpClientResponse } from "effect/unstable/http"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
import { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { TranscriptionModel, TranscriptionResponse, type TranscriptionRequestFor } from "../transcription.js"
import { ProviderShared } from "./shared.js"
import { MediaInput } from "./utils/media-input.js"
const ADAPTER = "deepgram-transcription"
const NAME = "Deepgram"
const PROVIDER = ProviderID.make("deepgram")
const route = MediaProtocol.identity({ id: "deepgram-transcription", name: "Deepgram", provider: "deepgram" })
export const DEFAULT_BASE_URL = "https://api.deepgram.com"
export const PATH = "/v1/listen"
@@ -24,7 +22,7 @@ export type DeepgramTranscriptionOptions = {
readonly utterances?: boolean
readonly detect_language?: boolean | ReadonlyArray<string>
readonly keyterm?: ReadonlyArray<string>
readonly diarize_model?: "latest" | "v1" | "v2" | (string & {})
readonly diarize_model?: OpenString<"latest" | "v1" | "v2">
readonly filler_words?: boolean
readonly numerals?: boolean
readonly mip_opt_out?: boolean
@@ -79,7 +77,7 @@ const ListenResponse = Schema.Struct({
const query = (request: Request) =>
MediaInput.query(
ADAPTER,
route.id,
mergeJsonRecords(
{
model: request.model.id,
@@ -100,8 +98,10 @@ const fromRequest = Effect.fn("DeepgramTranscription.fromRequest")(function* (re
if (url !== undefined)
return MediaProtocol.json(mergeJsonRecords({ url }, request.http?.body) ?? {}, yield* query(request))
if (request.http?.body !== undefined)
return yield* ProviderShared.invalidRequest(`${NAME} sends inline audio as the raw body, so http.body cannot apply`)
const audio = yield* MediaInput.inlineBytes(ADAPTER, request.audio)
return yield* ProviderShared.invalidRequest(
`${route.name} sends inline audio as the raw body, so http.body cannot apply`,
)
const audio = yield* MediaInput.inlineBytes(route.id, request.audio)
return MediaProtocol.binary(audio, request.audio.mediaType, yield* query(request))
})
@@ -109,7 +109,7 @@ const fromRequest = Effect.fn("DeepgramTranscription.fromRequest")(function* (re
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeListen = MediaProtocol.decodeJson(ADAPTER, NAME, ListenResponse)
const decodeListen = route.decodeJson(ListenResponse)
const speaker = (value: number | undefined) => (value === undefined ? undefined : String(value))
@@ -131,7 +131,7 @@ const decodeResponse = Effect.fn("DeepgramTranscription.decodeResponse")(functio
const output = yield* decodeListen(response)
const channel = output.value.results.channels[0]
const alternative = channel?.alternatives?.[0]
if (alternative === undefined) return yield* output.invalid(`${NAME} returned no transcript`)
if (alternative === undefined) return yield* output.invalid(`${route.name} returned no transcript`)
const duration = output.value.metadata?.duration
const requestID = output.value.metadata?.request_id
return new TranscriptionResponse({
@@ -171,19 +171,14 @@ const decodeResponse = Effect.fn("DeepgramTranscription.decodeResponse")(functio
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.inline<Request, TranscriptionResponse>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.inline<Request, TranscriptionResponse>(route, {
unsupported: ["prompt", "speakers"],
body: { from: fromRequest },
response: { decode: decodeResponse },
})
export const model = (input: MediaRoute.ModelInput) =>
TranscriptionModel.fromRoute<DeepgramTranscriptionOptions>(
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
input,
)
TranscriptionModel.fromRoute<DeepgramTranscriptionOptions>({ protocol, baseURL: DEFAULT_BASE_URL, path: PATH }, input)
export const DeepgramTranscription = {
protocol,
+15 -23
View File
@@ -2,14 +2,12 @@ import { Effect, Schema } from "effect"
import { Framing } from "../route/framing.js"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
import { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { SpeechModel, type SpeechEvent, type SpeechRequestFor } from "../speech.js"
import { ProviderShared, optionalNull } from "./shared.js"
import { SpeechStream } from "./utils/speech-stream.js"
const ADAPTER = "elevenlabs-speech"
const NAME = "ElevenLabs"
const PROVIDER = ProviderID.make("elevenlabs")
const route = MediaProtocol.identity({ id: "elevenlabs-speech", name: "ElevenLabs", provider: "elevenlabs" })
export const DEFAULT_BASE_URL = "https://api.elevenlabs.io"
export const PATH = "/v1/text-to-speech"
@@ -17,9 +15,7 @@ export const PATH = "/v1/text-to-speech"
// 1. Public model input
// ---------------------------------------------------------------------------
export type ElevenLabsSpeechString<Known extends string> = Known | (string & {})
export type ElevenLabsOutputFormat = ElevenLabsSpeechString<
export type ElevenLabsOutputFormat = OpenString<
| "mp3_22050_32"
| "mp3_24000_48"
| "mp3_44100_32"
@@ -59,7 +55,7 @@ export type ElevenLabsSpeechOptions = {
readonly use_speaker_boost?: boolean
}
readonly seed?: number
readonly apply_text_normalization?: ElevenLabsSpeechString<"auto" | "on" | "off">
readonly apply_text_normalization?: OpenString<"auto" | "on" | "off">
} & Record<string, unknown>
export type Request = SpeechRequestFor<ElevenLabsSpeechOptions>
@@ -79,7 +75,7 @@ const TimestampedAudio = Schema.Struct({
alignment: optionalNull(Alignment),
})
const decodeRecord = MediaProtocol.decodeFrame(ADAPTER, NAME, TimestampedAudio)
const decodeRecord = route.decodeFrame(TimestampedAudio)
// ---------------------------------------------------------------------------
// 4. Parser state
@@ -102,23 +98,21 @@ const OUTPUT_FORMATS: Readonly<Record<string, string>> = {
const outputFormat = Effect.fn("ElevenLabsSpeech.outputFormat")(function* (request: MediaProtocol.Addressed<Request>) {
const format = request.providerOptions?.outputFormat ?? OUTPUT_FORMATS[request.format ?? "mp3"]
if (format === undefined)
return yield* SpeechStream.unsupportedFormat(
PROVIDER,
ADAPTER,
`${NAME} has no default output format for "${request.format}"; pass providerOptions.outputFormat`,
return yield* route.unsupported(
"media.format",
`${route.name} has no default output format for "${request.format}"; pass providerOptions.outputFormat`,
)
if (request.mode === "stream" && format.startsWith("wav_"))
return yield* SpeechStream.unsupportedFormat(
PROVIDER,
ADAPTER,
`${NAME} streams mp3, pcm, opus, ulaw, and alaw but not "${format}"; use generate for WAV`,
return yield* route.unsupported(
"media.format",
`${route.name} streams mp3, pcm, opus, ulaw, and alaw but not "${format}"; use generate for WAV`,
)
return format
})
const fromRequest = Effect.fn("ElevenLabsSpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
if (request.voice === undefined)
return yield* ProviderShared.invalidRequest(`${NAME} requires a voice id; pass it as \`voice\``)
return yield* ProviderShared.invalidRequest(`${route.name} requires a voice id; pass it as \`voice\``)
const { outputFormat: _outputFormat, ...native } = request.providerOptions ?? {}
return MediaProtocol.json(
mergeJsonRecords(
@@ -180,7 +174,7 @@ const finish = Effect.fn("ElevenLabsSpeech.finish")(function* (
context: MediaProtocol.ResponseContext<Request>,
) {
const requestID = context.http.headers["request-id"]
return yield* SpeechStream.finish(ADAPTER, state, {
return yield* SpeechStream.finish(route, state, {
...describeOutput(yield* outputFormat(context.request)),
// `character-cost` is billed credits, not a character count (3 for 20 characters on `eleven_flash_v2_5`).
usage: SpeechStream.headerUsage("credits", context.http.headers["character-cost"]),
@@ -192,9 +186,7 @@ const finish = Effect.fn("ElevenLabsSpeech.finish")(function* (
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint8Array, State>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint8Array, State>(route, {
unsupported: ["instructions"],
body: { from: fromRequest },
frames: (bytes, context) => {
@@ -208,7 +200,7 @@ export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint
export const model = (input: MediaRoute.ModelInput) =>
SpeechModel.fromRoute<ElevenLabsSpeechOptions, string | Uint8Array, State>(
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: ({ request }) => path(request) },
{ protocol, baseURL: DEFAULT_BASE_URL, path: ({ request }) => path(request) },
input,
)
+21 -39
View File
@@ -4,28 +4,21 @@ 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 { mergeJsonRecords, type OpenString } 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")
const route = MediaProtocol.identity({ id: "fal-images", name: "fal Images", provider: "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 image_size?: OpenString<
"square_hd" | "square" | "portrait_4_3" | "portrait_16_9" | "landscape_4_3" | "landscape_16_9"
>
readonly enable_safety_checker?: boolean
} & Record<string, unknown>
@@ -61,25 +54,19 @@ const sizing = (model: string) => {
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`))
return Effect.fail(ProviderShared.invalidRequest(`${route.name} accepts either size or aspectRatio, not both`))
if (request.size !== undefined && field === "aspect_ratio")
return Effect.fail(unsupported(id, "size", "sizes by aspectRatio"))
return Effect.fail(route.unsupported("media.size", `${id} sizes by aspectRatio`))
if (request.aspectRatio !== undefined && field === "image_size")
return Effect.fail(unsupported(id, "aspectRatio", "sizes by size (image_size)"))
return Effect.fail(route.unsupported("media.aspectRatio", `${id} 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.fail(
route.unsupported("media.images", `${id} takes one image_url; use an /edit endpoint for several images`),
)
return Effect.void
}
@@ -88,7 +75,7 @@ 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 images = yield* Effect.forEach(request.images ?? [], (image) => FalQueue.mediaUrl(image, route.name))
const edit = isEdit(request.model.id)
return MediaProtocol.json(
mergeJsonRecords(
@@ -101,7 +88,7 @@ const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request: Reque
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),
mask_url: request.mask === undefined ? undefined : yield* FalQueue.mediaUrl(request.mask, route.name),
},
request.providerOptions,
request.http?.body,
@@ -113,7 +100,7 @@ const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request: Reque
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeQueueResult = MediaProtocol.decodeJson(ADAPTER, NAME, QueueResult)
const decodeQueueResult = route.decodeJson(QueueResult)
const decodeResult = Effect.fn("FalImages.decodeResult")(function* (
response: HttpClientResponse.HttpClientResponse,
@@ -121,7 +108,7 @@ const decodeResult = Effect.fn("FalImages.decodeResult")(function* (
) {
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`)
if (images.length === 0) return yield* output.invalid(`${route.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({
@@ -134,7 +121,10 @@ const decodeResult = Effect.fn("FalImages.decodeResult")(function* (
notices:
flagged.length === 0
? undefined
: flagged.map((index) => ({ type: "moderated" as const, message: `${NAME} flagged image ${index} as NSFW` })),
: flagged.map((index) => ({
type: "moderated" as const,
message: `${route.name} flagged image ${index} as NSFW`,
})),
providerMetadata: { fal: { requestId: context.token.requestID, seed: seed ?? undefined, ...rest } },
})
})
@@ -143,22 +133,14 @@ const decodeResult = Effect.fn("FalImages.decodeResult")(function* (
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = FalQueue.protocol<Request, ImageResponse>({
id: ADAPTER,
name: NAME,
export const protocol = FalQueue.protocol<Request, ImageResponse>(route, {
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}`,
},
{ protocol, baseURL: FalQueue.DEFAULT_BASE_URL, path: ({ request }) => `/${request.model.id}` },
input,
)
+13 -27
View File
@@ -3,28 +3,24 @@ import type { HttpClientResponse } from "effect/unstable/http"
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 { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js"
import { ProviderShared, optionalNull } from "./shared.js"
import { optionalNull } from "./shared.js"
import { FalQueue } from "./utils/fal-queue.js"
const ADAPTER = "fal-video"
const NAME = "fal Video"
const PROVIDER = ProviderID.make("fal")
const route = MediaProtocol.identity({ id: "fal-video", name: "fal Video", provider: "fal" })
// ---------------------------------------------------------------------------
// 1. Public model input
// ---------------------------------------------------------------------------
export type FalVideoString<Known extends string> = Known | (string & {})
/**
* Provider-native input. fal video endpoints are model-specific: `duration` is a string enum whose values differ per
* model (`"8s"` for Veo, `"5"` for Kling), and last-frame fields are named per model (`end_image_url`,
* `last_frame_url`, `tail_image_url`), so those pass through here instead of lowering from common fields.
*/
export type FalVideoOptions = {
readonly duration?: FalVideoString<"4s" | "6s" | "8s" | "5" | "10">
readonly duration?: OpenString<"4s" | "6s" | "8s" | "5" | "10">
} & Record<string, unknown>
export type Request = VideoRequestFor<FalVideoOptions>
@@ -52,15 +48,13 @@ const QueueResult = Schema.StructWithRest(
const fromRequest = Effect.fn("FalVideo.fromRequest")(function* (request: Request) {
if (request.frames?.last !== undefined)
return yield* ProviderShared.unsupportedOperation({
operation: "video.frames.last",
provider: PROVIDER,
route: ADAPTER,
message: `${NAME} names the last frame per model; pass it through providerOptions (e.g. end_image_url) instead of frames.last`,
})
return yield* route.unsupported(
"video.frames.last",
`${route.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)
request.frames?.first === undefined ? undefined : yield* FalQueue.mediaUrl(request.frames.first, route.name)
const videoUrl = request.video === undefined ? undefined : yield* FalQueue.mediaUrl(request.video, route.name)
return MediaProtocol.json(
mergeJsonRecords(
{
@@ -83,7 +77,7 @@ const fromRequest = Effect.fn("FalVideo.fromRequest")(function* (request: Reques
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeQueueResult = MediaProtocol.decodeJson(ADAPTER, NAME, QueueResult)
const decodeQueueResult = route.decodeJson(QueueResult)
const decodeResult = Effect.fn("FalVideo.decodeResult")(function* (
response: HttpClientResponse.HttpClientResponse,
@@ -109,9 +103,7 @@ const decodeResult = Effect.fn("FalVideo.decodeResult")(function* (
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = FalQueue.protocol<Request, VideoResponse>({
id: ADAPTER,
name: NAME,
export const protocol = FalQueue.protocol<Request, VideoResponse>(route, {
unsupported: ["n", "durationSeconds", "references"],
from: fromRequest,
decodeResult,
@@ -119,13 +111,7 @@ export const protocol = FalQueue.protocol<Request, VideoResponse>({
export const model = (input: MediaRoute.ModelInput) =>
VideoModel.fromRoute<FalVideoOptions, FalQueue.Token>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: FalQueue.DEFAULT_BASE_URL,
path: ({ request }) => `/${request.model.id}`,
},
{ protocol, baseURL: FalQueue.DEFAULT_BASE_URL, path: ({ request }) => `/${request.model.id}` },
input,
)
+19 -25
View File
@@ -3,26 +3,22 @@ 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 { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { ProviderShared } from "./shared.js"
import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
import { MediaInput } from "./utils/media-input.js"
const ADAPTER = "google-images"
const NAME = "Google Images"
const PROVIDER = ProviderID.make("google")
const route = MediaProtocol.identity({ id: "google-images", name: "Google Images", provider: "google" })
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
// ---------------------------------------------------------------------------
// 1. Public model input
// ---------------------------------------------------------------------------
export type GoogleImageString<Known extends string> = Known | (string & {})
/** Provider-native options. Common fields (`aspectRatio`, `seed`, `images`) live on the request. */
export type GoogleImageOptions = {
readonly imageSize?: GoogleImageString<"1K" | "2K" | "4K">
readonly thinkingLevel?: GoogleImageString<"MINIMAL" | "LOW" | "MEDIUM" | "HIGH">
readonly imageSize?: OpenString<"1K" | "2K" | "4K">
readonly thinkingLevel?: OpenString<"MINIMAL" | "LOW" | "MEDIUM" | "HIGH">
readonly includeThoughts?: boolean
} & Record<string, unknown>
@@ -104,13 +100,13 @@ const generationConfig = (request: Request) => {
const fromRequest = Effect.fn("GoogleImages.fromRequest")(function* (request: Request) {
if (request.n !== undefined && request.n > 1)
return yield* ProviderShared.unsupportedOperation({
operation: "image.n",
provider: PROVIDER,
route: ADAPTER,
message: `${NAME} generates one image per request; call it once per image instead of n=${request.n}`,
})
const parts = yield* Effect.forEach(request.images ?? [], (image) => GeminiGenerateContent.mediaPart(NAME, image))
return yield* route.unsupported(
"image.n",
`${route.name} generates one image per request; call it once per image instead of n=${request.n}`,
)
const parts = yield* Effect.forEach(request.images ?? [], (image) =>
GeminiGenerateContent.mediaPart(route.name, image),
)
return MediaProtocol.json(
mergeJsonRecords(
{
@@ -126,10 +122,12 @@ const fromRequest = Effect.fn("GoogleImages.fromRequest")(function* (request: Re
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeDocument = route.decodeJson(GoogleImageResponse)
const decodeResponse = Effect.fn("GoogleImages.decodeResponse")(function* (
response: HttpClientResponse.HttpClientResponse,
) {
const output = yield* MediaProtocol.decodeJson(ADAPTER, NAME, GoogleImageResponse)(response)
const output = yield* decodeDocument(response)
const decoded = output.value
const candidates = decoded.candidates ?? []
const candidateMetadata = candidates.map((candidate, candidateIndex) => ({
@@ -169,7 +167,7 @@ const decodeResponse = Effect.fn("GoogleImages.decodeResponse")(function* (
const images = yield* Effect.forEach(encoded, (item) =>
MediaInput.decodedAsset(
output.invalid,
`${NAME} candidate ${item.candidateIndex} part ${item.partIndex}`,
`${route.name} candidate ${item.candidateIndex} part ${item.partIndex}`,
item.inlineData.data,
item.inlineData.mimeType,
{
@@ -192,7 +190,7 @@ const decodeResponse = Effect.fn("GoogleImages.decodeResponse")(function* (
candidate.finishReason === undefined ? [] : [candidate.finishReason],
)
return yield* output.invalid(
`${NAME} returned no final images${
`${route.name} returned no final images${
finishReasons.length === 0 ? "" : ` (finish reasons: ${finishReasons.join(", ")})`
}; inspect body for prompt feedback and candidate details`,
)
@@ -204,7 +202,7 @@ const decodeResponse = Effect.fn("GoogleImages.decodeResponse")(function* (
: [
{
type: "filtered" as const,
message: `${NAME} reported prompt feedback`,
message: `${route.name} reported prompt feedback`,
providerMetadata: { google: { promptFeedback: decoded.promptFeedback } },
},
]),
@@ -214,7 +212,7 @@ const decodeResponse = Effect.fn("GoogleImages.decodeResponse")(function* (
: [
{
type: "filtered" as const,
message: `${NAME} candidate ${candidate.index ?? index} finished with ${candidate.finishReason}${
message: `${route.name} candidate ${candidate.index ?? index} finished with ${candidate.finishReason}${
candidate.finishMessage === undefined ? "" : `: ${candidate.finishMessage}`
}`,
providerMetadata: {
@@ -264,9 +262,7 @@ const decodeResponse = Effect.fn("GoogleImages.decodeResponse")(function* (
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.inline<Request, ImageResponse>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.inline<Request, ImageResponse>(route, {
unsupported: ["mask", "size", "format"],
body: { from: fromRequest },
response: { decode: decodeResponse },
@@ -275,8 +271,6 @@ export const protocol = MediaProtocol.inline<Request, ImageResponse>({
export const model = (input: MediaRoute.ModelInput) =>
ImageModel.fromRoute<GoogleImageOptions>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
path: ({ request }) => `/models/${request.model.id}:generateContent`,
+9 -16
View File
@@ -1,14 +1,12 @@
import { Effect, Schema } from "effect"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
import { mergeJsonRecords } from "../schema/index.js"
import { SpeechModel, type SpeechEvent, type SpeechRequestFor } from "../speech.js"
import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
import { SpeechStream } from "./utils/speech-stream.js"
const ADAPTER = "google-speech"
const NAME = "Google Speech"
const PROVIDER = ProviderID.make("google")
const route = MediaProtocol.identity({ id: "google-speech", name: "Google Speech", provider: "google" })
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
const DEFAULT_SAMPLE_RATE = 24000
@@ -43,7 +41,7 @@ const GenerateContentChunk = GeminiGenerateContent.chunk(
}),
)
const decodeChunk = MediaProtocol.decodeFrame(ADAPTER, NAME, GenerateContentChunk)
const decodeChunk = route.decodeFrame(GenerateContentChunk)
// ---------------------------------------------------------------------------
// 4. Parser state
@@ -59,10 +57,9 @@ interface State extends SpeechStream.Audio, GeminiGenerateContent.Metadata {
const fromRequest = Effect.fn("GoogleSpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
if (request.format !== undefined && request.format !== "pcm")
return yield* SpeechStream.unsupportedFormat(
PROVIDER,
ADAPTER,
`${NAME} only returns raw PCM; request format "pcm" or omit it, then wrap the samples yourself`,
return yield* route.unsupported(
"media.format",
`${route.name} only returns raw PCM; request format "pcm" or omit it, then wrap the samples yourself`,
)
const voiceName = SpeechStream.voiceID(request.voice)
return MediaProtocol.json(
@@ -91,7 +88,7 @@ const fromRequest = Effect.fn("GoogleSpeech.fromRequest")(function* (request: Me
const step = Effect.fn("GoogleSpeech.step")(function* (state: State, frame: string) {
const chunk = yield* decodeChunk(frame)
const blocked = GeminiGenerateContent.blocked(NAME, chunk, frame)
const blocked = GeminiGenerateContent.blocked(route.name, chunk, frame)
if (blocked !== undefined) return yield* blocked
const audio = (chunk.candidates?.[0]?.content?.parts ?? []).flatMap((part) =>
part.inlineData === undefined ? [] : [part.inlineData],
@@ -102,7 +99,7 @@ const step = Effect.fn("GoogleSpeech.step")(function* (state: State, frame: stri
const finish = (state: State) => {
const sampleRate = SpeechStream.sampleRate(state.mimeType) ?? DEFAULT_SAMPLE_RATE
return SpeechStream.finish(ADAPTER, state, {
return SpeechStream.finish(route, state, {
...SpeechStream.pcm("pcm_s16le", sampleRate, state.mimeType ?? `audio/L16;codec=pcm;rate=${sampleRate}`),
usage: GeminiGenerateContent.usage(state.usage),
providerMetadata: GeminiGenerateContent.providerMetadata(state),
@@ -114,9 +111,7 @@ const finish = (state: State) => {
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string, State>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string, State>(route, {
unsupported: ["instructions", "speed", "timestamps"],
body: { from: fromRequest },
frames: (bytes, context) => GeminiGenerateContent.frames(bytes, context.request.mode),
@@ -128,8 +123,6 @@ export const protocol = MediaProtocol.stream<Request, SpeechEvent, string, State
export const model = (input: MediaRoute.ModelInput) =>
SpeechModel.fromRoute<GoogleSpeechOptions, string, State>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
// Only `gemini-3.1-flash-tts-preview` and later stream; earlier TTS models reject `streamGenerateContent`.
@@ -1,7 +1,7 @@
import { Effect, Schema, SchemaGetter } from "effect"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
import { mergeJsonRecords, type OpenString } from "../schema/index.js"
import {
TranscriptionFinishEvent,
TranscriptionModel,
@@ -12,12 +12,9 @@ import {
type TranscriptionWord,
type TranscriptionEvent,
} from "../transcription.js"
import { ProviderShared } from "./shared.js"
import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
const ADAPTER = "google-transcription"
const NAME = "Google Transcription"
const PROVIDER = ProviderID.make("google")
const route = MediaProtocol.identity({ id: "google-transcription", name: "Google Transcription", provider: "google" })
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
// ---------------------------------------------------------------------------
@@ -30,7 +27,7 @@ export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1bet
*/
export type GoogleTranscriptionOptions = {
readonly audioTranscriptionConfig?: {
readonly mode?: "VERBATIM" | "SMART" | (string & {})
readonly mode?: OpenString<"VERBATIM" | "SMART">
readonly customVocabulary?: ReadonlyArray<string>
readonly languageCodes?: ReadonlyArray<string>
}
@@ -64,9 +61,7 @@ const AudioTranscription = Schema.Struct({
),
})
const decodeChunk = MediaProtocol.decodeFrame(
ADAPTER,
NAME,
const decodeChunk = route.decodeFrame(
GeminiGenerateContent.chunk(Schema.Struct({ audioTranscription: Schema.optional(AudioTranscription) })),
)
@@ -87,16 +82,14 @@ interface State extends GeminiGenerateContent.Metadata {
const fromRequest = Effect.fn("GoogleTranscription.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
// General Gemini models ignore `audioTranscriptionConfig` and answer the audio conversationally.
if (!request.model.id.includes("transcribe"))
return yield* ProviderShared.unsupportedOperation({
operation: "transcription.model",
provider: PROVIDER,
route: ADAPTER,
message: `${request.model.id} is not a transcription model; use a transcribe model such as gemini-3.5-transcribe`,
})
return yield* route.unsupported(
"transcription.model",
`${request.model.id} is not a transcription model; use a transcribe model such as gemini-3.5-transcribe`,
)
return MediaProtocol.json(
mergeJsonRecords(
{
contents: [{ role: "user", parts: [yield* GeminiGenerateContent.mediaPart(ADAPTER, request.audio)] }],
contents: [{ role: "user", parts: [yield* GeminiGenerateContent.mediaPart(route.id, request.audio)] }],
generationConfig: mergeJsonRecords(
{
audioTranscriptionConfig: {
@@ -147,7 +140,7 @@ const turn = (part: Schema.Schema.Type<typeof AudioTranscription>) => {
const step = Effect.fn("GoogleTranscription.step")(function* (state: State, frame: string) {
const chunk = yield* decodeChunk(frame)
const blocked = GeminiGenerateContent.blocked(NAME, chunk, frame)
const blocked = GeminiGenerateContent.blocked(route.name, chunk, frame)
if (blocked !== undefined) return yield* blocked
const turns = (chunk.candidates?.[0]?.content?.parts ?? []).flatMap((part) =>
part.audioTranscription === undefined ? [] : [turn(part.audioTranscription)],
@@ -169,7 +162,7 @@ const step = Effect.fn("GoogleTranscription.step")(function* (state: State, fram
})
const finish = (state: State) => {
if (state.finishReason === undefined) return Effect.fail(MediaProtocol.incomplete(ADAPTER))
if (state.finishReason === undefined) return Effect.fail(route.incomplete())
return Effect.succeed([
TranscriptionFinishEvent.make({
text: state.text,
@@ -185,9 +178,7 @@ const finish = (state: State) => {
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, TranscriptionEvent, string, State>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.stream<Request, TranscriptionEvent, string, State>(route, {
unsupported: ["prompt", "speakers"],
body: { from: fromRequest },
frames: (bytes, context) => GeminiGenerateContent.frames(bytes, context.request.mode),
@@ -199,8 +190,6 @@ export const protocol = MediaProtocol.stream<Request, TranscriptionEvent, string
export const model = (input: MediaRoute.ModelInput) =>
TranscriptionModel.fromRoute<GoogleTranscriptionOptions, string, State>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
path: ({ request }) => GeminiGenerateContent.path(request.model.id, request.mode),
+21 -33
View File
@@ -4,13 +4,11 @@ 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 { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js"
import { ProviderShared, optionalArray } from "./shared.js"
const ADAPTER = "google-video"
const NAME = "Google Veo"
const PROVIDER = ProviderID.make("google")
const route = MediaProtocol.identity({ id: "google-video", name: "Google Veo", provider: "google" })
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
/** Veo keeps generated files for two days; the asset carries that deadline so callers materialize in time. */
const FILE_RETENTION = Duration.days(2)
@@ -19,11 +17,9 @@ const FILE_RETENTION = Duration.days(2)
// 1. Public model input
// ---------------------------------------------------------------------------
export type GoogleVideoString<Known extends string> = Known | (string & {})
/** Provider-native `parameters`. Common fields (`aspectRatio`, `resolution`, `durationSeconds`, `seed`) live on the request. */
export type GoogleVideoOptions = {
readonly personGeneration?: GoogleVideoString<"allow_all" | "allow_adult" | "dont_allow">
readonly personGeneration?: OpenString<"allow_all" | "allow_adult" | "dont_allow">
} & Record<string, unknown>
export type Request = VideoRequestFor<GoogleVideoOptions>
@@ -70,27 +66,23 @@ const Operation = Schema.Struct({
// Veo takes inline media only; a prior Veo output is `Media.url` with transient auth, so materialize it first.
const inlineMedia = (asset: Media.Asset) =>
ProviderShared.requireInlineMedia(NAME, asset).pipe(
ProviderShared.requireInlineMedia(route.name, asset).pipe(
Effect.map((inline) => ({ inlineData: { mimeType: inline.mime, data: inline.base64 } })),
)
const fromRequest = Effect.fn("GoogleVideo.fromRequest")(function* (request: Request) {
if (request.n !== undefined && request.n > 1)
return yield* ProviderShared.unsupportedOperation({
operation: "video.n",
provider: PROVIDER,
route: ADAPTER,
message: `${NAME} generates one video per request; call it once per video instead of n=${request.n}`,
})
return yield* route.unsupported(
"video.n",
`${route.name} generates one video per request; call it once per video instead of n=${request.n}`,
)
if (request.audio === false)
return yield* ProviderShared.unsupportedOperation({
operation: "video.audio",
provider: PROVIDER,
route: ADAPTER,
message: `${NAME} always generates audio; audio: false cannot be honored`,
})
return yield* route.unsupported(
"video.audio",
`${route.name} always generates audio; audio: false cannot be honored`,
)
if (request.frames?.last !== undefined && request.frames.first === undefined)
return yield* ProviderShared.invalidRequest(`${NAME} requires frames.first when frames.last is set`)
return yield* ProviderShared.invalidRequest(`${route.name} requires frames.first when frames.last is set`)
const image = request.frames?.first === undefined ? undefined : yield* inlineMedia(request.frames.first)
const lastFrame = request.frames?.last === undefined ? undefined : yield* inlineMedia(request.frames.last)
const video = request.video === undefined ? undefined : yield* inlineMedia(request.video)
@@ -129,7 +121,7 @@ const fromRequest = Effect.fn("GoogleVideo.fromRequest")(function* (request: Req
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
const decodeStart = route.decodeStarted(StartResponse, (value) => ({
token: { operation: value.name },
snapshot: { id: value.name, status: "running" },
}))
@@ -140,7 +132,7 @@ const statusOf = (operation: typeof Operation.Type): Status => {
return operation.error === undefined ? "completed" : "failed"
}
const decodeOperation = MediaProtocol.decodeJson(ADAPTER, NAME, Operation)
const decodeOperation = route.decodeJson(Operation)
const decodeStatus = Effect.fn("GoogleVideo.decodeStatus")(function* (
response: HttpClientResponse.HttpClientResponse,
@@ -158,11 +150,11 @@ const decodeResult = Effect.fn("GoogleVideo.decodeResult")(function* (
const operation = output.value
const status = statusOf(operation)
if (status === "running")
return yield* output.invalid(`${NAME} operation ${context.token.operation} has not finished`)
return yield* output.invalid(`${route.name} operation ${context.token.operation} has not finished`)
if (status === "failed")
return yield* output.ended(
"failed",
`${NAME} operation failed${operation.error?.message === undefined ? "" : `: ${operation.error.message}`}`,
`${route.name} operation failed${operation.error?.message === undefined ? "" : `: ${operation.error.message}`}`,
)
const generated = operation.response?.generateVideoResponse
// Downloads require the same API key as the poll; the asset carries it transiently and follows the redirect.
@@ -179,14 +171,14 @@ const decodeResult = Effect.fn("GoogleVideo.decodeResult")(function* (
const reasons = generated?.raiMediaFilteredReasons ?? []
const notices = reasons.map((reason) => ({
type: "filtered" as const,
message: `${NAME} filtered media: ${reason}`,
message: `${route.name} filtered media: ${reason}`,
providerMetadata: { google: { raiMediaFilteredReason: reason } },
}))
if (videos.length === 0 && (reasons.length > 0 || (generated?.raiMediaFilteredCount ?? 0) > 0))
return yield* output.contentPolicy(
`${NAME} filtered every video${reasons.length === 0 ? "" : `: ${reasons.join("; ")}`}`,
`${route.name} filtered every video${reasons.length === 0 ? "" : `: ${reasons.join("; ")}`}`,
)
if (videos.length === 0) return yield* output.invalid(`${NAME} operation completed without any video`)
if (videos.length === 0) return yield* output.invalid(`${route.name} operation completed without any video`)
return new VideoResponse({
videos,
notices: notices.length === 0 ? undefined : notices,
@@ -206,9 +198,7 @@ const decodeResult = Effect.fn("GoogleVideo.decodeResult")(function* (
const operationPath = (token: Token) => `/${token.operation}`
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>(route, {
token: Token,
start: { body: { from: fromRequest }, decode: decodeStart },
status: { path: operationPath, decode: decodeStatus },
@@ -218,8 +208,6 @@ export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>({
export const model = (input: MediaRoute.ModelInput) =>
VideoModel.fromRoute<GoogleVideoOptions, Token>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
path: ({ request }) => `/models/${request.model.id}:predictLongRunning`,
+16 -23
View File
@@ -4,20 +4,17 @@ 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 { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { JsonObject, ProviderShared, optionalNull } from "./shared.js"
import { MediaInput } from "./utils/media-input.js"
const ADAPTER = "meta-images"
const NAME = "Meta Images"
const PROVIDER = ProviderID.make("meta")
const route = MediaProtocol.identity({ id: "meta-images", name: "Meta Images", provider: "meta" })
export const DEFAULT_BASE_URL = "https://api.meta.ai/v1"
// ---------------------------------------------------------------------------
// 1. Public model input
// ---------------------------------------------------------------------------
type OpenString<Known extends string> = Known | (string & {})
/** Provider-native options. Common fields (`n`, `size`, `format`, `images`) live on the request. */
export type ImageOptions = {
readonly responseFormat?: OpenString<"b64_json" | "url">
@@ -72,7 +69,7 @@ const isEdit = (request: Request) => (request.images?.length ?? 0) > 0
// Meta has no file handles: refs are rejected even when they name this provider.
const reference = (asset: Media.Asset) =>
ProviderShared.mediaReference(asset, undefined, NAME).pipe(Effect.map((item) => ({ image_url: item.value })))
ProviderShared.mediaReference(asset, undefined, route.name).pipe(Effect.map((item) => ({ image_url: item.value })))
const fromRequest = Effect.fn("MetaImages.fromRequest")(function* (request: Request) {
const images = yield* Effect.forEach(request.images ?? [], reference)
@@ -101,24 +98,23 @@ const fromRequest = Effect.fn("MetaImages.fromRequest")(function* (request: Requ
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeDocument = route.decodeJson(Response)
const decodeResponse = Effect.fn("MetaImages.decodeResponse")(function* (
response: HttpClientResponse.HttpClientResponse,
context: MediaProtocol.DecodeContext<Request>,
) {
const output = yield* MediaProtocol.decodeJson(ADAPTER, NAME, Response)(response)
const output = yield* decodeDocument(response)
const decoded = output.value
const requested = context.body.type === "json" ? context.body.value.output_format : undefined
const format = decoded.output_format ?? (typeof requested === "string" ? requested : "webp")
const mediaType = `image/${format}`
const images = yield* Effect.forEach(decoded.data, (item, index) => {
if (item.b64_json)
return MediaInput.decodedAsset(output.invalid, `${NAME} result ${index}`, item.b64_json, mediaType, {
info: { format },
})
if (item.url) return Effect.succeed(Media.url(item.url, { mediaType, info: { format } }))
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`)
const images = yield* Effect.forEach(decoded.data, (item, index) =>
MediaInput.imageOutput(output.invalid, `${route.name} result ${index}`, item, mediaType, {
info: { format },
}),
)
if (images.length === 0) return yield* output.invalid(`${route.name} returned no images`)
return new ImageResponse({
images,
usage:
@@ -139,20 +135,17 @@ const decodeResponse = Effect.fn("MetaImages.decodeResponse")(function* (
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.inline<Request, ImageResponse>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.inline<Request, ImageResponse>(route, {
unsupported: ["mask", "aspectRatio", "seed"],
body: { from: fromRequest },
response: { decode: decodeResponse },
})
export const model = (input: MediaRoute.ModelInput & { readonly baseURL: string }) =>
export const model = (input: MediaRoute.ModelInput) =>
ImageModel.fromRoute<ImageOptions>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
path: ({ request }) => `/images/${isEdit(request) ? "edits" : "generations"}`,
},
input,
+6 -2
View File
@@ -6,7 +6,7 @@ import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { MetaImage } from "./utils/meta-image.js"
import { detectMediaType } from "../utils/media-type.js"
const ADAPTER = "meta-responses"
const NAME = "Meta Responses"
@@ -151,7 +151,11 @@ const HOSTED_TOOLS = {
),
),
)
const mime = MetaImage.mediaType(data, item.output_format)
// Responses image items can omit output_format, including when PNG/JPEG was requested.
const mime =
item.output_format === undefined
? (detectMediaType(data) ?? "application/octet-stream")
: `image/${item.output_format}`
return {
type: "content" as const,
value: [{ type: "file" as const, uri: `data:${mime};base64,${item.result}`, mime }],
+10 -3
View File
@@ -13,6 +13,7 @@ import {
UnknownProviderError,
Usage,
type FinishReasonDetails,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ToolCallPart,
@@ -22,6 +23,7 @@ import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { MistralToolID } from "./utils/mistral-tool-id.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "mistral-chat"
@@ -366,9 +368,9 @@ const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request:
return messages
})
const lowerTool = (tool: ToolDefinition): MistralTool => ({
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): MistralTool => ({
type: "function",
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, strict: false },
function: { name: tool.name, description: tool.description, parameters: inputSchema, strict: false },
})
export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (request: LLMRequest) {
@@ -394,7 +396,12 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque
return {
model: request.model.id,
messages: yield* lowerMessages(flattened.request),
tools: flattened.tools.length > 0 ? flattened.tools.map(lowerTool) : undefined,
tools:
flattened.tools.length > 0
? flattened.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)),
)
: undefined,
tool_choice: toolChoice,
stream: true as const,
max_tokens: request.generation?.maxTokens,
+34 -66
View File
@@ -11,13 +11,11 @@ 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 { mergeJsonRecords, type MediaUsage, type OpenString } from "../schema/index.js"
import { ProviderShared } from "./shared.js"
import { MediaInput } from "./utils/media-input.js"
const ADAPTER = "openai-images"
const NAME = "OpenAI Images"
const PROVIDER = ProviderID.make("openai")
const route = MediaProtocol.identity({ id: "openai-images", name: "OpenAI Images", provider: "openai" })
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/images/generations"
export const EDIT_PATH = "/images/edits"
@@ -26,13 +24,11 @@ export const EDIT_PATH = "/images/edits"
// 1. Public model input
// ---------------------------------------------------------------------------
export type OpenAIImageString<Known extends string> = Known | (string & {})
/** Provider-native options. Common fields (`n`, `size`, `format`, `images`, `mask`) live on the request. */
export type OpenAIImageOptions = {
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
readonly moderation?: OpenAIImageString<"auto" | "low">
readonly quality?: OpenString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
readonly background?: OpenString<"auto" | "opaque" | "transparent">
readonly moderation?: OpenString<"auto" | "low">
readonly outputCompression?: number
/** Previews sent before the final image when streaming (default 2); ignored by `Image.generate`. */
readonly partialImages?: number
@@ -83,7 +79,7 @@ const StreamEvent = Schema.Union([
}),
])
const decodeEvent = MediaProtocol.decodeFrame(ADAPTER, NAME, StreamEvent)
const decodeEvent = route.decodeFrame(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. */
@@ -116,21 +112,11 @@ 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`,
}),
route.unsupported("media.stream", `${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}`,
}),
route.unsupported("media.n", `${route.name} streams one image; use Image.generate for n=${request.n}`),
)
return Effect.succeed({ stream: true, partial_images: request.providerOptions?.partialImages ?? 2 })
}
@@ -140,7 +126,7 @@ const isEdit = (request: Request) => (request.images?.length ?? 0) > 0
const isInline = (asset: Media.Asset) => asset.source.type === "bytes" || asset.source.type === "base64"
const reference = (asset: Media.Asset) =>
ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(
ProviderShared.mediaReference(asset, route.provider, route.name).pipe(
Effect.map((item) => (item.type === "ref" ? { file_id: item.value } : { image_url: item.value })),
)
@@ -158,18 +144,17 @@ const fromRequest = Effect.fn("OpenAIImages.fromRequest")(function* (request: Me
// Owned bytes go through multipart edits; remote URLs and file IDs use the JSON edits body instead.
if (images.length > 0 && images.every(isInline) && (mask === undefined || isInline(mask))) {
const form = new FormData()
form.append("model", request.model.id)
form.append("prompt", request.prompt)
Object.entries(fields ?? {}).forEach(([key, value]) => {
if (RESERVED_FORM_FIELDS.has(key)) return
form.append(key, typeof value === "string" ? value : ProviderShared.encodeJson(value))
})
const uploads = yield* Effect.forEach(images, (image) => MediaInput.inlineBytes(ADAPTER, image))
MediaInput.appendFields(
form,
{ model: request.model.id, prompt: request.prompt },
{ overlay: fields, reserved: RESERVED_FORM_FIELDS },
)
const uploads = yield* Effect.forEach(images, (image) => MediaInput.inlineBytes(route.id, image))
uploads.forEach((data, index) =>
form.append("image[]", MediaInput.blob(data, images[index].mediaType), `image-${index}`),
)
if (mask !== undefined)
form.append("mask", MediaInput.blob(yield* MediaInput.inlineBytes(ADAPTER, mask), mask.mediaType), "mask")
form.append("mask", MediaInput.blob(yield* MediaInput.inlineBytes(route.id, mask), mask.mediaType), "mask")
return MediaProtocol.multipart(form)
}
@@ -210,22 +195,18 @@ const usage = (value: Schema.Schema.Type<typeof Usage> | undefined): MediaUsage
}
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 } },
)
MediaInput.decodedAsset((message, cause) => route.frameError(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)
const image = yield* eventImage(frame, `${route.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)
const image = yield* eventImage(frame, `${route.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 })],
@@ -233,25 +214,20 @@ const onEvent = Effect.fn("OpenAIImages.onEvent")(function* (state: State, frame
})
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 invalid = (message: string, cause?: unknown) => route.frameError(message, frame.document, cause)
const decoded = yield* decodeDocument(frame.document).pipe(
Effect.mapError((cause) => invalid(`${NAME} returned an invalid response`, cause)),
Effect.mapError((cause) => invalid(`${route.name} returned an invalid response`, cause)),
)
const format = decoded.output_format ?? frame.requested ?? "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, {
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`))
})
if (images.length === 0) return yield* invalid(`${NAME} returned no images`)
const images = yield* Effect.forEach(decoded.data, (item, index) =>
MediaInput.imageOutput(invalid, `${route.name} result ${index}`, item, mediaType, {
info: { format },
providerMetadata:
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
}),
)
if (images.length === 0) return yield* invalid(`${route.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
})
@@ -259,7 +235,7 @@ const onDocument = Effect.fn("OpenAIImages.onDocument")(function* (frame: Exclud
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))
if (state.completed === 0) return Effect.fail(route.incomplete())
return Effect.succeed([
ImageFinishEvent.make({ usage: state.usage, providerMetadata: { openai: { outputFormat: state.format } } }),
])
@@ -269,9 +245,7 @@ const finish = (state: State) => {
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, ImageEvent, Frame, State>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.stream<Request, ImageEvent, Frame, State>(route, {
unsupported: ["aspectRatio", "seed"],
body: { from: fromRequest },
frames: (bytes, context) =>
@@ -287,13 +261,7 @@ export const protocol = MediaProtocol.stream<Request, ImageEvent, Frame, State>(
export const model = (input: MediaRoute.ModelInput) =>
ImageModel.fromRoute<OpenAIImageOptions, Frame, State>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
path: ({ request }) => (isEdit(request) ? EDIT_PATH : PATH),
},
{ protocol, baseURL: DEFAULT_BASE_URL, path: ({ request }) => (isEdit(request) ? EDIT_PATH : PATH) },
input,
)
+10 -2
View File
@@ -17,7 +17,6 @@ import { resolveEffortUpdates } from "../effort-updates.js"
import { OpenResponses } from "./open-responses.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
@@ -48,7 +47,16 @@ const OpenAIResponsesImageGenerationTool = Schema.Struct({
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
partial_images: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
size: Schema.optional(OpenAIImage.Size),
size: Schema.optional(
Schema.String.check(
Schema.makeFilter((value) => {
if (value === "auto") return undefined
const match = /^(\d+)x(\d+)$/.exec(value)
if (!match) return "image size must be `auto` or `{width}x{height}`"
return Number(match[1]) > 0 && Number(match[2]) > 0 ? undefined : "image dimensions must be positive integers"
}),
),
),
})
const OpenAIResponsesHostedToolItem = Schema.Union([
+7 -11
View File
@@ -2,13 +2,11 @@ import { Effect, Schema } from "effect"
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 { mergeJsonRecords, type MediaUsage } from "../schema/index.js"
import { SpeechModel, type SpeechEvent, type SpeechRequestFor } from "../speech.js"
import { SpeechStream } from "./utils/speech-stream.js"
const ADAPTER = "openai-speech"
const NAME = "OpenAI Speech"
const PROVIDER = ProviderID.make("openai")
const route = MediaProtocol.identity({ id: "openai-speech", name: "OpenAI Speech", provider: "openai" })
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/audio/speech"
/** `pcm` is raw 24 kHz, 16-bit signed little-endian mono samples without a header. */
@@ -44,7 +42,7 @@ const SpeechStreamEvent = Schema.Union([
}),
])
const decodeEvent = MediaProtocol.decodeFrame(ADAPTER, NAME, SpeechStreamEvent)
const decodeEvent = route.decodeFrame(SpeechStreamEvent)
// ---------------------------------------------------------------------------
// 4. Parser state
@@ -110,9 +108,9 @@ const onEvent = Effect.fn("OpenAISpeech.onEvent")(function* (state: State, frame
})
const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) => {
if (isSse(context.body) && !state.done) return Effect.fail(MediaProtocol.incomplete(ADAPTER))
if (isSse(context.body) && !state.done) return Effect.fail(route.incomplete())
const format = context.request.format ?? "mp3"
return SpeechStream.finish(ADAPTER, state, {
return SpeechStream.finish(route, state, {
...(format === "pcm" ? SpeechStream.pcm("pcm_s16le", PCM_SAMPLE_RATE) : SpeechStream.container(format)),
usage: state.usage,
})
@@ -122,9 +120,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint8Array, State>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint8Array, State>(route, {
unsupported: ["language", "timestamps"],
body: { from: fromRequest },
frames: (bytes, context) => (isSse(context.body) ? Framing.sse.frame(bytes) : bytes),
@@ -135,7 +131,7 @@ export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint
export const model = (input: MediaRoute.ModelInput) =>
SpeechModel.fromRoute<OpenAISpeechOptions, string | Uint8Array, State>(
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
{ protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
input,
)
@@ -2,7 +2,7 @@ import { Effect, Schema, Stream } from "effect"
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 { mergeJsonRecords, type MediaUsage } from "../schema/index.js"
import {
TranscriptionFinishEvent,
TranscriptionModel,
@@ -16,9 +16,7 @@ import { mediaTypeExtension } from "../utils/media-type.js"
import { ProviderShared } from "./shared.js"
import { MediaInput } from "./utils/media-input.js"
const ADAPTER = "openai-transcription"
const NAME = "OpenAI Transcription"
const PROVIDER = ProviderID.make("openai")
const route = MediaProtocol.identity({ id: "openai-transcription", name: "OpenAI Transcription", provider: "openai" })
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/audio/transcriptions"
@@ -85,8 +83,8 @@ const Event = Schema.Union([
const Transcript = Schema.Struct(transcriptFields)
type Transcript = Schema.Schema.Type<typeof Transcript>
const decodeEvent = MediaProtocol.decodeFrame(ADAPTER, NAME, Event)
const decodeTranscript = MediaProtocol.decodeFrame(ADAPTER, NAME, Transcript)
const decodeEvent = route.decodeFrame(Event)
const decodeTranscript = route.decodeFrame(Transcript)
type Frame = string | { readonly document: string }
@@ -120,24 +118,21 @@ const capabilities = (model: string): Capabilities => {
return TRANSCRIBE
}
const unsupported = (operation: string, message: string) =>
Effect.fail(ProviderShared.unsupportedOperation({ operation, provider: PROVIDER, route: ADAPTER, message }))
const validate = (request: MediaProtocol.Addressed<Request>, model: Capabilities) => {
const id = request.model.id
if (request.mode === "stream" && !model.stream)
return unsupported("media.stream", `${id} does not stream; use Transcription.generate`)
return Effect.fail(route.unsupported("media.stream", `${id} does not stream; use Transcription.generate`))
if (request.diarize === true && !model.diarize)
return unsupported("media.diarize", `${id} does not diarize; use gpt-4o-transcribe-diarize`)
return Effect.fail(route.unsupported("media.diarize", `${id} does not diarize; use gpt-4o-transcribe-diarize`))
if (request.prompt !== undefined && model.diarize)
return unsupported("media.prompt", `${id} does not accept a prompt`)
return Effect.fail(route.unsupported("media.prompt", `${id} does not accept a prompt`))
if (
request.timestamps === undefined ||
request.timestamps === "none" ||
model.timestamps.includes(request.timestamps)
)
return Effect.void
return unsupported("media.timestamps", `${id} does not return ${request.timestamps} timestamps`)
return Effect.fail(route.unsupported("media.timestamps", `${id} does not return ${request.timestamps} timestamps`))
}
const RESERVED_FORM_FIELDS = new Set([
@@ -150,11 +145,6 @@ const RESERVED_FORM_FIELDS = new Set([
"stream",
])
const appendField = (form: FormData, key: string, value: unknown) => {
if (Array.isArray(value)) return value.forEach((item) => form.append(`${key}[]`, String(item)))
form.append(key, typeof value === "object" && value !== null ? ProviderShared.encodeJson(value) : String(value))
}
const fromRequest = Effect.fn("OpenAITranscription.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
const model = capabilities(request.model.id)
yield* validate(request, model)
@@ -162,18 +152,18 @@ const fromRequest = Effect.fn("OpenAITranscription.fromRequest")(function* (requ
const extension = mediaTypeExtension(request.audio.mediaType)
if (extension === undefined)
return yield* ProviderShared.invalidRequest(
`${NAME} cannot name a ${request.audio.mediaType} upload; send mp3, mp4, m4a, wav, webm, ogg, or flac audio`,
`${route.name} cannot name a ${request.audio.mediaType} upload; send mp3, mp4, m4a, wav, webm, ogg, or flac audio`,
)
const audio = yield* MediaInput.inlineBytes(ADAPTER, request.audio)
const audio = yield* MediaInput.inlineBytes(route.id, request.audio)
const responseFormat = model.diarize
? "diarized_json"
: request.timestamps === undefined || request.timestamps === "none"
? undefined
: "verbose_json"
const native = Object.entries(mergeJsonRecords(request.providerOptions, request.http?.body) ?? {}).filter(
([key]) => !RESERVED_FORM_FIELDS.has(key),
)
const fields = mergeJsonRecords(
const form = new FormData()
form.append("file", MediaInput.blob(audio, request.audio.mediaType), `audio.${extension}`)
MediaInput.appendFields(
form,
{
model: request.model.id,
language: model.languageField === "language" ? request.language : undefined,
@@ -185,11 +175,12 @@ const fromRequest = Effect.fn("OpenAITranscription.fromRequest")(function* (requ
chunking_strategy: model.diarize ? "auto" : undefined,
stream: request.mode === "stream" ? true : undefined,
},
Object.fromEntries(native),
{
overlay: mergeJsonRecords(request.providerOptions, request.http?.body),
reserved: RESERVED_FORM_FIELDS,
repeatArrays: true,
},
)
const form = new FormData()
form.append("file", MediaInput.blob(audio, request.audio.mediaType), `audio.${extension}`)
Object.entries(fields ?? {}).forEach(([key, value]) => appendField(form, key, value))
return MediaProtocol.multipart(form)
})
@@ -233,7 +224,7 @@ const usage = (value: Transcript["usage"]): MediaUsage | undefined => {
const finish = (state: State) => {
const transcript = state.transcript
if (transcript === undefined) return Effect.fail(MediaProtocol.incomplete(ADAPTER))
if (transcript === undefined) return Effect.fail(route.incomplete())
const segments = transcript.segments?.map(segment) ?? state.segments
return Effect.succeed([
TranscriptionFinishEvent.make({
@@ -251,9 +242,7 @@ const finish = (state: State) => {
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.stream<Request, TranscriptionEvent, Frame, State>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.stream<Request, TranscriptionEvent, Frame, State>(route, {
unsupported: ["speakers"],
body: { from: fromRequest },
frames: (bytes, context) =>
@@ -267,7 +256,7 @@ export const protocol = MediaProtocol.stream<Request, TranscriptionEvent, Frame,
export const model = (input: MediaRoute.ModelInput) =>
TranscriptionModel.fromRoute<OpenAITranscriptionOptions, Frame, State>(
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
{ protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
input,
)
+14 -17
View File
@@ -5,12 +5,10 @@ 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 { 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")
const route = MediaProtocol.identity({ id: "replicate-images", name: "Replicate", provider: "replicate" })
export const DEFAULT_BASE_URL = "https://api.replicate.com"
const OUTPUT_RETENTION = Duration.hours(1)
const MAX_DATA_URL_BYTES = 256 * 1024
@@ -71,9 +69,11 @@ const inlineSize = (source: Media.Source) => {
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`),
ProviderShared.invalidRequest(
`${route.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))
return ProviderShared.mediaReference(asset, undefined, route.name).pipe(Effect.map((reference) => reference.value))
}
const inputValue = (value: unknown): Effect.Effect<unknown, AIError> => {
@@ -98,7 +98,7 @@ const fromRequest = Effect.fn("ReplicateImages.fromRequest")(function* (request:
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodePrediction = MediaProtocol.decodeJson(ADAPTER, NAME, Prediction)
const decodePrediction = route.decodeJson(Prediction)
const decodeStart = Effect.fn("ReplicateImages.decodeStart")(function* (
response: HttpClientResponse.HttpClientResponse,
@@ -130,15 +130,16 @@ const decodeResult = Effect.fn("ReplicateImages.decodeResult")(function* (
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}` : ""}`,
`${route.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 (status !== "completed")
return yield* output.invalid(`${route.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}`)
return yield* output.ended("expired", `${route.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`)
return yield* output.invalid(`${route.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`)
if (urls.length === 0) return yield* output.invalid(`${route.name} prediction ${context.token.id} returned no images`)
const predictTime = prediction.metrics?.predict_time ?? undefined
const completedAt = prediction.completed_at ?? undefined
const expiresAt =
@@ -154,9 +155,7 @@ const decodeResult = Effect.fn("ReplicateImages.decodeResult")(function* (
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.queued<Request, ImageResponse, Token>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.queued<Request, ImageResponse, Token>(route, {
token: Token,
unsupported: ["images", "mask", "n", "size", "aspectRatio", "seed", "format"],
start: { body: { from: fromRequest }, decode: decodeStart },
@@ -168,8 +167,6 @@ export const protocol = MediaProtocol.queued<Request, ImageResponse, Token>({
export const model = (input: MediaRoute.ModelInput) =>
ImageModel.fromRoute<ReplicateImageOptions, Token>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
path: ({ request }) =>
+13 -20
View File
@@ -4,13 +4,11 @@ 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 { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js"
import { ProviderShared, optionalArray, optionalNull } from "./shared.js"
const ADAPTER = "runway-video"
const NAME = "Runway"
const PROVIDER = ProviderID.make("runway")
const route = MediaProtocol.identity({ id: "runway-video", name: "Runway", provider: "runway" })
export const DEFAULT_BASE_URL = "https://api.dev.runwayml.com/v1"
/** Every Runway request must pin the API version. */
export const API_VERSION = "2024-11-06"
@@ -25,16 +23,14 @@ const OUTPUT_RETENTION = Duration.hours(24)
// 1. Public model input
// ---------------------------------------------------------------------------
export type RunwayVideoString<Known extends string> = Known | (string & {})
/**
* Provider-native options. Common fields lower to Runway's names: `aspectRatio` → `ratio` (Runway expects pixel
* ratios such as `1280:720` for most models), `durationSeconds` → `duration`, `audio`, `negativePrompt`,
* `resolution`, `references`, and `frames` → `promptImage`.
*/
export type RunwayVideoOptions = {
readonly contentModeration?: { readonly publicFigureThreshold?: RunwayVideoString<"auto" | "low"> }
readonly outputFormat?: RunwayVideoString<"mp4" | "prores" | "png_sequence">
readonly contentModeration?: { readonly publicFigureThreshold?: OpenString<"auto" | "low"> }
readonly outputFormat?: OpenString<"mp4" | "prores" | "png_sequence">
} & Record<string, unknown>
export type Request = VideoRequestFor<RunwayVideoOptions>
@@ -75,7 +71,7 @@ const STATUS = {
// Runway accepts HTTPS URLs, `runway://` upload URIs, and data URIs, all as one string.
const mediaUri = (asset: Media.Asset) =>
ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(Effect.map((reference) => reference.value))
ProviderShared.mediaReference(asset, route.provider, route.name).pipe(Effect.map((reference) => reference.value))
const fromRequest = Effect.fn("RunwayVideo.fromRequest")(function* (request: Request) {
const first = request.frames?.first === undefined ? undefined : yield* mediaUri(request.frames.first)
@@ -113,12 +109,12 @@ const fromRequest = Effect.fn("RunwayVideo.fromRequest")(function* (request: Req
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
const decodeStart = route.decodeStarted(StartResponse, (value) => ({
token: { taskID: value.id },
snapshot: { id: value.id, status: "queued" },
}))
const decodeTask = MediaProtocol.decodeJson(ADAPTER, NAME, Task)
const decodeTask = route.decodeJson(Task)
const decodeStatus = Effect.fn("RunwayVideo.decodeStatus")(function* (
response: HttpClientResponse.HttpClientResponse,
@@ -138,16 +134,17 @@ const decodeResult = Effect.fn("RunwayVideo.decodeResult")(function* (
const status = yield* MediaProtocol.status(STATUS, task.status, output)
if (status === "failed") {
const code = task.failureCode ?? undefined
const message = `${NAME} task failed${code === undefined ? "" : ` (${code})`}${task.failure ? `: ${task.failure}` : ""}`
const message = `${route.name} task failed${code === undefined ? "" : ` (${code})`}${task.failure ? `: ${task.failure}` : ""}`
// Runway failure codes are dotted paths; every moderation outcome carries a SAFETY segment.
if (code !== undefined && /(^|\.)SAFETY(\.|$)/.test(code)) return yield* output.contentPolicy(message)
return yield* output.ended("failed", message)
}
if (status === "cancelled")
return yield* output.ended("cancelled", `${NAME} task ${context.token.taskID} was cancelled`)
if (status !== "completed") return yield* output.invalid(`${NAME} task ${context.token.taskID} has not finished`)
return yield* output.ended("cancelled", `${route.name} task ${context.token.taskID} was cancelled`)
if (status !== "completed")
return yield* output.invalid(`${route.name} task ${context.token.taskID} has not finished`)
const urls = task.output ?? []
if (urls.length === 0) return yield* output.invalid(`${NAME} task succeeded without any output`)
if (urls.length === 0) return yield* output.invalid(`${route.name} task succeeded without any output`)
return new VideoResponse({
videos: yield* Effect.forEach(urls, (url) =>
MediaProtocol.expiringUrl(url, OUTPUT_RETENTION, { mediaType: "video/mp4" }),
@@ -168,9 +165,7 @@ const decodeResult = Effect.fn("RunwayVideo.decodeResult")(function* (
const taskPath = (token: Token) => `${TASKS_PATH}/${token.taskID}`
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>(route, {
token: Token,
unsupported: ["n"],
start: { body: { from: fromRequest }, decode: decodeStart },
@@ -188,8 +183,6 @@ const startPath = (request: Request) => {
export const model = (input: MediaRoute.ModelInput) =>
VideoModel.fromRoute<RunwayVideoOptions, Token>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
headers: { "X-Runway-Version": API_VERSION },
+5 -57
View File
@@ -1,6 +1,5 @@
import { Tool } from "@opencode/schema/tool"
import { Effect, Option, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Effect, Option, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Media } from "../media.js"
import {
@@ -13,17 +12,16 @@ import {
ToolDefinition,
type ContentPart,
type MediaPart,
type OpenString,
type ProviderID,
type TextPart,
type ToolEntry,
type ToolResultPart,
} from "../schema/index.js"
import { Json, decodeJson, encodeJson } from "../utils/json.js"
import { isRecord } from "../utils/record.js"
export { isRecord }
export { Json, decodeJson, encodeJson, isRecord }
export const Json = Schema.fromJsonString(Schema.Unknown)
export const decodeJson = Schema.decodeUnknownSync(Json)
export const encodeJson = Schema.encodeSync(Json)
const isJson = Schema.is(Schema.Json)
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
@@ -35,7 +33,7 @@ export const lenient = <const S extends Schema.Top>(schema: S) =>
)
/** Provider-defined string enum: known values for autocomplete, any string accepted at runtime. */
export const knownString = <Known extends string>() =>
Schema.declare<Known | (string & {})>((value): value is Known | (string & {}) => typeof value === "string", {
Schema.declare<OpenString<Known>>((value): value is OpenString<Known> => typeof value === "string", {
expected: "string",
})
@@ -229,8 +227,6 @@ export const toolFileMedia = (item: Tool.FileContent): MediaPart => {
return Message.media(asset, { filename: item.name })
}
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
export const toolResultText = (part: ToolResultPart) => {
if (part.result.type === "text") return String(part.result.value)
if (part.result.type === "error") {
@@ -252,54 +248,6 @@ export const errorText = (error: unknown) => {
return "Unknown stream error"
}
/**
* `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
* interrupting the stream. Decoder failures become provider output errors so
* the public error channel stays `AIError`.
*/
export const sseFraming = (
bytes: Stream.Stream<Uint8Array, AIError>,
events?: ReadonlySet<string>,
includeDone = false,
): Stream.Stream<string, AIError> =>
bytes.pipe(
Stream.decodeText(),
Stream.mapAccumEffect(
() => {
const output: Sse.Event[] = []
return {
output,
parser: Sse.makeParser((event) => {
if (event._tag === "Event") output.push(event)
}),
}
},
(state, chunk) =>
Effect.gen(function* () {
const error = state.parser.feed(chunk)
if (error) return yield* eventError("sse", error.message, chunk, error)
return [state, state.output.splice(0)] as const
}),
),
Stream.filter(
(event) =>
(events === undefined || events.has(event.event)) &&
event.data.length > 0 &&
// Some OpenAI-compatible proxies serialize an empty flush as a bare
// `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),
)
/**
* Canonical invalid-request constructor shared by protocol lowering.
*/
+27 -42
View File
@@ -3,14 +3,12 @@ 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 { mergeJsonRecords, type OpenString } 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")
const route = MediaProtocol.identity({ id: "stability-images", name: "Stability AI", provider: "stability" })
const upscaleRoute = MediaProtocol.identity({ id: "stability-upscale", name: "Stability AI", provider: "stability" })
export const DEFAULT_BASE_URL = "https://api.stability.ai"
const RESULTS_PATH = "/v2beta/results"
const UPSCALE_MODEL = "creative"
@@ -21,7 +19,7 @@ const HEADERS = { accept: "application/json" }
// 1. Public model input
// ---------------------------------------------------------------------------
export type StabilityStylePreset =
export type StabilityStylePreset = OpenString<
| "enhance"
| "anime"
| "photographic"
@@ -39,7 +37,7 @@ export type StabilityStylePreset =
| "3d-model"
| "pixel-art"
| "tile-texture"
| (string & {})
>
export type StabilityImageOptions = {
readonly negative_prompt?: string
@@ -84,36 +82,31 @@ 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,
identity: MediaProtocol.Identity,
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`)
if ((source?.length ?? 0) > 1)
return yield* identity.unsupported("media.images", `${identity.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)),
)
MediaInput.appendFields(body, fields, { overlay: native, reserved: RESERVED_FORM_FIELDS })
const image = source?.[0]
if (image !== undefined)
body.append("image", MediaInput.blob(yield* MediaInput.inlineBytes(route, image), image.mediaType), "image")
body.append("image", MediaInput.blob(yield* MediaInput.inlineBytes(identity.id, 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`)
return yield* route.unsupported("media.n", `${route.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* route.unsupported("media.images", `${route.name} core is text-to-image only; use ultra or sd3.5-*`)
return yield* form(
ADAPTER,
route,
{
prompt: request.prompt,
aspect_ratio: request.aspectRatio,
@@ -129,9 +122,9 @@ const fromRequest = Effect.fn("StabilityImages.fromRequest")(function* (request:
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* ProviderShared.invalidRequest(`${upscaleRoute.name} upscale requires the source image in images`)
return yield* form(
UPSCALE_ADAPTER,
upscaleRoute,
{ prompt: request.prompt, seed: request.seed, output_format: request.format },
mergeJsonRecords(request.providerOptions, request.http?.body),
request.images,
@@ -142,29 +135,29 @@ const fromUpscaleRequest = Effect.fn("StabilityImages.fromUpscaleRequest")(funct
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeImageDocument = (route: string) => {
const decode = MediaProtocol.decodeJson(route, NAME, ImageDocument)
const decodeImageDocument = (identity: MediaProtocol.Identity) => {
const decode = identity.decodeJson(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)
if (data === undefined) return yield* output.invalid(`${identity.name} returned no image`)
const image = yield* MediaInput.decodedAsset(output.invalid, `${identity.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` }]
? [{ type: "moderated", message: `${identity.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 decodeResponse = decodeImageDocument(route)
const decodeUpscaleImage = decodeImageDocument(upscaleRoute)
const decodeStart = MediaProtocol.decodeStarted(UPSCALE_ADAPTER, NAME, Started, (value) => ({
const decodeStart = upscaleRoute.decodeStarted(Started, (value) => ({
token: { id: value.id },
snapshot: { id: value.id, status: "queued" },
}))
@@ -181,8 +174,8 @@ const decodeUpscaleResult = Effect.fn("StabilityImages.decodeUpscaleResult")(fun
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`)
const output = yield* upscaleRoute.text(response)
return yield* output.invalid(`${upscaleRoute.name} upscale ${context.token.id} has not finished`)
}
return yield* decodeUpscaleImage(response)
})
@@ -191,17 +184,13 @@ const decodeUpscaleResult = Effect.fn("StabilityImages.decodeUpscaleResult")(fun
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.inline<Request, ImageResponse>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.inline<Request, ImageResponse>(route, {
unsupported: ["size", "mask"],
body: { from: fromRequest },
response: { decode: decodeResponse },
})
export const upscaleProtocol = MediaProtocol.queued<UpscaleRequest, ImageResponse, Token>({
id: UPSCALE_ADAPTER,
name: NAME,
export const upscaleProtocol = MediaProtocol.queued<UpscaleRequest, ImageResponse, Token>(upscaleRoute, {
token: Token,
unsupported: ["n", "size", "aspectRatio", "mask"],
start: { body: { from: fromUpscaleRequest }, decode: decodeStart },
@@ -212,8 +201,6 @@ export const upscaleProtocol = MediaProtocol.queued<UpscaleRequest, ImageRespons
export const model = (input: MediaRoute.ModelInput) =>
ImageModel.fromRoute<StabilityImageOptions>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
headers: HEADERS,
@@ -225,8 +212,6 @@ export const model = (input: MediaRoute.ModelInput) =>
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,
+14 -15
View File
@@ -41,25 +41,24 @@ const STATUS = {
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,
export const protocol = <Request, Response>(
route: MediaProtocol.Identity,
input: {
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 = route.decodeJson(QueueStatus)
return MediaProtocol.queued<Request, Response, Token>(route, {
token: Token,
unsupported: input.unsupported,
start: {
body: { from: input.from },
decode: MediaProtocol.decodeStarted(input.id, input.name, StartResponse, (value) => ({
decode: route.decodeStarted(StartResponse, (value) => ({
token: {
requestID: value.request_id,
statusURL: value.status_url,
+36 -1
View File
@@ -1,7 +1,8 @@
import { Effect, Encoding } from "effect"
import { Media } from "../../media.js"
import type { MediaProtocol } from "../../route/media-protocol.js"
import type { AIError, ProviderID } from "../../schema/index.js"
import { mergeJsonRecords, type AIError, type ProviderID } from "../../schema/index.js"
import { encodeJson } from "../../utils/json.js"
import { ProviderShared } from "../shared.js"
/** Owned bytes for multipart uploads; decodes `base64` sources and rejects remote sources. */
@@ -56,4 +57,38 @@ export const decodedAsset = (
Effect.map((bytes) => Media.bytes(bytes, mediaType, options)),
)
/** One image of an OpenAI-shaped `data` array, which carries either `b64_json` or a `url`. */
export const imageOutput = (
invalid: (message: string, cause?: unknown) => AIError,
label: string,
item: { readonly b64_json?: string | null; readonly url?: string | null },
mediaType: string | undefined,
options?: Media.AssetOptions,
) => {
if (item.b64_json) return decodedAsset(invalid, label, item.b64_json, mediaType, options)
if (item.url) return Effect.succeed(Media.url(item.url, { ...options, mediaType }))
return Effect.fail(invalid(`${label} has neither image data nor a URL`))
}
/**
* Append multipart text fields: strings as-is, other values as JSON, or arrays as repeated `key[]` parts with
* `repeatArrays`. `overlay` keys in `reserved` are dropped so `http.body` cannot replace route-owned fields.
*/
export const appendFields = (
form: FormData,
fields: Record<string, unknown>,
options: {
readonly overlay?: Record<string, unknown>
readonly reserved: ReadonlySet<string>
readonly repeatArrays?: true
},
) => {
const overlay = Object.entries(options.overlay ?? {}).filter(([key]) => !options.reserved.has(key))
Object.entries(mergeJsonRecords(fields, Object.fromEntries(overlay)) ?? {}).forEach(([key, value]) => {
if (Array.isArray(value) && options.repeatArrays)
return value.forEach((item) => form.append(`${key}[]`, String(item)))
form.append(key, typeof value === "string" ? value : encodeJson(value))
})
}
export * as MediaInput from "./media-input.js"
@@ -1,11 +0,0 @@
// Responses image items can omit output_format, including when PNG/JPEG was requested.
export const mediaType = (data: Uint8Array, format?: string) => {
if (format !== undefined) return `image/${format}`
if (data[0] === 137 && data[1] === 80 && data[2] === 78 && data[3] === 71) return "image/png"
if (data[0] === 255 && data[1] === 216 && data[2] === 255) return "image/jpeg"
if (new TextDecoder().decode(data.slice(0, 4)) === "RIFF" && new TextDecoder().decode(data.slice(8, 12)) === "WEBP")
return "image/webp"
return "application/octet-stream"
}
export * as MetaImage from "./meta-image.js"
@@ -1,20 +0,0 @@
import { Schema } from "effect"
const dimensions = (value: string) => {
const match = /^(\d+)x(\d+)$/.exec(value)
if (!match) return undefined
return { width: Number(match[1]), height: Number(match[2]) }
}
export const Size = Schema.String.check(
Schema.makeFilter((value) => {
if (value === "auto") return undefined
const parsed = dimensions(value)
if (!parsed) return "image size must be `auto` or `{width}x{height}`"
return parsed.width > 0 && parsed.height > 0 ? undefined : "image dimensions must be positive integers"
}),
)
export const OpenAIImage = {
Size,
} as const
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { Media } from "../../media.js"
import { MediaProtocol } from "../../route/media-protocol.js"
import type { AIError, MediaUsage, ProviderID, ProviderMetadata } from "../../schema/index.js"
import type { MediaProtocol } from "../../route/media-protocol.js"
import type { AIError, MediaUsage, ProviderMetadata } from "../../schema/index.js"
import {
SpeechAudioDeltaEvent,
SpeechFinishEvent,
@@ -10,7 +10,6 @@ import {
type SpeechVoice,
} from "../../speech.js"
import { concatBytes } from "../../utils/bytes.js"
import { ProviderShared } from "../shared.js"
export interface Audio {
/** Appended in place: the route creates fresh state for each response through `initial`. */
@@ -78,12 +77,9 @@ export const sampleRate = (mediaType: string | undefined) => {
return rate === undefined ? undefined : Number(rate)
}
export const unsupportedFormat = (provider: ProviderID, route: string, message: string) =>
ProviderShared.unsupportedOperation({ operation: "media.format", provider, route, message })
/** A declared `mediaType` wins over sniffing: headerless PCM can start with bytes that look like an MPEG frame sync. */
export const finish = (
route: string,
route: MediaProtocol.Identity,
state: Audio,
output: {
readonly mediaType: string | undefined
@@ -95,10 +91,7 @@ export const finish = (
): Effect.Effect<ReadonlyArray<SpeechEvent>, AIError> => {
if (state.chunks.length === 0)
return Effect.fail(
MediaProtocol.frameError(
route,
`The provider returned no audio${output.detail === undefined ? "" : ` (${output.detail})`}`,
),
route.frameError(`The provider returned no audio${output.detail === undefined ? "" : ` (${output.detail})`}`),
)
return Effect.succeed([
SpeechFinishEvent.make({
+21 -6
View File
@@ -53,22 +53,37 @@ const MODEL_NAMES = [
[/kimi/i, "moonshot"],
] as const
// An explicit `sanitizer` wins, and `none` opts out. Otherwise the protocol's own default
// applies (the Gemini API always uses Gemini's rules), then the model name selects the family's rules
// so models reached through gateways and OpenAI-compatible endpoints get the same handling.
// Tool arguments are always a JSON object, and most providers reject a tool schema whose root does not
// declare `type: "object"`, such as `{}` or a bare `properties` map. Effect encodes an empty struct as
// `anyOf` object or array; every object matches its bare object branch, so that `anyOf` is dropped.
const objectRoot = (schema: JsonSchema): JsonSchema => {
if (schema.type !== undefined) return schema
if (
Array.isArray(schema.anyOf) &&
schema.anyOf.some((branch) => isRecord(branch) && branch.type === "object" && Object.keys(branch).length === 1)
)
return { type: "object", ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")) }
return { type: "object", ...schema }
}
// Every tool schema gets an object root. Then an explicit `sanitizer` wins, and `none` opts out.
// Otherwise the protocol's own default applies (the Gemini API always uses Gemini's rules), then the
// model name selects the family's rules so models reached through gateways and OpenAI-compatible
// endpoints get the same handling.
const modelCompatibility = (
schema: JsonSchema,
model: LanguageModel,
protocolDefault?: LanguageModelSanitizerCompatibility,
): JsonSchema => {
const root = objectRoot(schema)
switch (model.compatibility?.sanitizer ?? protocolDefault ?? MODEL_NAMES.find(([name]) => name.test(model.id))?.[1]) {
case "gemini":
return gemini(schema)
return gemini(root)
case "moonshot":
return moonshot(schema)
return moonshot(root)
case "none":
case undefined:
return schema
return root
}
}
+20 -41
View File
@@ -4,13 +4,11 @@ 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 { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { ProviderShared, optionalNull } from "./shared.js"
import { MediaInput } from "./utils/media-input.js"
const ADAPTER = "xai-images"
const NAME = "xAI Images"
const PROVIDER = ProviderID.make("xai")
const route = MediaProtocol.identity({ id: "xai-images", name: "xAI Images", provider: "xai" })
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
export const PATH = "/images/generations"
export const EDIT_PATH = "/images/edits"
@@ -19,13 +17,11 @@ export const EDIT_PATH = "/images/edits"
// 1. Public model input
// ---------------------------------------------------------------------------
export type XAIImageString<Known extends string> = Known | (string & {})
/** Provider-native options. Common fields (`n`, `aspectRatio`, `images`) live on the request. */
export type XAIImageOptions = {
readonly resolution?: XAIImageString<"1k" | "2k">
readonly responseFormat?: XAIImageString<"url" | "b64_json">
readonly response_format?: XAIImageString<"url" | "b64_json">
readonly resolution?: OpenString<"1k" | "2k">
readonly responseFormat?: OpenString<"url" | "b64_json">
readonly response_format?: OpenString<"url" | "b64_json">
} & Record<string, unknown>
export type Request = ImageRequestFor<XAIImageOptions>
@@ -59,7 +55,7 @@ const nativeOptions = (options: XAIImageOptions | undefined) => {
const isEdit = (request: Request) => (request.images?.length ?? 0) > 0
const reference = (asset: Media.Asset) =>
ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(
ProviderShared.mediaReference(asset, route.provider, route.name).pipe(
Effect.map((item) =>
item.type === "ref" ? { file_id: item.value } : { url: item.value, type: "image_url" as const },
),
@@ -88,31 +84,22 @@ const fromRequest = Effect.fn("XAIImages.fromRequest")(function* (request: Reque
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeDocument = route.decodeJson(XAIImageResponse)
const decodeResponse = Effect.fn("XAIImages.decodeResponse")(function* (
response: HttpClientResponse.HttpClientResponse,
) {
const output = yield* MediaProtocol.decodeJson(ADAPTER, NAME, XAIImageResponse)(response)
const output = yield* decodeDocument(response)
const decoded = output.value
const images = yield* Effect.forEach(decoded.data, (item, index) => {
const providerMetadata =
item.revised_prompt === undefined || item.revised_prompt === null
? undefined
: { xai: { revisedPrompt: item.revised_prompt } }
if (item.b64_json)
return MediaInput.decodedAsset(
output.invalid,
`${NAME} result ${index}`,
item.b64_json,
item.mime_type ?? undefined,
{
providerMetadata,
},
)
if (item.url)
return Effect.succeed(Media.url(item.url, { mediaType: item.mime_type ?? undefined, providerMetadata }))
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`)
const images = yield* Effect.forEach(decoded.data, (item, index) =>
MediaInput.imageOutput(output.invalid, `${route.name} result ${index}`, item, item.mime_type ?? undefined, {
providerMetadata:
item.revised_prompt === undefined || item.revised_prompt === null
? undefined
: { xai: { revisedPrompt: item.revised_prompt } },
}),
)
if (images.length === 0) return yield* output.invalid(`${route.name} returned no images`)
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
// xAI reports image counts rather than tokens, seconds, or credits; the raw record stays in provider metadata.
return new ImageResponse({
@@ -125,9 +112,7 @@ const decodeResponse = Effect.fn("XAIImages.decodeResponse")(function* (
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.inline<Request, ImageResponse>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.inline<Request, ImageResponse>(route, {
unsupported: ["mask", "size", "seed", "format"],
body: { from: fromRequest },
response: { decode: decodeResponse },
@@ -135,13 +120,7 @@ export const protocol = MediaProtocol.inline<Request, ImageResponse>({
export const model = (input: MediaRoute.ModelInput) =>
ImageModel.fromRoute<XAIImageOptions>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
path: ({ request }) => (isEdit(request) ? EDIT_PATH : PATH),
},
{ protocol, baseURL: DEFAULT_BASE_URL, path: ({ request }) => (isEdit(request) ? EDIT_PATH : PATH) },
input,
)
+13 -18
View File
@@ -4,13 +4,11 @@ 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 { mergeJsonRecords } from "../schema/index.js"
import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js"
import { ProviderShared, optionalNull } from "./shared.js"
const ADAPTER = "xai-video"
const NAME = "xAI Video"
const PROVIDER = ProviderID.make("xai")
const route = MediaProtocol.identity({ id: "xai-video", name: "xAI Video", provider: "xai" })
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
export const PATH = "/videos/generations"
export const EDIT_PATH = "/videos/edits"
@@ -72,7 +70,7 @@ const STATUS = {
// ---------------------------------------------------------------------------
const mediaInput = (asset: Media.Asset) =>
ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(
ProviderShared.mediaReference(asset, route.provider, route.name).pipe(
Effect.map((reference) => (reference.type === "ref" ? { file_id: reference.value } : { url: reference.value })),
)
@@ -111,7 +109,7 @@ const fromRequest = Effect.fn("XAIVideo.fromRequest")(function* (request: Reques
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
const decodeStart = route.decodeStarted(StartResponse, (value) => ({
token: { requestID: value.request_id },
snapshot: { id: value.request_id, status: "running" },
}))
@@ -120,7 +118,7 @@ const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (v
const fraction = (progress: number | null | undefined) =>
progress !== undefined && progress !== null && progress >= 0 && progress <= 100 ? progress / 100 : undefined
const decodeVideoStatus = MediaProtocol.decodeJson(ADAPTER, NAME, VideoStatus)
const decodeVideoStatus = route.decodeJson(VideoStatus)
const decodeStatus = Effect.fn("XAIVideo.decodeStatus")(function* (
response: HttpClientResponse.HttpClientResponse,
@@ -138,26 +136,27 @@ const decodeResult = Effect.fn("XAIVideo.decodeResult")(function* (
const output = yield* decodeVideoStatus(response)
const decoded = output.value
const status = yield* MediaProtocol.status(STATUS, decoded.status, output)
if (status === "running") return yield* output.invalid(`${NAME} request ${context.token.requestID} has not finished`)
if (status === "running")
return yield* output.invalid(`${route.name} request ${context.token.requestID} has not finished`)
if (status === "failed") {
const code = decoded.error?.code ?? undefined
const message = decoded.error?.message ?? undefined
return yield* output.ended(
"failed",
`${NAME} generation failed${code === undefined ? "" : ` (${code})`}${message === undefined ? "" : `: ${message}`}`,
`${route.name} generation failed${code === undefined ? "" : ` (${code})`}${message === undefined ? "" : `: ${message}`}`,
)
}
if (status !== "completed")
return yield* output.ended("expired", `${NAME} request ${context.token.requestID} expired`)
return yield* output.ended("expired", `${route.name} request ${context.token.requestID} expired`)
// `respect_moderation: false` marks a filtered result; a URL may still be present, so report it as a notice.
const notices =
decoded.video?.respect_moderation === false
? [{ type: "moderated" as const, message: `${NAME} flagged the generated video for moderation` }]
? [{ type: "moderated" as const, message: `${route.name} flagged the generated video for moderation` }]
: undefined
const url = decoded.video?.url ?? undefined
if (url === undefined && notices !== undefined)
return yield* output.contentPolicy(`${NAME} withheld the video for moderation`)
if (url === undefined) return yield* output.invalid(`${NAME} completed without a video URL`)
return yield* output.contentPolicy(`${route.name} withheld the video for moderation`)
if (url === undefined) return yield* output.invalid(`${route.name} completed without a video URL`)
const duration = decoded.video?.duration ?? undefined
return new VideoResponse({
videos: [
@@ -177,9 +176,7 @@ const decodeResult = Effect.fn("XAIVideo.decodeResult")(function* (
const statusPath = (token: Token) => `${STATUS_PATH}/${token.requestID}`
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>(route, {
token: Token,
unsupported: ["n", "seed", "negativePrompt"],
start: { body: { from: fromRequest }, decode: decodeStart },
@@ -196,8 +193,6 @@ const startPath = (request: Request) => {
export const model = (input: MediaRoute.ModelInput) =>
VideoModel.fromRoute<XAIVideoOptions, Token>(
{
id: ADAPTER,
provider: PROVIDER,
protocol,
baseURL: DEFAULT_BASE_URL,
path: ({ request }) => startPath(request),
+10 -17
View File
@@ -4,11 +4,9 @@ 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 { mergeJsonRecords, type OpenString } from "../schema/index.js"
const ADAPTER = "zai-images"
const NAME = "Z.ai Images"
const PROVIDER = ProviderID.make("zai")
const route = MediaProtocol.identity({ id: "zai-images", name: "Z.ai Images", provider: "zai" })
export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4"
export const PATH = "/images/generations"
@@ -16,11 +14,9 @@ export const PATH = "/images/generations"
// 1. Public model input
// ---------------------------------------------------------------------------
export type ZAIImageString<Known extends string> = Known | (string & {})
/** Provider-native options. The common `size` field lives on the request. */
export type ZAIImageOptions = {
readonly quality?: ZAIImageString<"hd" | "standard">
readonly quality?: OpenString<"hd" | "standard">
readonly userID?: string
} & Record<string, unknown>
@@ -69,12 +65,14 @@ const fromRequest = Effect.fn("ZAIImages.fromRequest")(function* (request: Reque
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeDocument = route.decodeJson(ZAIImageResponse)
const decodeResponse = Effect.fn("ZAIImages.decodeResponse")(function* (
response: HttpClientResponse.HttpClientResponse,
) {
const output = yield* MediaProtocol.decodeJson(ADAPTER, NAME, ZAIImageResponse)(response)
const output = yield* decodeDocument(response)
const decoded = output.value
if (decoded.data.length === 0) return yield* output.invalid(`${NAME} returned no images`)
if (decoded.data.length === 0) return yield* output.invalid(`${route.name} returned no images`)
const filters = decoded.content_filter ?? []
return new ImageResponse({
// Z.ai returns only URLs and no content type; the media type resolves when the asset is materialized.
@@ -85,7 +83,7 @@ const decodeResponse = Effect.fn("ZAIImages.decodeResponse")(function* (
? undefined
: filters.map((filter) => ({
type: "moderated" as const,
message: `${NAME} applied a content filter${filter.role === undefined ? "" : ` for ${filter.role}`}${
message: `${route.name} applied a content filter${filter.role === undefined ? "" : ` for ${filter.role}`}${
filter.level === undefined ? "" : ` at level ${filter.level}`
}`,
providerMetadata: { zai: filter },
@@ -105,19 +103,14 @@ const decodeResponse = Effect.fn("ZAIImages.decodeResponse")(function* (
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.inline<Request, ImageResponse>({
id: ADAPTER,
name: NAME,
export const protocol = MediaProtocol.inline<Request, ImageResponse>(route, {
unsupported: ["images", "mask", "n", "aspectRatio", "seed", "format"],
body: { from: fromRequest },
response: { decode: decodeResponse },
})
export const model = (input: MediaRoute.ModelInput) =>
ImageModel.fromRoute<ZAIImageOptions>(
{ id: ADAPTER, provider: PROVIDER, protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
input,
)
ImageModel.fromRoute<ZAIImageOptions>({ protocol, baseURL: DEFAULT_BASE_URL, path: PATH }, input)
export const ZAIImages = {
protocol,
+5 -11
View File
@@ -1,12 +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 { AssemblyAITranscription, DEFAULT_BASE_URL } from "../protocols/assemblyai-transcription.js"
import { MediaRoute } from "../route/media.js"
import { type HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { AssemblyAITranscription } from "../protocols/assemblyai-transcription.js"
export type { AssemblyAITranscriptionOptions } from "../protocols/assemblyai-transcription.js"
export const id = ProviderID.make("assemblyai")
const baseURL = DEFAULT_BASE_URL
export type Config = ProviderAuthOption<"optional"> & {
/** `https://api.eu.assemblyai.com` for the EU region. */
@@ -24,14 +24,8 @@ const auth = (options: ProviderAuthOption<"optional">) => {
}
export const configure = (input: Config = {}) => {
const transcription = (modelID: string | ModelID) =>
AssemblyAITranscription.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
const media = MediaRoute.deployment(input, auth(input))
const transcription = (modelID: string | ModelID) => AssemblyAITranscription.model({ ...media, id: modelID })
return {
id,
transcription,
+2 -2
View File
@@ -1,11 +1,11 @@
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { ProviderShared } from "../protocols/shared.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("azure")
@@ -108,7 +108,7 @@ const configuredRoute = <Body, Prepared, Compact extends CompactionOperations |
})
function endpoint(input: Config, modelID: string | ModelID) {
const baseURL = ProviderShared.trimBaseUrl(input.baseURL ?? resourceBaseURL(input.resourceName!))
const baseURL = Endpoint.trimBaseUrl(input.baseURL ?? resourceBaseURL(input.resourceName!))
const query = { "api-version": input.apiVersion ?? "v1", ...input.queryParams }
if (input.useDeploymentBasedUrls) return { baseURL: `${baseURL}/deployments/${modelID}`, query }
+5 -11
View File
@@ -1,12 +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 { BlackForestLabsImages, DEFAULT_BASE_URL } from "../protocols/bfl-images.js"
import { MediaRoute } from "../route/media.js"
import { type HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { BlackForestLabsImages } 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. */
@@ -23,14 +23,8 @@ const auth = (options: ProviderAuthOption<"optional">) => {
}
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),
})
const media = MediaRoute.deployment(input, auth(input))
const image = (modelID: string | ModelID) => BlackForestLabsImages.model({ ...media, id: modelID })
return {
id,
image,
+5 -11
View File
@@ -1,11 +1,11 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { CartesiaSpeech, DEFAULT_BASE_URL } from "../protocols/cartesia-speech.js"
import { MediaRoute } from "../route/media.js"
import { type HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { CartesiaSpeech } from "../protocols/cartesia-speech.js"
export type { CartesiaEncoding, CartesiaSpeechOptions } from "../protocols/cartesia-speech.js"
export const id = ProviderID.make("cartesia")
const baseURL = DEFAULT_BASE_URL
export type Config = ProviderAuthOption<"optional"> & {
readonly baseURL?: string
@@ -16,14 +16,8 @@ export type Config = ProviderAuthOption<"optional"> & {
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "CARTESIA_API_KEY")
export const configure = (input: Config = {}) => {
const speech = (modelID: string | ModelID) =>
CartesiaSpeech.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
const media = MediaRoute.deployment(input, auth(input))
const speech = (modelID: string | ModelID) => CartesiaSpeech.model({ ...media, id: modelID })
return {
id,
speech,
+6 -12
View File
@@ -1,14 +1,14 @@
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { DEFAULT_BASE_URL, DeepgramSpeech } from "../protocols/deepgram-speech.js"
import { MediaRoute } from "../route/media.js"
import { type HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { DeepgramSpeech } from "../protocols/deepgram-speech.js"
import { DeepgramTranscription } from "../protocols/deepgram-transcription.js"
export type { DeepgramEncoding, DeepgramSpeechOptions } from "../protocols/deepgram-speech.js"
export type { DeepgramTranscriptionOptions } from "../protocols/deepgram-transcription.js"
export const id = ProviderID.make("deepgram")
const baseURL = DEFAULT_BASE_URL
export type Config = ProviderAuthOption<"optional"> & {
readonly baseURL?: string
@@ -24,17 +24,11 @@ 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 media = MediaRoute.deployment(input, auth(input))
return {
id,
speech: (modelID: string | ModelID) => DeepgramSpeech.model(media(modelID)),
transcription: (modelID: string | ModelID) => DeepgramTranscription.model(media(modelID)),
speech: (modelID: string | ModelID) => DeepgramSpeech.model({ ...media, id: modelID }),
transcription: (modelID: string | ModelID) => DeepgramTranscription.model({ ...media, id: modelID }),
configure,
}
}
+5 -11
View File
@@ -1,12 +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 { DEFAULT_BASE_URL, ElevenLabsSpeech } from "../protocols/elevenlabs-speech.js"
import { MediaRoute } from "../route/media.js"
import { type HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { ElevenLabsSpeech } from "../protocols/elevenlabs-speech.js"
export type { ElevenLabsOutputFormat, ElevenLabsSpeechOptions } from "../protocols/elevenlabs-speech.js"
export const id = ProviderID.make("elevenlabs")
const baseURL = DEFAULT_BASE_URL
export type Config = ProviderAuthOption<"optional"> & {
readonly baseURL?: string
@@ -22,14 +22,8 @@ const auth = (options: ProviderAuthOption<"optional">) => {
}
export const configure = (input: Config = {}) => {
const speech = (modelID: string | ModelID) =>
ElevenLabsSpeech.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
const media = MediaRoute.deployment(input, auth(input))
const speech = (modelID: string | ModelID) => ElevenLabsSpeech.model({ ...media, id: modelID })
return {
id,
speech,
+5 -12
View File
@@ -1,15 +1,14 @@
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { MediaRoute } from "../route/media.js"
import { type 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"
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
export type Config = ProviderAuthOption<"optional"> & {
readonly baseURL?: string
@@ -26,17 +25,11 @@ 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 media = MediaRoute.deployment(input, auth(input))
return {
id,
image: (modelID: string | ModelID) => FalImages.model(media(modelID)),
video: (modelID: string | ModelID) => FalVideo.model(media(modelID)),
image: (modelID: string | ModelID) => FalImages.model({ ...media, id: modelID }),
video: (modelID: string | ModelID) => FalVideo.model({ ...media, id: modelID }),
configure,
}
}
+7 -12
View File
@@ -1,8 +1,9 @@
import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import { MediaRoute } from "../route/media.js"
import type { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { Gemini } from "../protocols/gemini.js"
import { GoogleImages } from "../protocols/google-images.js"
import { GoogleSpeech } from "../protocols/google-speech.js"
@@ -46,20 +47,14 @@ const configuredRoute = (input: Config) => {
export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
const media = (modelID: string | ModelID) => ({
id: modelID,
auth: auth(input),
baseURL: input.baseURL,
headers: input.headers,
http: mergeHttpOptions(input.http === undefined ? undefined : HttpOptions.make(input.http)),
})
const media = MediaRoute.deployment(input, auth(input))
return {
id,
model: (modelID: string | ModelID) => route.model<Gemini.ProviderOptionsInput>({ id: modelID }),
image: (modelID: string | ModelID) => GoogleImages.model(media(modelID)),
video: (modelID: string | ModelID) => GoogleVideo.model(media(modelID)),
speech: (modelID: string | ModelID) => GoogleSpeech.model(media(modelID)),
transcription: (modelID: string | ModelID) => GoogleTranscription.model(media(modelID)),
image: (modelID: string | ModelID) => GoogleImages.model({ ...media, id: modelID }),
video: (modelID: string | ModelID) => GoogleVideo.model({ ...media, id: modelID }),
speech: (modelID: string | ModelID) => GoogleSpeech.model({ ...media, id: modelID }),
transcription: (modelID: string | ModelID) => GoogleTranscription.model({ ...media, id: modelID }),
configure,
}
}
+6 -11
View File
@@ -7,7 +7,8 @@ import { MetaImages } from "../protocols/meta-images.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, ToolDefinition, type ModelID, type OpenString } from "../schema/index.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
export const id = ProviderID.make("meta")
@@ -48,8 +49,8 @@ export const webSearch = (options: WebSearchOptions = {}) =>
export interface ImageGenerationOptions {
readonly size?: string
readonly outputFormat?: "webp" | "png" | "jpeg" | (string & {})
readonly reasoningStrength?: "low" | "high" | (string & {})
readonly outputFormat?: OpenString<"webp" | "png" | "jpeg">
readonly reasoningStrength?: OpenString<"low" | "high">
readonly enableImageSearch?: boolean
readonly enableWebSearch?: boolean
readonly enableShell?: boolean
@@ -139,14 +140,8 @@ export const configure = (input: LanguageModelOptions = {}) => {
id: modelID,
compatibility: { requireSignature: false },
})
const image = (modelID: string | ModelID) =>
MetaImages.model({
id: modelID,
baseURL: endpoint ?? baseURL,
auth: options.auth,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
const media = MediaRoute.deployment(input, options.auth)
const image = (modelID: string | ModelID) => MetaImages.model({ ...media, id: modelID })
return { id, model: responses, responses, chat, messages, image, configure }
}
+24 -18
View File
@@ -1,11 +1,19 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import { MediaRoute } from "../route/media.js"
import type { ProviderPackage } from "../provider-package.js"
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
import {
HttpOptions,
ProviderID,
ToolDefinition,
mergeHttpOptions,
type ModelID,
type OpenString,
} from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images.js"
import { OpenAIImages } from "../protocols/openai-images.js"
import { OpenAISpeech } from "../protocols/openai-speech.js"
import { OpenAITranscription } from "../protocols/openai-transcription.js"
@@ -29,14 +37,14 @@ export type Config = RouteDefaultsInput &
}
export interface ImageGenerationOptions {
readonly action?: OpenAIImageString<"auto" | "generate" | "edit">
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
readonly inputFidelity?: OpenAIImageString<"low" | "high">
readonly action?: OpenString<"auto" | "generate" | "edit">
readonly background?: OpenString<"auto" | "opaque" | "transparent">
readonly inputFidelity?: OpenString<"low" | "high">
readonly outputCompression?: number
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
readonly outputFormat?: OpenString<"png" | "jpeg" | "webp">
readonly partialImages?: number
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
readonly size?: OpenAIImageString<
readonly quality?: OpenString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
readonly size?: OpenString<
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
>
}
@@ -99,19 +107,17 @@ export const configure = (input: Config = {}) => {
id,
compatibility: { supportsPromptCacheKey: true },
})
const media = (modelID: string | ModelID) => ({
id: modelID,
auth: auth(input),
baseURL: input.baseURL,
headers: input.headers,
const deployment = MediaRoute.deployment(input, auth(input))
const media = {
...deployment,
http: mergeHttpOptions(
input.http === undefined ? undefined : HttpOptions.make(input.http),
deployment.http,
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
),
})
const image = (modelID: string | ModelID) => OpenAIImages.model(media(modelID))
const speech = (modelID: string | ModelID) => OpenAISpeech.model(media(modelID))
const transcription = (modelID: string | ModelID) => OpenAITranscription.model(media(modelID))
}
const image = (modelID: string | ModelID) => OpenAIImages.model({ ...media, id: modelID })
const speech = (modelID: string | ModelID) => OpenAISpeech.model({ ...media, id: modelID })
const transcription = (modelID: string | ModelID) => OpenAITranscription.model({ ...media, id: modelID })
return {
id,
+1 -1
View File
@@ -20,7 +20,7 @@ export const configure = (input: Options = {}) => {
auth: AuthOptions.bearer(input, "OPENCODE_API_KEY"),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
http: HttpOptions.make(input.http),
})
return { id, experimental: { evaluation }, configure }
}
+7 -9
View File
@@ -3,7 +3,7 @@ import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { HttpOptions, ProviderID, type CacheHint, type ModelID } from "../schema/index.js"
import { HttpOptions, ProviderID, type CacheHint, type ModelID, type OpenString } from "../schema/index.js"
import type { ProviderPackage } from "../provider-package.js"
import { SystemOne } from "../experimental/system-one.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
@@ -14,18 +14,16 @@ export const id = ProviderID.make("openrouter")
const baseURL = "https://openrouter.ai/api/v1"
const ADAPTER = "openrouter"
type OpenRouterString<Known extends string> = Known | (string & {})
export interface OpenRouterProviderRouting {
readonly [key: string]: unknown
readonly order?: ReadonlyArray<string>
readonly allow_fallbacks?: boolean
readonly require_parameters?: boolean
readonly data_collection?: OpenRouterString<"allow" | "deny">
readonly data_collection?: OpenString<"allow" | "deny">
readonly only?: ReadonlyArray<string>
readonly ignore?: ReadonlyArray<string>
readonly quantizations?: ReadonlyArray<string>
readonly sort?: OpenRouterString<"price" | "throughput" | "latency">
readonly sort?: OpenString<"price" | "throughput" | "latency">
readonly max_price?: Readonly<{
prompt?: number | string
completion?: number | string
@@ -41,7 +39,7 @@ export type OpenRouterPlugin =
id: "web"
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
engine?: OpenString<"native" | "exa">
}>
| Readonly<{ id: "file-parser"; max_files?: number; pdf?: { engine?: string } }>
| Readonly<{ id: "moderation" }>
@@ -58,7 +56,7 @@ export interface OpenRouterOptions {
readonly reasoning?: Readonly<{
enabled?: boolean
exclude?: boolean
effort?: OpenRouterString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
effort?: OpenString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
max_tokens?: number
}>
readonly usage?: boolean | Readonly<{ include: boolean }>
@@ -66,7 +64,7 @@ export interface OpenRouterOptions {
readonly web_search_options?: Readonly<{
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
engine?: OpenString<"native" | "exa">
}>
}
@@ -198,7 +196,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
auth: AuthOptions.bearer(input, "OPENROUTER_API_KEY"),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
http: HttpOptions.make(input.http),
})
return {
id,
+5 -11
View File
@@ -1,11 +1,11 @@
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"
import { MediaRoute } from "../route/media.js"
import { type HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { 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
@@ -17,14 +17,8 @@ export type Config = ProviderAuthOption<"optional"> & {
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),
})
const media = MediaRoute.deployment(input, auth(input))
const image = (modelID: string | ModelID) => ReplicateImages.model({ ...media, id: modelID })
return {
id,
image,
+5 -11
View File
@@ -1,11 +1,11 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { DEFAULT_BASE_URL, RunwayVideo } from "../protocols/runway-video.js"
import { MediaRoute } from "../route/media.js"
import { type HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { RunwayVideo } from "../protocols/runway-video.js"
export type { RunwayVideoOptions } from "../protocols/runway-video.js"
export const id = ProviderID.make("runway")
const baseURL = DEFAULT_BASE_URL
export type Config = ProviderAuthOption<"optional"> & {
readonly baseURL?: string
@@ -16,14 +16,8 @@ export type Config = ProviderAuthOption<"optional"> & {
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "RUNWAYML_API_SECRET")
export const configure = (input: Config = {}) => {
const video = (modelID: string | ModelID) =>
RunwayVideo.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
const media = MediaRoute.deployment(input, auth(input))
const video = (modelID: string | ModelID) => RunwayVideo.model({ ...media, id: modelID })
return {
id,
video,
+6 -11
View File
@@ -1,11 +1,11 @@
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"
import { MediaRoute } from "../route/media.js"
import { type HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { 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
@@ -16,16 +16,11 @@ export type Config = ProviderAuthOption<"optional"> & {
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),
}
const media = MediaRoute.deployment(input, auth(input))
return {
id,
image: (modelID: string | ModelID) => StabilityImages.model({ ...deployment, id: modelID }),
upscale: () => StabilityImages.upscaleModel(deployment),
image: (modelID: string | ModelID) => StabilityImages.model({ ...media, id: modelID }),
upscale: () => StabilityImages.upscaleModel(media),
configure,
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ export const configure = (input: Options = {}) => {
auth: AuthOptions.bearer(input, "TYPESAFE_API_KEY"),
baseURL: input.baseURL ?? baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
http: HttpOptions.make(input.http),
})
return { id, experimental: { evaluation }, configure }
}
@@ -67,7 +67,7 @@ export const configure = (input: Options = {}) => {
EvaluationModel.make<EvaluationOptions>({
id: modelID,
provider: id,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
http: HttpOptions.make(input.http),
route: {
id: "vercel-evaluation",
evaluate: (req, send) =>
+5 -10
View File
@@ -1,7 +1,8 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
import { XAIResponses } from "../protocols/xai-responses.js"
@@ -89,20 +90,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
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 media = MediaRoute.deployment(input, auth(input))
return {
id,
model: responses,
responses,
chat,
image: (modelID: string | ModelID) => XAIImages.model(media(modelID)),
video: (modelID: string | ModelID) => XAIVideo.model(media(modelID)),
image: (modelID: string | ModelID) => XAIImages.model({ ...media, id: modelID }),
video: (modelID: string | ModelID) => XAIVideo.model({ ...media, id: modelID }),
configure,
}
}
+4 -9
View File
@@ -5,7 +5,8 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { MediaRoute } from "../route/media.js"
import { ProviderID, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("zai")
@@ -48,14 +49,8 @@ export const configure = (input: Config = {}) => {
auth: auth(input),
})
.model<ChatOptionsInput>({ id: modelID, compatibility: ZAIChat.compatibility })
const image = (modelID: string | ModelID) =>
ZAIImages.model({
id: modelID,
auth: auth(input),
baseURL: input.baseURL,
headers: input.headers,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
})
const media = MediaRoute.deployment(input, auth(input))
const image = (modelID: string | ModelID) => ZAIImages.model({ ...media, id: modelID })
return {
id,
+1 -6
View File
@@ -152,7 +152,7 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
http: mergeHttpOptions(
base?.http,
httpOptions(patch.http),
HttpOptions.make(patch.http),
headers === undefined ? undefined : new HttpOptions({ headers }),
),
}
@@ -172,11 +172,6 @@ const mergeHeaders = (...items: ReadonlyArray<Record<string, string> | undefined
export const generationOptions = (input: GenerationOptions.Input | undefined) =>
input === undefined ? undefined : GenerationOptions.make(input)
export const httpOptions = (input: HttpOptionsInput | undefined) => {
if (input === undefined) return input
return HttpOptions.make(input)
}
export interface Interface {
readonly compact: CompactMethod
readonly stream: StreamMethod
+3 -2
View File
@@ -1,5 +1,4 @@
import type { LLMRequest } from "../schema/index.js"
import * as ProviderShared from "../protocols/shared.js"
export interface EndpointInput<Body, Request = LLMRequest> {
readonly request: Request
@@ -47,6 +46,8 @@ export const merge = <Body, Request = LLMRequest>(
query: patch.query === undefined ? base.query : { ...base.query, ...patch.query },
})
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
const renderPart = <Body, Request>(part: EndpointPart<Body, Request>, input: EndpointInput<Body, Request>) =>
typeof part === "function" ? part(input) : part
@@ -54,7 +55,7 @@ export const render = <Body, Request = LLMRequest>(
endpoint: Definition<Body, Request>,
input: EndpointInput<Body, Request>,
) => {
const url = new URL(`${ProviderShared.trimBaseUrl(endpoint.baseURL ?? "")}${renderPart(endpoint.path, input)}`)
const url = new URL(`${trimBaseUrl(endpoint.baseURL ?? "")}${renderPart(endpoint.path, input)}`)
for (const [key, value] of Object.entries(endpoint.query ?? {})) url.searchParams.set(key, value)
return url
}
+62 -6
View File
@@ -1,6 +1,6 @@
import { Stream } from "effect"
import * as ProviderShared from "../protocols/shared.js"
import type { AIError } from "../schema/index.js"
import { Effect, Stream } from "effect"
import { makeParser, type Event } from "effect/unstable/encoding/Sse"
import { AIError, InvalidProviderOutputError } from "../schema/index.js"
/**
* Decode a streaming HTTP response body into provider-protocol frames.
@@ -25,19 +25,75 @@ export interface Definition<Frame> {
readonly body?: (frame: Frame) => string | undefined
}
/**
* `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
* interrupting the stream. Decoder failures become provider output errors so
* the public error channel stays `AIError`.
*/
export const sseFraming = (
bytes: Stream.Stream<Uint8Array, AIError>,
events?: ReadonlySet<string>,
includeDone = false,
): Stream.Stream<string, AIError> =>
bytes.pipe(
Stream.decodeText(),
Stream.mapAccumEffect(
() => {
const output: Event[] = []
return {
output,
parser: makeParser((event) => {
if (event._tag === "Event") output.push(event)
}),
}
},
(state, chunk) =>
Effect.gen(function* () {
const error = state.parser.feed(chunk)
if (error)
return yield* new AIError({
reason: new InvalidProviderOutputError({
route: "sse",
message: error.message,
body: chunk,
cause: error,
}),
})
return [state, state.output.splice(0)] as const
}),
),
Stream.filter(
(event) =>
(events === undefined || events.has(event.event)) &&
event.data.length > 0 &&
// Some OpenAI-compatible proxies serialize an empty flush as a bare
// `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),
)
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
export const sse: Definition<string> = { id: "sse", frame: ProviderShared.sseFraming }
export const sse: Definition<string> = { id: "sse", frame: sseFraming }
/** Server-Sent Events framing that retains the conventional `[DONE]` sentinel. */
export const sseWithDone: Definition<string> = {
id: "sse",
frame: (bytes) => ProviderShared.sseFraming(bytes, undefined, true),
frame: (bytes) => sseFraming(bytes, undefined, true),
}
/** SSE framing restricted to protocol-recognized event names. */
export const sseEvents = (events: ReadonlySet<string>): Definition<string> => ({
id: "sse",
frame: (bytes) => ProviderShared.sseFraming(bytes, events),
frame: (bytes) => sseFraming(bytes, events),
})
export const lines: Definition<string> = {
+101 -99
View File
@@ -9,7 +9,9 @@ import {
HttpContext,
InvalidProviderOutputError,
InvalidRequestError,
ProviderID,
ProviderInternalError,
UnsupportedOperationError,
} from "../schema/index.js"
// ---------------------------------------------------------------------------
@@ -61,7 +63,7 @@ export interface DecodeContext<Request> {
export interface Inline<Request, Response> {
readonly kind: "inline"
readonly id: string
readonly name: string
readonly provider: ProviderID
/** Common request fields this protocol cannot lower; the route rejects them before `body.from` runs. */
readonly unsupported?: ReadonlyArray<keyof Request & string>
readonly body: { readonly from: (request: Request) => Effect.Effect<Body, AIError> }
@@ -74,11 +76,9 @@ export interface Inline<Request, Response> {
}
export const inline = <Request, Response>(
input: Omit<Inline<Request, Response>, "kind">,
): Inline<Request, Response> => ({
kind: "inline",
...input,
})
route: Identity,
input: Omit<Inline<Request, Response>, "kind" | "id" | "provider">,
): Inline<Request, Response> => ({ kind: "inline", id: route.id, provider: route.provider, ...input })
/** What `start` learned from the submission response: the route-owned handle plus the first observation. */
export interface Started<Token> {
@@ -107,7 +107,7 @@ export interface PollContext<Token> {
export interface Queued<Request, Response, Token> {
readonly kind: "queued"
readonly id: string
readonly name: string
readonly provider: ProviderID
/** Common request fields this protocol cannot lower; the route rejects them before `start.body.from` runs. */
readonly unsupported?: ReadonlyArray<keyof Request & string>
/** Serializable handle. `Generation.token` carries the encoded form so it can be persisted and resumed elsewhere. */
@@ -141,11 +141,9 @@ export interface Queued<Request, Response, Token> {
}
export const queued = <Request, Response, Token>(
input: Omit<Queued<Request, Response, Token>, "kind">,
): Queued<Request, Response, Token> => ({
kind: "queued",
...input,
})
route: Identity,
input: Omit<Queued<Request, Response, Token>, "kind" | "id" | "provider">,
): Queued<Request, Response, Token> => ({ kind: "queued", id: route.id, provider: route.provider, ...input })
export type Mode = "generate" | "stream"
@@ -162,7 +160,7 @@ export interface ResponseContext<Request> extends DecodeContext<Addressed<Reques
export interface Streamed<Request, Event, Frame, State> {
readonly kind: "stream"
readonly id: string
readonly name: string
readonly provider: ProviderID
/** Common request fields this protocol cannot lower; the route rejects them before `body.from` runs. */
readonly unsupported?: ReadonlyArray<keyof Request & string>
readonly body: { readonly from: (request: Addressed<Request>) => Effect.Effect<Body, AIError> }
@@ -177,11 +175,9 @@ export interface Streamed<Request, Event, Frame, State> {
}
export const stream = <Request, Event, Frame, State>(
input: Omit<Streamed<Request, Event, Frame, State>, "kind">,
): Streamed<Request, Event, Frame, State> => ({
kind: "stream",
...input,
})
route: Identity,
input: Omit<Streamed<Request, Event, Frame, State>, "kind" | "id" | "provider">,
): Streamed<Request, Event, Frame, State> => ({ kind: "stream", id: route.id, provider: route.provider, ...input })
// ---------------------------------------------------------------------------
// Response helpers
@@ -190,71 +186,98 @@ export const stream = <Request, Event, Frame, State>(
const context = (response: HttpClientResponse.HttpClientResponse) =>
new HttpContext({ url: response.request.url, status: response.status, headers: response.headers })
/**
* Read a text body while retaining the original payload and HTTP context on every downstream error. `invalid` is a
* malformed provider document; `ended` is a generation that reached a terminal status without output (`failed` is
* provider-side, `cancelled`/`expired` mean the result will never exist); `contentPolicy` is a moderated result.
*/
export const text = Effect.fn("MediaProtocol.text")(function* (
route: string,
name: string,
response: HttpClientResponse.HttpClientResponse,
) {
const http = context(response)
const body = yield* response.text.pipe(
Effect.mapError(
(cause) =>
new AIError({
reason: new InvalidProviderOutputError({
route,
message: `Failed to read the ${name} response`,
http,
cause,
/** One protocol's route id, display name, and provider, with the decoders and errors that carry them. */
export const identity = (input: { readonly id: string; readonly name: string; readonly provider: string }) => {
const provider = ProviderID.make(input.provider)
const frameError = (message: string, body?: string, cause?: unknown) =>
new AIError({ reason: new InvalidProviderOutputError({ route: input.id, message, body, cause }) })
/**
* Read a text body while retaining the original payload and HTTP context on every downstream error. `invalid` is a
* malformed provider document; `ended` is a generation that reached a terminal status without output (`failed` is
* provider-side, `cancelled`/`expired` mean the result will never exist); `contentPolicy` is a moderated result.
*/
const text = Effect.fn("MediaProtocol.text")(function* (response: HttpClientResponse.HttpClientResponse) {
const http = context(response)
const body = yield* response.text.pipe(
Effect.mapError(
(cause) =>
new AIError({
reason: new InvalidProviderOutputError({
route: input.id,
message: `Failed to read the ${input.name} response`,
http,
cause,
}),
}),
}),
),
)
return {
body,
http,
invalid: (message: string, cause?: unknown) =>
new AIError({ reason: new InvalidProviderOutputError({ route, message, body, http, cause }) }),
ended: (status: Exclude<Status, "queued" | "running" | "completed">, message: string) =>
new AIError({
reason:
status === "failed"
? new ProviderInternalError({ message, body, http })
: new InvalidRequestError({ message, body, http }),
}),
contentPolicy: (message: string) => new AIError({ reason: new ContentPolicyError({ message, body, http }) }),
}
})
export type Output = Effect.Success<ReturnType<typeof text>>
/** Read and Schema-decode a JSON body. Decode failures keep the raw body as `reason.body`. */
export const decodeJson = <A>(route: string, name: string, schema: Schema.Codec<A, unknown>) => {
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(schema))
return Effect.fn("MediaProtocol.decodeJson")(function* (response: HttpClientResponse.HttpClientResponse) {
const output = yield* text(route, name, response)
const value = yield* decode(output.body).pipe(
Effect.mapError((cause) => output.invalid(`${name} returned an invalid response`, cause)),
),
)
return { ...output, value }
return {
body,
http,
invalid: (message: string, cause?: unknown) =>
new AIError({ reason: new InvalidProviderOutputError({ route: input.id, message, body, http, cause }) }),
ended: (status: Exclude<Status, "queued" | "running" | "completed">, message: string) =>
new AIError({
reason:
status === "failed"
? new ProviderInternalError({ message, body, http })
: new InvalidRequestError({ message, body, http }),
}),
contentPolicy: (message: string) => new AIError({ reason: new ContentPolicyError({ message, body, http }) }),
}
})
/** Read and Schema-decode a JSON body. Decode failures keep the raw body as `reason.body`. */
const decodeJson = <A>(schema: Schema.Codec<A, unknown>) => {
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(schema))
return Effect.fn("MediaProtocol.decodeJson")(function* (response: HttpClientResponse.HttpClientResponse) {
const output = yield* text(response)
const value = yield* decode(output.body).pipe(
Effect.mapError((cause) => output.invalid(`${input.name} returned an invalid response`, cause)),
)
return { ...output, value }
})
}
return {
id: input.id,
name: input.name,
provider,
text,
decodeJson,
/** Decode a submission response into the token and first snapshot. */
decodeStarted: <A, Token>(schema: Schema.Codec<A, unknown>, started: (value: A) => Started<Token>) => {
const decode = decodeJson(schema)
return (response: HttpClientResponse.HttpClientResponse) =>
decode(response).pipe(Effect.map((output) => started(output.value)))
},
/** Schema-decode one JSON stream frame. Decode failures keep the frame as `reason.body`. */
decodeFrame: <A>(schema: Schema.Codec<A, unknown>) => {
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(schema))
return (frame: string) =>
decode(frame).pipe(
Effect.mapError((cause) => frameError(`${input.name} sent an invalid stream event`, frame, cause)),
)
},
/** A stream-time failure; the frame stays on `reason.body`. */
frameError,
incomplete: () =>
new AIError({
reason: new InvalidProviderOutputError({
route: input.id,
message: "The provider response ended unexpectedly.",
classification: "incomplete-stream",
}),
}),
unsupported: (operation: string, message: string) =>
new AIError({ reason: new UnsupportedOperationError({ operation, provider, route: input.id, message }) }),
}
}
/** Decode a submission response into the token and first snapshot. */
export const decodeStarted = <A, Token>(
route: string,
name: string,
schema: Schema.Codec<A, unknown>,
started: (value: A) => Started<Token>,
) => {
const decode = decodeJson(route, name, schema)
return (response: HttpClientResponse.HttpClientResponse) =>
decode(response).pipe(Effect.map((output) => started(output.value)))
}
export type Identity = ReturnType<typeof identity>
export type Output = Effect.Success<ReturnType<Identity["text"]>>
/** Map a provider status string through the protocol's table; unknown values are an invalid provider document. */
export const status = <Table extends Record<string, Status>>(
@@ -267,27 +290,6 @@ export const status = <Table extends Record<string, Status>>(
return Effect.succeed(normalized)
}
export const frameError = (route: string, message: string, body?: string, cause?: unknown) =>
new AIError({ reason: new InvalidProviderOutputError({ route, message, body, cause }) })
export const incomplete = (route: string) =>
new AIError({
reason: new InvalidProviderOutputError({
route,
message: "The provider response ended unexpectedly.",
classification: "incomplete-stream",
}),
})
/** Schema-decode one JSON stream frame. Decode failures keep the frame as `reason.body`. */
export const decodeFrame = <A>(route: string, name: string, schema: Schema.Codec<A, unknown>) => {
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(schema))
return (frame: string) =>
decode(frame).pipe(
Effect.mapError((cause) => frameError(route, `${name} sent an invalid stream event`, frame, cause)),
)
}
/** A `url` asset whose provider-declared retention window starts now. */
export const expiringUrl = (url: string, retention: Duration.Duration, options?: Parameters<typeof Media.url>[1]) =>
Clock.currentTimeMillis.pipe(
+47 -37
View File
@@ -2,26 +2,21 @@ 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 { RequestExecutorService, 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 { Generation, resultEvents, type AwaitOptions, type Observation } from "../generation.js"
import type { Media } from "../media.js"
import { ProviderShared } from "../protocols/shared.js"
import {
AIError,
AIErrorReason,
HttpOptions,
InvalidRequestError,
ProviderID,
UnsupportedOperationError,
mergeHttpOptions,
} from "../schema/index.js"
import { encodeJson } from "../utils/json.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
export type Execute = Interface["execute"]
@@ -41,6 +36,17 @@ export interface ModelInput {
readonly http?: HttpOptions
}
/** A provider facade's `configure(...)` input as the `ModelInput` every media selector shares, minus the model id. */
export const deployment = (
input: { readonly baseURL?: string; readonly headers?: Record<string, string>; readonly http?: HttpOptions.Input },
auth: Auth.Definition,
): Omit<ModelInput, "id"> => ({
auth,
baseURL: input.baseURL,
headers: input.headers,
http: HttpOptions.make(input.http),
})
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
@@ -85,8 +91,6 @@ export type AnyRoute<Request extends MediaRequest, Event, Response> =
| QueuedRoute<Request, Response>
export interface Composition<Request extends MediaRequest> {
readonly id: string
readonly provider: string | ProviderID
readonly endpoint: Endpoint.Definition<MediaProtocol.Body, Request>
readonly auth: Auth.Definition
/** Deployment headers applied before transport authentication. */
@@ -119,8 +123,8 @@ export const inline = <Request extends MediaRequest, Response>(
const transport = makeTransport(input)
return {
kind: "inline",
id: input.id,
provider: transport.provider,
id: input.protocol.id,
provider: input.protocol.provider,
protocol: input.protocol.id,
generate: Effect.fn(`MediaRoute.generate`)(function* (request: Request, execute: Execute) {
const submitted = yield* transport.submit(
@@ -161,7 +165,7 @@ export const queued = <Request extends MediaRequest, Response, Token>(
.call("GET", operation.path(token), http, execute)
.pipe(Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })))
const cancel = protocol.cancel
const route: GenerationRoute<Response> = {
return {
status: poll(protocol.status),
result: poll(protocol.result),
cancel:
@@ -169,7 +173,6 @@ export const queued = <Request extends MediaRequest, Response, Token>(
? undefined
: transport.call(cancel.method, cancel.path(token), http, execute).pipe(Effect.asVoid),
}
return route
}
const start = Effect.fn("MediaRoute.start")(function* (request: Request, execute: Execute) {
@@ -193,7 +196,7 @@ export const queued = <Request extends MediaRequest, Response, Token>(
(cause) =>
new AIError({
reason: new InvalidRequestError({
message: `${input.id} cannot resume a generation from this token`,
message: `${protocol.id} cannot resume a generation from this token`,
cause,
}),
}),
@@ -203,7 +206,7 @@ export const queued = <Request extends MediaRequest, Response, Token>(
return new Generation(route, encodeToken(token), yield* route.status)
})
return { kind: "queued", id: input.id, provider: transport.provider, protocol: protocol.id, start, resume }
return { kind: "queued", id: protocol.id, provider: protocol.provider, protocol: protocol.id, start, resume }
}
/** Compose a streaming media protocol; `generate` runs the same stream in `generate` mode and folds it with `collect`. */
@@ -255,8 +258,8 @@ export const stream = <Request extends MediaRequest, Event, Response, Frame, Sta
)
return {
kind: "stream",
id: input.id,
provider: transport.provider,
id: protocol.id,
provider: protocol.provider,
protocol: protocol.id,
stream: (request, execute) => events(request, execute, "stream"),
generate: (request, execute) =>
@@ -270,11 +273,13 @@ export const dispatch = <Event, Response>(input: {
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`,
new AIError({
reason: new UnsupportedOperationError({
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"))
@@ -319,12 +324,13 @@ export const dispatch = <Event, Response>(input: {
// Transport plumbing shared by every kind
// ---------------------------------------------------------------------------
const makeTransport = <Request extends MediaRequest>(input: Composition<Request>) => {
const provider = ProviderID.make(input.provider)
const makeTransport = <Request extends MediaRequest>(
input: Composition<Request> & { readonly protocol: { readonly id: string; readonly provider: ProviderID } },
) => {
const routeHttp = input.headers === undefined ? undefined : new HttpOptions({ headers: input.headers })
const authorize = Auth.toEffect(input.auth)
const baseURL = (path: string) => new URL(`${ProviderShared.trimBaseUrl(input.endpoint.baseURL ?? "")}${path}`)
/** `auth` is only what `Auth` added, never deployment headers. */
const baseURL = (path: string) => new URL(`${Endpoint.trimBaseUrl(input.endpoint.baseURL ?? "")}${path}`)
/** `auth` is only what `Auth` added or changed, never untouched deployment headers. */
const send = Effect.fn("MediaRoute.send")(function* (
call: {
readonly method: AuthInput["method"]
@@ -347,10 +353,12 @@ const makeTransport = <Request extends MediaRequest>(input: Composition<Request>
const response = yield* execute(
encoded.apply(HttpClientRequest.make(call.method)(url).pipe(HttpClientRequest.setHeaders(headers))),
)
return { response, auth: Object.fromEntries(Object.entries(headers).filter(([key]) => !(key in call.headers))) }
return {
response,
auth: Object.fromEntries(Object.entries(headers).filter(([key, value]) => encoded.headers[key] !== value)),
}
})
return {
provider,
/** Route and model overlays; `start` additionally merges the request's own `http`. */
http: (model: MediaRequest["model"]) => mergeHttpOptions(routeHttp, model.http),
/** POST the protocol body to the route endpoint. */
@@ -363,7 +371,7 @@ const makeTransport = <Request extends MediaRequest>(input: Composition<Request>
},
execute: Execute,
) {
yield* rejectUnsupported(input.id, provider, request, protocol.unsupported)
yield* rejectUnsupported(input.protocol.id, input.protocol.provider, request, protocol.unsupported)
const http = mergeHttpOptions(routeHttp, request.model.http, request.http)
const headers = Headers.fromInput(http?.headers)
const prepared =
@@ -408,7 +416,7 @@ const withQuery = (url: URL, query: MediaProtocol.Query | undefined) => {
const encode = (body: MediaProtocol.Body | undefined, headers: Headers.Headers) => {
if (body === undefined) return { text: "", headers, apply: (request: HttpClientRequest.HttpClientRequest) => request }
if (body.type === "json") {
const text = ProviderShared.encodeJson(body.value)
const text = encodeJson(body.value)
return { text, headers, apply: HttpClientRequest.bodyText(text, "application/json") }
}
if (body.type === "binary")
@@ -438,11 +446,13 @@ const rejectUnsupported = <Request extends object>(
})
if (present.length === 0) return Effect.void
return Effect.fail(
ProviderShared.unsupportedOperation({
operation: `media.${present[0]}`,
provider,
route,
message: `${provider}/${route} does not support ${present.join(", ")}`,
new AIError({
reason: new UnsupportedOperationError({
operation: `media.${present[0]}`,
provider,
route,
message: `${provider}/${route} does not support ${present.join(", ")}`,
}),
}),
)
}
+12 -4
View File
@@ -57,8 +57,13 @@ export class HttpOptions extends Schema.Class<HttpOptions>("AI.HttpOptions")({
export namespace HttpOptions {
export type Input = HttpOptions | ConstructorParameters<typeof HttpOptions>[0]
/** Normalize HTTP option input into the canonical `HttpOptions` class. */
export const make = (input: Input) => (input instanceof HttpOptions ? input : new HttpOptions(input))
/** Normalize HTTP option input into the canonical `HttpOptions` class; `undefined` stays `undefined`. */
export function make(input: Input): HttpOptions
export function make(input: Input | undefined): HttpOptions | undefined
export function make(input: Input | undefined) {
if (input === undefined || input instanceof HttpOptions) return input
return new HttpOptions(input)
}
}
export const mergeHttpOptions = (...items: ReadonlyArray<HttpOptions | undefined>): HttpOptions | undefined => {
@@ -140,13 +145,16 @@ export namespace LanguageModelDefaults {
return new LanguageModelDefaults({
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
providerOptions: input.providerOptions,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
http: HttpOptions.make(input.http),
})
}
}
/** Provider-defined string enum: known values for autocomplete, any string accepted. */
export type OpenString<Known extends string> = Known | (string & {})
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
export type ReasoningEffort = OpenString<(typeof ReasoningEfforts)[number]>
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
(value): value is ReasoningEffort => typeof value === "string",
{ title: "ReasoningEffort" },
+4 -4
View File
@@ -3,7 +3,7 @@ import { Media } from "./media.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"
import { AIError, HttpOptions, MediaUsage, ProviderMetadata, type OpenString } from "./schema/index.js"
import { SpeechClient, Service } from "./speech-client.js"
// ---------------------------------------------------------------------------
@@ -35,7 +35,7 @@ export class SpeechModel<Options extends SpeechOptions = SpeechOptions> extends
) {
return new SpeechModel<Options>({
id: input.id,
provider: route.provider,
provider: route.protocol.provider,
http: input.http,
route: composeRoute(
(composition) => MediaRoute.stream({ ...composition, collect: collectResponse }),
@@ -71,7 +71,7 @@ export const SpeechVoice = Schema.Union([Schema.String, Schema.Struct({ id: Sche
})
export type SpeechVoice = Schema.Schema.Type<typeof SpeechVoice>
export type SpeechFormat = "mp3" | "wav" | "pcm" | "opus" | "aac" | "flac" | (string & {})
export type SpeechFormat = OpenString<"mp3" | "wav" | "pcm" | "opus" | "aac" | "flac">
/** Granularity is provider-native: characters on ElevenLabs, words on Cartesia. */
export const SpeechTimestamp = Schema.Struct({
@@ -188,7 +188,7 @@ export function request(input: SpeechRequest | SpeechRequestInput) {
if (input instanceof SpeechRequest) return input
return new SpeechRequest({
...input,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
http: HttpOptions.make(input.http),
})
}
+2 -2
View File
@@ -50,7 +50,7 @@ export class TranscriptionModel<Options extends TranscriptionOptions = Transcrip
) {
return new TranscriptionModel<Options>({
id: input.id,
provider: route.provider,
provider: route.protocol.provider,
http: input.http,
route: composeAnyRoute(route, input, collectResponse),
})
@@ -236,7 +236,7 @@ export function request(input: TranscriptionRequest | TranscriptionRequestInput)
if (input instanceof TranscriptionRequest) return input
return new TranscriptionRequest({
...input,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
http: HttpOptions.make(input.http),
})
}
+5
View File
@@ -0,0 +1,5 @@
import { Schema } from "effect"
export const Json = Schema.fromJsonString(Schema.Unknown)
export const decodeJson = Schema.decodeUnknownSync(Json)
export const encodeJson = Schema.encodeSync(Json)
+4 -4
View File
@@ -4,7 +4,7 @@ import { Media } from "./media.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"
import { AIError, HttpOptions, MediaUsage, ProviderMetadata, type OpenString } from "./schema/index.js"
import { VideoClient, Service } from "./video-client.js"
// ---------------------------------------------------------------------------
@@ -32,7 +32,7 @@ export class VideoModel<Options extends VideoOptions = VideoOptions> extends Med
) {
return new VideoModel<Options>({
id: input.id,
provider: route.provider,
provider: route.protocol.provider,
http: input.http,
route: composeRoute(MediaRoute.queued, route, input),
})
@@ -57,7 +57,7 @@ export const VideoModelSchema = Schema.declare((value): value is VideoModel => v
export type VideoAspectRatio = Media.AspectRatio
export const VideoAspectRatio = Media.AspectRatio
export type VideoResolution = "480p" | "720p" | "1080p" | "4k" | (string & {})
export type VideoResolution = OpenString<"480p" | "720p" | "1080p" | "4k">
/** Pinned frames. Routes that accept only a first frame fail typed when `last` is present. */
export const VideoFrames = Schema.Struct({
@@ -171,7 +171,7 @@ export function request(input: VideoRequest | VideoRequestInput) {
if (input instanceof VideoRequest) return input
return new VideoRequest({
...input,
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
http: HttpOptions.make(input.http),
})
}
+6 -4
View File
@@ -144,7 +144,7 @@ describe("request option precedence", () => {
type: "function",
name: "crm",
description: "Top-level CRM tool",
parameters: {},
parameters: { type: "object" },
strict: false,
},
{
@@ -152,15 +152,17 @@ describe("request option precedence", () => {
name: "crm",
description: "CRM tools",
tools: [
{ type: "function", name: "lookup", description: "new", parameters: {}, strict: false },
{ type: "function", name: "search", description: "search", parameters: {}, strict: false },
{ type: "function", name: "lookup", description: "new", parameters: { type: "object" }, strict: false },
{ type: "function", name: "search", description: "search", parameters: { type: "object" }, strict: false },
],
},
{
type: "namespace",
name: "support",
description: "Support tools",
tools: [{ type: "function", name: "lookup", description: "support", parameters: {}, strict: false }],
tools: [
{ type: "function", name: "lookup", description: "support", parameters: { type: "object" }, strict: false },
],
},
])
}),
+17
View File
@@ -323,6 +323,23 @@ describe("Image", () => {
),
)
it.effect("decodes URL images and rejects items with neither data nor a URL", () =>
Effect.gen(function* () {
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model")
const respond = (data: ReadonlyArray<object>) =>
layer((input) =>
Effect.succeed(input.respond(JSON.stringify({ data }), { headers: { "content-type": "application/json" } })),
)
const response = yield* Image.generate({ model, prompt: "A kite" }).pipe(
Effect.provide(respond([{ url: "https://xai.test/a.png", mime_type: "image/png" }])),
)
expect(response.images[0].source).toEqual({ type: "url", url: "https://xai.test/a.png", mediaType: "image/png" })
const error = yield* Image.generate({ model, prompt: "A kite" }).pipe(Effect.provide(respond([{}])), Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("xAI Images result 0 has neither image data nor a URL")
}),
)
it.effect("lowers ordered Google image inputs into generateContent parts", () =>
Image.generate({
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).image("future-model"),
@@ -225,6 +225,25 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("omits maxTokens only for Nova 2 at high reasoning effort", () =>
Effect.gen(function* () {
const inferenceConfig = (modelID: string, maxReasoningEffort: string) =>
compileRequest(
LLMRequest.update(baseRequest, {
model: AmazonBedrock.model(modelID, {
baseURL: "https://bedrock-runtime.test",
apiKey: "test-bearer",
body: { additionalModelRequestFields: { reasoningConfig: { type: "enabled", maxReasoningEffort } } },
}),
}),
).pipe(Effect.map((prepared) => prepared.body.inferenceConfig))
expect(yield* inferenceConfig("us.amazon.nova-2-lite-v1:0", "high")).toEqual({ temperature: 0 })
expect(yield* inferenceConfig("us.amazon.nova-2-lite-v1:0", "low")).toEqual({ maxTokens: 64, temperature: 0 })
expect(yield* inferenceConfig("us.xai.grok-4.6", "high")).toEqual({ maxTokens: 64, temperature: 0 })
}),
)
it.effect("omits additionalModelRequestFields when topK is unset", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(baseRequest)
@@ -59,7 +59,7 @@ describe("Mistral Chat", () => {
],
tools: [
ToolDefinition.make({ name: "lookup", description: "Look up a city", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "other", description: "Other operation", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "other", description: "Other operation", inputSchema: {} }),
],
toolChoice: "lookup",
promptCacheKey: "session-1",
@@ -84,7 +84,10 @@ describe("Mistral Chat", () => {
expect(prepared.body).toMatchObject({
model: "mistral-large-latest",
tools: [{ function: { name: "lookup", strict: false } }, { function: { name: "other", strict: false } }],
tools: [
{ function: { name: "lookup", strict: false } },
{ function: { name: "other", strict: false, parameters: { type: "object" } } },
],
tool_choice: { type: "function", function: { name: "lookup" } },
stream: true,
max_tokens: 64,
@@ -202,10 +202,16 @@ describe("Open Responses-compatible route", () => {
type: "function",
name: "acme_billing_lookup",
description: "Lookup billing",
parameters: {},
parameters: { type: "object" },
strict: false,
},
{
type: "function",
name: "acme_users",
description: "Lookup users",
parameters: { type: "object" },
strict: false,
},
{ type: "function", name: "acme_users", description: "Lookup users", parameters: {}, strict: false },
])
}),
)
@@ -196,8 +196,20 @@ describe("OpenAI Responses route", () => {
name: "crm",
description: "Customer management",
tools: [
{ type: "function", name: "lookup", description: "Look up a customer", parameters: {}, strict: false },
{ type: "function", name: "orders", description: "List customer orders", parameters: {}, strict: false },
{
type: "function",
name: "lookup",
description: "Look up a customer",
parameters: { type: "object" },
strict: false,
},
{
type: "function",
name: "orders",
description: "List customer orders",
parameters: { type: "object" },
strict: false,
},
],
},
])
@@ -232,7 +244,15 @@ describe("OpenAI Responses route", () => {
type: "namespace",
name: "crm",
description: "Customer management",
tools: [{ type: "function", name: "orders_list", description: "List orders", parameters: {}, strict: false }],
tools: [
{
type: "function",
name: "orders_list",
description: "List orders",
parameters: { type: "object" },
strict: false,
},
],
},
])
}),
+6 -5
View File
@@ -28,6 +28,7 @@ import {
Usage,
} from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js"
import { Framing } from "../src/route/framing.js"
import { it } from "./lib/effect.js"
const model = new LanguageModel({
@@ -137,7 +138,7 @@ describe("AI.Usage", () => {
it.effect("sseFraming maps decoder failures to AI errors", () =>
Effect.gen(function* () {
const error = yield* ProviderShared.sseFraming(
const error = yield* Framing.sseFraming(
Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`)),
).pipe(Stream.runCollect, Effect.flip)
@@ -149,7 +150,7 @@ describe("AI.Usage", () => {
it.effect("sseFraming ignores retry directives without ending the stream", () =>
Effect.gen(function* () {
const encoder = new TextEncoder()
const frames = yield* ProviderShared.sseFraming(
const frames = yield* Framing.sseFraming(
Stream.make(
encoder.encode("retry: 1000\n\n"),
encoder.encode('data: {"first":true}\n\n'),
@@ -165,7 +166,7 @@ describe("AI.Usage", () => {
it.effect("sseFraming preserves event data around retry directives", () =>
Effect.gen(function* () {
const encoder = new TextEncoder()
const frames = yield* ProviderShared.sseFraming(
const frames = yield* Framing.sseFraming(
Stream.make(
encoder.encode("event: update\ndata: first\n"),
encoder.encode("retry: 1000\n"),
@@ -180,7 +181,7 @@ describe("AI.Usage", () => {
it.effect("sseFraming drops bare null frames and keeps other payloads", () =>
Effect.gen(function* () {
const frames = yield* ProviderShared.sseFraming(
const frames = yield* Framing.sseFraming(
Stream.make(
new TextEncoder().encode(
'data: {"first":true}\n\ndata: null\n\nevent: update\ndata: null\n\ndata: "null"\n\ndata: 0\n\ndata: {"second":true}\n\ndata: [DONE]\n\ndata: null\n\n',
@@ -194,7 +195,7 @@ describe("AI.Usage", () => {
it.effect("sseFraming drops keepalive comments sent as data and keeps other payloads", () =>
Effect.gen(function* () {
const frames = yield* ProviderShared.sseFraming(
const frames = yield* Framing.sseFraming(
Stream.make(
new TextEncoder().encode(
'data: {"first":true}\n\ndata: : keepalive\n\n: keepalive\n\ndata: : ping\n\ndata: {"second":true}\n\n',
+1
View File
@@ -54,6 +54,7 @@ describe("Speech", () => {
["UnsupportedOperation", "media.voice"],
],
)
expect(errors[1].reason).toMatchObject({ provider: "google", route: "google-speech" })
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
)
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import { LLM } from "../src/index.js"
import { OpenAIChat } from "../src/protocols.js"
import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema.js"
import { Tool, toDefinitions } from "../src/tool.js"
import { Auth } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { it } from "./lib/effect.js"
@@ -60,6 +61,56 @@ describe("tool schema projections", () => {
})
})
it.effect("declares every tool schema root as an object", () =>
Effect.gen(function* () {
const route = OpenAIChat.route.with({
endpoint: { baseURL: "https://api.openai.test/v1/" },
auth: Auth.bearer("test"),
})
const parameters = (inputSchema: Record<string, unknown>, model = route.model({ id: "gpt-6-luna" })) =>
compileRequest(
LLM.request({
model,
prompt: "Use the tool.",
tools: [{ name: "lookup", description: "Lookup data.", inputSchema }],
}),
).pipe(Effect.map((prepared) => prepared.body.tools?.[0]?.function.parameters))
const parameterless = toDefinitions({
lookup: Tool.make({
description: "Lookup data.",
parameters: Schema.Struct({}).annotate({ description: "No input." }),
success: Schema.String,
}),
})[0].inputSchema
const union = {
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "object", properties: { b: { type: "string" } } },
],
}
const exclusive = { oneOf: [{ type: "object" }, { type: "object", required: ["a"] }] }
const object = { type: "object", properties: {} }
expect(yield* parameters(parameterless)).toEqual({ type: "object", description: "No input." })
expect(yield* parameters({})).toEqual({ type: "object" })
expect(yield* parameters({ description: "Query", properties: { q: { type: "string" } } })).toEqual({
type: "object",
description: "Query",
properties: { q: { type: "string" } },
})
expect(yield* parameters(union)).toEqual({ type: "object", ...union })
expect(yield* parameters(exclusive)).toEqual({ type: "object", ...exclusive })
expect(yield* parameters(object)).toEqual(object)
expect(yield* parameters({}, route.model({ id: "gpt-6-luna", compatibility: { sanitizer: "none" } }))).toEqual({
type: "object",
})
expect(yield* parameters({ properties: { mode: { enum: ["fast"] } } }, route.model({ id: "kimi-k3" }))).toEqual({
type: "object",
properties: { mode: { type: "string", enum: ["fast"] } },
})
}),
)
it.effect("selects tool schema handling from the model name unless compatibility is explicit", () =>
Effect.gen(function* () {
const route = OpenAIChat.route.with({
+29
View File
@@ -133,6 +133,35 @@ describe("Video / Google Veo", () => {
}),
)
it.effect("hands the asset the auth header that overwrote a deployment header", () =>
Effect.gen(function* () {
const generation = yield* Video.start({
model: Google.configure({
apiKey: "test",
baseURL: "https://google.test/v1beta",
headers: { "x-goog-api-key": "stale" },
}).video("veo-3.1-generate-preview"),
prompt: "A kite",
})
const response = yield* generation.result()
expect(response.video.headers).toEqual({ "x-goog-api-key": "test" })
}).pipe(
Effect.provide(
layer((input) =>
input.request.method === "POST"
? Effect.succeed(json(input, { name: operation }))
: Effect.succeed(
json(input, {
name: operation,
done: true,
response: { generateVideoResponse: { generatedSamples: [{ video: { uri: fileUri } }] } },
}),
),
),
),
),
)
it.effect("surfaces an operation error as a failed generation with the provider body", () =>
Effect.gen(function* () {
const failure = {
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -14,12 +14,7 @@ type InboxRow = {
sessionID: string
time: { created: number }
type: "user"
payload: {
text: string
metadata?: Record<string, unknown>
files?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["files"]
agents?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["agents"]
}
payload: { text: string; metadata?: Record<string, unknown> }
delivery: "steer" | "queue"
}
@@ -34,7 +29,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
}))
const events: OpenCodeEvent[] = []
const prompts: Record<string, unknown>[] = []
const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = []
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const log: string[] = []
let sequence = 0
const emit = <Type extends OpenCodeEvent["type"]>(
@@ -239,113 +234,6 @@ test("editing restores the existing draft and replaces only the original queue p
expect(mock.log[0]).toBe("prompt:queue")
})
test("Move Back cancels only the selected queued prompt and focuses the restored input", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(3)
const row = view.rows.filter({ hasText: "second queued prompt" })
const actions = row.locator('[data-slot="session-queue-actions"] button')
await expect(actions).toHaveCount(3)
expect(
await actions.evaluateAll((buttons) =>
buttons.map((button) => button.getAttribute("aria-label") ?? button.textContent?.trim()),
),
).toEqual(["Steer", "Move Back", "Remove"])
const moveBack = row.getByRole("button", { name: "Move Back" })
await expect(moveBack).toHaveText("")
await expect(moveBack.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-undo-down")
await moveBack.hover()
await expect(page.getByRole("tooltip")).toHaveText("Move Back")
await moveBack.click()
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
"first queued prompt",
"third queued prompt",
])
await expect(view.input).toHaveText("second queued prompt")
await expect(view.input).toBeFocused()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_2", action: "cancel" }])
expect(mock.prompts).toEqual([])
})
test("Move Back preserves an existing draft and restores inline attachments", async ({ page }) => {
const mock = createQueueMock(["queued with image"])
mock.rows[0].payload.files = [
{
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL/nwAAAABJRU5ErkJggg==",
mime: "image/png",
source: { type: "inline" },
name: "shot.png",
},
]
const view = await openSession(page, mock)
await view.input.fill("my draft")
await view.rows.getByRole("button", { name: "Move Back" }).click()
await expect(view.input).toHaveText("my draft")
expect(mock.changes).toEqual([])
await view.input.fill("")
await view.rows.getByRole("button", { name: "Move Back" }).click()
await expect(view.rows).toHaveCount(0)
await expect(view.input).toHaveText("queued with image")
await expect(view.input).toBeFocused()
await expect(view.composer.getByRole("img", { name: "shot.png" })).toBeVisible()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
})
test("Move Back stays usable with a long queue on a narrow screen", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
const text = "Review the detailed error report and check every step of the retry path ".repeat(4)
const mock = createQueueMock([text, ...Array.from({ length: 6 }, (_, index) => `queued follow-up ${index + 1}`)])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(7)
const row = view.rows.filter({ hasText: text })
await row.getByRole("button", { name: "Move Back" }).hover()
await expect(page.getByRole("tooltip")).toHaveText("Move Back")
await page.screenshot({ path: testInfo.outputPath("move-back-narrow-queue.png") })
await row.getByRole("button", { name: "Move Back" }).click()
await expect(view.rows).toHaveCount(6)
await expect(view.input).toHaveText(text)
await expect(view.input).toBeFocused()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
})
test("Move Back preserves mentioned file and agent references on resubmission", async ({ page }) => {
const mock = createQueueMock(["inspect @main.ts with @build"])
mock.rows[0].payload.files = [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { start: 8, end: 16, text: "@main.ts" },
},
]
mock.rows[0].payload.agents = [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }]
const view = await openSession(page, mock)
await view.rows.getByRole("button", { name: "Move Back" }).click()
await expect(view.input).toHaveText("inspect @main.ts with @build")
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.prompts[0].files).toMatchObject([
{ uri: "data:text/plain;base64,aGk=", mention: { text: "@main.ts", start: 8, end: 16 } },
])
expect(mock.prompts[0].agents).toMatchObject([{ name: "build", mention: { text: "@build" } }])
})
test("Move Back does not discard hidden file context", async ({ page }) => {
const mock = createQueueMock(["inspect this file"])
mock.rows[0].payload.files = [
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
]
const view = await openSession(page, mock)
await view.rows.getByRole("button", { name: "Move Back" }).click()
await expect(page.getByText("Edit this prompt in the queue to preserve its file context")).toBeVisible()
await expect(view.rows).toHaveCount(1)
await expect(view.input).toHaveText("")
expect(mock.changes).toEqual([])
})
for (const delivery of ["steer", "queue"] as const) {
test(`keeps finished tools above a pending ${delivery === "queue" ? "queue-to-steer" : "steer"} follow-up`, async ({
page,
-1
View File
@@ -47,7 +47,6 @@ export type ComposerDelivery = "steer" | "queue"
// is loaded in the editor.
export type ComposerQueue = {
count: Accessor<number>
movingBack: Accessor<boolean>
// Delivery a plain submit uses right now.
delivery: Accessor<ComposerDelivery>
// Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint.
@@ -168,7 +168,6 @@ function ComposerStory(props: {
alternate: () => props.alternate,
editing: () => undefined,
confirmEdit() {},
movingBack: () => false,
cancelEdit() {},
editFirst: () => false,
}
-2
View File
@@ -16,7 +16,6 @@ export function Composer(props: {
class?: string
model: ComposerModel
borderUnderlay?: boolean
readOnly?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const dialog = useDialog()
@@ -28,7 +27,6 @@ export function Composer(props: {
<ComposerEditor
controller={props.model}
borderUnderlay={props.borderUnderlay}
readOnly={props.readOnly}
class={props.class}
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
-1
View File
@@ -371,7 +371,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
onSubmit: (submitOptions) => {
if (!available()) return
const queue = options?.queue
if (queue?.movingBack()) return
// Confirming an edit re-admits the queued prompt instead of sending
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
-3
View File
@@ -874,9 +874,6 @@ export const dict = {
"session.queue.send": "Send",
"session.queue.steerTooltip": "Send without interrupting",
"session.queue.remove": "Remove",
"session.queue.moveBack": "Move Back",
"session.queue.moveBackDraft": "Clear your draft before moving a prompt back",
"session.queue.moveBackUnavailable": "Edit this prompt in the queue to preserve its file context",
"session.queue.reorder": "Reorder queued prompt",
"session.queue.attachments.one": "{{count}} attachment",
"session.queue.attachments.other": "{{count}} attachments",
@@ -175,18 +175,6 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
</Button>
</Tooltip>
<Tooltip placement="top" value={language.t("session.queue.moveBack")}>
<IconButton
data-action="session-queue-move-back"
type="button"
size="small"
variant="ghost-muted"
icon={<Icon name="arrow-undo-down" />}
disabled={props.queue.busy()}
aria-label={language.t("session.queue.moveBack")}
onClick={() => props.queue.moveBack(props.id)}
/>
</Tooltip>
</Show>
<Tooltip placement="top" value={language.t("session.queue.remove")}>
<IconButton
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo } from "@opencode/client/promise"
import { queuedPromptAttachments, queuedPromptMoveBackDraft, queuedPromptRows } from "./queue"
import { queuedPromptAttachments, queuedPromptRows } from "./queue"
const queued = [
{
@@ -104,43 +104,3 @@ describe("queuedPromptAttachments", () => {
expect(queuedPromptAttachments(item)).toEqual([])
})
})
describe("queuedPromptMoveBackDraft", () => {
test("keeps full text, structured mentions, and inline images", () => {
const item = {
...queued[0],
payload: {
text: "inspect @main.ts with @build",
files: [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri" as const, uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { start: 8, end: 16, text: "@main.ts" },
},
{ data: "aGk=", mime: "image/png", source: { type: "inline" as const }, name: "shot.png" },
],
agents: [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }],
},
} satisfies SessionInboxInfo
expect(queuedPromptMoveBackDraft(item)).toMatchObject([
{ type: "text", content: "inspect " },
{ type: "file", content: "@main.ts", url: "data:text/plain;base64,aGk=" },
{ type: "text", content: " with " },
{ type: "agent", content: "@build", name: "build" },
{ type: "image", filename: "shot.png" },
])
})
test("does not drop hidden file context", () => {
const item = {
...queued[0],
payload: {
text: "inspect this",
files: [{ data: "aGk=", mime: "text/plain", source: { type: "uri" as const, uri: "file:///repo/main.ts" } }],
},
} satisfies SessionInboxInfo
expect(queuedPromptMoveBackDraft(item)).toBeUndefined()
})
})
+2 -115
View File
@@ -3,7 +3,6 @@ import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import type { SessionInboxInfo } from "@opencode/client/promise"
import { SessionMessage } from "@opencode/schema/session-message"
import { Skill } from "@opencode/schema/skill"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
@@ -43,7 +42,6 @@ export function createSessionQueue(input: {
mutationFn: async (
change:
| { type: "reorder"; inboxIDs: string[] }
| { type: "move-back"; item: QueuedPrompt; prompt: Prompt }
| {
type: "edit"
inboxIDs: string[]
@@ -56,13 +54,6 @@ export function createSessionQueue(input: {
},
) => {
if (change.type === "reorder") return rewrite(change.inboxIDs)
if (change.type === "move-back") {
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.item.id })
input.draft.mode.set("normal")
input.draft.set(change.prompt, promptLength(change.prompt))
input.restoreFocus(promptLength(change.prompt))
return
}
const replacement = await editedPromptInput(
input.sessionID,
location().directory,
@@ -148,25 +139,6 @@ export function createSessionQueue(input: {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const moveBack = (id: string) => {
if (mutation.isPending || state.editing) return
const item = queued().find((entry) => entry.id === id)
if (!item) return
if (
input.draft.current().some((part) => ("content" in part ? !!part.content.length : true)) ||
input.draft.mode.current() !== "normal" ||
input.draft.retry.current()
) {
showToast({ title: language.t("session.queue.moveBackDraft") })
return
}
const prompt = queuedPromptMoveBackDraft(item)
if (!prompt) {
showToast({ title: language.t("session.queue.moveBackUnavailable") })
return
}
mutation.mutate({ type: "move-back", item, prompt })
}
const reorder = (inboxIDs: string[]) => {
if (mutation.isPending) return Promise.resolve()
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
@@ -254,11 +226,9 @@ export function createSessionQueue(input: {
editFirst,
rows,
busy: () => mutation.isPending,
movingBack: () => mutation.isPending && mutation.variables?.type === "move-back",
working: input.working,
steer,
remove,
moveBack,
edit,
reorder,
}
@@ -269,7 +239,7 @@ export type SessionQueue = ReturnType<typeof createSessionQueue>
// The slice of the queue the panel renders and drives.
export type SessionQueueView = Pick<
SessionQueue,
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "moveBack" | "edit" | "reorder"
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
>
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
@@ -279,8 +249,7 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
.map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments:
(item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
}))
}
@@ -318,88 +287,6 @@ export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPar
]
}
// Use the full model-visible text so comment notes and path references remain
// in the draft. Convert mentioned files, agents, and skills back into editor
// parts; a detached draft cannot represent non-mentioned file context.
export function queuedPromptMoveBackDraft(item: QueuedPrompt): Prompt | undefined {
if (
item.payload.files?.some((file) => !isComposerAttachment(file) && !file.mention) ||
item.payload.agents?.some((agent) => !agent.mention) ||
item.payload.skills?.some((skill) => !skill.mention)
)
return
const text = item.payload.text
const references = [
...(item.payload.files ?? []).flatMap((file) =>
file.mention
? [
{
type: "file" as const,
content: file.mention.text,
start: file.mention.start,
end: file.mention.end,
path: file.name ?? file.mention.text.replace(/^@/, ""),
filename: file.name,
mime: file.mime,
url: `data:${file.mime};base64,${file.data}`,
},
]
: [],
),
...(item.payload.agents ?? []).flatMap((agent) =>
agent.mention
? [
{
type: "agent" as const,
content: agent.mention.text,
start: agent.mention.start,
end: agent.mention.end,
name: agent.name,
},
]
: [],
),
...(item.payload.skills ?? []).flatMap((skill) =>
skill.mention
? [
{
type: "skill" as const,
content: skill.mention.text,
start: skill.mention.start,
end: skill.mention.end,
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
},
]
: [],
),
].sort((left, right) => left.start - right.start)
if (
references.some(
(part, index) =>
part.start < (references[index - 1]?.end ?? 0) || text.slice(part.start, part.end) !== part.content,
)
)
return
const parts: Prompt = references.flatMap((part, index) => {
const start = references[index - 1]?.end ?? 0
return [
...(part.start > start
? [{ type: "text" as const, content: text.slice(start, part.start), start, end: part.start }]
: []),
part,
]
})
const start = references.at(-1)?.end ?? 0
return [
...parts,
...(text.length > start || !parts.length
? [{ type: "text" as const, content: text.slice(start), start, end: text.length }]
: []),
...queuedPromptAttachments(item).filter((part) => part.type === "image"),
]
}
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
return !file.mention && file.source.type === "inline"
}
+1 -6
View File
@@ -224,12 +224,7 @@ export function ActiveSessionComposerRegion(props: {
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.movingBack()}
suggestionBoundary={props.suggestionBoundary}
/>
<Composer model={props.model.composer} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
</div>
</div>
}
+1 -1
View File
@@ -21,7 +21,7 @@
"build:node": "bun run script/build-node.ts",
"dev": "bun run src/index.ts",
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo -b"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
+2 -1
View File
@@ -7,5 +7,6 @@
"lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"noUncheckedIndexedAccess": false
},
"exclude": ["dist", "dist-node"]
"exclude": ["dist", "dist-node"],
"references": [{ "path": "../core" }]
}
@@ -188,6 +188,7 @@ export type ShellInfo = {
file: string
pid?: number
exit?: number
signal?: string
metadata: { [x: string]: any }
time: { started: number; completed?: number }
}
@@ -395,6 +396,7 @@ export type ShellInfo1 = {
file: string
pid?: number
exit?: number
signal?: string
metadata: { [x: string]: JsonValue }
time: { started: number; completed?: number }
}
@@ -193,7 +193,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const flush = Effect.fnUntraced(function* () {
for (const id of Array.from(chunks.keys())) yield* end(id)
})
return { start, append, end, flush, has: (id: string) => chunks.has(id) }
/** Publish batched deltas now, keeping every fragment open. */
const publishPending = Effect.fnUntraced(function* () {
for (const [id, current] of Array.from(chunks)) {
if (current.timer) yield* Fiber.interrupt(current.timer)
yield* publishDelta(id)
}
})
return { start, append, end, flush, publishPending, has: (id: string) => chunks.has(id) }
}
const text = fragments(
@@ -255,6 +262,13 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}),
)
// Deltas are batched, but block starts are not. Publishing held deltas first keeps each
// block's content ahead of the next block, so the published order matches the model's.
const publishPendingDeltas = Effect.fnUntraced(function* () {
yield* text.publishPending()
yield* reasoning.publishPending()
})
const flushFragments = Effect.fnUntraced(function* () {
yield* text.flush()
yield* reasoning.flush()
@@ -276,6 +290,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}
tools.set(event.id, tool)
yield* toolInput.start(event.id)
yield* publishPendingDeltas()
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID: input.sessionID,
assistantMessageID,
@@ -390,6 +405,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
case "text-start":
outputStarted = true
const startedTextOrdinal = yield* text.start(event.id, providerState(event.providerMetadata))
yield* publishPendingDeltas()
yield* bus.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
@@ -405,6 +421,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
case "reasoning-start":
outputStarted = true
const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata))
yield* publishPendingDeltas()
yield* bus.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
+11 -2
View File
@@ -6,6 +6,7 @@ import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer"
import { Shell } from "@opencode/schema/shell"
import { AppProcess } from "@opencode/util/process"
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
import { makeGlobalNode, makeLocationNode } from "@opencode/util/effect/app-node"
import { FSUtil } from "@opencode/util/fs-util"
import { Bus } from "./bus.js"
@@ -345,12 +346,13 @@ const layer = () =>
}),
)
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void, signal?: string) =>
Effect.gen(function* () {
if (command.info.status !== "running") return
command.info = produce(command.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
if (signal !== undefined) draft.signal = signal
draft.time.completed = Date.now()
})
yield* beforeWait
@@ -401,7 +403,14 @@ const layer = () =>
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => finish("exited")),
Effect.catch((error) =>
finish(
"exited",
undefined,
Effect.void,
error.cause instanceof CrossSpawnSpawner.KilledBySignal ? error.cause.signal : undefined,
),
),
),
)
+5 -2
View File
@@ -7,7 +7,7 @@ export type Result = {
capture: { output: string; truncated: boolean } | undefined
}
type Output = { output: string; truncated: boolean; exit?: number; timeout?: boolean }
type Output = { output: string; truncated: boolean; exit?: number; signal?: string; timeout?: boolean }
const missing = "Shell command output is no longer available."
export const unavailable: Shell.Output = {
@@ -22,12 +22,14 @@ export function output(result: Result): Output {
output: result.capture?.output ?? unavailable.output,
truncated: result.capture?.truncated ?? false,
...(result.info.exit !== undefined ? { exit: result.info.exit } : {}),
...(result.info.signal !== undefined ? { signal: result.info.signal } : {}),
...(result.info.status === "timeout" ? { timeout: true } : {}),
}
}
export function notice(output: Pick<Output, "exit" | "timeout">) {
export function notice(output: Pick<Output, "exit" | "signal" | "timeout">) {
if (output.timeout) return "Timed out before completion"
if (output.signal !== undefined) return `Killed by ${output.signal}`
if (output.exit !== undefined && output.exit !== 0) return `Exited with code ${output.exit}`
}
@@ -35,6 +37,7 @@ export function metadata(output: Output) {
return {
truncated: output.truncated,
...(output.exit !== undefined ? { exit: output.exit } : {}),
...(output.signal !== undefined ? { signal: output.signal } : {}),
...(output.timeout !== undefined ? { timeout: output.timeout } : {}),
}
}
+1
View File
@@ -61,6 +61,7 @@ export const Input = Schema.Struct({
const StructuredOutput = Schema.Struct({
exit: Schema.optionalKey(Schema.Number),
signal: Schema.optionalKey(Schema.String),
shellID: Schema.optionalKey(Schema.String),
truncated: Schema.Boolean,
timeout: Schema.optionalKey(Schema.Boolean),
+1 -1
View File
@@ -378,7 +378,7 @@ const bedrockConverse: Protocol = (model, support) => {
output_config: { effort },
})
if (id.includes("openai.gpt-oss")) return fields({ reasoning_effort: effort })
if (id.includes("openai.")) return fields({ reasoning: { effort } })
if (id.includes("openai.") || id.includes("xai.")) return fields({ reasoning: { effort } })
return fields({ reasoningConfig: { type: "enabled", maxReasoningEffort: effort } })
})
case "toggle":
@@ -387,6 +387,46 @@ it.effect("batches text deltas and flushes pending text before the terminal even
}),
)
it.effect("publishes batched deltas before the next block starts", () =>
Effect.gen(function* () {
const { published, publisher } = capture()
const types = () =>
published
.map((event) => event.type)
.filter((type) => type !== "session.step.started.1" && type !== "session.step.streamed")
yield* Effect.forEach(
[
LLMEvent.reasoningStart({ id: "reasoning" }),
LLMEvent.reasoningDelta({ id: "reasoning", text: "Plan the edits." }),
LLMEvent.textStart({ id: "text" }),
LLMEvent.textDelta({ id: "text", text: "Now the edits:" }),
LLMEvent.toolInputStart({ id: "call", name: "edit" }),
],
publisher.publish,
{ discard: true },
)
expect(types()).toEqual([
"session.reasoning.started.1",
"session.reasoning.delta",
"session.text.started.1",
"session.text.delta",
"session.tool.input.started.1",
])
// Blocks stay open: later chunks still batch, and nothing is published twice.
yield* publisher.publish(LLMEvent.textDelta({ id: "text", text: " more" }))
yield* TestClock.adjust("1 second")
yield* publisher.publish(LLMEvent.textEnd({ id: "text" }))
expect(published.filter((event) => event.type === "session.text.delta").map((event) => event.data)).toMatchObject([
{ delta: "Now the edits:" },
{ delta: " more" },
])
expect(published.find((event) => event.type === "session.text.ended.1")?.data).toMatchObject({
text: "Now the edits: more",
})
}),
)
it.effect("retains new chunks and orders text-end behind an in-flight timer publication", () =>
Effect.gen(function* () {
const entered = yield* Deferred.make<void>()
+1 -1
View File
@@ -1679,7 +1679,7 @@ describe("ShellTool", () => {
process.kill(-info.pid, "SIGTERM")
const result = yield* shell.wait(id).pipe(Effect.timeoutOption(Duration.seconds(1)))
expect(result._tag).toBe("Some")
if (result._tag === "Some") expect(result.value.status).toBe("exited")
if (result._tag === "Some") expect(result.value).toMatchObject({ status: "exited", signal: "SIGTERM" })
expect((yield* shell.list()).map((item) => item.id)).not.toContain(id)
}),
)
+18
View File
@@ -196,6 +196,24 @@ test("spells Chat Completions variants for direct providers", () => {
])
})
test("spells Bedrock Converse effort for Grok and Nova", () => {
const supports: Variant.Support[] = [{ type: "effort", values: ["low", "xhigh"] }]
expect(resolve(model("@opencode/ai/providers/amazon-bedrock", "us.xai.grok-4.6"), supports)).toEqual([
{ id: "low", body: { additionalModelRequestFields: { reasoning: { effort: "low" } } } },
{ id: "xhigh", body: { additionalModelRequestFields: { reasoning: { effort: "xhigh" } } } },
])
expect(resolve(model("@opencode/ai/providers/amazon-bedrock", "us.amazon.nova-2-lite-v1:0"), supports)).toEqual([
{
id: "low",
body: { additionalModelRequestFields: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } } },
},
{
id: "xhigh",
body: { additionalModelRequestFields: { reasoningConfig: { type: "enabled", maxReasoningEffort: "xhigh" } } },
},
])
})
test("spells Chat Completions variants for hosting providers", () => {
expect(
resolve(model("@opencode/ai/providers/openai-compatible", "deepseek-ai/deepseek-v4-pro", undefined, "nvidia"), [

Some files were not shown because too many files have changed in this diff Show More