Skip to content
Merged
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
5 changes: 5 additions & 0 deletions apps/api/src/routes/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,14 @@ const logSchema = z.object({
status_code: z.number().optional(),
error_type: z.string().optional(),
rate_limited: z.boolean().optional(),
contentFilterProvider: z.boolean().optional(),
excludedByContentFilter: z.boolean().optional(),
}),
)
.optional(),
contentFilterMatched: z.boolean().optional(),
contentFilterRerouted: z.boolean().optional(),
contentFilterExcludedProviders: z.array(z.string()).optional(),
routing: z
.array(
z.object({
Expand Down
10 changes: 10 additions & 0 deletions apps/code/src/lib/api/v1.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -975,7 +975,12 @@ export interface paths {
status_code?: number;
error_type?: string;
rate_limited?: boolean;
contentFilterProvider?: boolean;
excludedByContentFilter?: boolean;
}[];
contentFilterMatched?: boolean;
contentFilterRerouted?: boolean;
contentFilterExcludedProviders?: string[];
routing?: {
provider: string;
model: string;
Expand Down Expand Up @@ -1211,7 +1216,12 @@ export interface paths {
status_code?: number;
error_type?: string;
rate_limited?: boolean;
contentFilterProvider?: boolean;
excludedByContentFilter?: boolean;
}[];
contentFilterMatched?: boolean;
contentFilterRerouted?: boolean;
contentFilterExcludedProviders?: string[];
routing?: {
provider: string;
model: string;
Expand Down
246 changes: 193 additions & 53 deletions apps/gateway/src/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
providers,
type WebSearchTool,
expandAllProviderRegions,
getProviderDefinition,
getRegionSpecificEnvValue,
stripRegionFromModelName,
} from "@llmgateway/models";
Expand Down Expand Up @@ -330,6 +331,110 @@ function filterEligibleModelProviders(
});
}

interface ContentFilterRoutingDecision {
candidates: ProviderModelMapping[];
excludedProviders: ProviderModelMapping[];
rerouted: boolean;
}

function isContentFilterProvider(providerId: string): boolean {
return getProviderDefinition(providerId)?.contentFilter === true;
}

function getContentFilterRoutingDecision(
availableModelProviders: ProviderModelMapping[],
contentFilterMatched: boolean,
): ContentFilterRoutingDecision {
if (!contentFilterMatched) {
return {
candidates: availableModelProviders,
excludedProviders: [],
rerouted: false,
};
}

const preferredProviders = availableModelProviders.filter(
(provider) => !isContentFilterProvider(provider.providerId),
);

if (preferredProviders.length === 0) {
return {
candidates: availableModelProviders,
excludedProviders: [],
rerouted: false,
};
}

const excludedProviders = availableModelProviders.filter((provider) =>
isContentFilterProvider(provider.providerId),
);

if (excludedProviders.length === 0) {
return {
candidates: availableModelProviders,
excludedProviders: [],
rerouted: false,
};
}

return {
candidates: preferredProviders,
excludedProviders,
rerouted: true,
};
}

function addContentFilterRoutingMetadata(
routingMetadata: RoutingMetadata,
contentFilterMatched: boolean,
excludedProviders: ProviderModelMapping[],
modelId: string | undefined,
metricsMap: Map<string, ProviderMetrics>,
): RoutingMetadata {
if (!contentFilterMatched) {
return routingMetadata;
}

const contentFilterExcludedProviders = [
...new Set(excludedProviders.map((provider) => provider.providerId)),
];

const providerScores =
excludedProviders.length === 0 || !modelId
? routingMetadata.providerScores
: [
...excludedProviders.map((provider) => {
const metrics = metricsMap.get(
metricsKey(modelId, provider.providerId, provider.region),
);

return {
providerId: provider.providerId,
region: provider.region,
score: -1,
uptime: metrics?.uptime ?? 0,
latency: metrics?.averageLatency ?? 0,
throughput: metrics?.throughput ?? 0,
price: getProviderSelectionPrice(provider),
contentFilterProvider: true,
excludedByContentFilter: true,
};
}),
Comment on lines +418 to +422

Copilot AI Mar 30, 2026

Copy link

Choose a reason for hiding this comment

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

contentFilterProvider is only set on the synthetic score entries created for excludedProviders here. The existing routingMetadata.providerScores entries (including the selected provider) are never annotated, so logs/UI can’t tell which scored providers are content-filter providers unless they were excluded. Consider mapping over providerScores to set contentFilterProvider based on getProviderDefinition(providerId)?.contentFilter, and separately marking excludedByContentFilter when applicable.

Copilot uses AI. Check for mistakes.
...routingMetadata.providerScores,
];

return {
...routingMetadata,
contentFilterMatched: true,
contentFilterRerouted: contentFilterExcludedProviders.length > 0,
contentFilterExcludedProviders:
contentFilterExcludedProviders.length > 0
? contentFilterExcludedProviders
: undefined,
providerScores,
};
}

function usesGoogleQueryToken(provider: string): boolean {
return (
provider === "google-ai-studio" ||
Expand Down Expand Up @@ -1418,6 +1523,36 @@ chat.openapi(completions, async (c) => {
}
}

const contentFilterMode = getContentFilterMode();
const contentFilterMethod = getContentFilterMethod();
const shouldApplyGatewayContentFilter =
contentFilterMode !== "disabled" &&
shouldApplyContentFilterToModel(requestedModel);
Comment on lines +1528 to +1530

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

Use the resolved model for gateway filter gating.

At this point modelInfo can already hold the concrete model chosen by the auto-router, but this condition still keys off requestedModel (the raw input token). That means auto requests can skip the content-filter match/reroute path entirely, or be evaluated against the wrong model. Gate shouldApplyContentFilterToModel(...) off modelInfo.id here instead.

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

In `@apps/gateway/src/chat/chat.ts` around lines 1528 - 1530, The gating for
applying the gateway content filter currently uses requestedModel, which can be
the raw token and bypasses or mis-evaluates auto-routed selections; update the
shouldApplyGatewayContentFilter condition to use the resolved model id from
modelInfo (e.g., modelInfo.id) instead of requestedModel so
shouldApplyContentFilterToModel(...) is invoked against the concrete model
chosen by the auto-router (ensure modelInfo is defined/available in scope before
calling).

const keywordContentFilterMatch =
shouldApplyGatewayContentFilter && contentFilterMethod === "keywords"
? checkContentFilter(messages as BaseMessage[])
: null;
const openAIContentFilterResult =
shouldApplyGatewayContentFilter && contentFilterMethod === "openai"
? await checkOpenAIContentFilter(
Comment on lines +1535 to +1537

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 Defer OpenAI moderation until after hard rejections

This now executes checkOpenAIContentFilter(...) before downstream hard-fail checks (for example provider RPM 429 with x-no-fallback, insufficient retention credits, or missing token), so prompts can be sent to OpenAI moderation and billed even when the request is guaranteed to be rejected locally. In the previous flow moderation happened later, after those gates. This is a behavioral regression in cost/privacy for LLM_CONTENT_FILTER_METHOD=openai and can be avoided by moving moderation evaluation behind non-recoverable rejection paths.

Useful? React with 👍 / 👎.

messages as BaseMessage[],
{
requestId,
organizationId: project.organizationId,
projectId: project.id,
apiKeyId: apiKey.id,
},
c.req.raw.signal,
)
: null;
const contentFilterMatched =
keywordContentFilterMatch !== null ||
openAIContentFilterResult?.flagged === true;
const shouldRerouteContentFilter =
contentFilterMode === "enabled" && contentFilterMatched;
let contentFilterRoutingExcludedProviders: ProviderModelMapping[] = [];
let contentFilterRoutingApplied = false;

// Check provider RPM caps for specifically requested providers
// If rate-limited, route to an alternative (or 429 if no-fallback)
if (
Expand Down Expand Up @@ -1823,23 +1958,33 @@ chat.openapi(completions, async (c) => {
});
}

const contentFilterRoutingDecision = getContentFilterRoutingDecision(
availableModelProviders,
shouldRerouteContentFilter,
);
Comment on lines +1961 to +1964

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 Apply content-filter reroute to direct-provider flows

Content-filter rerouting is only computed in the auto-routing branch, so requests with an explicitly selected provider never get rerouted away from contentFilter providers even when alternates exist and fallback is otherwise allowed. In enabled mode those requests remain blocked (contentFilterRoutingApplied stays false), which makes the new reroute behavior unavailable for direct-provider requests despite existing fallback behavior for other failure modes (rate-limit/uptime).

Useful? React with 👍 / 👎.

const contentFilterPreferredProviders =
contentFilterRoutingDecision.candidates;
contentFilterRoutingExcludedProviders =
contentFilterRoutingDecision.excludedProviders;
contentFilterRoutingApplied = contentFilterRoutingDecision.rerouted;
Comment on lines +1961 to +1969

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

Apply the exclusion before provider selection is finalized.

This reroute only runs in the implicit multi-provider branch. auto can already pin usedProvider above, so a matched auto request can still land on a contentFilter provider even when a non-filter alternative exists. The same preferred/excluded candidate split needs to participate in the auto-router too, not just here.

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

In `@apps/gateway/src/chat/chat.ts` around lines 1961 - 1969, Compute
getContentFilterRoutingDecision (using availableModelProviders and
shouldRerouteContentFilter) before the implicit multi-provider "auto" selection
and use its candidates/excludedProviders to influence the auto-router's provider
choice; specifically, apply contentFilterRoutingDecision.excludedProviders to
filter availableModelProviders and prefer
contentFilterRoutingDecision.candidates when determining usedProvider in the
auto-routing code path, and ensure contentFilterRoutingApplied is set from the
decision so later logic sees the exclusion.


// Filter out rate-limited providers during routing
const rateLimitedProviderIds = await filterRateLimitedProviders(
project.organizationId,
availableModelProviders.map((p) => ({
contentFilterPreferredProviders.map((p) => ({
providerId: p.providerId,
model: (modelInfo as ModelDefinition).id,
providerModelName: p.modelName,
})),
);
const nonRateLimitedProviders = availableModelProviders.filter(
const nonRateLimitedProviders = contentFilterPreferredProviders.filter(
(p) => !rateLimitedProviderIds.has(p.providerId),
);
// Fail-open: if all are rate-limited, use them all anyway
const routingCandidates =
nonRateLimitedProviders.length > 0
? nonRateLimitedProviders
: availableModelProviders;
: contentFilterPreferredProviders;

const rawModelWithPricing = models.find((m) => m.id === usedModel);
const modelWithPricing = rawModelWithPricing
Expand All @@ -1853,10 +1998,13 @@ chat.openapi(completions, async (c) => {

if (modelWithPricing) {
// Fetch uptime/latency metrics from last 5 minutes for provider selection
const metricsCombinations = routingCandidates.map((p) => ({
const metricsCombinations = [
...routingCandidates,
...contentFilterRoutingExcludedProviders,
].map((provider) => ({
modelId: modelWithPricing.id,
providerId: p.providerId,
region: p.region,
providerId: provider.providerId,
region: provider.region,
}));
const metricsMap =
await getProviderMetricsForCombinations(metricsCombinations);
Expand All @@ -1877,10 +2025,16 @@ chat.openapi(completions, async (c) => {
usedProvider = cheapestResult.provider.providerId;
usedModel = cheapestResult.provider.modelName;
usedRegion = cheapestResult.provider.region;
routingMetadata = {
...cheapestResult.metadata,
...(noFallback ? { noFallback: true } : {}),
};
routingMetadata = addContentFilterRoutingMetadata(
{
...cheapestResult.metadata,
...(noFallback ? { noFallback: true } : {}),
},
contentFilterMatched,
contentFilterRoutingExcludedProviders,
modelWithPricing.id,
metricsMap,
);
// Annotate rate-limited providers in routing metadata
if (rateLimitedProviderIds.size > 0) {
// Add filtered-out rate-limited providers as score entries
Expand Down Expand Up @@ -1912,9 +2066,9 @@ chat.openapi(completions, async (c) => {
usedRegion = routingCandidates[0].region;
}
} else {
usedProvider = availableModelProviders[0].providerId;
usedModel = availableModelProviders[0].modelName;
usedRegion = availableModelProviders[0].region;
usedProvider = contentFilterPreferredProviders[0].providerId;
usedModel = contentFilterPreferredProviders[0].modelName;
usedRegion = contentFilterPreferredProviders[0].region;
}
}
}
Expand Down Expand Up @@ -2007,10 +2161,13 @@ chat.openapi(completions, async (c) => {
let metricsMap: Map<string, ProviderMetrics> = new Map();

if (baseModelId && usedProvider !== "custom") {
const metricsCombinations = routingMetadataProviders.map((p) => ({
const metricsCombinations = [
...routingMetadataProviders,
...contentFilterRoutingExcludedProviders,
].map((provider) => ({
modelId: baseModelId,
providerId: p.providerId,
region: p.region,
providerId: provider.providerId,
region: provider.region,
}));
metricsMap = await getProviderMetricsForCombinations(metricsCombinations);
}
Expand Down Expand Up @@ -2052,13 +2209,19 @@ chat.openapi(completions, async (c) => {
};
});

routingMetadata = {
availableProviders: routingMetadataProviders.map((p) => p.providerId),
selectedProvider: usedProvider,
selectionReason,
providerScores: allProviderScores,
...(noFallback ? { noFallback: true } : {}),
};
routingMetadata = addContentFilterRoutingMetadata(
{
availableProviders: routingMetadataProviders.map((p) => p.providerId),
selectedProvider: usedProvider,
selectionReason,
providerScores: allProviderScores,
...(noFallback ? { noFallback: true } : {}),
},
contentFilterMatched,
contentFilterRoutingExcludedProviders,
baseModelId,
metricsMap,
);
}

// Update baseModelName to match the final usedModel after routing
Expand Down Expand Up @@ -2462,39 +2625,16 @@ chat.openapi(completions, async (c) => {
});
}

// Check gateway-level content filter before routing the request upstream.
const contentFilterMode = getContentFilterMode();
const contentFilterMethod = getContentFilterMethod();
const shouldApplyGatewayContentFilter =
contentFilterMode !== "disabled" &&
shouldApplyContentFilterToModel(requestedModel);
const keywordContentFilterMatch =
shouldApplyGatewayContentFilter && contentFilterMethod === "keywords"
? checkContentFilter(messages as BaseMessage[])
: null;
const openAIContentFilterResult =
shouldApplyGatewayContentFilter && contentFilterMethod === "openai"
? await checkOpenAIContentFilter(
messages as BaseMessage[],
{
requestId,
organizationId: project.organizationId,
projectId: project.id,
apiKeyId: apiKey.id,
},
c.req.raw.signal,
)
: null;
const contentFilterMatched =
keywordContentFilterMatch !== null ||
openAIContentFilterResult?.flagged === true;
const contentFilterBlocked =
contentFilterMode === "enabled" && contentFilterMatched;
contentFilterMode === "enabled" &&
contentFilterMatched &&
!contentFilterRoutingApplied;
Comment on lines +2629 to +2631

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 | 🔴 Critical

Don't equate “no reroute happened” with “this request must be blocked.”

contentFilterRoutingApplied is only set by the implicit reroute path, so this will also block explicit-provider and single-provider requests that ended up on a non-contentFilter provider. Blocking needs to be tied to the selected provider (or to the fact that no non-filter candidate existed), not just !contentFilterRoutingApplied.

Minimal safeguard
 const contentFilterBlocked =
 	contentFilterMode === "enabled" &&
 	contentFilterMatched &&
-	!contentFilterRoutingApplied;
+	!contentFilterRoutingApplied &&
+	isContentFilterProvider(usedProvider);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/chat/chat.ts` around lines 2629 - 2631, The current block
decision uses contentFilterMode === "enabled" && contentFilterMatched &&
!contentFilterRoutingApplied which treats "no implicit reroute occurred" as
equivalent to "request must be blocked"; instead, tie blocking to the actual
selected provider or to the lack of any non-filter candidate. Update the logic
so that you only mark the request blocked when contentFilterMode === "enabled"
&& contentFilterMatched && (the chosen/selected provider is a content-filter
provider OR there were zero non-content-filter candidate providers), rather than
relying on !contentFilterRoutingApplied; use the actual selected provider
identifier (e.g., selectedProvider or chosenProvider) or inspect the
candidateProviders list to determine if any non-filter provider existed, and
ensure explicit-provider and single-provider flows are allowed when they
resolved to a non-contentFilter provider.


// In monitor mode, tag logs with internalContentFilter when the selected
// filter method would have blocked the request.
// Preserve monitor tagging, and also tag successful reroutes triggered by a
// gateway content-filter match so the decision remains visible in logs.
const shouldTagContentFilter =
contentFilterMode === "monitor" && contentFilterMatched;
(contentFilterMode === "monitor" && contentFilterMatched) ||
contentFilterRoutingApplied;
const gatewayContentFilterResponse = openAIContentFilterResult?.responses
.length
? openAIContentFilterResult.responses
Expand Down
Loading
Loading