mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-24 17:47:34 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c832432d89 | ||
|
|
1d431a80df | ||
|
|
19a9e41c28 | ||
|
|
d932cad09c | ||
|
|
e22c1622e0 | ||
|
|
0e4ad0ce76 | ||
|
|
6afcc887c8 | ||
|
|
c1f50659a7 | ||
|
|
32d3535f66 |
@@ -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`).
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,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 }],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(", ")}`,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user