-
Notifications
You must be signed in to change notification settings - Fork 191
feat: content filter routing #1936
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2bbed7b
c9bf92d
db95b47
c6882b0
8e02458
f1d3539
b9d3845
73d4adb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -82,6 +82,7 @@ import { | |
| providers, | ||
| type WebSearchTool, | ||
| expandAllProviderRegions, | ||
| getProviderDefinition, | ||
| getRegionSpecificEnvValue, | ||
| stripRegionFromModelName, | ||
| } from "@llmgateway/models"; | ||
|
|
@@ -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, | ||
| }; | ||
| }), | ||
| ...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" || | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the resolved model for gateway filter gating. At this point 🤖 Prompt for AI Agents |
||
| const keywordContentFilterMatch = | ||
| shouldApplyGatewayContentFilter && contentFilterMethod === "keywords" | ||
| ? checkContentFilter(messages as BaseMessage[]) | ||
| : null; | ||
| const openAIContentFilterResult = | ||
| shouldApplyGatewayContentFilter && contentFilterMethod === "openai" | ||
| ? await checkOpenAIContentFilter( | ||
|
Comment on lines
+1535
to
+1537
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This now executes 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 ( | ||
|
|
@@ -1823,23 +1958,33 @@ chat.openapi(completions, async (c) => { | |
| }); | ||
| } | ||
|
|
||
| const contentFilterRoutingDecision = getContentFilterRoutingDecision( | ||
| availableModelProviders, | ||
| shouldRerouteContentFilter, | ||
| ); | ||
|
Comment on lines
+1961
to
+1964
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Content-filter rerouting is only computed in the auto-routing branch, so requests with an explicitly selected provider never get rerouted away from Useful? React with 👍 / 👎. |
||
| const contentFilterPreferredProviders = | ||
| contentFilterRoutingDecision.candidates; | ||
| contentFilterRoutingExcludedProviders = | ||
| contentFilterRoutingDecision.excludedProviders; | ||
| contentFilterRoutingApplied = contentFilterRoutingDecision.rerouted; | ||
|
Comment on lines
+1961
to
+1969
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Apply the exclusion before provider selection is finalized. This reroute only runs in the implicit multi-provider branch. 🤖 Prompt for AI Agents |
||
|
|
||
| // 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 | ||
|
|
@@ -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); | ||
|
|
@@ -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 | ||
|
|
@@ -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; | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -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); | ||
| } | ||
|
|
@@ -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 | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't equate “no reroute happened” with “this request must be blocked.”
Minimal safeguard const contentFilterBlocked =
contentFilterMode === "enabled" &&
contentFilterMatched &&
- !contentFilterRoutingApplied;
+ !contentFilterRoutingApplied &&
+ isContentFilterProvider(usedProvider);🤖 Prompt for AI Agents |
||
|
|
||
| // 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 | ||
|
|
||
There was a problem hiding this comment.
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.