fix(ai): classify OpenAI image moderation blocks as content policy (#58544)

This commit is contained in:
Aiden Cline
2026-10-23 21:12:51 -05:00
committed by GitHub
Unverified
parent ddaf06adaf
commit 43f7997bfa
4 changed files with 78 additions and 4 deletions
+13 -1
View File
@@ -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)
+4 -3
View File
@@ -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
+32
View File
@@ -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" })
+29
View File
@@ -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.