diff --git a/packages/ai/src/protocols/openai-images.ts b/packages/ai/src/protocols/openai-images.ts index 6ff18e775705..f059d9aa991a 100644 --- a/packages/ai/src/protocols/openai-images.ts +++ b/packages/ai/src/protocols/openai-images.ts @@ -13,7 +13,7 @@ import { Framing } from "../route/framing.js" import { MediaProtocol } from "../route/media-protocol.js" import { MediaRoute } from "../route/media.js" import { AIError, mergeJsonRecords, ProviderID, tokenUsage, type MediaUsage, type OpenString } from "../schema/index.js" -import { optionalNull, ProviderShared } from "./shared.js" +import { optionalNullish, optionalNull, ProviderShared } from "./shared.js" import { ImageSizing } from "./utils/image-sizing.js" import { MediaInput } from "./utils/media-input.js" @@ -56,18 +56,21 @@ const Usage = Schema.Struct({ }) /** What the provider actually rendered; it can differ from the request when `auto` or a default applied. */ +// `optionalNullish` (rather than `Schema.optional`) tolerates OpenAI-compatible hosts that report an absent optional +// field (e.g. `quality`, `background`, or `usage`) as an explicit `null` instead of omitting it, and normalizes it to +// `undefined`. This matches the null-tolerant fields already used for `data[].url` / `data[].b64_json`. const Settings = { - output_format: Schema.optional(Schema.String), - size: Schema.optional(Schema.String), - quality: Schema.optional(Schema.String), - background: Schema.optional(Schema.String), + output_format: optionalNullish(Schema.String), + size: optionalNullish(Schema.String), + quality: optionalNullish(Schema.String), + background: optionalNullish(Schema.String), } const OpenAIImageResponse = Schema.Struct({ // GPT Image models always return `b64_json`; compatible hosts that default to `url` can return either. data: Schema.Array(Schema.Struct({ b64_json: optionalNull(Schema.String), url: optionalNull(Schema.String) })), ...Settings, - usage: Schema.optional(Usage), + usage: optionalNullish(Usage), }) // --------------------------------------------------------------------------- diff --git a/packages/ai/test/image.test.ts b/packages/ai/test/image.test.ts index 1a8f1f5aca44..b76b0d6504aa 100644 --- a/packages/ai/test/image.test.ts +++ b/packages/ai/test/image.test.ts @@ -239,6 +239,43 @@ describe("Image", () => { ) }) + it.effect( + "decodes a response whose optional fields are null instead of omitted (OpenAI-compatible hosts)", + () => + Effect.gen(function* () { + const response = yield* ai.image.generate({ + model: OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).image("gpt-image-2"), + prompt: "A lighthouse", + }).pipe( + Effect.provide( + layer((input) => + Effect.succeed( + json(input, { + background: null, + data: [{ b64_json: "AQID", url: null }], + output_format: "png", + quality: null, + size: "1024x1024", + usage: null, + }), + ), + ), + ), + ) + expect(response.images).toHaveLength(1) + expect(response.image.source).toEqual({ + type: "bytes", + data: Uint8Array.from([1, 2, 3]), + mediaType: "image/png", + }) + expect(response.image.info).toEqual({ format: "png", width: 1024, height: 1024 }) + expect(response.usage).toBeUndefined() + expect(response.providerMetadata).toEqual({ + openai: { outputFormat: "png", size: "1024x1024" }, + }) + }), + ) + it.effect("classifies an OpenAI stream moderation error event as a content policy rejection", () => Effect.gen(function* () { const error = yield* ai.image