From f0d7e50dd1a7cd645d9b11e3ae99c4bafe5b97ec Mon Sep 17 00:00:00 2001 From: Robert Ciemny Date: Thu, 29 Oct 2026 11:42:41 +0100 Subject: [PATCH 1/4] fix(ai): tolerate null optional fields from openai-image hosts OpenAI-compatible image hosts (e.g. PSNC's llm.hpc.psnc.pl serving Z-Image-Turbo) commonly report absent optional response fields such as `quality` and `background` as an explicit `null` instead of omitting them. The openai-images response schema declared those with Schema.optional, which rejects null, so a valid 200 response failed to decode with "OpenAI Images returned an invalid response". Use optionalNullish for the rendered-setting fields (output_format, size, quality, background), matching the null-tolerant fields already used for data[].url / data[].b64_json, and normalizing null to undefined. Closes #60189 --- packages/ai/src/protocols/openai-images.ts | 12 +++++--- packages/ai/test/image.test.ts | 36 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/ai/src/protocols/openai-images.ts b/packages/ai/src/protocols/openai-images.ts index 6ff18e775705..34507c0ae9d2 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,11 +56,13 @@ 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 compatible hosts that report an absent optional field +// (e.g. `quality` or `background`) as an explicit `null` instead of omitting it, and normalizes it to `undefined`. 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({ diff --git a/packages/ai/test/image.test.ts b/packages/ai/test/image.test.ts index fe49cfb341cc..91a4ae36e7c5 100644 --- a/packages/ai/test/image.test.ts +++ b/packages/ai/test/image.test.ts @@ -237,6 +237,42 @@ describe("Image", () => { ) }) + it.effect( + "decodes a response whose optional metadata fields are null instead of omitted (OpenAI-compatible hosts)", + () => + Effect.gen(function* () { + const response = yield* 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: { total_tokens: 1 }, + }), + ), + ), + ), + ) + 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.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* Image.stream({ From 5e0a5f07b190dc800aa3c53f52873febf797fab1 Mon Sep 17 00:00:00 2001 From: Robert Ciemny Date: Fri, 30 Oct 2026 23:28:47 +0100 Subject: [PATCH 2/4] fix(ai): also tolerate null usage from openai-image hosts The previous commit handled null for the rendered-setting fields (output_format, size, quality, background). Some OpenAI-compatible hosts (e.g. SGLang) also return usage: null, which Schema.optional rejected just the same. Switch the response usage field to optionalNullish too, normalizing null to undefined. Refs #60189 --- packages/ai/src/protocols/openai-images.ts | 7 +++-- packages/ai/test/image.test.ts | 34 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/protocols/openai-images.ts b/packages/ai/src/protocols/openai-images.ts index 34507c0ae9d2..f059d9aa991a 100644 --- a/packages/ai/src/protocols/openai-images.ts +++ b/packages/ai/src/protocols/openai-images.ts @@ -56,8 +56,9 @@ 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 compatible hosts that report an absent optional field -// (e.g. `quality` or `background`) as an explicit `null` instead of omitting it, and normalizes it to `undefined`. +// `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: optionalNullish(Schema.String), size: optionalNullish(Schema.String), @@ -69,7 +70,7 @@ 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 91a4ae36e7c5..e4b688a34e9e 100644 --- a/packages/ai/test/image.test.ts +++ b/packages/ai/test/image.test.ts @@ -273,6 +273,40 @@ describe("Image", () => { }), ) + it.effect("decodes a response whose usage is null instead of omitted (OpenAI-compatible hosts)", () => + Effect.gen(function* () { + const response = yield* 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: "opaque", + data: [{ b64_json: "AQID", url: null }], + output_format: "png", + quality: "standard", + 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.usage).toBeUndefined() + expect(response.providerMetadata).toEqual({ + openai: { outputFormat: "png", size: "1024x1024", quality: "standard", background: "opaque" }, + }) + }), + ) + it.effect("classifies an OpenAI stream moderation error event as a content policy rejection", () => Effect.gen(function* () { const error = yield* Image.stream({ From 5f35983b107e2d2ab8095a5428043cf9b73d255e Mon Sep 17 00:00:00 2001 From: Robert Ciemny Date: Sat, 31 Oct 2026 09:10:41 +0100 Subject: [PATCH 3/4] test(ai): adapt image tests to the ai.image rename after dev sync Merging the latest dev brought the Image -> ai.image refactor, so image.test.ts only imports ai now. Two calls still referenced the old Image.generate entrypoint and failed with ReferenceError: Image is not defined. Switch both to ai.image.generate. Refs #60189 --- packages/ai/test/image.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ai/test/image.test.ts b/packages/ai/test/image.test.ts index a1a690da80f9..53f52726a9c8 100644 --- a/packages/ai/test/image.test.ts +++ b/packages/ai/test/image.test.ts @@ -243,7 +243,7 @@ describe("Image", () => { "decodes a response whose optional metadata fields are null instead of omitted (OpenAI-compatible hosts)", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).image("gpt-image-2"), prompt: "A lighthouse", }).pipe( @@ -277,7 +277,7 @@ describe("Image", () => { it.effect("decodes a response whose usage is null instead of omitted (OpenAI-compatible hosts)", () => Effect.gen(function* () { - const response = yield* Image.generate({ + const response = yield* ai.image.generate({ model: OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).image("gpt-image-2"), prompt: "A lighthouse", }).pipe( From 0b38fd66d6e9b6959e3d3ccfad2e52b369c16c73 Mon Sep 17 00:00:00 2001 From: Robert Ciemny Date: Sat, 31 Oct 2026 10:07:37 +0100 Subject: [PATCH 4/4] test(ai): merge the two null-response tests into one The split existed only because usage support landed as a separate commit. One fixture now returns null for every optional field (quality, background, url, usage) and asserts the full decode. --- packages/ai/test/image.test.ts | 39 +++------------------------------- 1 file changed, 3 insertions(+), 36 deletions(-) diff --git a/packages/ai/test/image.test.ts b/packages/ai/test/image.test.ts index 53f52726a9c8..b76b0d6504aa 100644 --- a/packages/ai/test/image.test.ts +++ b/packages/ai/test/image.test.ts @@ -240,7 +240,7 @@ describe("Image", () => { }) it.effect( - "decodes a response whose optional metadata fields are null instead of omitted (OpenAI-compatible hosts)", + "decodes a response whose optional fields are null instead of omitted (OpenAI-compatible hosts)", () => Effect.gen(function* () { const response = yield* ai.image.generate({ @@ -256,7 +256,7 @@ describe("Image", () => { output_format: "png", quality: null, size: "1024x1024", - usage: { total_tokens: 1 }, + usage: null, }), ), ), @@ -269,46 +269,13 @@ describe("Image", () => { 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("decodes a response whose usage is 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: "opaque", - data: [{ b64_json: "AQID", url: null }], - output_format: "png", - quality: "standard", - 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.usage).toBeUndefined() - expect(response.providerMetadata).toEqual({ - openai: { outputFormat: "png", size: "1024x1024", quality: "standard", background: "opaque" }, - }) - }), - ) - it.effect("classifies an OpenAI stream moderation error event as a content policy rejection", () => Effect.gen(function* () { const error = yield* ai.image