diff --git a/packages/ai/README.md b/packages/ai/README.md index e78dee0153..34dce7b243 100644 --- a/packages/ai/README.md +++ b/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 diff --git a/packages/ai/package.json b/packages/ai/package.json index 36c3e22673..f83d89430f 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 f6d85dd097..79db227725 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 fe850d9ede..300f6637ab 100644 --- a/packages/ai/src/image.ts +++ b/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 { 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: Options) => Options readonly id: ModelID readonly provider: ProviderID - readonly route: ImageRoute - readonly defaults?: ImageModelDefaults + readonly route: ImageRoute + readonly http?: HttpOptions - constructor(input: ImageModel.Input) { + 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) { - return new ImageModel({ + static make(input: ImageModel.MakeInput) { + return new ImageModel({ 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 { readonly id: ModelID readonly provider: ProviderID - readonly route: ImageRoute - readonly defaults?: ImageModelDefaults + readonly route: ImageRoute + readonly http?: HttpOptions } - 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> - 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 - 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"> & { - readonly http?: HttpOptions.Input +}) { + 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 ImageRequestInput = Omit< + ConstructorParameters[0], + "model" | "options" | "http" +> & { + readonly model: Model + readonly options?: NoInfer> + readonly http?: HttpOptions.Input +} & (Model extends ImageModel> ? unknown : never) + export class GeneratedImage extends Schema.Class("Image.Generated")({ mediaType: Schema.String, data: Schema.Union([Schema.String, Schema.Uint8Array]), @@ -91,24 +96,34 @@ export class ImageResponse extends Schema.Class("Image.Response") } } -export const request = (input: ImageRequest | ImageRequestInput) => { +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 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", 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 = { request, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 46433c7ad1..206cd45a20 100644 --- a/packages/ai/src/index.ts +++ b/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" diff --git a/packages/ai/src/protocols/openai-images.ts b/packages/ai/src/protocols/openai-images.ts index 78ea527bd3..c22fd0fa5a 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 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" - readonly outputCompression?: number -} +export type OpenAIImageString = Known | (string & {}) -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 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 + +export type OpenAIImageBody = Record & { + 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 - 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 | 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 | 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)) - 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, 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({ 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 9885be27e4..862333e2e5 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 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 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 { diff --git a/packages/ai/test/auth-options.types.ts b/packages/ai/test/auth-options.types.ts index 4f3d1da039..c430106789 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.test.ts b/packages/ai/test/image.test.ts index 6ab4a72d42..8f4c5bbb44 100644 --- a/packages/ai/test/image.test.ts +++ b/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" }, + }), + ) + }), + ), ), ), ), diff --git a/packages/ai/test/image.types.ts b/packages/ai/test/image.types.ts new file mode 100644 index 0000000000..227608c97b --- /dev/null +++ b/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 + +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, + 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 = { 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 +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. +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 } }) diff --git a/packages/ai/test/provider/openai-images.recorded.test.ts b/packages/ai/test/provider/openai-images.recorded.test.ts index 71734495f7..a3180cb12a 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: { - 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) diff --git a/packages/ai/tsconfig.types.json b/packages/ai/tsconfig.types.json new file mode 100644 index 0000000000..473bdc222a --- /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/**/*.types.ts"] +}