refactor(ai): infer image provider options (#37948)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
This commit is contained in:
parent
0b2e2cbab0
commit
17c1e9b083
12 changed files with 292 additions and 198 deletions
|
|
@ -36,15 +36,33 @@ const program = Effect.gen(function* () {
|
||||||
const response = yield* Image.generate({
|
const response = yield* Image.generate({
|
||||||
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
|
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
|
||||||
prompt: "A robot tending a rooftop garden",
|
prompt: "A robot tending a rooftop garden",
|
||||||
count: 2,
|
options: {
|
||||||
size: { width: 1024, height: 1024 },
|
n: 2,
|
||||||
providerOptions: { openai: { quality: "high", outputFormat: "webp" } },
|
size: "1024x1024",
|
||||||
|
quality: "high", // inferred from the OpenAI image model
|
||||||
|
outputFormat: "webp",
|
||||||
|
future_option: true, // unknown native options pass through unchanged
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return response.images // GeneratedImage[] with owned bytes or a provider URL
|
return response.images // GeneratedImage[] with owned bytes or a provider URL
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const model = OpenAI.configure({ apiKey }).image("gpt-image-2")
|
||||||
|
|
||||||
|
yield *
|
||||||
|
Image.generate({
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
options: { quality: "medium" },
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
|
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"setup:recording-env": "bun run script/setup-recording-env.ts",
|
"setup:recording-env": "bun run script/setup-recording-env.ts",
|
||||||
"test": "bun test --timeout 30000 --only-failures",
|
"test": "bun test --timeout 30000 --only-failures",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.types.json",
|
||||||
"build": "tsc -p tsconfig.build.json"
|
"build": "tsc -p tsconfig.build.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
import { Context, Effect, Layer } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { RequestExecutor } from "./route/executor"
|
import { RequestExecutor } from "./route/executor"
|
||||||
import type { ImageRequest, ImageResponse } from "./image"
|
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image"
|
||||||
import type { LLMError } from "./schema"
|
import type { LLMError } from "./schema"
|
||||||
|
|
||||||
export type Execute = RequestExecutor.Interface["execute"]
|
export type Execute = RequestExecutor.Interface["execute"]
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly generate: (request: ImageRequest) => Effect.Effect<ImageResponse, LLMError>
|
readonly generate: <Options extends ImageOptions>(
|
||||||
|
request: ImageRequestFor<Options>,
|
||||||
|
) => Effect.Effect<ImageResponse, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
|
||||||
|
|
||||||
export const generate = (request: ImageRequest): Effect.Effect<ImageResponse, LLMError> =>
|
export const generate = <Options extends ImageOptions>(
|
||||||
|
request: ImageRequestFor<Options>,
|
||||||
|
): Effect.Effect<ImageResponse, LLMError> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const client = yield* Service
|
const client = yield* Service
|
||||||
return yield* client.generate(request)
|
return yield* client.generate(request)
|
||||||
|
|
|
||||||
|
|
@ -1,80 +1,85 @@
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
|
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
|
||||||
import { ImageClient, type Execute as ImageExecute } from "./image-client"
|
import { ImageClient, Service, type Execute as ImageExecute } from "./image-client"
|
||||||
|
|
||||||
export interface ImageRoute {
|
export interface ImageRoute<Options extends ImageOptions = ImageOptions> {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly generate: (request: ImageRequest, execute: ImageExecute) => Effect.Effect<ImageResponse, LLMError>
|
readonly generate: (
|
||||||
|
request: ImageRequestFor<Options>,
|
||||||
|
execute: ImageExecute,
|
||||||
|
) => Effect.Effect<ImageResponse, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ImageModel {
|
export type ImageOptions = Record<string, unknown>
|
||||||
|
|
||||||
|
export class ImageModel<Options extends ImageOptions = ImageOptions> {
|
||||||
|
declare protected readonly _Options: (options: Options) => Options
|
||||||
readonly id: ModelID
|
readonly id: ModelID
|
||||||
readonly provider: ProviderID
|
readonly provider: ProviderID
|
||||||
readonly route: ImageRoute
|
readonly route: ImageRoute<Options>
|
||||||
readonly defaults?: ImageModelDefaults
|
readonly http?: HttpOptions
|
||||||
|
|
||||||
constructor(input: ImageModel.Input) {
|
constructor(input: ImageModel.Input<Options>) {
|
||||||
this.id = input.id
|
this.id = input.id
|
||||||
this.provider = input.provider
|
this.provider = input.provider
|
||||||
this.route = input.route
|
this.route = input.route
|
||||||
this.defaults = input.defaults
|
this.http = input.http
|
||||||
}
|
}
|
||||||
|
|
||||||
static make(input: ImageModel.MakeInput) {
|
static make<Options extends ImageOptions = ImageOptions>(input: ImageModel.MakeInput<Options>) {
|
||||||
return new ImageModel({
|
return new ImageModel<Options>({
|
||||||
id: ModelID.make(input.id),
|
id: ModelID.make(input.id),
|
||||||
provider: ProviderID.make(input.provider),
|
provider: ProviderID.make(input.provider),
|
||||||
route: input.route,
|
route: input.route,
|
||||||
defaults: input.defaults,
|
http: input.http,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export namespace ImageModel {
|
export namespace ImageModel {
|
||||||
export interface Input {
|
export interface Input<Options extends ImageOptions = ImageOptions> {
|
||||||
readonly id: ModelID
|
readonly id: ModelID
|
||||||
readonly provider: ProviderID
|
readonly provider: ProviderID
|
||||||
readonly route: ImageRoute
|
readonly route: ImageRoute<Options>
|
||||||
readonly defaults?: ImageModelDefaults
|
readonly http?: HttpOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MakeInput extends Omit<Input, "id" | "provider"> {
|
export interface MakeInput<Options extends ImageOptions = ImageOptions>
|
||||||
|
extends Omit<Input<Options>, "id" | "provider"> {
|
||||||
readonly id: string | ModelID
|
readonly id: string | ModelID
|
||||||
readonly provider: string | ProviderID
|
readonly provider: string | ProviderID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageModelDefaults {
|
|
||||||
readonly providerOptions?: Record<string, Record<string, unknown>>
|
|
||||||
readonly http?: HttpOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
|
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
|
||||||
expected: "Image.Model",
|
expected: "Image.Model",
|
||||||
})
|
})
|
||||||
|
|
||||||
export const ImageSize = Schema.Struct({
|
|
||||||
width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
|
|
||||||
height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
|
|
||||||
}).annotate({ identifier: "Image.Size" })
|
|
||||||
export type ImageSize = Schema.Schema.Type<typeof ImageSize>
|
|
||||||
|
|
||||||
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
|
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
|
||||||
model: ImageModelSchema,
|
model: ImageModelSchema,
|
||||||
prompt: Schema.String,
|
prompt: Schema.String,
|
||||||
count: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||||
size: Schema.optional(ImageSize),
|
|
||||||
aspectRatio: Schema.optional(Schema.String),
|
|
||||||
seed: Schema.optional(Schema.Number),
|
|
||||||
providerOptions: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))),
|
|
||||||
http: Schema.optional(HttpOptions),
|
http: Schema.optional(HttpOptions),
|
||||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
}) {
|
||||||
}) {}
|
declare protected readonly _ImageRequest: void
|
||||||
|
|
||||||
export type ImageRequestInput = Omit<ConstructorParameters<typeof ImageRequest>[0], "http"> & {
|
|
||||||
readonly http?: HttpOptions.Input
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImageRequestFor<Options extends ImageOptions = ImageOptions> = Omit<ImageRequest, "model" | "options"> & {
|
||||||
|
readonly model: ImageModel<Options>
|
||||||
|
readonly options?: Options
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImageModelOptions<Model> = Model extends ImageModel<infer Options> ? Options : never
|
||||||
|
|
||||||
|
export type ImageRequestInput<Model extends object = ImageModel> = Omit<
|
||||||
|
ConstructorParameters<typeof ImageRequest>[0],
|
||||||
|
"model" | "options" | "http"
|
||||||
|
> & {
|
||||||
|
readonly model: Model
|
||||||
|
readonly options?: NoInfer<ImageModelOptions<Model>>
|
||||||
|
readonly http?: HttpOptions.Input
|
||||||
|
} & (Model extends ImageModel<ImageModelOptions<Model>> ? unknown : never)
|
||||||
|
|
||||||
export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
|
export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
|
||||||
mediaType: Schema.String,
|
mediaType: Schema.String,
|
||||||
data: Schema.Union([Schema.String, Schema.Uint8Array]),
|
data: Schema.Union([Schema.String, Schema.Uint8Array]),
|
||||||
|
|
@ -91,24 +96,34 @@ export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const request = (input: ImageRequest | ImageRequestInput) => {
|
export function request<const Model extends object>(
|
||||||
|
input: ImageRequestInput<Model>,
|
||||||
|
): ImageRequestFor<ImageModelOptions<Model>>
|
||||||
|
export function request(input: ImageRequest): ImageRequest
|
||||||
|
export function request(input: ImageRequest | ImageRequestInput) {
|
||||||
if (input instanceof ImageRequest) return input
|
if (input instanceof ImageRequest) return input
|
||||||
return new ImageRequest({
|
return new ImageRequest({
|
||||||
...input,
|
...input,
|
||||||
|
model: input.model as unknown as ImageModel,
|
||||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generate = (input: ImageRequest | ImageRequestInput) =>
|
export function generate<const Model extends object>(
|
||||||
Effect.try({
|
input: ImageRequestInput<Model>,
|
||||||
try: () => request(input),
|
): Effect.Effect<ImageResponse, LLMError, Service>
|
||||||
|
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, LLMError, Service>
|
||||||
|
export function generate(input: ImageRequest | ImageRequestInput) {
|
||||||
|
return Effect.try({
|
||||||
|
try: () => (input instanceof ImageRequest ? input : request(input)),
|
||||||
catch: (error) =>
|
catch: (error) =>
|
||||||
new LLMError({
|
new LLMError({
|
||||||
module: "Image",
|
module: "Image",
|
||||||
method: "generate",
|
method: "generate",
|
||||||
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
|
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
|
||||||
}),
|
}),
|
||||||
}).pipe(Effect.flatMap(ImageClient.generate))
|
}).pipe(Effect.flatMap((request) => ImageClient.generate(request as unknown as ImageRequestFor<ImageOptions>)))
|
||||||
|
}
|
||||||
|
|
||||||
export const Image = {
|
export const Image = {
|
||||||
request,
|
request,
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ export type {
|
||||||
Service as LLMClientService,
|
Service as LLMClientService,
|
||||||
} from "./route/client"
|
} from "./route/client"
|
||||||
export * from "./schema"
|
export * from "./schema"
|
||||||
export { GeneratedImage, ImageModel, ImageRequest, ImageResponse, ImageSize } from "./image"
|
export { GeneratedImage, ImageModel, ImageRequest, ImageResponse } from "./image"
|
||||||
export type { ImageModelDefaults, ImageRequestInput, ImageRoute } from "./image"
|
export type { ImageModelOptions, ImageOptions, ImageRequestFor, ImageRequestInput, ImageRoute } from "./image"
|
||||||
export { Image } from "./image"
|
export { Image } from "./image"
|
||||||
export { Tool, ToolFailure, toDefinitions } from "./tool"
|
export { Tool, ToolFailure, toDefinitions } from "./tool"
|
||||||
export { ToolRuntime } from "./tool-runtime"
|
export { ToolRuntime } from "./tool-runtime"
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { Effect, Encoding, Schema } from "effect"
|
import { Effect, Encoding, Schema } from "effect"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
import {
|
import { ImageModel, GeneratedImage, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
|
||||||
ImageModel,
|
|
||||||
GeneratedImage,
|
|
||||||
ImageResponse,
|
|
||||||
type ImageRequest,
|
|
||||||
type ImageModelDefaults,
|
|
||||||
type ImageRoute,
|
|
||||||
} from "../image"
|
|
||||||
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||||
import { InvalidProviderOutputReason, LLMError, Usage, mergeHttpOptions, mergeJsonRecords } from "../schema"
|
import {
|
||||||
|
InvalidProviderOutputReason,
|
||||||
|
LLMError,
|
||||||
|
Usage,
|
||||||
|
mergeHttpOptions,
|
||||||
|
mergeJsonRecords,
|
||||||
|
type HttpOptions,
|
||||||
|
} from "../schema"
|
||||||
import { ProviderShared } from "./shared"
|
import { ProviderShared } from "./shared"
|
||||||
import { OpenAIImage } from "./utils/openai-image"
|
import { OpenAIImage } from "./utils/openai-image"
|
||||||
|
|
||||||
|
|
@ -17,26 +17,24 @@ const ADAPTER = "openai-images"
|
||||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||||
export const PATH = "/images/generations"
|
export const PATH = "/images/generations"
|
||||||
|
|
||||||
export interface OpenAIImageOptions {
|
export type OpenAIImageString<Known extends string> = Known | (string & {})
|
||||||
readonly quality?: "auto" | "low" | "medium" | "high"
|
|
||||||
readonly background?: "auto" | "opaque" | "transparent"
|
|
||||||
readonly moderation?: "auto" | "low"
|
|
||||||
readonly outputFormat?: "png" | "jpeg" | "webp"
|
|
||||||
readonly outputCompression?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const OpenAIImageBody = Schema.Struct({
|
export type OpenAIImageOptions = {
|
||||||
model: Schema.String,
|
readonly n?: number
|
||||||
prompt: Schema.String,
|
readonly size?: OpenAIImageString<
|
||||||
n: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
|
||||||
size: Schema.optional(Schema.String),
|
>
|
||||||
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
|
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
|
||||||
background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])),
|
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
|
||||||
moderation: Schema.optional(Schema.Literals(["auto", "low"])),
|
readonly moderation?: OpenAIImageString<"auto" | "low">
|
||||||
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
|
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
|
||||||
output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
|
readonly outputCompression?: number
|
||||||
})
|
} & Record<string, unknown>
|
||||||
export type OpenAIImageBody = Schema.Schema.Type<typeof OpenAIImageBody>
|
|
||||||
|
export type OpenAIImageBody = Record<string, unknown> & {
|
||||||
|
readonly model: string
|
||||||
|
readonly prompt: string
|
||||||
|
}
|
||||||
|
|
||||||
const OpenAIImageResponse = Schema.Struct({
|
const OpenAIImageResponse = Schema.Struct({
|
||||||
data: Schema.Array(
|
data: Schema.Array(
|
||||||
|
|
@ -63,26 +61,16 @@ export interface ModelInput {
|
||||||
readonly auth: AuthDefinition
|
readonly auth: AuthDefinition
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly headers?: Record<string, string>
|
readonly headers?: Record<string, string>
|
||||||
readonly defaults?: ImageModelDefaults
|
readonly http?: HttpOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerOptions = (request: ImageRequest): OpenAIImageOptions => ({
|
const nativeOptions = (options: Record<string, unknown> | undefined) => {
|
||||||
...request.model.defaults?.providerOptions?.openai,
|
if (!options) return undefined
|
||||||
...request.providerOptions?.openai,
|
const { outputFormat, outputCompression, ...native } = options
|
||||||
})
|
|
||||||
|
|
||||||
const body = (request: ImageRequest): OpenAIImageBody => {
|
|
||||||
const options = providerOptions(request)
|
|
||||||
return {
|
return {
|
||||||
model: request.model.id,
|
output_format: outputFormat,
|
||||||
prompt: request.prompt,
|
output_compression: outputCompression,
|
||||||
n: request.count,
|
...native,
|
||||||
size: request.size === undefined ? undefined : `${request.size.width}x${request.size.height}`,
|
|
||||||
quality: options.quality,
|
|
||||||
background: options.background,
|
|
||||||
moderation: options.moderation,
|
|
||||||
output_format: options.outputFormat,
|
|
||||||
output_compression: options.outputCompression,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,44 +88,17 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
return next.toString()
|
return next.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROTOCOL_BODY_FIELDS = new Set([
|
|
||||||
"model",
|
|
||||||
"prompt",
|
|
||||||
"n",
|
|
||||||
"size",
|
|
||||||
"quality",
|
|
||||||
"background",
|
|
||||||
"moderation",
|
|
||||||
"output_format",
|
|
||||||
"output_compression",
|
|
||||||
])
|
|
||||||
|
|
||||||
const bodyWithOverlay = Effect.fn("OpenAIImages.bodyWithOverlay")(function* (
|
|
||||||
imageBody: OpenAIImageBody,
|
|
||||||
overlay: Record<string, unknown> | undefined,
|
|
||||||
) {
|
|
||||||
if (!overlay) return imageBody
|
|
||||||
const reserved = Object.keys(overlay).filter((key) => PROTOCOL_BODY_FIELDS.has(key))
|
|
||||||
if (reserved.length > 0)
|
|
||||||
return yield* ProviderShared.invalidRequest(
|
|
||||||
`http.body cannot overlay protocol-owned field(s): ${reserved.join(", ")}`,
|
|
||||||
)
|
|
||||||
return mergeJsonRecords(imageBody, overlay) ?? imageBody
|
|
||||||
})
|
|
||||||
|
|
||||||
export const model = (input: ModelInput) => {
|
export const model = (input: ModelInput) => {
|
||||||
const route: ImageRoute = {
|
const route: ImageRoute<OpenAIImageOptions> = {
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequest, execute) {
|
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
|
||||||
if (request.aspectRatio !== undefined)
|
const http = mergeHttpOptions(request.model.http, request.http)
|
||||||
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common aspectRatio option")
|
const requestBody = mergeJsonRecords(
|
||||||
if (request.seed !== undefined)
|
{ model: request.model.id, prompt: request.prompt },
|
||||||
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common seed option")
|
nativeOptions(request.options),
|
||||||
|
http?.body,
|
||||||
const requestBody = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIImageBody))(body(request))
|
) as OpenAIImageBody
|
||||||
const http = mergeHttpOptions(request.model.defaults?.http, request.http)
|
const text = ProviderShared.encodeJson(requestBody)
|
||||||
const overlaidBody = yield* bodyWithOverlay(requestBody, http?.body)
|
|
||||||
const text = ProviderShared.encodeJson(overlaidBody)
|
|
||||||
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
||||||
const headers = yield* Auth.toEffect(input.auth)({
|
const headers = yield* Auth.toEffect(input.auth)({
|
||||||
request,
|
request,
|
||||||
|
|
@ -158,7 +119,8 @@ export const model = (input: ModelInput) => {
|
||||||
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
|
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
|
||||||
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
|
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
|
||||||
)
|
)
|
||||||
const format = decoded.output_format ?? providerOptions(request).outputFormat ?? "png"
|
const format =
|
||||||
|
decoded.output_format ?? (typeof requestBody.output_format === "string" ? requestBody.output_format : "png")
|
||||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||||
if (item.b64_json)
|
if (item.b64_json)
|
||||||
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||||
|
|
@ -200,7 +162,7 @@ export const model = (input: ModelInput) => {
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
return ImageModel.make({ id: input.id, provider: "openai", route, defaults: input.defaults })
|
return ImageModel.make<OpenAIImageOptions>({ id: input.id, provider: "openai", route, http: input.http })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OpenAIImages = {
|
export const OpenAIImages = {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID
|
||||||
import * as OpenAIChat from "../protocols/openai-chat"
|
import * as OpenAIChat from "../protocols/openai-chat"
|
||||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||||
import { OpenAIImages, type OpenAIImageOptions } from "../protocols/openai-images"
|
import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images"
|
||||||
|
|
||||||
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
||||||
export type { OpenAIImageOptions } from "../protocols/openai-images"
|
export type { OpenAIImageOptions } from "../protocols/openai-images"
|
||||||
|
|
@ -22,22 +22,19 @@ export type Config = RouteDefaultsInput &
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly queryParams?: Record<string, string>
|
readonly queryParams?: Record<string, string>
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
readonly image?: ImageConfig
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageConfig {
|
|
||||||
readonly providerOptions?: OpenAIImageOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageGenerationOptions {
|
export interface ImageGenerationOptions {
|
||||||
readonly action?: "auto" | "generate" | "edit"
|
readonly action?: OpenAIImageString<"auto" | "generate" | "edit">
|
||||||
readonly background?: "auto" | "opaque" | "transparent"
|
readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">
|
||||||
readonly inputFidelity?: "low" | "high"
|
readonly inputFidelity?: OpenAIImageString<"low" | "high">
|
||||||
readonly outputCompression?: number
|
readonly outputCompression?: number
|
||||||
readonly outputFormat?: "png" | "jpeg" | "webp"
|
readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">
|
||||||
readonly partialImages?: number
|
readonly partialImages?: number
|
||||||
readonly quality?: "auto" | "low" | "medium" | "high"
|
readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">
|
||||||
readonly size?: string
|
readonly size?: OpenAIImageString<
|
||||||
|
"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792"
|
||||||
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
||||||
|
|
@ -73,7 +70,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
||||||
|
|
||||||
const defaults = (input: Config) => {
|
const defaults = (input: Config) => {
|
||||||
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, image: _image, ...rest } = input
|
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, ...rest } = input
|
||||||
return rest
|
return rest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,14 +96,10 @@ export const configure = (input: Config = {}) => {
|
||||||
auth: auth(input),
|
auth: auth(input),
|
||||||
baseURL: input.baseURL,
|
baseURL: input.baseURL,
|
||||||
headers: input.headers,
|
headers: input.headers,
|
||||||
defaults: {
|
http: mergeHttpOptions(
|
||||||
providerOptions:
|
input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||||
input.image?.providerOptions === undefined ? undefined : { openai: { ...input.image.providerOptions } },
|
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
|
||||||
http: mergeHttpOptions(
|
),
|
||||||
input.http === undefined ? undefined : HttpOptions.make(input.http),
|
|
||||||
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { Config } from "effect"
|
import { Config } from "effect"
|
||||||
import type { Auth } from "../src/route/auth"
|
import { Auth } from "../src/route"
|
||||||
import type { ModelFactory } from "../src/route/auth-options"
|
import type { ModelFactory } from "../src/route/auth-options"
|
||||||
import { Auth as RuntimeAuth } from "../src/route/auth"
|
|
||||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||||
import * as AmazonBedrock from "../src/providers/amazon-bedrock"
|
import * as AmazonBedrock from "../src/providers/amazon-bedrock"
|
||||||
import * as Anthropic from "../src/providers/anthropic"
|
import * as Anthropic from "../src/providers/anthropic"
|
||||||
|
|
@ -28,7 +27,7 @@ type Model = {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
declare const auth: Auth
|
declare const auth: Auth.Definition
|
||||||
declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model>
|
declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model>
|
||||||
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
|
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
|
||||||
const configApiKey = Config.redacted("OPENAI_API_KEY")
|
const configApiKey = Config.redacted("OPENAI_API_KEY")
|
||||||
|
|
@ -76,9 +75,9 @@ OpenAI.responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({}).responses("gpt-4.1-mini")
|
OpenAI.configure({}).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini")
|
OpenAI.configure({ apiKey: "sk-test" }).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini")
|
OpenAI.configure({ apiKey: configApiKey }).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).responses("gpt-4.1-mini")
|
OpenAI.configure({ auth: Auth.bearer("oauth-token") }).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({
|
OpenAI.configure({
|
||||||
auth: RuntimeAuth.headers({ authorization: "Bearer gateway" }),
|
auth: Auth.headers({ authorization: "Bearer gateway" }),
|
||||||
baseURL: "https://gateway.example.com/v1",
|
baseURL: "https://gateway.example.com/v1",
|
||||||
}).responses("gpt-4.1-mini")
|
}).responses("gpt-4.1-mini")
|
||||||
OpenAI.configure({
|
OpenAI.configure({
|
||||||
|
|
@ -102,40 +101,40 @@ OpenAI.configure({ generation: { maxTokens: "many" } })
|
||||||
OpenAI.configure({ providerOptions: { openai: { store: "false" } } })
|
OpenAI.configure({ providerOptions: { openai: { store: "false" } } })
|
||||||
|
|
||||||
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
|
// @ts-expect-error auth is an override, so OpenAI rejects apiKey with auth.
|
||||||
OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
|
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
|
||||||
|
|
||||||
OpenAI.chat("gpt-4.1-mini")
|
OpenAI.chat("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini")
|
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini")
|
||||||
OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini")
|
OpenAI.configure({ apiKey: configApiKey }).chat("gpt-4.1-mini")
|
||||||
OpenAI.configure({ auth: RuntimeAuth.bearer("oauth-token") }).chat("gpt-4.1-mini")
|
OpenAI.configure({ auth: Auth.bearer("oauth-token") }).chat("gpt-4.1-mini")
|
||||||
|
|
||||||
// @ts-expect-error OpenAI chat selectors only accept model ids.
|
// @ts-expect-error OpenAI chat selectors only accept model ids.
|
||||||
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini", {})
|
OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini", {})
|
||||||
|
|
||||||
// @ts-expect-error auth is an override, so OpenAI Chat rejects apiKey with auth.
|
// @ts-expect-error auth is an override, so OpenAI Chat rejects apiKey with auth.
|
||||||
OpenAI.configure({ apiKey: "sk-test", auth: RuntimeAuth.bearer("oauth-token") })
|
OpenAI.configure({ apiKey: "sk-test", auth: Auth.bearer("oauth-token") })
|
||||||
|
|
||||||
// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`.
|
// @ts-expect-error Azure requires at least one of `resourceName` or `baseURL`.
|
||||||
Azure.configure()
|
Azure.configure()
|
||||||
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment")
|
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment")
|
||||||
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).responses("deployment")
|
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).responses("deployment")
|
||||||
Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).responses("deployment")
|
Azure.configure({ auth: Auth.header("api-key", "azure-key"), resourceName: "resource" }).responses("deployment")
|
||||||
|
|
||||||
// @ts-expect-error Azure model selectors only accept deployment ids.
|
// @ts-expect-error Azure model selectors only accept deployment ids.
|
||||||
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment", {})
|
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment", {})
|
||||||
|
|
||||||
// @ts-expect-error auth is an override, so Azure rejects apiKey with auth.
|
// @ts-expect-error auth is an override, so Azure rejects apiKey with auth.
|
||||||
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
|
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") })
|
||||||
|
|
||||||
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment")
|
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment")
|
||||||
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).chat("deployment")
|
Azure.configure({ apiKey: configApiKey, resourceName: "resource" }).chat("deployment")
|
||||||
Azure.configure({ auth: RuntimeAuth.header("api-key", "azure-key"), resourceName: "resource" }).chat("deployment")
|
Azure.configure({ auth: Auth.header("api-key", "azure-key"), resourceName: "resource" }).chat("deployment")
|
||||||
|
|
||||||
// @ts-expect-error Azure chat model selectors only accept deployment ids.
|
// @ts-expect-error Azure chat model selectors only accept deployment ids.
|
||||||
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment", {})
|
Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment", {})
|
||||||
|
|
||||||
// @ts-expect-error auth is an override, so Azure Chat rejects apiKey with auth.
|
// @ts-expect-error auth is an override, so Azure Chat rejects apiKey with auth.
|
||||||
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: RuntimeAuth.header("api-key", "override") })
|
Azure.configure({ resourceName: "resource", apiKey: "azure-key", auth: Auth.header("api-key", "override") })
|
||||||
|
|
||||||
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
|
Anthropic.configure({ apiKey: "anthropic-key" }).model("claude-haiku")
|
||||||
// @ts-expect-error Anthropic model selectors only accept model ids.
|
// @ts-expect-error Anthropic model selectors only accept model ids.
|
||||||
|
|
@ -165,7 +164,7 @@ Google.configure({ apiKey: "google-key" }).model("gemini-2.5-flash", {})
|
||||||
|
|
||||||
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash")
|
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash")
|
||||||
GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
|
GoogleVertex.configure({ accessToken: "vertex-token", project: "project" }).model("gemini-3.5-flash")
|
||||||
GoogleVertex.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
|
GoogleVertex.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("gemini-3.5-flash")
|
||||||
// @ts-expect-error Vertex Gemini model selectors only accept model ids.
|
// @ts-expect-error Vertex Gemini model selectors only accept model ids.
|
||||||
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
|
GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {})
|
||||||
// @ts-expect-error Vertex Gemini config accepts only one auth source.
|
// @ts-expect-error Vertex Gemini config accepts only one auth source.
|
||||||
|
|
@ -174,7 +173,7 @@ GoogleVertex.configure({ accessToken: "vertex-token", apiKey: "vertex-key", proj
|
||||||
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
|
GoogleVertex.model("gemini-3.5-flash", { accessToken: "vertex-token", apiKey: "vertex-key", project: "project" })
|
||||||
|
|
||||||
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
|
GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).model("deepseek-ai/deepseek-v3.2-maas")
|
||||||
GoogleVertexChat.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
|
GoogleVertexChat.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
||||||
"deepseek-ai/deepseek-v3.2-maas",
|
"deepseek-ai/deepseek-v3.2-maas",
|
||||||
)
|
)
|
||||||
// @ts-expect-error Vertex Chat package settings do not accept API keys.
|
// @ts-expect-error Vertex Chat package settings do not accept API keys.
|
||||||
|
|
@ -187,12 +186,12 @@ GoogleVertexChat.configure({ accessToken: "vertex-token", project: "project" }).
|
||||||
GoogleVertexChat.configure({
|
GoogleVertexChat.configure({
|
||||||
accessToken: "vertex-token",
|
accessToken: "vertex-token",
|
||||||
// @ts-expect-error Vertex Chat config accepts only one auth source.
|
// @ts-expect-error Vertex Chat config accepts only one auth source.
|
||||||
auth: RuntimeAuth.bearer("vertex-token"),
|
auth: Auth.bearer("vertex-token"),
|
||||||
project: "project",
|
project: "project",
|
||||||
})
|
})
|
||||||
|
|
||||||
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model("xai/grok-4.20-reasoning")
|
GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project" }).model("xai/grok-4.20-reasoning")
|
||||||
GoogleVertexResponses.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
|
GoogleVertexResponses.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model(
|
||||||
"xai/grok-4.20-reasoning",
|
"xai/grok-4.20-reasoning",
|
||||||
)
|
)
|
||||||
// @ts-expect-error Vertex Responses package settings do not accept API keys.
|
// @ts-expect-error Vertex Responses package settings do not accept API keys.
|
||||||
|
|
@ -205,16 +204,14 @@ GoogleVertexResponses.configure({ accessToken: "vertex-token", project: "project
|
||||||
GoogleVertexResponses.configure({
|
GoogleVertexResponses.configure({
|
||||||
accessToken: "vertex-token",
|
accessToken: "vertex-token",
|
||||||
// @ts-expect-error Vertex Responses config accepts only one auth source.
|
// @ts-expect-error Vertex Responses config accepts only one auth source.
|
||||||
auth: RuntimeAuth.bearer("vertex-token"),
|
auth: Auth.bearer("vertex-token"),
|
||||||
project: "project",
|
project: "project",
|
||||||
})
|
})
|
||||||
|
|
||||||
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
|
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6")
|
||||||
// @ts-expect-error Vertex Messages package settings do not accept API keys.
|
// @ts-expect-error Vertex Messages package settings do not accept API keys.
|
||||||
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
|
GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" })
|
||||||
GoogleVertexMessages.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model(
|
GoogleVertexMessages.configure({ auth: Auth.bearer("vertex-token"), project: "project" }).model("claude-sonnet-4-6")
|
||||||
"claude-sonnet-4-6",
|
|
||||||
)
|
|
||||||
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
|
GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model(
|
||||||
"claude-sonnet-4-6",
|
"claude-sonnet-4-6",
|
||||||
// @ts-expect-error Vertex Messages model selectors only accept model ids.
|
// @ts-expect-error Vertex Messages model selectors only accept model ids.
|
||||||
|
|
@ -223,7 +220,7 @@ GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project"
|
||||||
GoogleVertexMessages.configure({
|
GoogleVertexMessages.configure({
|
||||||
accessToken: "vertex-token",
|
accessToken: "vertex-token",
|
||||||
// @ts-expect-error Vertex Messages config accepts only one auth source.
|
// @ts-expect-error Vertex Messages config accepts only one auth source.
|
||||||
auth: RuntimeAuth.bearer("vertex-token"),
|
auth: Auth.bearer("vertex-token"),
|
||||||
project: "project",
|
project: "project",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,20 @@ describe("Image", () => {
|
||||||
http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
|
http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
|
||||||
}).image("gpt-image-2"),
|
}).image("gpt-image-2"),
|
||||||
prompt: "A robot tending a rooftop garden",
|
prompt: "A robot tending a rooftop garden",
|
||||||
count: 2,
|
options: {
|
||||||
size: { width: 1024, height: 1024 },
|
n: 2,
|
||||||
providerOptions: {
|
size: "2048x2048",
|
||||||
openai: { quality: "high", outputFormat: "webp" },
|
quality: "future-quality",
|
||||||
|
outputFormat: "jpeg",
|
||||||
|
output_format: "avif",
|
||||||
|
outputCompression: 30,
|
||||||
|
output_compression: 40,
|
||||||
|
background: "opaque",
|
||||||
|
native_default: true,
|
||||||
|
future_option: true,
|
||||||
},
|
},
|
||||||
http: {
|
http: {
|
||||||
body: { request_metadata: "value" },
|
body: { output_format: "webp", output_compression: 50, future_option: "http", request_metadata: "value" },
|
||||||
headers: { "x-request": "yes" },
|
headers: { "x-request": "yes" },
|
||||||
query: { trace: "1" },
|
query: { trace: "1" },
|
||||||
},
|
},
|
||||||
|
|
@ -49,9 +56,13 @@ describe("Image", () => {
|
||||||
model: "gpt-image-2",
|
model: "gpt-image-2",
|
||||||
prompt: "A robot tending a rooftop garden",
|
prompt: "A robot tending a rooftop garden",
|
||||||
n: 2,
|
n: 2,
|
||||||
size: "1024x1024",
|
size: "2048x2048",
|
||||||
quality: "high",
|
quality: "future-quality",
|
||||||
|
background: "opaque",
|
||||||
output_format: "webp",
|
output_format: "webp",
|
||||||
|
output_compression: 50,
|
||||||
|
native_default: true,
|
||||||
|
future_option: "http",
|
||||||
deployment: "test",
|
deployment: "test",
|
||||||
request_metadata: "value",
|
request_metadata: "value",
|
||||||
})
|
})
|
||||||
|
|
@ -71,23 +82,44 @@ describe("Image", () => {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects invalid common and OpenAI image options locally", () =>
|
it.effect("preserves native snake_case and unknown request options", () =>
|
||||||
Image.generate({
|
Image.generate({
|
||||||
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("gpt-image-2"),
|
model: OpenAI.configure({
|
||||||
prompt: "A robot tending a rooftop garden",
|
apiKey: "test",
|
||||||
count: -1,
|
baseURL: "https://api.openai.test/v1",
|
||||||
size: { width: -1, height: 0.5 },
|
}).image("future-image-model"),
|
||||||
providerOptions: { openai: { outputCompression: 101 } },
|
prompt: "A lighthouse in fog",
|
||||||
|
options: {
|
||||||
|
outputFormat: "jpeg",
|
||||||
|
output_format: "avif",
|
||||||
|
outputCompression: 30,
|
||||||
|
output_compression: 40,
|
||||||
|
provider_future_option: { enabled: true },
|
||||||
|
},
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.flip,
|
Effect.tap((response) =>
|
||||||
Effect.tap((error) =>
|
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
expect(error.reason._tag).toBe("InvalidRequest")
|
expect(response.image?.mediaType).toBe("image/avif")
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
ImageClient.layer.pipe(
|
ImageClient.layer.pipe(
|
||||||
Layer.provide(dynamicResponse(() => Effect.die("invalid request should not reach the provider"))),
|
Layer.provide(
|
||||||
|
dynamicResponse((input) => {
|
||||||
|
expect(JSON.parse(input.text)).toEqual({
|
||||||
|
model: "future-image-model",
|
||||||
|
prompt: "A lighthouse in fog",
|
||||||
|
output_format: "avif",
|
||||||
|
output_compression: 40,
|
||||||
|
provider_future_option: { enabled: true },
|
||||||
|
})
|
||||||
|
return Effect.succeed(
|
||||||
|
input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
71
packages/ai/test/image.types.ts
Normal file
71
packages/ai/test/image.types.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import {
|
||||||
|
Image,
|
||||||
|
ImageModel,
|
||||||
|
type ImageModelOptions,
|
||||||
|
type ImageOptions,
|
||||||
|
type ImageRequestFor,
|
||||||
|
type ImageRoute,
|
||||||
|
} from "../src"
|
||||||
|
import { OpenAI } from "../src/providers"
|
||||||
|
|
||||||
|
type GoogleLikeOptions = {
|
||||||
|
readonly aspectRatio?: "1:1" | "16:9"
|
||||||
|
readonly imageSize?: "1K" | "2K"
|
||||||
|
} & Record<string, unknown>
|
||||||
|
|
||||||
|
declare const route: ImageRoute<GoogleLikeOptions>
|
||||||
|
const google = ImageModel.make<GoogleLikeOptions>({ id: "gemini-image", provider: "google", route })
|
||||||
|
// @ts-expect-error Extracted model options retain known provider fields.
|
||||||
|
const invalidGoogleOptions: ImageModelOptions<typeof google> = { aspectRatio: "wide" }
|
||||||
|
void invalidGoogleOptions
|
||||||
|
|
||||||
|
Image.generate({
|
||||||
|
model: google,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { aspectRatio: "16:9", imageSize: "2K", futureOption: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const openai = OpenAI.image("gpt-image-2")
|
||||||
|
// @ts-expect-error Image generation options are request-scoped, not provider configuration.
|
||||||
|
OpenAI.configure({ image: { options: { quality: "medium" } } })
|
||||||
|
const futureOpenAIOptions: ImageModelOptions<typeof openai> = { quality: "future-quality" }
|
||||||
|
void futureOpenAIOptions
|
||||||
|
Image.generate({
|
||||||
|
model: openai,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { quality: "hd", outputFormat: "webp", size: "2048x2048", future_option: true },
|
||||||
|
})
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: "future-quality", size: "256x256" } })
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { size: "1792x1024" } })
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { native_future_option: true } })
|
||||||
|
// @ts-expect-error Known OpenAI string options retain their value kind.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: 1 } })
|
||||||
|
// @ts-expect-error Known OpenAI numeric options retain their value kind.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", options: { outputCompression: "80" } })
|
||||||
|
OpenAI.imageGeneration({ action: "future-action", quality: "future-quality", size: "2048x2048" })
|
||||||
|
// @ts-expect-error Hosted image generation numeric options retain their value kind.
|
||||||
|
OpenAI.imageGeneration({ partialImages: "2" })
|
||||||
|
// @ts-expect-error Known Google-like options are inferred from the selected model.
|
||||||
|
Image.generate({ model: google, prompt: "A lighthouse", options: { aspectRatio: "wide" } })
|
||||||
|
|
||||||
|
declare const generic: ImageModel<ImageOptions>
|
||||||
|
Image.generate({ model: generic, prompt: "A lighthouse", options: { arbitrary: true } })
|
||||||
|
|
||||||
|
const request = Image.request({
|
||||||
|
model: google,
|
||||||
|
prompt: "A lighthouse",
|
||||||
|
options: { aspectRatio: "1:1", futureOption: true },
|
||||||
|
})
|
||||||
|
const typedRequest: ImageRequestFor<GoogleLikeOptions> = request
|
||||||
|
void typedRequest
|
||||||
|
|
||||||
|
// @ts-expect-error Image requests no longer expose a common count option.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", count: 2 })
|
||||||
|
// @ts-expect-error Image requests no longer expose a common size option.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", size: { width: 1024, height: 1024 } })
|
||||||
|
// @ts-expect-error Image requests no longer expose a common aspectRatio option.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", aspectRatio: "16:9" })
|
||||||
|
// @ts-expect-error Image requests no longer expose a common seed option.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", seed: 1 })
|
||||||
|
// @ts-expect-error Image requests do not expose metadata.
|
||||||
|
Image.generate({ model: openai, prompt: "A lighthouse", metadata: { trace: true } })
|
||||||
|
|
@ -6,13 +6,6 @@ import { recordedTests } from "../recorded-test"
|
||||||
|
|
||||||
const model = OpenAI.configure({
|
const model = OpenAI.configure({
|
||||||
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
||||||
image: {
|
|
||||||
providerOptions: {
|
|
||||||
quality: "low",
|
|
||||||
outputFormat: "jpeg",
|
|
||||||
outputCompression: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}).image("gpt-image-1-mini")
|
}).image("gpt-image-1-mini")
|
||||||
|
|
||||||
const recorded = recordedTests({
|
const recorded = recordedTests({
|
||||||
|
|
@ -28,7 +21,7 @@ describe("OpenAI Images recorded", () => {
|
||||||
const response = yield* Image.generate({
|
const response = yield* Image.generate({
|
||||||
model,
|
model,
|
||||||
prompt: "A simple flat black circle centered on a plain white background.",
|
prompt: "A simple flat black circle centered on a plain white background.",
|
||||||
size: { width: 1024, height: 1024 },
|
options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(response.images).toHaveLength(1)
|
expect(response.images).toHaveLength(1)
|
||||||
|
|
|
||||||
9
packages/ai/tsconfig.types.json
Normal file
9
packages/ai/tsconfig.types.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["test/**/*.types.ts"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue