Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
36 changes: 28 additions & 8 deletions apps/gateway/src/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,9 +364,9 @@ function isContentFilterProvider(providerId: string): boolean {

function getContentFilterRoutingDecision(
availableModelProviders: ProviderModelMapping[],
contentFilterMatched: boolean,
shouldAvoidContentFilterProviders: boolean,
): ContentFilterRoutingDecision {
if (!contentFilterMatched) {
if (!shouldAvoidContentFilterProviders) {
return {
candidates: availableModelProviders,
excludedProviders: [],
Expand Down Expand Up @@ -408,11 +408,12 @@ function getContentFilterRoutingDecision(
function addContentFilterRoutingMetadata(
routingMetadata: RoutingMetadata,
contentFilterMatched: boolean,
contentFilterUnavailable: boolean,
excludedProviders: ProviderModelMapping[],
modelId: string | undefined,
metricsMap: Map<string, ProviderMetrics>,
): RoutingMetadata {
if (!contentFilterMatched) {
if (!contentFilterMatched && !contentFilterUnavailable) {
return routingMetadata;
}

Expand All @@ -438,15 +439,18 @@ function addContentFilterRoutingMetadata(
throughput: metrics?.throughput ?? 0,
price: getProviderSelectionPrice(provider),
contentFilterProvider: true,
excludedByContentFilter: true,
...(contentFilterMatched
? { excludedByContentFilter: true }
: { excludedByModerationFailure: true }),
};
}),
...routingMetadata.providerScores,
];

return {
...routingMetadata,
contentFilterMatched: true,
...(contentFilterMatched ? { contentFilterMatched: true } : {}),
...(contentFilterUnavailable ? { contentFilterUnavailable: true } : {}),
contentFilterRerouted: contentFilterExcludedProviders.length > 0,
contentFilterExcludedProviders:
contentFilterExcludedProviders.length > 0
Expand Down Expand Up @@ -1899,8 +1903,11 @@ chat.openapi(completions, async (c) => {
const contentFilterMatched =
keywordContentFilterMatch !== null ||
openAIContentFilterResult?.flagged === true;
const shouldRerouteContentFilter =
contentFilterMode === "enabled" && contentFilterMatched;
const contentFilterUnavailable =
openAIContentFilterResult?.unavailable === true;
const shouldAvoidContentFilterProviders =
contentFilterMode === "enabled" &&
(contentFilterMatched || contentFilterUnavailable);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
let contentFilterRoutingExcludedProviders: ProviderModelMapping[] = [];
let contentFilterRoutingApplied = false;

Expand Down Expand Up @@ -2311,7 +2318,7 @@ chat.openapi(completions, async (c) => {

const contentFilterRoutingDecision = getContentFilterRoutingDecision(
availableModelProviders,
shouldRerouteContentFilter,
shouldAvoidContentFilterProviders,
);
const contentFilterPreferredProviders =
contentFilterRoutingDecision.candidates;
Expand Down Expand Up @@ -2382,6 +2389,7 @@ chat.openapi(completions, async (c) => {
...(noFallback ? { noFallback: true } : {}),
},
contentFilterMatched,
contentFilterUnavailable,
contentFilterRoutingExcludedProviders,
modelWithPricing.id,
metricsMap,
Expand Down Expand Up @@ -2569,6 +2577,7 @@ chat.openapi(completions, async (c) => {
...(noFallback ? { noFallback: true } : {}),
},
contentFilterMatched,
contentFilterUnavailable,
contentFilterRoutingExcludedProviders,
baseModelId,
metricsMap,
Expand Down Expand Up @@ -2984,6 +2993,10 @@ chat.openapi(completions, async (c) => {
contentFilterMode === "enabled" &&
contentFilterMatched &&
!contentFilterRoutingApplied;
const contentFilterSensitiveProviderBlocked =
contentFilterMode === "enabled" &&
contentFilterUnavailable &&
isContentFilterProvider(usedProvider);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Preserve monitor tagging, and also tag successful reroutes triggered by a
// gateway content-filter match so the decision remains visible in logs.
Expand Down Expand Up @@ -3124,6 +3137,13 @@ chat.openapi(completions, async (c) => {
});
}

if (contentFilterSensitiveProviderBlocked) {
throw new HTTPException(503, {
message:
"OpenAI moderation is unavailable and no eligible provider without provider-side content filtering is available.",
});
}

// Check if the selected provider supports reasoning (from specific mapping, not any)
const selectedProviderMapping = modelInfo.providers.find(
(p) =>
Expand Down
3 changes: 3 additions & 0 deletions apps/gateway/src/chat/tools/openai-content-filter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,7 @@ describe("checkOpenAIContentFilter", () => {

expect(result).toEqual({
flagged: false,
unavailable: true,
model: "omni-moderation-latest",
upstreamRequestId: null,
results: [],
Expand Down Expand Up @@ -631,6 +632,7 @@ describe("checkOpenAIContentFilter", () => {

expect(result).toEqual({
flagged: false,
unavailable: true,
model: "omni-moderation-latest",
upstreamRequestId: null,
results: [],
Expand Down Expand Up @@ -715,6 +717,7 @@ describe("checkOpenAIContentFilter", () => {

expect(result).toEqual({
flagged: false,
unavailable: true,
model: "omni-moderation-latest",
upstreamRequestId: null,
results: [],
Expand Down
7 changes: 6 additions & 1 deletion apps/gateway/src/chat/tools/openai-content-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ interface OpenAIModerationResult {

export interface OpenAIContentFilterCheckResult {
flagged: boolean;
unavailable: boolean;
model: string;
upstreamRequestId: string | null;
results: OpenAIModerationResult[];
Expand Down Expand Up @@ -339,9 +340,11 @@ function buildModerationErrorDetails(error: unknown): Record<string, string> {

function createFailedOpenAIContentFilterResult(
upstreamRequestId: string | null = null,
unavailable = true,
): OpenAIContentFilterCheckResult {
return {
flagged: false,
unavailable,
model: OPENAI_MODERATION_MODEL,
upstreamRequestId,
results: [],
Expand Down Expand Up @@ -446,6 +449,7 @@ async function runOpenAIContentFilterRequest(
flagged: (moderationResponse.results ?? []).some((result) =>
isOpenAIModerationResultFlagged(result),
),
unavailable: false,
model: moderationResponse.model ?? OPENAI_MODERATION_MODEL,
upstreamRequestId,
results: moderationResponse.results ?? [],
Expand Down Expand Up @@ -476,7 +480,7 @@ export async function checkOpenAIContentFilter(
results: [],
});

return createFailedOpenAIContentFilterResult();
return createFailedOpenAIContentFilterResult(null, false);
}
Comment on lines 470 to 484
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

In the moderationRequests.length === 0 path, the code returns createFailedOpenAIContentFilterResult(null, false). Since this isn’t actually a failure case, consider introducing a separate helper (or renaming the existing one) to represent a successful “no moderation needed” result, to avoid confusing semantics around unavailable vs. failure.

Copilot uses AI. Check for mistakes.

const signal = requestSignal
Expand Down Expand Up @@ -530,6 +534,7 @@ export async function checkOpenAIContentFilter(

return {
flagged,
unavailable: moderationResults.some((result) => !result.success),
model,
upstreamRequestId,
results,
Expand Down
3 changes: 2 additions & 1 deletion apps/gateway/src/chat/tools/retry-with-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export function selectNextProvider(
score: number;
region?: string;
excludedByContentFilter?: boolean;
excludedByModerationFailure?: boolean;
}>,
failedProviders: Set<string>,
modelProviders: Array<{
Expand All @@ -95,7 +96,7 @@ export function selectNextProvider(
): { providerId: string; modelName: string; region?: string } | null {
const sorted = [...providerScores].sort((a, b) => a.score - b.score);
for (const score of sorted) {
if (score.excludedByContentFilter) {
if (score.excludedByContentFilter || score.excludedByModerationFailure) {
continue;
}
Comment on lines 97 to 101
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

selectNextProvider() now skips scores marked excludedByModerationFailure, but there’s no accompanying unit test verifying this new exclusion behavior (the existing spec only covers excludedByContentFilter). Please add a test case to ensure providers excluded due to moderation unavailability are never selected during fallback retries.

Copilot uses AI. Check for mistakes.

Expand Down
Loading
Loading