From 167405bdfa14ad4a0e07edfe54c8eff480aaaceb Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 20 Jul 2026 15:22:39 +0000 Subject: [PATCH 1/5] refactor(ai): infer image provider options --- packages/ai/README.md | 27 +++++- packages/ai/src/image.ts | 78 +++++++++------ packages/ai/src/index.ts | 11 ++- packages/ai/src/protocols/openai-images.ts | 97 ++++++------------- packages/ai/src/providers/openai.ts | 5 +- packages/ai/test/image.test.ts | 33 +------ packages/ai/test/image.types.ts | 39 ++++++++ .../provider/openai-images.recorded.test.ts | 4 +- 8 files changed, 156 insertions(+), 138 deletions(-) create mode 100644 packages/ai/test/image.types.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index e78dee015334..ac4df304c627 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -36,15 +36,36 @@ const program = Effect.gen(function* () { const response = yield* Image.generate({ model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"), prompt: "A robot tending a rooftop garden", - count: 2, - size: { width: 1024, height: 1024 }, - providerOptions: { openai: { quality: "high", outputFormat: "webp" } }, + options: { + n: 2, + 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 }) ``` +Provider option defaults can be configured once and overridden per request. Raw `http.body` fields have final +precedence over both: + +```ts +const model = OpenAI.configure({ + apiKey: process.env.OPENAI_API_KEY, + image: { options: { quality: "medium", outputFormat: "png" } }, +}).image("gpt-image-2") + +Image.generate({ + model, + prompt: "A glass observatory above the clouds", + options: { quality: "high" }, + http: { body: { quality: "low" } }, +}) +``` + Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool: ```ts diff --git a/packages/ai/src/image.ts b/packages/ai/src/image.ts index fe850d9ede17..7c83f1ae3f13 100644 --- a/packages/ai/src/image.ts +++ b/packages/ai/src/image.ts @@ -1,27 +1,33 @@ import { Effect, Schema } from "effect" 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 { readonly id: string - readonly generate: (request: ImageRequest, execute: ImageExecute) => Effect.Effect + readonly generate: ( + request: ImageRequestFor, + execute: ImageExecute, + ) => Effect.Effect } -export class ImageModel { +export type ImageOptions = Record + +export class ImageModel { + declare protected readonly _Options: Options readonly id: ModelID readonly provider: ProviderID - readonly route: ImageRoute - readonly defaults?: ImageModelDefaults + readonly route: ImageRoute + readonly defaults?: ImageModelDefaults - constructor(input: ImageModel.Input) { + constructor(input: ImageModel.Input) { this.id = input.id this.provider = input.provider this.route = input.route this.defaults = input.defaults } - static make(input: ImageModel.MakeInput) { - return new ImageModel({ + static make(input: ImageModel.MakeInput) { + return new ImageModel({ id: ModelID.make(input.id), provider: ProviderID.make(input.provider), route: input.route, @@ -31,21 +37,22 @@ export class ImageModel { } export namespace ImageModel { - export interface Input { + export interface Input { readonly id: ModelID readonly provider: ProviderID - readonly route: ImageRoute - readonly defaults?: ImageModelDefaults + readonly route: ImageRoute + readonly defaults?: ImageModelDefaults } - export interface MakeInput extends Omit { + export interface MakeInput + extends Omit, "id" | "provider"> { readonly id: string | ModelID readonly provider: string | ProviderID } } -export interface ImageModelDefaults { - readonly providerOptions?: Record> +export interface ImageModelDefaults { + readonly options?: Options readonly http?: HttpOptions } @@ -53,25 +60,27 @@ export const ImageModelSchema = Schema.declare((value): value is ImageModel => v 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 - export class ImageRequest extends Schema.Class("Image.Request")({ model: ImageModelSchema, prompt: Schema.String, - count: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))), - 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))), + options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), http: Schema.optional(HttpOptions), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} -export type ImageRequestInput = Omit[0], "http"> & { +export type ImageRequestFor = Omit & { + readonly model: ImageModel + readonly options?: Options +} + +export type ImageModelOptions = Model extends ImageModel ? Options : never + +export type ImageRequestInput = Omit< + ConstructorParameters[0], + "model" | "options" | "http" +> & { + readonly model: Model + readonly options?: ImageModelOptions> readonly http?: HttpOptions.Input } @@ -91,7 +100,9 @@ export class ImageResponse extends Schema.Class("Image.Response") } } -export const request = (input: ImageRequest | ImageRequestInput) => { +export function request(input: ImageRequestInput): ImageRequest +export function request(input: ImageRequest): ImageRequest +export function request(input: ImageRequest | ImageRequestInput) { if (input instanceof ImageRequest) return input return new ImageRequest({ ...input, @@ -99,9 +110,13 @@ export const request = (input: ImageRequest | ImageRequestInput) => { }) } -export const generate = (input: ImageRequest | ImageRequestInput) => - Effect.try({ - try: () => request(input), +export function generate( + input: ImageRequestInput, +): Effect.Effect +export function generate(input: ImageRequest): Effect.Effect +export function generate(input: ImageRequest | ImageRequestInput) { + return Effect.try({ + try: () => (input instanceof ImageRequest ? input : request(input)), catch: (error) => new LLMError({ module: "Image", @@ -109,6 +124,7 @@ export const generate = (input: ImageRequest | ImageRequestInput) => reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }), }), }).pipe(Effect.flatMap(ImageClient.generate)) +} export const Image = { request, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 46433c7ad11e..90e6ea3267dd 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -11,8 +11,15 @@ export type { Service as LLMClientService, } from "./route/client" export * from "./schema" -export { GeneratedImage, ImageModel, ImageRequest, ImageResponse, ImageSize } from "./image" -export type { ImageModelDefaults, ImageRequestInput, ImageRoute } from "./image" +export { GeneratedImage, ImageModel, ImageRequest, ImageResponse } from "./image" +export type { + ImageModelDefaults, + ImageModelOptions, + ImageOptions, + ImageRequestFor, + ImageRequestInput, + ImageRoute, +} from "./image" export { Image } from "./image" export { Tool, ToolFailure, toDefinitions } from "./tool" export { ToolRuntime } from "./tool-runtime" diff --git a/packages/ai/src/protocols/openai-images.ts b/packages/ai/src/protocols/openai-images.ts index 78ea527bd3df..59f89ffd772c 100644 --- a/packages/ai/src/protocols/openai-images.ts +++ b/packages/ai/src/protocols/openai-images.ts @@ -4,7 +4,7 @@ import { ImageModel, GeneratedImage, ImageResponse, - type ImageRequest, + type ImageRequestFor, type ImageModelDefaults, type ImageRoute, } from "../image" @@ -17,26 +17,20 @@ const ADAPTER = "openai-images" export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const PATH = "/images/generations" -export interface OpenAIImageOptions { +export type OpenAIImageOptions = { + readonly n?: number + readonly size?: "auto" | "1024x1024" | "1536x1024" | "1024x1536" readonly quality?: "auto" | "low" | "medium" | "high" readonly background?: "auto" | "opaque" | "transparent" readonly moderation?: "auto" | "low" readonly outputFormat?: "png" | "jpeg" | "webp" readonly outputCompression?: number -} +} & Record -const OpenAIImageBody = Schema.Struct({ - model: Schema.String, - prompt: Schema.String, - n: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))), - size: Schema.optional(Schema.String), - quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])), - background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])), - moderation: Schema.optional(Schema.Literals(["auto", "low"])), - output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])), - output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))), -}) -export type OpenAIImageBody = Schema.Schema.Type +export type OpenAIImageBody = Record & { + readonly model: string + readonly prompt: string +} const OpenAIImageResponse = Schema.Struct({ data: Schema.Array( @@ -63,26 +57,16 @@ export interface ModelInput { readonly auth: AuthDefinition readonly baseURL?: string readonly headers?: Record - readonly defaults?: ImageModelDefaults + readonly defaults?: ImageModelDefaults } -const providerOptions = (request: ImageRequest): OpenAIImageOptions => ({ - ...request.model.defaults?.providerOptions?.openai, - ...request.providerOptions?.openai, -}) - -const body = (request: ImageRequest): OpenAIImageBody => { - const options = providerOptions(request) +const nativeOptions = (options: Record | undefined) => { + if (!options) return undefined + const { outputFormat, outputCompression, ...native } = options return { - model: request.model.id, - prompt: request.prompt, - n: request.count, - 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, + ...native, + output_format: outputFormat, + output_compression: outputCompression, } } @@ -100,44 +84,18 @@ const applyQuery = (url: string, query: Record | undefined) => { 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 | 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) => { - const route: ImageRoute = { + const route: ImageRoute = { id: ADAPTER, - generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequest, execute) { - if (request.aspectRatio !== undefined) - return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common aspectRatio option") - if (request.seed !== undefined) - return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common seed option") - - const requestBody = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIImageBody))(body(request)) + generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor, execute) { const http = mergeHttpOptions(request.model.defaults?.http, request.http) - const overlaidBody = yield* bodyWithOverlay(requestBody, http?.body) - const text = ProviderShared.encodeJson(overlaidBody) + const requestBody = mergeJsonRecords( + { model: request.model.id, prompt: request.prompt }, + nativeOptions(request.model.defaults?.options), + nativeOptions(request.options), + http?.body, + ) as OpenAIImageBody + const text = ProviderShared.encodeJson(requestBody) const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query) const headers = yield* Auth.toEffect(input.auth)({ request, @@ -158,7 +116,8 @@ export const model = (input: ModelInput) => { const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe( 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) => { if (item.b64_json) return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe( @@ -200,7 +159,7 @@ export const model = (input: ModelInput) => { }) }), } - return ImageModel.make({ id: input.id, provider: "openai", route, defaults: input.defaults }) + return ImageModel.make({ id: input.id, provider: "openai", route, defaults: input.defaults }) } export const OpenAIImages = { diff --git a/packages/ai/src/providers/openai.ts b/packages/ai/src/providers/openai.ts index 9885be27e4c2..bf5eee85077f 100644 --- a/packages/ai/src/providers/openai.ts +++ b/packages/ai/src/providers/openai.ts @@ -26,7 +26,7 @@ export type Config = RouteDefaultsInput & } export interface ImageConfig { - readonly providerOptions?: OpenAIImageOptions + readonly options?: OpenAIImageOptions } export interface ImageGenerationOptions { @@ -100,8 +100,7 @@ export const configure = (input: Config = {}) => { baseURL: input.baseURL, headers: input.headers, defaults: { - providerOptions: - input.image?.providerOptions === undefined ? undefined : { openai: { ...input.image.providerOptions } }, + options: input.image?.options, http: mergeHttpOptions( input.http === undefined ? undefined : HttpOptions.make(input.http), input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }), diff --git a/packages/ai/test/image.test.ts b/packages/ai/test/image.test.ts index 6ab4a72d421a..0ba8f4d384ca 100644 --- a/packages/ai/test/image.test.ts +++ b/packages/ai/test/image.test.ts @@ -14,16 +14,13 @@ describe("Image", () => { apiKey: "test", baseURL: "https://api.openai.test/v1", queryParams: { "api-version": "v1" }, + image: { options: { quality: "medium", outputFormat: "png", background: "opaque" } }, http: { body: { deployment: "test" }, headers: { "x-default": "yes" } }, }).image("gpt-image-2"), prompt: "A robot tending a rooftop garden", - count: 2, - size: { width: 1024, height: 1024 }, - providerOptions: { - openai: { quality: "high", outputFormat: "webp" }, - }, + options: { n: 2, size: "1024x1024", quality: "high", outputFormat: "jpeg", future_option: true }, http: { - body: { request_metadata: "value" }, + body: { output_format: "webp", request_metadata: "value" }, headers: { "x-request": "yes" }, query: { trace: "1" }, }, @@ -51,7 +48,9 @@ describe("Image", () => { n: 2, size: "1024x1024", quality: "high", + background: "opaque", output_format: "webp", + future_option: true, deployment: "test", request_metadata: "value", }) @@ -70,26 +69,4 @@ describe("Image", () => { ), ), ) - - it.effect("rejects invalid common and OpenAI image options locally", () => - Image.generate({ - model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("gpt-image-2"), - prompt: "A robot tending a rooftop garden", - count: -1, - size: { width: -1, height: 0.5 }, - providerOptions: { openai: { outputCompression: 101 } }, - }).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error.reason._tag).toBe("InvalidRequest") - }), - ), - Effect.provide( - ImageClient.layer.pipe( - Layer.provide(dynamicResponse(() => Effect.die("invalid request should not reach the provider"))), - ), - ), - ), - ) }) diff --git a/packages/ai/test/image.types.ts b/packages/ai/test/image.types.ts new file mode 100644 index 000000000000..80a1fda0d706 --- /dev/null +++ b/packages/ai/test/image.types.ts @@ -0,0 +1,39 @@ +import { Image, ImageModel, type ImageOptions, type ImageRoute } from "../src" +import { OpenAI } from "../src/providers" + +type GoogleLikeOptions = { + readonly aspectRatio?: "1:1" | "16:9" + readonly imageSize?: "1K" | "2K" +} & Record + +declare const route: ImageRoute +const google = ImageModel.make({ id: "gemini-image", provider: "google", route }) + +Image.generate({ + model: google, + prompt: "A lighthouse", + options: { aspectRatio: "16:9", imageSize: "2K", futureOption: true }, +}) + +const openai = OpenAI.image("gpt-image-2") +Image.generate({ + model: openai, + prompt: "A lighthouse", + options: { quality: "high", outputFormat: "webp", future_option: true }, +}) +// @ts-expect-error Known OpenAI options retain their provider-specific value types. +Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: "ultra" } }) +// @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 +Image.generate({ model: generic, prompt: "A lighthouse", options: { arbitrary: true } }) + +// @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 }) diff --git a/packages/ai/test/provider/openai-images.recorded.test.ts b/packages/ai/test/provider/openai-images.recorded.test.ts index 71734495f78a..7550a58652b2 100644 --- a/packages/ai/test/provider/openai-images.recorded.test.ts +++ b/packages/ai/test/provider/openai-images.recorded.test.ts @@ -7,7 +7,7 @@ import { recordedTests } from "../recorded-test" const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture", image: { - providerOptions: { + options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, @@ -28,7 +28,7 @@ describe("OpenAI Images recorded", () => { const response = yield* Image.generate({ model, prompt: "A simple flat black circle centered on a plain white background.", - size: { width: 1024, height: 1024 }, + options: { size: "1024x1024" }, }) expect(response.images).toHaveLength(1) From 3c56dad8375154b354c2889cd84909b9a16f44a3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 20 Jul 2026 16:09:08 +0000 Subject: [PATCH 2/5] fix(ai): enforce image option types --- packages/ai/package.json | 2 +- packages/ai/src/image-client.ts | 10 +++++-- packages/ai/src/image.ts | 24 +++++++++------- packages/ai/test/auth-options.types.ts | 39 ++++++++++++-------------- packages/ai/test/image.types.ts | 27 ++++++++++++++++-- packages/ai/tsconfig.types.json | 9 ++++++ 6 files changed, 74 insertions(+), 37 deletions(-) create mode 100644 packages/ai/tsconfig.types.json diff --git a/packages/ai/package.json b/packages/ai/package.json index 36c3e2267330..f83d89430f41 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -7,7 +7,7 @@ "scripts": { "setup:recording-env": "bun run script/setup-recording-env.ts", "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" }, "files": [ diff --git a/packages/ai/src/image-client.ts b/packages/ai/src/image-client.ts index f6d85dd09758..79db227725ea 100644 --- a/packages/ai/src/image-client.ts +++ b/packages/ai/src/image-client.ts @@ -1,17 +1,21 @@ import { Context, Effect, Layer } from "effect" import { RequestExecutor } from "./route/executor" -import type { ImageRequest, ImageResponse } from "./image" +import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image" import type { LLMError } from "./schema" export type Execute = RequestExecutor.Interface["execute"] export interface Interface { - readonly generate: (request: ImageRequest) => Effect.Effect + readonly generate: ( + request: ImageRequestFor, + ) => Effect.Effect } export class Service extends Context.Service()("@opencode/ImageClient") {} -export const generate = (request: ImageRequest): Effect.Effect => +export const generate = ( + request: ImageRequestFor, +): Effect.Effect => Effect.gen(function* () { const client = yield* Service return yield* client.generate(request) diff --git a/packages/ai/src/image.ts b/packages/ai/src/image.ts index 7c83f1ae3f13..623273eba712 100644 --- a/packages/ai/src/image.ts +++ b/packages/ai/src/image.ts @@ -13,7 +13,7 @@ export interface ImageRoute { export type ImageOptions = Record export class ImageModel { - declare protected readonly _Options: Options + declare protected readonly _Options: (options: Options) => Options readonly id: ModelID readonly provider: ProviderID readonly route: ImageRoute @@ -65,24 +65,25 @@ export class ImageRequest extends Schema.Class("Image.Request")({ prompt: Schema.String, options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), http: Schema.optional(HttpOptions), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), -}) {} +}) { + declare protected readonly _ImageRequest: void +} export type ImageRequestFor = Omit & { readonly model: ImageModel readonly options?: Options } -export type ImageModelOptions = Model extends ImageModel ? Options : never +export type ImageModelOptions = Model extends ImageModel ? Options : never -export type ImageRequestInput = Omit< +export type ImageRequestInput = Omit< ConstructorParameters[0], "model" | "options" | "http" > & { readonly model: Model - readonly options?: ImageModelOptions> + readonly options?: NoInfer> readonly http?: HttpOptions.Input -} +} & (Model extends ImageModel> ? unknown : never) export class GeneratedImage extends Schema.Class("Image.Generated")({ mediaType: Schema.String, @@ -100,17 +101,20 @@ export class ImageResponse extends Schema.Class("Image.Response") } } -export function request(input: ImageRequestInput): ImageRequest +export function request( + input: ImageRequestInput, +): ImageRequestFor> export function request(input: ImageRequest): ImageRequest export function request(input: ImageRequest | ImageRequestInput) { if (input instanceof ImageRequest) return input return new ImageRequest({ ...input, + model: input.model as unknown as ImageModel, http: input.http === undefined ? undefined : HttpOptions.make(input.http), }) } -export function generate( +export function generate( input: ImageRequestInput, ): Effect.Effect export function generate(input: ImageRequest): Effect.Effect @@ -123,7 +127,7 @@ export function generate(input: ImageRequest | ImageRequestInput) { method: "generate", 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))) } export const Image = { diff --git a/packages/ai/test/auth-options.types.ts b/packages/ai/test/auth-options.types.ts index 4f3d1da03970..c43010678995 100644 --- a/packages/ai/test/auth-options.types.ts +++ b/packages/ai/test/auth-options.types.ts @@ -1,7 +1,6 @@ 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 { Auth as RuntimeAuth } from "../src/route/auth" import * as OpenAIChat from "../src/protocols/openai-chat" import * as AmazonBedrock from "../src/providers/amazon-bedrock" import * as Anthropic from "../src/providers/anthropic" @@ -28,7 +27,7 @@ type Model = { readonly id: string } -declare const auth: Auth +declare const auth: Auth.Definition declare const optionalAuthModel: ModelFactory declare const requiredAuthModel: ModelFactory 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({ apiKey: "sk-test" }).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({ - auth: RuntimeAuth.headers({ authorization: "Bearer gateway" }), + auth: Auth.headers({ authorization: "Bearer gateway" }), baseURL: "https://gateway.example.com/v1", }).responses("gpt-4.1-mini") OpenAI.configure({ @@ -102,40 +101,40 @@ OpenAI.configure({ generation: { maxTokens: "many" } }) OpenAI.configure({ providerOptions: { openai: { store: "false" } } }) // @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.configure({ apiKey: "sk-test" }).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. OpenAI.configure({ apiKey: "sk-test" }).chat("gpt-4.1-mini", {}) // @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`. Azure.configure() Azure.configure({ apiKey: "azure-key", 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. Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).responses("deployment", {}) // @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: 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. Azure.configure({ apiKey: "azure-key", resourceName: "resource" }).chat("deployment", {}) // @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") // @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({ 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. GoogleVertex.configure({ apiKey: "vertex-key" }).model("gemini-3.5-flash", {}) // @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" }) 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", ) // @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({ accessToken: "vertex-token", // @ts-expect-error Vertex Chat config accepts only one auth source. - auth: RuntimeAuth.bearer("vertex-token"), + auth: Auth.bearer("vertex-token"), project: "project", }) 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", ) // @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({ accessToken: "vertex-token", // @ts-expect-error Vertex Responses config accepts only one auth source. - auth: RuntimeAuth.bearer("vertex-token"), + auth: Auth.bearer("vertex-token"), project: "project", }) GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" }).model("claude-sonnet-4-6") // @ts-expect-error Vertex Messages package settings do not accept API keys. GoogleVertexMessages.model("claude-sonnet-4-6", { apiKey: "vertex-key", project: "project" }) -GoogleVertexMessages.configure({ auth: RuntimeAuth.bearer("vertex-token"), project: "project" }).model( - "claude-sonnet-4-6", -) +GoogleVertexMessages.configure({ auth: Auth.bearer("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 model selectors only accept model ids. @@ -223,7 +220,7 @@ GoogleVertexMessages.configure({ accessToken: "vertex-token", project: "project" GoogleVertexMessages.configure({ accessToken: "vertex-token", // @ts-expect-error Vertex Messages config accepts only one auth source. - auth: RuntimeAuth.bearer("vertex-token"), + auth: Auth.bearer("vertex-token"), project: "project", }) diff --git a/packages/ai/test/image.types.ts b/packages/ai/test/image.types.ts index 80a1fda0d706..3ded3f69e538 100644 --- a/packages/ai/test/image.types.ts +++ b/packages/ai/test/image.types.ts @@ -1,4 +1,11 @@ -import { Image, ImageModel, type ImageOptions, type ImageRoute } from "../src" +import { + Image, + ImageModel, + type ImageModelOptions, + type ImageOptions, + type ImageRequestFor, + type ImageRoute, +} from "../src" import { OpenAI } from "../src/providers" type GoogleLikeOptions = { @@ -6,8 +13,11 @@ type GoogleLikeOptions = { readonly imageSize?: "1K" | "2K" } & Record -declare const route: ImageRoute +declare const route: ImageRoute const google = ImageModel.make({ id: "gemini-image", provider: "google", route }) +// @ts-expect-error Extracted model options retain known provider fields. +const invalidGoogleOptions: ImageModelOptions = { aspectRatio: "wide" } +void invalidGoogleOptions Image.generate({ model: google, @@ -16,6 +26,9 @@ Image.generate({ }) const openai = OpenAI.image("gpt-image-2") +// @ts-expect-error Extracted model options retain known provider fields. +const invalidOpenAIOptions: ImageModelOptions = { quality: "ultra" } +void invalidOpenAIOptions Image.generate({ model: openai, prompt: "A lighthouse", @@ -29,6 +42,14 @@ Image.generate({ model: google, prompt: "A lighthouse", options: { aspectRatio: declare const generic: ImageModel 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 = 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. @@ -37,3 +58,5 @@ Image.generate({ model: openai, prompt: "A lighthouse", size: { width: 1024, hei 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 } }) diff --git a/packages/ai/tsconfig.types.json b/packages/ai/tsconfig.types.json new file mode 100644 index 000000000000..886c6ed454ab --- /dev/null +++ b/packages/ai/tsconfig.types.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["test/auth-options.types.ts", "test/image.types.ts"] +} From 1d81fc4d92e420204885cd814d0e9457b7546bb7 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 20 Jul 2026 16:37:55 +0000 Subject: [PATCH 3/5] fix(ai): preserve native image options --- packages/ai/src/protocols/openai-images.ts | 16 +++-- packages/ai/src/providers/openai.ts | 16 +++-- packages/ai/test/image.test.ts | 77 ++++++++++++++++++++-- packages/ai/test/image.types.ts | 19 ++++-- 4 files changed, 103 insertions(+), 25 deletions(-) diff --git a/packages/ai/src/protocols/openai-images.ts b/packages/ai/src/protocols/openai-images.ts index 59f89ffd772c..c21473523534 100644 --- a/packages/ai/src/protocols/openai-images.ts +++ b/packages/ai/src/protocols/openai-images.ts @@ -17,13 +17,17 @@ const ADAPTER = "openai-images" export const DEFAULT_BASE_URL = "https://api.openai.com/v1" export const PATH = "/images/generations" +export type OpenAIImageString = Known | (string & {}) + export type OpenAIImageOptions = { readonly n?: number - readonly size?: "auto" | "1024x1024" | "1536x1024" | "1024x1536" - readonly quality?: "auto" | "low" | "medium" | "high" - readonly background?: "auto" | "opaque" | "transparent" - readonly moderation?: "auto" | "low" - readonly outputFormat?: "png" | "jpeg" | "webp" + readonly size?: OpenAIImageString< + "auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792" + > + readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd"> + readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent"> + readonly moderation?: OpenAIImageString<"auto" | "low"> + readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp"> readonly outputCompression?: number } & Record @@ -64,9 +68,9 @@ const nativeOptions = (options: Record | undefined) => { if (!options) return undefined const { outputFormat, outputCompression, ...native } = options return { - ...native, output_format: outputFormat, output_compression: outputCompression, + ...native, } } diff --git a/packages/ai/src/providers/openai.ts b/packages/ai/src/providers/openai.ts index bf5eee85077f..cb1dd64a2ca3 100644 --- a/packages/ai/src/providers/openai.ts +++ b/packages/ai/src/providers/openai.ts @@ -5,7 +5,7 @@ import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" -import { OpenAIImages, type OpenAIImageOptions } from "../protocols/openai-images" +import { OpenAIImages, type OpenAIImageOptions, type OpenAIImageString } from "../protocols/openai-images" export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options" export type { OpenAIImageOptions } from "../protocols/openai-images" @@ -30,14 +30,16 @@ export interface ImageConfig { } export interface ImageGenerationOptions { - readonly action?: "auto" | "generate" | "edit" - readonly background?: "auto" | "opaque" | "transparent" - readonly inputFidelity?: "low" | "high" + readonly action?: OpenAIImageString<"auto" | "generate" | "edit"> + readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent"> + readonly inputFidelity?: OpenAIImageString<"low" | "high"> readonly outputCompression?: number - readonly outputFormat?: "png" | "jpeg" | "webp" + readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp"> readonly partialImages?: number - readonly quality?: "auto" | "low" | "medium" | "high" - readonly size?: string + readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd"> + readonly size?: OpenAIImageString< + "auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792" + > } export const imageGeneration = (options: ImageGenerationOptions = {}) => diff --git a/packages/ai/test/image.test.ts b/packages/ai/test/image.test.ts index 0ba8f4d384ca..a016606b542e 100644 --- a/packages/ai/test/image.test.ts +++ b/packages/ai/test/image.test.ts @@ -14,13 +14,32 @@ describe("Image", () => { apiKey: "test", baseURL: "https://api.openai.test/v1", queryParams: { "api-version": "v1" }, - image: { options: { quality: "medium", outputFormat: "png", background: "opaque" } }, + image: { + options: { + quality: "medium", + outputFormat: "png", + output_format: "gif", + outputCompression: 10, + output_compression: 20, + background: "opaque", + native_default: true, + }, + }, http: { body: { deployment: "test" }, headers: { "x-default": "yes" } }, }).image("gpt-image-2"), prompt: "A robot tending a rooftop garden", - options: { n: 2, size: "1024x1024", quality: "high", outputFormat: "jpeg", future_option: true }, + options: { + n: 2, + size: "2048x2048", + quality: "future-quality", + outputFormat: "jpeg", + output_format: "avif", + outputCompression: 30, + output_compression: 40, + future_option: true, + }, http: { - body: { output_format: "webp", request_metadata: "value" }, + body: { output_format: "webp", output_compression: 50, future_option: "http", request_metadata: "value" }, headers: { "x-request": "yes" }, query: { trace: "1" }, }, @@ -46,11 +65,13 @@ describe("Image", () => { model: "gpt-image-2", prompt: "A robot tending a rooftop garden", n: 2, - size: "1024x1024", - quality: "high", + size: "2048x2048", + quality: "future-quality", background: "opaque", output_format: "webp", - future_option: true, + output_compression: 50, + native_default: true, + future_option: "http", deployment: "test", request_metadata: "value", }) @@ -69,4 +90,48 @@ describe("Image", () => { ), ), ) + + it.effect("preserves native snake_case and unknown request options", () => + Image.generate({ + model: OpenAI.configure({ + apiKey: "test", + baseURL: "https://api.openai.test/v1", + image: { options: { outputFormat: "png", outputCompression: 10 } }, + }).image("future-image-model"), + prompt: "A lighthouse in fog", + options: { + outputFormat: "jpeg", + output_format: "avif", + outputCompression: 30, + output_compression: 40, + provider_future_option: { enabled: true }, + }, + }).pipe( + Effect.tap((response) => + Effect.sync(() => { + expect(response.image?.mediaType).toBe("image/avif") + }), + ), + Effect.provide( + ImageClient.layer.pipe( + 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" }, + }), + ) + }), + ), + ), + ), + ), + ) }) diff --git a/packages/ai/test/image.types.ts b/packages/ai/test/image.types.ts index 3ded3f69e538..c830fa1914bc 100644 --- a/packages/ai/test/image.types.ts +++ b/packages/ai/test/image.types.ts @@ -26,16 +26,23 @@ Image.generate({ }) const openai = OpenAI.image("gpt-image-2") -// @ts-expect-error Extracted model options retain known provider fields. -const invalidOpenAIOptions: ImageModelOptions = { quality: "ultra" } -void invalidOpenAIOptions +const futureOpenAIOptions: ImageModelOptions = { quality: "future-quality" } +void futureOpenAIOptions Image.generate({ model: openai, prompt: "A lighthouse", - options: { quality: "high", outputFormat: "webp", future_option: true }, + options: { quality: "hd", outputFormat: "webp", size: "2048x2048", future_option: true }, }) -// @ts-expect-error Known OpenAI options retain their provider-specific value types. -Image.generate({ model: openai, prompt: "A lighthouse", options: { quality: "ultra" } }) +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" } }) From f39cdf82a1a60458a8ae3b6056f9359188920b65 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 20 Jul 2026 18:17:31 +0000 Subject: [PATCH 4/5] test(ai): include all type contracts --- packages/ai/tsconfig.types.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/tsconfig.types.json b/packages/ai/tsconfig.types.json index 886c6ed454ab..473bdc222a8c 100644 --- a/packages/ai/tsconfig.types.json +++ b/packages/ai/tsconfig.types.json @@ -5,5 +5,5 @@ "noEmit": true, "rootDir": "." }, - "include": ["test/auth-options.types.ts", "test/image.types.ts"] + "include": ["test/**/*.types.ts"] } From 0f5a25484087a50d1d89fc239a45f74c1b40a23d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 20 Jul 2026 19:35:36 +0000 Subject: [PATCH 5/5] refactor(ai): scope image options to requests --- packages/ai/README.md | 23 ++++++++--------- packages/ai/src/image.ts | 13 +++------- packages/ai/src/index.ts | 9 +------ packages/ai/src/protocols/openai-images.ts | 25 +++++++++---------- packages/ai/src/providers/openai.ts | 20 +++++---------- packages/ai/test/image.test.ts | 14 ++--------- packages/ai/test/image.types.ts | 2 ++ .../provider/openai-images.recorded.test.ts | 9 +------ 8 files changed, 38 insertions(+), 77 deletions(-) diff --git a/packages/ai/README.md b/packages/ai/README.md index ac4df304c627..34dce7b243c7 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -49,21 +49,18 @@ const program = Effect.gen(function* () { }) ``` -Provider option defaults can be configured once and overridden per request. Raw `http.body` fields have final -precedence over both: +Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them: ```ts -const model = OpenAI.configure({ - apiKey: process.env.OPENAI_API_KEY, - image: { options: { quality: "medium", outputFormat: "png" } }, -}).image("gpt-image-2") - -Image.generate({ - model, - prompt: "A glass observatory above the clouds", - options: { quality: "high" }, - http: { body: { quality: "low" } }, -}) +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: diff --git a/packages/ai/src/image.ts b/packages/ai/src/image.ts index 623273eba712..300f6637ab46 100644 --- a/packages/ai/src/image.ts +++ b/packages/ai/src/image.ts @@ -17,13 +17,13 @@ export class ImageModel { readonly id: ModelID readonly provider: ProviderID readonly route: ImageRoute - readonly defaults?: ImageModelDefaults + readonly http?: HttpOptions constructor(input: ImageModel.Input) { this.id = input.id this.provider = input.provider this.route = input.route - this.defaults = input.defaults + this.http = input.http } static make(input: ImageModel.MakeInput) { @@ -31,7 +31,7 @@ export class ImageModel { id: ModelID.make(input.id), provider: ProviderID.make(input.provider), route: input.route, - defaults: input.defaults, + http: input.http, }) } } @@ -41,7 +41,7 @@ export namespace ImageModel { readonly id: ModelID readonly provider: ProviderID readonly route: ImageRoute - readonly defaults?: ImageModelDefaults + readonly http?: HttpOptions } export interface MakeInput @@ -51,11 +51,6 @@ export namespace ImageModel { } } -export interface ImageModelDefaults { - readonly options?: Options - readonly http?: HttpOptions -} - export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, { expected: "Image.Model", }) diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 90e6ea3267dd..206cd45a2053 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -12,14 +12,7 @@ export type { } from "./route/client" export * from "./schema" export { GeneratedImage, ImageModel, ImageRequest, ImageResponse } from "./image" -export type { - ImageModelDefaults, - ImageModelOptions, - ImageOptions, - ImageRequestFor, - ImageRequestInput, - ImageRoute, -} from "./image" +export type { ImageModelOptions, ImageOptions, ImageRequestFor, ImageRequestInput, ImageRoute } from "./image" export { Image } from "./image" export { Tool, ToolFailure, toDefinitions } from "./tool" export { ToolRuntime } from "./tool-runtime" diff --git a/packages/ai/src/protocols/openai-images.ts b/packages/ai/src/protocols/openai-images.ts index c21473523534..c22fd0fa5a2a 100644 --- a/packages/ai/src/protocols/openai-images.ts +++ b/packages/ai/src/protocols/openai-images.ts @@ -1,15 +1,15 @@ import { Effect, Encoding, Schema } from "effect" import { Headers, HttpClientRequest } from "effect/unstable/http" -import { - ImageModel, - GeneratedImage, - ImageResponse, - type ImageRequestFor, - type ImageModelDefaults, - type ImageRoute, -} from "../image" +import { ImageModel, GeneratedImage, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image" 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 { OpenAIImage } from "./utils/openai-image" @@ -61,7 +61,7 @@ export interface ModelInput { readonly auth: AuthDefinition readonly baseURL?: string readonly headers?: Record - readonly defaults?: ImageModelDefaults + readonly http?: HttpOptions } const nativeOptions = (options: Record | undefined) => { @@ -92,10 +92,9 @@ export const model = (input: ModelInput) => { const route: ImageRoute = { id: ADAPTER, generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor, execute) { - const http = mergeHttpOptions(request.model.defaults?.http, request.http) + const http = mergeHttpOptions(request.model.http, request.http) const requestBody = mergeJsonRecords( { model: request.model.id, prompt: request.prompt }, - nativeOptions(request.model.defaults?.options), nativeOptions(request.options), http?.body, ) as OpenAIImageBody @@ -163,7 +162,7 @@ export const model = (input: ModelInput) => { }) }), } - return ImageModel.make({ id: input.id, provider: "openai", route, defaults: input.defaults }) + return ImageModel.make({ id: input.id, provider: "openai", route, http: input.http }) } export const OpenAIImages = { diff --git a/packages/ai/src/providers/openai.ts b/packages/ai/src/providers/openai.ts index cb1dd64a2ca3..862333e2e52a 100644 --- a/packages/ai/src/providers/openai.ts +++ b/packages/ai/src/providers/openai.ts @@ -5,7 +5,7 @@ import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIResponses from "../protocols/openai-responses" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" -import { OpenAIImages, type OpenAIImageOptions, type OpenAIImageString } from "../protocols/openai-images" +import { OpenAIImages, type OpenAIImageString } from "../protocols/openai-images" export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options" export type { OpenAIImageOptions } from "../protocols/openai-images" @@ -22,13 +22,8 @@ export type Config = RouteDefaultsInput & readonly baseURL?: string readonly queryParams?: Record readonly providerOptions?: OpenAIProviderOptionsInput - readonly image?: ImageConfig } -export interface ImageConfig { - readonly options?: OpenAIImageOptions -} - export interface ImageGenerationOptions { readonly action?: OpenAIImageString<"auto" | "generate" | "edit"> readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent"> @@ -75,7 +70,7 @@ export interface Settings extends ProviderPackage.Settings { const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") 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 } @@ -101,13 +96,10 @@ export const configure = (input: Config = {}) => { auth: auth(input), baseURL: input.baseURL, headers: input.headers, - defaults: { - options: input.image?.options, - http: mergeHttpOptions( - input.http === undefined ? undefined : HttpOptions.make(input.http), - 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 { diff --git a/packages/ai/test/image.test.ts b/packages/ai/test/image.test.ts index a016606b542e..8f4c5bbb448a 100644 --- a/packages/ai/test/image.test.ts +++ b/packages/ai/test/image.test.ts @@ -14,17 +14,6 @@ describe("Image", () => { apiKey: "test", baseURL: "https://api.openai.test/v1", queryParams: { "api-version": "v1" }, - image: { - options: { - quality: "medium", - outputFormat: "png", - output_format: "gif", - outputCompression: 10, - output_compression: 20, - background: "opaque", - native_default: true, - }, - }, http: { body: { deployment: "test" }, headers: { "x-default": "yes" } }, }).image("gpt-image-2"), prompt: "A robot tending a rooftop garden", @@ -36,6 +25,8 @@ describe("Image", () => { output_format: "avif", outputCompression: 30, output_compression: 40, + background: "opaque", + native_default: true, future_option: true, }, http: { @@ -96,7 +87,6 @@ describe("Image", () => { model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1", - image: { options: { outputFormat: "png", outputCompression: 10 } }, }).image("future-image-model"), prompt: "A lighthouse in fog", options: { diff --git a/packages/ai/test/image.types.ts b/packages/ai/test/image.types.ts index c830fa1914bc..227608c97b48 100644 --- a/packages/ai/test/image.types.ts +++ b/packages/ai/test/image.types.ts @@ -26,6 +26,8 @@ Image.generate({ }) 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 = { quality: "future-quality" } void futureOpenAIOptions Image.generate({ diff --git a/packages/ai/test/provider/openai-images.recorded.test.ts b/packages/ai/test/provider/openai-images.recorded.test.ts index 7550a58652b2..a3180cb12ad9 100644 --- a/packages/ai/test/provider/openai-images.recorded.test.ts +++ b/packages/ai/test/provider/openai-images.recorded.test.ts @@ -6,13 +6,6 @@ import { recordedTests } from "../recorded-test" const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture", - image: { - options: { - quality: "low", - outputFormat: "jpeg", - outputCompression: 10, - }, - }, }).image("gpt-image-1-mini") const recorded = recordedTests({ @@ -28,7 +21,7 @@ describe("OpenAI Images recorded", () => { const response = yield* Image.generate({ model, prompt: "A simple flat black circle centered on a plain white background.", - options: { size: "1024x1024" }, + options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" }, }) expect(response.images).toHaveLength(1)