From 43f7997bfa7b6cd710ea08274a5eb9b603a73a40 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:12:51 -0500 Subject: [PATCH] fix(ai): classify OpenAI image moderation blocks as content policy (#58544) --- packages/ai/src/protocols/openai-images.ts | 14 +++++++++- packages/ai/src/provider-error.ts | 7 +++-- packages/ai/test/image.test.ts | 32 ++++++++++++++++++++++ packages/ai/test/provider-error.test.ts | 29 ++++++++++++++++++++ 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/protocols/openai-images.ts b/packages/ai/src/protocols/openai-images.ts index 62d0e89b81c..26794d6ce85 100644 --- a/packages/ai/src/protocols/openai-images.ts +++ b/packages/ai/src/protocols/openai-images.ts @@ -8,10 +8,11 @@ import { type ImageRequestFor, } from "../image.js" import { Media } from "../media.js" +import { classifyProviderFailure } from "../provider-error.js" import { Framing } from "../route/framing.js" import { MediaProtocol } from "../route/media-protocol.js" import { MediaRoute } from "../route/media.js" -import { mergeJsonRecords, ProviderID, type MediaUsage, type OpenString } from "../schema/index.js" +import { AIError, mergeJsonRecords, ProviderID, type MediaUsage, type OpenString } from "../schema/index.js" import { ProviderShared } from "./shared.js" import { ImageSizing } from "./utils/image-sizing.js" import { MediaInput } from "./utils/media-input.js" @@ -87,6 +88,10 @@ const StreamEvent = Schema.Union([ output_format: Schema.String, usage: Schema.optional(Usage), }), + Schema.Struct({ + type: Schema.Literal("error"), + error: Schema.optional(Schema.Struct({ message: Schema.optional(Schema.String) })), + }), ]) const decodeEvent = route.decodeFrame(StreamEvent) @@ -249,6 +254,13 @@ const eventImage = (frame: string, label: string, data: string, format: string, const onEvent = Effect.fnUntraced(function* (state: State, frame: string) { const event = yield* decodeEvent(frame) + if (event.type === "error") + return yield* new AIError({ + reason: classifyProviderFailure({ + message: `${route.name} stream failed: ${event.error?.message ?? "unknown error"}`, + rawBody: frame, + }), + }) const format = event.output_format if ("partial_image_index" in event) { const image = yield* eventImage(frame, `${route.name} partial image`, event.b64_json, format, event.size) diff --git a/packages/ai/src/provider-error.ts b/packages/ai/src/provider-error.ts index 7296f071fd0..dc63840dd09 100644 --- a/packages/ai/src/provider-error.ts +++ b/packages/ai/src/provider-error.ts @@ -154,7 +154,7 @@ const INVALID_REQUEST_CODES = new Set([ // Azure OpenAI reports `content_filter` with `innererror.code` ResponsibleAIPolicyViolation. // OpenRouter tags provider failures with a typed `error_type`; its Responses skin also // emits `image_content_policy_violation` as the native code. Alibaba Model Studio reports -// moderation rejections as `DataInspectionFailed`. +// moderation rejections as `DataInspectionFailed`. OpenAI Images reports `moderation_blocked`. const CONTENT_POLICY_CODES = new Set([ "content_filter", "content_moderation", @@ -163,6 +163,7 @@ const CONTENT_POLICY_CODES = new Set([ "image_content_policy_violation", "refusal", "datainspectionfailed", + "moderation_blocked", ]) // OpenCode Zen replaces upstream codes outside its allow-list but keeps the original // as a `[code]` label at the start of the rewritten message. @@ -177,9 +178,9 @@ const QUOTA_TEXT = // Policy rejections without a dedicated code, matched against the provider's own // explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while // Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output -// under `invalid_request_error`. +// under `invalid_request_error`. The Vercel AI Gateway drops OpenAI's `moderation_blocked` code. const CONTENT_POLICY_TEXT = - /violating our usage policy|blocked by content filtering policy|content[-_\s]?policy|rejected as a result of our safety system|detected potentially unsafe or sensitive content/i + /violating our usage policy|blocked by content filtering policy|content[-_\s]?policy|rejected (?:as a result of our|by the) safety system|detected potentially unsafe or sensitive content/i const SERVER_ERROR_TEXT = /\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i diff --git a/packages/ai/test/image.test.ts b/packages/ai/test/image.test.ts index 48a57583a8d..1152a4c145b 100644 --- a/packages/ai/test/image.test.ts +++ b/packages/ai/test/image.test.ts @@ -169,6 +169,38 @@ describe("Image", () => { }), ) + it.effect("classifies an OpenAI stream moderation error event as a content policy rejection", () => + Effect.gen(function* () { + const error = yield* Image.stream({ + model: OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).image("gpt-image-1-mini"), + prompt: "A lighthouse", + }).pipe(Stream.runDrain, Effect.flip) + expect(error.reason._tag).toBe("ContentPolicy") + expect(error.message).toContain("rejected by the safety system") + }).pipe( + Effect.provide( + layer((input) => + Effect.succeed( + input.respond( + `event: error\ndata: ${JSON.stringify({ + type: "error", + error: { + type: "image_generation_user_error", + code: "moderation_blocked", + message: + "Your request was rejected by the safety system. If you believe this is an error, contact us at help.openai.com and include the request ID req_123.", + param: null, + }, + sequence_number: 0, + })}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ), + ), + ), + ), + ), + ) + it.effect("lowers aspect to the nearest OpenAI size and reports the substitution", () => Effect.gen(function* () { const openai = OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }) diff --git a/packages/ai/test/provider-error.test.ts b/packages/ai/test/provider-error.test.ts index d7494780133..4246a1b2fc3 100644 --- a/packages/ai/test/provider-error.test.ts +++ b/packages/ai/test/provider-error.test.ts @@ -218,6 +218,35 @@ describe("provider error classification", () => { ).toBe("ContentPolicy") }) + test("classifies OpenAI image moderation blocks directly and through the Vercel AI Gateway", () => { + const message = + "Your request was rejected by the safety system. If you believe this is an error, contact us at help.openai.com and include the request ID req_123." + const openai = { + error: { + message, + type: "image_generation_user_error", + param: null, + code: "moderation_blocked", + moderation_details: { moderation_stage: "input", categories: ["other"] }, + }, + } + // The gateway keeps OpenAI's message but replaces its type and drops the code. + const vercel = { + error: { + message, + type: "AI_APICallError", + param: { error: message, statusCode: 400, name: "AI_APICallError", message, isRetryable: false }, + }, + } + + expect( + [openai, vercel].map( + (body) => + classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag, + ), + ).toEqual(["ContentPolicy", "ContentPolicy"]) + }) + test("recovers policy codes that OpenCode Zen preserves only in its message label", () => { // Zen drops upstream codes outside its allow-list and replaces the type, but // `Protocol.errorMessage` keeps the original code as a `[code]` prefix.