Skip to content

fix: split image moderation requests#1908

Merged
steebchen merged 2 commits into
mainfrom
split-image-moderation
Mar 28, 2026
Merged

fix: split image moderation requests#1908
steebchen merged 2 commits into
mainfrom
split-image-moderation

Conversation

@steebchen
Copy link
Copy Markdown
Member

@steebchen steebchen commented Mar 28, 2026

Summary

  • split gateway OpenAI moderation into one text request plus one request per input image
  • run image moderation calls in parallel and aggregate all moderation results into a single content-filter decision
  • keep fail-open behavior for moderation request failures while still blocking if any successful moderation result is flagged

Testing

  • pnpm exec vitest run apps/gateway/src/chat/tools/openai-content-filter.spec.ts --no-file-parallelism
  • pnpm exec vitest run apps/gateway/src/api.spec.ts --no-file-parallelism -t "openai content filter"
  • pnpm format
  • pnpm build

Summary by CodeRabbit

  • Tests

    • Expanded moderation tests to cover separate text and per-image moderation requests, base64 image handling, and aggregation/flagging behavior across multiple upstream responses.
  • Refactor

    • Content filtering now builds at most one text request plus one request per image, runs requests concurrently, converts inline images for moderation, aggregates results/flags, and returns a concise combined outcome with an empty-fast-path.
  • New Features

    • Added a script to perform two-image edits via the gateway, handling image encoding and response extraction.

Copilot AI review requested due to automatic review settings March 28, 2026 15:44
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 28, 2026

Walkthrough

Splits content-filter input generation into separate text and per-image builders, runs one moderation request per built input concurrently, aggregates successful upstream responses into a unified check result, updates logging and API signatures, and adds tests and a two-image edit helper script.

Changes

Cohort / File(s) Summary
Core Implementation
apps/gateway/src/chat/tools/openai-content-filter.ts
Replaces single builder with buildOpenAIContentFilterTextInput and buildOpenAIContentFilterImageInputs; adds request/result types and buildOpenAIContentFilterRequests; introduces runOpenAIContentFilterRequest; checkOpenAIContentFilter now accepts GatewayContentFilterContext, fast-paths 0 requests, runs requests concurrently, aggregates successful responses, and adjusts logging (requestCount, imageRequestCount, hasImages).
Tests
apps/gateway/src/chat/tools/openai-content-filter.spec.ts
Reworks tests to use new text/image builders; adds tests for image splitting and base64→data: URL conversion; extends checkOpenAIContentFilter tests to mock fetch and assert one text request + per-image requests, aggregated flagged/model/upstreamRequestId/results behavior.
Tooling Script
scripts/two-image-edit.sh
Adds new executable script to perform a two-image edit via gateway endpoint: builds data: URLs from local images, posts edit request, saves response, decodes base64 output to file.

Sequence Diagram

sequenceDiagram
    participant Client
    participant TextBuilder as buildOpenAIContentFilterTextInput
    participant ImageBuilder as buildOpenAIContentFilterImageInputs
    participant Runner as runOpenAIContentFilterRequest
    participant OpenAI as OpenAI_Moderation_API
    participant Aggregator

    Client->>TextBuilder: messages
    TextBuilder-->>Client: textInput

    Client->>ImageBuilder: messages
    ImageBuilder-->>Client: imageInputs[]

    par concurrent moderation requests
        alt text request exists
            Client->>Runner: textRequest
            Runner->>OpenAI: POST moderation (text)
            OpenAI-->>Runner: moderation response
            Runner-->>Client: {success,response}
        end
        loop per imageInput
            Client->>Runner: imageRequest(i)
            Runner->>OpenAI: POST moderation (image_url)
            OpenAI-->>Runner: moderation response
            Runner-->>Client: {success,response}
        end
    end

    Client->>Aggregator: successful responses[]
    Aggregator-->>Client: aggregatedResult (flagged, model, upstreamRequestId, results[])
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested labels

auto-merge

Suggested reviewers

  • smakosh
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'fix: split image moderation requests' directly and specifically addresses the main change: refactoring moderation to handle images separately from text, executing them in parallel, and aggregating results.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch split-image-moderation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the gateway’s OpenAI moderation integration to split moderation into a single text request plus one request per input image, execute image moderation in parallel, and aggregate the results into one final content-filter decision while preserving fail-open behavior on moderation failures.

Changes:

  • Split moderation input building into separate text and per-image inputs.
  • Add a per-request runner and aggregate multiple moderation responses into a single decision.
  • Update/expand unit tests to validate the new request splitting behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
