Procházet zdrojové kódy

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

Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
opencode-agent[bot] před 3 týdny
rodič
revize
17c1e9b083

+ 21 - 3
packages/ai/README.md

@@ -36,15 +36,33 @@ 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-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:
 
 ```ts

+ 1 - 1
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": [

+ 7 - 3
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<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 const generate = (request: ImageRequest): Effect.Effect<ImageResponse, LLMError> =>
+export const generate = <Options extends ImageOptions>(
+  request: ImageRequestFor<Options>,
+): Effect.Effect<ImageResponse, LLMError> =>
   Effect.gen(function* () {
     const client = yield* Service
     return yield* client.generate(request)

+ 55 - 40
packages/ai/src/image.ts

@@ -1,80 +1,85 @@
 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<Options extends ImageOptions = ImageOptions> {
   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 provider: ProviderID
-  readonly route: ImageRoute
-  readonly defaults?: ImageModelDefaults
+  readonly route: ImageRoute<Options>
+  readonly http?: HttpOptions
 
-  constructor(input: ImageModel.Input) {
+  constructor(input: ImageModel.Input<Options>) {
     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) {
-    return new ImageModel({
+  static make<Options extends ImageOptions = ImageOptions>(input: ImageModel.MakeInput<Options>) {
+    return new ImageModel<Options>({
       id: ModelID.make(input.id),
       provider: ProviderID.make(input.provider),
       route: input.route,
-      defaults: input.defaults,
+      http: input.http,
     })
   }
 }
 
 export namespace ImageModel {
-  export interface Input {
+  export interface Input<Options extends ImageOptions = ImageOptions> {
     readonly id: ModelID
     readonly provider: ProviderID
-    readonly route: ImageRoute
-    readonly defaults?: ImageModelDefaults
+    readonly route: ImageRoute<Options>
+    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 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, {
   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")({
   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)),
-}) {}
+}) {
+  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")({
   mediaType: Schema.String,
   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
   return new ImageRequest({
     ...input,
+    model: input.model as unknown as ImageModel,
     http: input.http === undefined ? undefined : HttpOptions.make(input.http),
   })
 }
 
-export const generate = (input: ImageRequest | ImageRequestInput) =>
-  Effect.try({
-    try: () => request(input),
+export function generate<const Model extends object>(
+  input: ImageRequestInput<Model>,
+): 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) =>
       new LLMError({
         module: "Image",
         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<ImageOptions>)))
+}
 
 export const Image = {
   request,

+ 2 - 2
packages/ai/src/index.ts

@@ -11,8 +11,8 @@ 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 { ImageModelOptions, ImageOptions, ImageRequestFor, ImageRequestInput, ImageRoute } from "./image"
 export { Image } from "./image"
 export { Tool, ToolFailure, toDefinitions } from "./tool"
 export { ToolRuntime } from "./tool-runtime"

+ 44 - 82
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 ImageRequest,
-  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"
 
@@ -17,26 +17,24 @@ const ADAPTER = "openai-images"
 export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
 export const PATH = "/images/generations"
 
-export interface OpenAIImageOptions {
-  readonly quality?: "auto" | "low" | "medium" | "high"
-  readonly background?: "auto" | "opaque" | "transparent"
-  readonly moderation?: "auto" | "low"
-  readonly outputFormat?: "png" | "jpeg" | "webp"
+export type OpenAIImageString<Known extends string> = Known | (string & {})
+
+export type OpenAIImageOptions = {
+  readonly n?: number
+  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<string, unknown>
 
-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<typeof OpenAIImageBody>
+export type OpenAIImageBody = Record<string, unknown> & {
+  readonly model: string
+  readonly prompt: string
+}
 
 const OpenAIImageResponse = Schema.Struct({
   data: Schema.Array(
@@ -63,26 +61,16 @@ export interface ModelInput {
   readonly auth: AuthDefinition
   readonly baseURL?: string
   readonly headers?: Record<string, string>
-  readonly defaults?: ImageModelDefaults
+  readonly http?: HttpOptions
 }
 
-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<string, unknown> | 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,
+    output_format: outputFormat,
+    output_compression: outputCompression,
+    ...native,
   }
 }
 
@@ -100,44 +88,17 @@ const applyQuery = (url: string, query: Record<string, string> | 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<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) => {
-  const route: ImageRoute = {
+  const route: ImageRoute<OpenAIImageOptions> = {
     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))
-      const http = mergeHttpOptions(request.model.defaults?.http, request.http)
-      const overlaidBody = yield* bodyWithOverlay(requestBody, http?.body)
-      const text = ProviderShared.encodeJson(overlaidBody)
+    generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequestFor<OpenAIImageOptions>, execute) {
+      const http = mergeHttpOptions(request.model.http, request.http)
+      const requestBody = mergeJsonRecords(
+        { model: request.model.id, prompt: request.prompt },
+        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 +119,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 +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 = {

+ 14 - 21
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 OpenAIImageString } from "../protocols/openai-images"
 
 export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
 export type { OpenAIImageOptions } from "../protocols/openai-images"
@@ -22,22 +22,19 @@ export type Config = RouteDefaultsInput &
     readonly baseURL?: string
     readonly queryParams?: Record<string, string>
     readonly providerOptions?: OpenAIProviderOptionsInput
-    readonly image?: ImageConfig
   }
 
-export interface ImageConfig {
-  readonly providerOptions?: OpenAIImageOptions
-}
-
 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 = {}) =>
@@ -73,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
 }
 
@@ -99,14 +96,10 @@ export const configure = (input: Config = {}) => {
       auth: auth(input),
       baseURL: input.baseURL,
       headers: input.headers,
-      defaults: {
-        providerOptions:
-          input.image?.providerOptions === undefined ? undefined : { openai: { ...input.image.providerOptions } },
-        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 {

+ 18 - 21
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<BaseOptions, "optional", Model>
 declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model>
 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",
 })
 

+ 49 - 17
packages/ai/test/image.test.ts

@@ -17,13 +17,20 @@ describe("Image", () => {
           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: "2048x2048",
+          quality: "future-quality",
+          outputFormat: "jpeg",
+          output_format: "avif",
+          outputCompression: 30,
+          output_compression: 40,
+          background: "opaque",
+          native_default: true,
+          future_option: true,
         },
         http: {
-          body: { request_metadata: "value" },
+          body: { output_format: "webp", output_compression: 50, future_option: "http", request_metadata: "value" },
           headers: { "x-request": "yes" },
           query: { trace: "1" },
         },
@@ -49,9 +56,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",
+                  output_compression: 50,
+                  native_default: true,
+                  future_option: "http",
                   deployment: "test",
                   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({
-      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 } },
+      model: OpenAI.configure({
+        apiKey: "test",
+        baseURL: "https://api.openai.test/v1",
+      }).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.flip,
-      Effect.tap((error) =>
+      Effect.tap((response) =>
         Effect.sync(() => {
-          expect(error.reason._tag).toBe("InvalidRequest")
+          expect(response.image?.mediaType).toBe("image/avif")
         }),
       ),
       Effect.provide(
         ImageClient.layer.pipe(
-          Layer.provide(dynamicResponse(() => Effect.die("invalid request should not reach the provider"))),
+          Layer.provide(
+            dynamicResponse((input) => {
+              expect(JSON.parse(input.text)).toEqual({
+                model: "future-image-model",
+                prompt: "A lighthouse in fog",
+                output_format: "avif",
+                output_compression: 40,
+                provider_future_option: { enabled: true },
+              })
+              return Effect.succeed(
+                input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
+                  headers: { "content-type": "application/json" },
+                }),
+              )
+            }),
+          ),
         ),
       ),
     ),

+ 71 - 0
packages/ai/test/image.types.ts

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

+ 1 - 8
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: {
-    providerOptions: {
-      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.",
-        size: { width: 1024, height: 1024 },
+        options: { quality: "low", outputFormat: "jpeg", outputCompression: 10, size: "1024x1024" },
       })
 
       expect(response.images).toHaveLength(1)

+ 9 - 0
packages/ai/tsconfig.types.json

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