refactor(ai): infer image provider options (#37948)

Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-07-20 14:44:30 -05:00 committed by GitHub
commit 17c1e9b083
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 292 additions and 198 deletions

View file

@ -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

View file

@ -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": [

View file

@ -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)

View file

@ -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,

View file

@ -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"

View file

@ -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 = {

View file

@ -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 {

View file

@ -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",
}) })

View file

@ -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" },
}),
)
}),
),
), ),
), ),
), ),

View 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 } })

View file

@ -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)

View file

@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["test/**/*.types.ts"]
}