apps/gateway/src/chat/tools/openai-content-filter.ts Refactors OpenAI moderation to run one text request plus N image requests in parallel and aggregates results/logging.
apps/gateway/src/chat/tools/openai-content-filter.spec.ts Updates tests for the split text/image input builders and adds a test asserting one request per image.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

upstreamText = await upstreamResponse.text();
} catch (error) {
if (requestSignal?.aborted || isCancellationError(error)) {
if (signal.aborted || isCancellationError(error)) {
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runOpenAIContentFilterRequest rethrows any fetch error when signal.aborted is true. Because signal includes AbortSignal.timeout(...), this makes timeout errors behave like cancellations and can cause Promise.all(...) to reject, dropping any successful (and potentially flagged) moderation results and failing open for the whole check. To keep the intended fail-open behavior, only rethrow user aborts (AbortError / requestSignal abort), and treat TimeoutError as a normal moderation failure (log + return success:false).

Suggested change
if (signal.aborted || isCancellationError(error)) {
if (isCancellationError(error)) {

Copilot uses AI. Check for mistakes.
Comment on lines +393 to +403
const moderationResults = await Promise.all(
moderationRequests.map((request) =>
runOpenAIContentFilterRequest(
request,
context,
providerEnv.token,
signal,
),
),
);

Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Promise.all(moderationRequests.map(...)) fires one moderation request per image with unbounded parallelism. A prompt with many images could create a large burst of outbound requests, increasing rate-limit risk and tying up gateway resources. Consider adding a concurrency limit (e.g., a small pool) and/or enforcing a maximum number of images to moderate per request (with clear logging when capped).

Suggested change
const moderationResults = await Promise.all(
moderationRequests.map((request) =>
runOpenAIContentFilterRequest(
request,
context,
providerEnv.token,
signal,
),
),
);
const OPENAI_MODERATION_CONCURRENCY_LIMIT = 5;
const moderationResults = new Array<
Awaited<ReturnType<typeof runOpenAIContentFilterRequest>>
>(moderationRequests.length);
let nextIndex = 0;
const workerCount = Math.min(
OPENAI_MODERATION_CONCURRENCY_LIMIT,
moderationRequests.length,
);
const workers = Array.from({ length: workerCount }, async () => {
for (;;) {
const currentIndex = nextIndex++;
if (currentIndex >= moderationRequests.length) {
break;
}
const request = moderationRequests[currentIndex];
moderationResults[currentIndex] =
await runOpenAIContentFilterRequest(
request,
context,
providerEnv.token,
signal,
);
}
});
await Promise.all(workers);

Copilot uses AI. Check for mistakes.
): Promise<OpenAIContentFilterCheckResult> {
const startTime = Date.now();
const moderationRequests = buildOpenAIContentFilterRequests(messages);
const imageInputs = buildOpenAIContentFilterImageInputs(messages);
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildOpenAIContentFilterImageInputs(messages) is called twice (once indirectly via buildOpenAIContentFilterRequests and again for imageInputs). This duplicates work and can lead to subtle inconsistencies if the extraction logic changes. Consider computing textInput/imageInputs once and reusing them for both request construction and logging.

Suggested change
const imageInputs = buildOpenAIContentFilterImageInputs(messages);

Copilot uses AI. Check for mistakes.
Comment on lines +169 to +289
it("submits one moderation request for text and one per image", async () => {
process.env.LLM_OPENAI_API_KEY = "sk-openai-test";
const requestBodies: Array<{ model: string; input: unknown }> = [];

vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
model: string;
input: string | Array<{ image_url?: { url: string } }>;
};
requestBodies.push(body);

if (typeof body.input === "string") {
return new Response(
JSON.stringify({
id: "modr-text",
model: "omni-moderation-latest",
results: [{ flagged: false, categories: {} }],
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
"x-request-id": "req-text",
},
},
);
}

const imageUrl = body.input[0]?.image_url?.url;
return new Response(
JSON.stringify({
id: `modr-${imageUrl}`,
model: "omni-moderation-latest",
results: [
{
flagged: imageUrl === "https://example.com/dog.png",
categories: {
violence: imageUrl === "https://example.com/dog.png",
},
},
],
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
"x-request-id":
imageUrl === "https://example.com/dog.png"
? "req-dog"
: "req-cat",
},
},
);
});

const result = await checkOpenAIContentFilter(
[
{
role: "user",
content: [
{
type: "text",
text: "Please inspect these images.",
},
{
type: "image_url",
image_url: {
url: "https://example.com/cat.png",
},
},
{
type: "image_url",
image_url: {
url: "https://example.com/dog.png",
},
},
],
},
],
{
requestId: "request-id",
organizationId: "org-id",
projectId: "project-id",
apiKeyId: "api-key-id",
},
);

expect(requestBodies).toEqual([
{
model: "omni-moderation-latest",
input: "user: Please inspect these images.",
},
{
model: "omni-moderation-latest",
input: [
{
type: "image_url",
image_url: {
url: "https://example.com/cat.png",
},
},
],
},
{
model: "omni-moderation-latest",
input: [
{
type: "image_url",
image_url: {
url: "https://example.com/dog.png",
},
},
],
},
]);
expect(result.flagged).toBe(true);
expect(result.model).toBe("omni-moderation-latest");
expect(result.upstreamRequestId).toBe("req-dog");
expect(result.results).toHaveLength(3);
expect(result.results.some((entry) => entry.flagged)).toBe(true);
});
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage doesn’t currently exercise the key guarantee described in the PR summary: if some moderation calls fail (e.g., 500/invalid JSON/timeout) but at least one successful result is flagged, the aggregated decision should still block. Add a spec case where one image moderation request fails while another returns flagged: true, and assert checkOpenAIContentFilter returns flagged: true and preserves the successful flagged categories.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65b4e56bed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +284 to 285
if (signal.aborted || isCancellationError(error)) {
throw error;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep timeouted requests from discarding flagged results

When any per-image moderation fetch hits the 60s timeout, signal.aborted becomes true and this branch rethrows, which causes Promise.all(...) in checkOpenAIContentFilter to reject and fall into the top-level fail-open return. In a mixed outcome (e.g., one image already flagged true, another times out), the flagged success is dropped and the gateway returns flagged: false, allowing content that should be blocked. This regression is introduced by splitting moderation into multiple requests and treating timeout aborts as hard failures.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/gateway/src/chat/tools/openai-content-filter.ts (1)

159-180: Reuse the extracted image inputs.

buildOpenAIContentFilterImageInputs() is called once inside buildOpenAIContentFilterRequests() and again immediately after for logging. For base64 images that means rebuilding the full data: URLs twice on the hot path. Consider returning the image count/inputs from the request builder so this work only happens once.

Also applies to: 363-365

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/chat/tools/openai-content-filter.ts` around lines 159 - 180,
buildOpenAIContentFilterRequests currently calls
buildOpenAIContentFilterImageInputs() and the caller re-calls it again for
logging, causing expensive re-construction of base64 data URLs; change the flow
so buildOpenAIContentFilterRequests returns (or exposes) the imageInputs/count
alongside the requests (e.g., return an object { requests, imageInputs } or
include imageCount) and reuse that returned imageInputs/count for logging
instead of calling buildOpenAIContentFilterImageInputs() a second time; update
callers that currently call buildOpenAIContentFilterImageInputs() after
buildOpenAIContentFilterRequests() (and the usage at the later location
mentioned) to use the returned imageInputs/count to eliminate duplicate work.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/gateway/src/chat/tools/openai-content-filter.ts`:
- Around line 284-298: The current error path in runOpenAIContentFilterRequest
(where signal.aborted/isCancellationError leads to throwing and later logging
via logModerationError and returning createFailedOpenAIContentFilterResult)
abandons the whole batch on a timeout/cancel and can drop earlier flagged
responses; change this flow to collect/merge settled sibling results before
returning: catch timeout/cancel errors but do not immediately fail-open—inspect
any previously fulfilled moderation results from the aggregated promises, return
a flagged result if any successful response is flagged, otherwise log the
timeout via logModerationError and return the failed result from
createFailedOpenAIContentFilterResult; apply the same aggregation fix to the
analogous branches around the other occurrences (lines referenced in review: the
blocks around 393-402 and 436-449) and add a regression test for the "flagged
success + timed-out sibling" scenario.

---

Nitpick comments:
In `@apps/gateway/src/chat/tools/openai-content-filter.ts`:
- Around line 159-180: buildOpenAIContentFilterRequests currently calls
buildOpenAIContentFilterImageInputs() and the caller re-calls it again for
logging, causing expensive re-construction of base64 data URLs; change the flow
so buildOpenAIContentFilterRequests returns (or exposes) the imageInputs/count
alongside the requests (e.g., return an object { requests, imageInputs } or
include imageCount) and reuse that returned imageInputs/count for logging
instead of calling buildOpenAIContentFilterImageInputs() a second time; update
callers that currently call buildOpenAIContentFilterImageInputs() after
buildOpenAIContentFilterRequests() (and the usage at the later location
mentioned) to use the returned imageInputs/count to eliminate duplicate work.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f4c8a815-009d-428c-a0b3-3d460f16a665

📥 Commits

Reviewing files that changed from the base of the PR and between 8a0634a and 65b4e56.

📒 Files selected for processing (2)
  • apps/gateway/src/chat/tools/openai-content-filter.spec.ts
  • apps/gateway/src/chat/tools/openai-content-filter.ts

Comment thread apps/gateway/src/chat/tools/openai-content-filter.ts
@steebchen steebchen force-pushed the split-image-moderation branch from 65b4e56 to a22bf99 Compare March 28, 2026 16:35
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a22bf99a90

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +284 to 285
if (signal.aborted || isCancellationError(error)) {
throw error;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat timeout aborts as per-request failures

This branch now throws whenever the shared moderation signal is aborted, which includes the 60s timeout used for all parallel image/text requests. In a mixed outcome (e.g., one request already returned flagged: true and another times out), Promise.all(...) rejects and checkOpenAIContentFilter falls back to fail-open, dropping the successful flagged result and returning flagged: false. That allows content that should be blocked whenever any single moderation call times out.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
apps/gateway/src/chat/tools/openai-content-filter.ts (1)

393-402: ⚠️ Potential issue | 🔴 Critical

Partial timeout can drop already-fulfilled flagged results.

At Line 393, Promise.all(...) rejects on the first thrown sibling request, so fulfilled results from other requests are not aggregated. Combined with Line 284 throwing on aborted/cancelled requests, this can fail-open even when another completed request already returned flagged: true.

💡 Suggested fix (aggregate settled siblings before deciding)
-		const moderationResults = await Promise.all(
+		const moderationSettled = await Promise.allSettled(
 			moderationRequests.map((request) =>
 				runOpenAIContentFilterRequest(
 					request,
 					context,
 					providerEnv.token,
 					signal,
 				),
 			),
 		);

-		const successfulResults = moderationResults
-			.filter((result) => result.success)
-			.map((result) => result.response);
+		if (requestSignal?.aborted) {
+			throw new Error("Request aborted");
+		}
+
+		const successfulResults = moderationSettled
+			.filter(
+				(
+					result,
+				): result is PromiseFulfilledResult<OpenAIContentFilterRequestResult> =>
+					result.status === "fulfilled" && result.value.success,
+			)
+			.map((result) => result.value.response);
+
+		const hasRejected = moderationSettled.some(
+			(result) => result.status === "rejected",
+		);
+		if (hasRejected && successfulResults.length === 0) {
+			logModerationError(context, {
+				durationMs: Date.now() - startTime,
+				error: "One or more moderation requests failed",
+				timeout: signal.aborted,
+			});
+			return createFailedOpenAIContentFilterResult();
+		}
#!/bin/bash
# Verify aggregation semantics and regression coverage for timeout + flagged sibling.
rg -nP 'Promise\.all(Settled)?\(' apps/gateway/src/chat/tools/openai-content-filter.ts
rg -nP 'signal\.aborted|isCancellationError|isTimeoutError' apps/gateway/src/chat/tools/openai-content-filter.ts
rg -nP 'flagged.*timeout|timeout.*flagged|timed-out sibling|allSettled' apps/gateway/src/chat/tools/openai-content-filter.spec.ts

Also applies to: 436-449

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/chat/tools/openai-content-filter.ts` around lines 393 - 402,
The current use of Promise.all when awaiting runOpenAIContentFilterRequest calls
(assigned to moderationResults) causes the whole aggregation to reject on the
first sibling error and can drop already-fulfilled flagged results; change the
aggregation to use Promise.allSettled for the moderationRequests (and the
similar block around the other Promise.all at the 436-449 area), then iterate
the settled results: treat fulfilled entries by inspecting their value for
flagged:true, treat rejected entries that are cancellation/timeout (e.g., checks
used elsewhere like isCancellationError/isTimeoutError or signal.aborted) as
non-fatal but ignored, and only throw or surface errors for non-cancellation
failures—finally compute the overall flagged result as true if any fulfilled
result reported flagged:true.
🧹 Nitpick comments (1)
apps/gateway/src/chat/tools/openai-content-filter.ts (1)

363-365: Avoid double image traversal in checkOpenAIContentFilter.

buildOpenAIContentFilterRequests(messages) already includes image requests, but Lines 364 and 423-425 recompute image inputs. Derive imageRequestCount from moderationRequests to keep one source of truth.

♻️ Suggested DRY refactor
-	const imageInputs = buildOpenAIContentFilterImageInputs(messages);
+	const imageRequestCount = moderationRequests.filter(
+		(request) => request.kind === "image",
+	).length;
...
-			hasImages: imageInputs.length > 0,
+			hasImages: imageRequestCount > 0,
 			requestCount: moderationRequests.length,
-			imageRequestCount: imageInputs.length,
+			imageRequestCount,

As per coding guidelines, "Apply DRY principles for code reuse".

Also applies to: 423-425

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/chat/tools/openai-content-filter.ts` around lines 363 - 365,
checkOpenAIContentFilter is double-traversing images:
buildOpenAIContentFilterRequests(messages) already includes image requests but
the code also calls buildOpenAIContentFilterImageInputs(messages) and recomputes
image counts later; update checkOpenAIContentFilter to derive imageRequestCount
(and any image-related inputs) from the returned moderationRequests array
instead of calling buildOpenAIContentFilterImageInputs again, remove the
redundant buildOpenAIContentFilterImageInputs call and any separate image
traversal, and ensure all logic that relied on imageRequestCount now reads it
from moderationRequests (use the structure produced by
buildOpenAIContentFilterRequests) to maintain a single source of truth.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@apps/gateway/src/chat/tools/openai-content-filter.ts`:
- Around line 393-402: The current use of Promise.all when awaiting
runOpenAIContentFilterRequest calls (assigned to moderationResults) causes the
whole aggregation to reject on the first sibling error and can drop
already-fulfilled flagged results; change the aggregation to use
Promise.allSettled for the moderationRequests (and the similar block around the
other Promise.all at the 436-449 area), then iterate the settled results: treat
fulfilled entries by inspecting their value for flagged:true, treat rejected
entries that are cancellation/timeout (e.g., checks used elsewhere like
isCancellationError/isTimeoutError or signal.aborted) as non-fatal but ignored,
and only throw or surface errors for non-cancellation failures—finally compute
the overall flagged result as true if any fulfilled result reported
flagged:true.

---

Nitpick comments:
In `@apps/gateway/src/chat/tools/openai-content-filter.ts`:
- Around line 363-365: checkOpenAIContentFilter is double-traversing images:
buildOpenAIContentFilterRequests(messages) already includes image requests but
the code also calls buildOpenAIContentFilterImageInputs(messages) and recomputes
image counts later; update checkOpenAIContentFilter to derive imageRequestCount
(and any image-related inputs) from the returned moderationRequests array
instead of calling buildOpenAIContentFilterImageInputs again, remove the
redundant buildOpenAIContentFilterImageInputs call and any separate image
traversal, and ensure all logic that relied on imageRequestCount now reads it
from moderationRequests (use the structure produced by
buildOpenAIContentFilterRequests) to maintain a single source of truth.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 17beb761-1e28-4e3f-bbd9-a833b0ba4852

📥 Commits

Reviewing files that changed from the base of the PR and between 65b4e56 and a22bf99.

📒 Files selected for processing (2)
  • apps/gateway/src/chat/tools/openai-content-filter.spec.ts
  • apps/gateway/src/chat/tools/openai-content-filter.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/gateway/src/chat/tools/openai-content-filter.spec.ts

@steebchen steebchen enabled auto-merge March 28, 2026 17:04
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
scripts/two-image-edit.sh (1)

22-33: Add explicit input-file readability checks before encoding.

Currently, missing/unreadable inputs fail indirectly during file/base64. A direct check gives clearer failures earlier.

Suggested fix
 IMAGE1=$1
 IMAGE2=$2
+
+for img in "$IMAGE1" "$IMAGE2"; do
+	if [[ ! -r "$img" ]]; then
+		echo "Input image is not readable: $img" >&2
+		exit 1
+	fi
+done

Also applies to: 35-36

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/two-image-edit.sh` around lines 22 - 33, Add an explicit readability
check for input files in mime_type and data_url so missing/unreadable files fail
with a clear message before calling file/base64: in mime_type (function
mime_type) validate the argument exists and is readable (e.g., test -r "$1") and
print a descriptive error and non-zero exit if not; in data_url (function
data_url) check the local path variable is non-empty and readable (test -r
"$path") and similarly error out before calling mime_type or base64, ensuring
callers get immediate, clear failures rather than cryptic file/base64 errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/two-image-edit.sh`:
- Around line 41-61: Update the curl invocation in scripts/two-image-edit.sh to
add HTTP-failure and timeout handling: include curl flags like --fail (or
--fail-with-body if available), --max-time (e.g. 30), and --connect-timeout
(e.g. 10) alongside -sS, then immediately check curl's exit status ($?) after
the request and on non-zero exit log an error to stderr and exit with a non-zero
code (remove or avoid treating the response file as valid on failure). Target
the curl call that posts to "$GATEWAY_URL/v1/images/edits" and the surrounding
logic that writes to "$RESPONSE_FILE" so failures/timeouts are detected and
handled rather than silently parsed.
- Around line 46-58: The JSON payload is unsafe because variables MODEL,
IMAGE1_URL, IMAGE2_URL, ASPECT_RATIO, SIZE, and QUALITY are injected raw and can
break JSON if they contain quotes/backslashes/newlines; fix by JSON-escaping
these shell variables before interpolation (use the same technique used for
PROMPT: pipe each variable through a JSON encoder such as python3 -c 'import
json,sys; print(json.dumps(sys.stdin.read()))' or jq -R -s `@json`) and replace
the raw uses of $MODEL, $IMAGE1_URL, $IMAGE2_URL, $ASPECT_RATIO, $SIZE, and
$QUALITY in the payload with their escaped counterparts so the resulting JSON
remains valid.
- Around line 59-60: The request body in scripts/two-image-edit.sh fails to set
response_format, causing the API to return URLs while later code (the parser
around lines 79-81) expects data[0].b64_json; update the request JSON to include
"response_format": "b64_json" so the response contains data[0].b64_json, and
verify the parser still reads data[0].b64_json; specifically modify the payload
construction used for image edits (the block that currently ends with "n": 1) to
add the response_format field set to "b64_json".

---

Nitpick comments:
In `@scripts/two-image-edit.sh`:
- Around line 22-33: Add an explicit readability check for input files in
mime_type and data_url so missing/unreadable files fail with a clear message
before calling file/base64: in mime_type (function mime_type) validate the
argument exists and is readable (e.g., test -r "$1") and print a descriptive
error and non-zero exit if not; in data_url (function data_url) check the local
path variable is non-empty and readable (test -r "$path") and similarly error
out before calling mime_type or base64, ensuring callers get immediate, clear
failures rather than cryptic file/base64 errors.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c56b9fa3-88a1-4563-ab9d-898a0bd80ddb

📥 Commits

Reviewing files that changed from the base of the PR and between a22bf99 and 5fffaf9.

📒 Files selected for processing (1)
  • scripts/two-image-edit.sh

Comment thread scripts/two-image-edit.sh
Comment on lines +41 to +61
curl -sS -X POST "$GATEWAY_URL/v1/images/edits" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @- <<EOF > "$RESPONSE_FILE"
{
"model": "$MODEL",
"prompt": $(printf '%s' "$PROMPT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'),
"images": [
{
"image_url": "$IMAGE1_URL"
},
{
"image_url": "$IMAGE2_URL"
}
],
"aspect_ratio": "$ASPECT_RATIO",
"size": "$SIZE",
"quality": "$QUALITY",
"n": 1
}
EOF
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify reliability flags are present on curl invocation.
rg -n --fixed-strings 'curl -sS' scripts/two-image-edit.sh
rg -n -- '--fail-with-body|--connect-timeout|--max-time' scripts/two-image-edit.sh
# Expected: second command returns matches for all three flags.

Repository: theopenco/llmgateway

Length of output: 116


Add error handling and timeout bounds to curl invocation.

The curl -sS command at line 41 will exit with status 0 on HTTP 4xx/5xx errors and has no timeout limits, causing error responses to be silently parsed and network stalls to hang indefinitely.

Suggested fix
-curl -sS -X POST "$GATEWAY_URL/v1/images/edits" \
+curl -sS --fail-with-body \
+  --connect-timeout "${CONNECT_TIMEOUT:-10}" \
+  --max-time "${MAX_TIME:-120}" \
+  -X POST "$GATEWAY_URL/v1/images/edits" \
 	-H "Authorization: Bearer $TOKEN" \
 	-H "Content-Type: application/json" \
 	-d `@-` <<EOF > "$RESPONSE_FILE"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
curl -sS -X POST "$GATEWAY_URL/v1/images/edits" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @- <<EOF > "$RESPONSE_FILE"
{
"model": "$MODEL",
"prompt": $(printf '%s' "$PROMPT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'),
"images": [
{
"image_url": "$IMAGE1_URL"
},
{
"image_url": "$IMAGE2_URL"
}
],
"aspect_ratio": "$ASPECT_RATIO",
"size": "$SIZE",
"quality": "$QUALITY",
"n": 1
}
EOF
curl -sS --fail-with-body \
--connect-timeout "${CONNECT_TIMEOUT:-10}" \
--max-time "${MAX_TIME:-120}" \
-X POST "$GATEWAY_URL/v1/images/edits" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d `@-` <<EOF > "$RESPONSE_FILE"
{
"model": "$MODEL",
"prompt": $(printf '%s' "$PROMPT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'),
"images": [
{
"image_url": "$IMAGE1_URL"
},
{
"image_url": "$IMAGE2_URL"
}
],
"aspect_ratio": "$ASPECT_RATIO",
"size": "$SIZE",
"quality": "$QUALITY",
"n": 1
}
EOF
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/two-image-edit.sh` around lines 41 - 61, Update the curl invocation
in scripts/two-image-edit.sh to add HTTP-failure and timeout handling: include
curl flags like --fail (or --fail-with-body if available), --max-time (e.g. 30),
and --connect-timeout (e.g. 10) alongside -sS, then immediately check curl's
exit status ($?) after the request and on non-zero exit log an error to stderr
and exit with a non-zero code (remove or avoid treating the response file as
valid on failure). Target the curl call that posts to
"$GATEWAY_URL/v1/images/edits" and the surrounding logic that writes to
"$RESPONSE_FILE" so failures/timeouts are detected and handled rather than
silently parsed.

Comment thread scripts/two-image-edit.sh
Comment on lines +46 to +58
"model": "$MODEL",
"prompt": $(printf '%s' "$PROMPT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'),
"images": [
{
"image_url": "$IMAGE1_URL"
},
{
"image_url": "$IMAGE2_URL"
}
],
"aspect_ratio": "$ASPECT_RATIO",
"size": "$SIZE",
"quality": "$QUALITY",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify raw interpolated JSON string fields are eliminated.
rg -nP '"(model|aspect_ratio|size|quality|image_url)":\s*"\$[A-Z0-9_]+"' scripts/two-image-edit.sh
# Expected after fix: no matches.

Repository: theopenco/llmgateway

Length of output: 237


🏁 Script executed:

cat -n scripts/two-image-edit.sh | sed -n '40,65p'

Repository: theopenco/llmgateway

Length of output: 791


JSON payload interpolation is unsafe for non-trivial env values.

At lines 46, 50, 53, 56–58, values like MODEL, IMAGE1_URL, IMAGE2_URL, ASPECT_RATIO, SIZE, and QUALITY are injected as raw strings. If any contains quotes, newlines, or backslashes, the JSON becomes invalid and the API request fails. Note that PROMPT (line 47) is already properly escaped using Python's json.dumps(), but other fields are not.

Suggested fix
+json_escape() {
+	python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'
+}
+
+MODEL_JSON=$(printf '%s' "$MODEL" | json_escape)
+ASPECT_RATIO_JSON=$(printf '%s' "$ASPECT_RATIO" | json_escape)
+SIZE_JSON=$(printf '%s' "$SIZE" | json_escape)
+QUALITY_JSON=$(printf '%s' "$QUALITY" | json_escape)
+IMAGE1_URL_JSON=$(printf '%s' "$IMAGE1_URL" | json_escape)
+IMAGE2_URL_JSON=$(printf '%s' "$IMAGE2_URL" | json_escape)
+
 {
-	"model": "$MODEL",
+	"model": $MODEL_JSON,
 	"prompt": $(printf '%s' "$PROMPT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'),
 	"images": [
 		{
-			"image_url": "$IMAGE1_URL"
+			"image_url": $IMAGE1_URL_JSON
 		},
 		{
-			"image_url": "$IMAGE2_URL"
+			"image_url": $IMAGE2_URL_JSON
 		}
 	],
-	"aspect_ratio": "$ASPECT_RATIO",
-	"size": "$SIZE",
-	"quality": "$QUALITY",
+	"aspect_ratio": $ASPECT_RATIO_JSON,
+	"size": $SIZE_JSON,
+	"quality": $QUALITY_JSON,
 	"n": 1
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"model": "$MODEL",
"prompt": $(printf '%s' "$PROMPT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'),
"images": [
{
"image_url": "$IMAGE1_URL"
},
{
"image_url": "$IMAGE2_URL"
}
],
"aspect_ratio": "$ASPECT_RATIO",
"size": "$SIZE",
"quality": "$QUALITY",
json_escape() {
python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'
}
MODEL_JSON=$(printf '%s' "$MODEL" | json_escape)
ASPECT_RATIO_JSON=$(printf '%s' "$ASPECT_RATIO" | json_escape)
SIZE_JSON=$(printf '%s' "$SIZE" | json_escape)
QUALITY_JSON=$(printf '%s' "$QUALITY" | json_escape)
IMAGE1_URL_JSON=$(printf '%s' "$IMAGE1_URL" | json_escape)
IMAGE2_URL_JSON=$(printf '%s' "$IMAGE2_URL" | json_escape)
{
"model": $MODEL_JSON,
"prompt": $(printf '%s' "$PROMPT" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'),
"images": [
{
"image_url": $IMAGE1_URL_JSON
},
{
"image_url": $IMAGE2_URL_JSON
}
],
"aspect_ratio": $ASPECT_RATIO_JSON,
"size": $SIZE_JSON,
"quality": $QUALITY_JSON,
"n": 1
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/two-image-edit.sh` around lines 46 - 58, The JSON payload is unsafe
because variables MODEL, IMAGE1_URL, IMAGE2_URL, ASPECT_RATIO, SIZE, and QUALITY
are injected raw and can break JSON if they contain quotes/backslashes/newlines;
fix by JSON-escaping these shell variables before interpolation (use the same
technique used for PROMPT: pipe each variable through a JSON encoder such as
python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))' or jq -R -s
`@json`) and replace the raw uses of $MODEL, $IMAGE1_URL, $IMAGE2_URL,
$ASPECT_RATIO, $SIZE, and $QUALITY in the payload with their escaped
counterparts so the resulting JSON remains valid.

Comment thread scripts/two-image-edit.sh
Comment on lines +59 to +60
"n": 1
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For OpenAI-compatible POST /v1/images/edits, what are the supported response_format values and what is the default if omitted?

💡 Result:

The supported response_format values for OpenAI-compatible POST /v1/images/edits are "url" and "b64_json". The default if omitted is "url". This matches the legacy DALL-E 2 behavior, which many OpenAI-compatible servers (e.g., LocalAI, vLLM-Omni, OpenVINO) emulate for compatibility. Official OpenAI docs note this parameter for DALL-E 2 (default "url"), while GPT Image models ignore it and always return b64_json.

Citations:


🏁 Script executed:

# First, locate and examine the script
fd "two-image-edit.sh" -type f

Repository: theopenco/llmgateway

Length of output: 234


🏁 Script executed:

# Correct syntax for fd to find the script
fd "two-image-edit.sh"

Repository: theopenco/llmgateway

Length of output: 89


🏁 Script executed:

# Check file size and read the relevant sections
wc -l scripts/two-image-edit.sh
echo "---"
# Read around lines 59-60 (request body with response_format concern)
sed -n '50,70p' scripts/two-image-edit.sh
echo "---"
# Read around lines 79-81 (parser implementation)
sed -n '70,90p' scripts/two-image-edit.sh

Repository: theopenco/llmgateway

Length of output: 917


Request/response format contract mismatch will cause script failure.

The request body at lines 59-60 does not explicitly set response_format, causing the API to default to "url". However, the parser at lines 79-81 expects data[0].b64_json, which is only returned when response_format is explicitly set to "b64_json". The script will fail with "Response missing data[0].b64_json" when using any OpenAI-compatible endpoint that follows the standard default behavior.

Required fix
 	"quality": "$QUALITY",
+	"response_format": "b64_json",
 	"n": 1
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"n": 1
}
"response_format": "b64_json",
"n": 1
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/two-image-edit.sh` around lines 59 - 60, The request body in
scripts/two-image-edit.sh fails to set response_format, causing the API to
return URLs while later code (the parser around lines 79-81) expects
data[0].b64_json; update the request JSON to include "response_format":
"b64_json" so the response contains data[0].b64_json, and verify the parser
still reads data[0].b64_json; specifically modify the payload construction used
for image edits (the block that currently ends with "n": 1) to add the
response_format field set to "b64_json".

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5fffaf90f7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +393 to +394
const moderationResults = await Promise.all(
moderationRequests.map((request) =>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound fan-out of moderation requests

This Promise.all fans out every moderation request at once, and each input image is now a separate upstream call; under image-heavy prompts this can easily cause 429/timeout responses from the moderation API. Because aggregation later drops failed calls and fail-opens when none succeed, high fan-out can return flagged: false even when some images were never effectively moderated. Please add a per-request concurrency limit (and/or a hard image cap) so overload cannot degrade moderation coverage.

Useful? React with 👍 / 👎.

@steebchen steebchen added this pull request to the merge queue Mar 28, 2026
Merged via the queue into main with commit 7c6da58 Mar 28, 2026
13 checks passed
@steebchen steebchen deleted the split-image-moderation branch March 28, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants