diff --git a/apps/docs/content/features/moderations.mdx b/apps/docs/content/features/moderations.mdx index 0152a34e7c..a3fa22b138 100644 --- a/apps/docs/content/features/moderations.mdx +++ b/apps/docs/content/features/moderations.mdx @@ -34,7 +34,9 @@ The `input` field accepts: - A single string - An array of strings -- An array of multimodal content items with `text` and `image_url` +- An array of multimodal content items where each item is type-driven and + contains only the fields relevant to its type, such as `text` for text items + or `image_url` for image items The default model is `omni-moderation-latest`. diff --git a/apps/gateway/src/api.spec.ts b/apps/gateway/src/api.spec.ts index 3d48539141..5168747ab1 100644 --- a/apps/gateway/src/api.spec.ts +++ b/apps/gateway/src/api.spec.ts @@ -198,6 +198,83 @@ describe("api", () => { } }); + test("/v1/moderations rejects invalid multimodal items before proxying upstream", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const res = await app.request("/v1/moderations", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + input: [ + { + type: "text", + }, + ], + }), + }); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + error: { + message: "Invalid request parameters", + type: "invalid_request_error", + param: null, + code: "invalid_parameters", + }, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test("/v1/moderations maps undocumented upstream statuses to 502", async () => { + await db.insert(tables.apiKey).values({ + id: "token-id", + token: "real-token", + projectId: "project-id", + description: "Test API Key", + createdBy: "user-id", + }); + + await db.insert(tables.providerKey).values({ + id: "provider-key-id", + token: "sk-test-key", + provider: "openai", + organizationId: "org-id", + baseUrl: mockServerUrl, + }); + + const requestId = "moderation-undocumented-status-request-id"; + const res = await app.request("/v1/moderations", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer real-token", + "x-request-id": requestId, + }, + body: JSON.stringify({ + input: "TRIGGER_STATUS_418 moderation upstream status", + }), + }); + + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ + error: { + message: "Mock error with status 418", + type: "server_error", + param: null, + code: "error_418", + }, + }); + + const logs = await waitForLogs(1); + const moderationLog = logs.find((log) => log.requestId === requestId); + + expect(moderationLog).toBeTruthy(); + expect(moderationLog?.hasError).toBe(true); + expect(moderationLog?.errorDetails?.statusCode).toBe(418); + }); + test("/v1/images/edits accepts Gemini size and aspect ratio", async () => { await db.insert(tables.apiKey).values({ id: "token-id-image-edits", diff --git a/apps/gateway/src/moderations/moderations.ts b/apps/gateway/src/moderations/moderations.ts index b436e44e5a..4d4e39ebaf 100644 --- a/apps/gateway/src/moderations/moderations.ts +++ b/apps/gateway/src/moderations/moderations.ts @@ -27,16 +27,25 @@ const moderationInputTextSchema = z.string().openapi({ example: "I want to harm someone.", }); -const moderationInputContentSchema = z +const moderationTextContentSchema = z .object({ - type: z.enum(["text", "image_url"]).openapi({ + type: z.literal("text").openapi({ description: "Input item type.", example: "text", }), - text: z.string().optional().openapi({ + text: z.string().openapi({ description: "Text content for `type: text` items.", example: "Please review this sentence.", }), + }) + .strict(); + +const moderationImageContentSchema = z + .object({ + type: z.literal("image_url").openapi({ + description: "Input item type.", + example: "image_url", + }), image_url: z .object({ url: z.string().openapi({ @@ -44,11 +53,17 @@ const moderationInputContentSchema = z example: "https://example.com/image.png", }), }) - .optional() .openapi({ description: "Image payload for `type: image_url` items.", }), }) + .strict(); + +const moderationInputContentSchema = z + .discriminatedUnion("type", [ + moderationTextContentSchema, + moderationImageContentSchema, + ]) .openapi({ description: "Multimodal moderation input item.", }); @@ -123,14 +138,60 @@ const moderationResponseSchema = z }); const moderationErrorSchema = z.object({ - error: z.object({ - message: z.string(), - type: z.string(), - param: z.string().nullable(), - code: z.string(), - }), + error: z + .object({ + message: z.string(), + type: z.string().nullable().optional(), + param: z.string().nullable().optional(), + code: z.string().nullable().optional(), + }) + .passthrough(), }); +const documentedErrorStatuses = [ + 400, 401, 403, 404, 410, 429, 500, 502, 503, 504, +] as const; +type DocumentedErrorStatus = (typeof documentedErrorStatuses)[number]; + +function normalizeDocumentedErrorStatus(status: number): DocumentedErrorStatus { + return documentedErrorStatuses.includes(status as DocumentedErrorStatus) + ? (status as DocumentedErrorStatus) + : 502; +} + +function normalizeModerationErrorPayload(upstreamJson: unknown) { + if (typeof upstreamJson === "string") { + return { + error: { + message: upstreamJson, + type: "upstream_error", + param: null, + code: "upstream_error", + }, + }; + } + + if ( + upstreamJson && + typeof upstreamJson === "object" && + "error" in upstreamJson && + upstreamJson.error && + typeof upstreamJson.error === "object" && + "message" in upstreamJson.error + ) { + return upstreamJson; + } + + return { + error: { + message: "Upstream request failed", + type: "upstream_error", + param: null, + code: "upstream_error", + }, + }; +} + const moderationRequestSchema = z.object({ input: moderationInputSchema, model: z.string().optional().default("omni-moderation-latest").openapi({ @@ -599,20 +660,8 @@ moderations.openapi(createModeration, async (c): Promise => { }); return c.json( - (typeof upstreamJson === "string" - ? { error: { message: upstreamJson } } - : upstreamJson) ?? { error: true }, - upstreamResponse.status as - | 400 - | 401 - | 403 - | 404 - | 410 - | 429 - | 500 - | 502 - | 503 - | 504, + normalizeModerationErrorPayload(upstreamJson), + normalizeDocumentedErrorStatus(upstreamResponse.status), ); }