Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/docs/content/features/moderations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
77 changes: 77 additions & 0 deletions apps/gateway/src/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 73 additions & 24 deletions apps/gateway/src/moderations/moderations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,28 +27,43 @@ 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({
description: "Image URL or data URL for `type: image_url` items.",
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.",
});
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -599,20 +660,8 @@ moderations.openapi(createModeration, async (c): Promise<any> => {
});

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),
);
}

Expand Down
Loading