Skip to content

feat: content filter routing - #1936

Merged
steebchen merged 8 commits into
mainfrom
codex/content-filter-routing
Mar 30, 2026
Merged

steebchen merged 8 commits into
mainfrom
codex/content-filter-routing

Conversation

@steebchen

@steebchen steebchen commented Mar 30, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Content-filter-aware routing that can reroute requests away from designated providers and exposes content-filter metadata in logs.
    • Added a CLI analysis tool and script to evaluate and tune content-filter rules.
  • UI

    • Visual indicators in activity logs and dashboard showing providers excluded by content filtering and rate-limit caps.
  • Tests

    • Added tests covering content-filter routing in enabled and monitor modes.
  • Documentation

    • Added findings document with content-filter evaluation and threshold recommendations.

Copilot AI review requested due to automatic review settings March 30, 2026 12:29
@coderabbitai

coderabbitai Bot commented Mar 30, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Adds gateway content-filter–aware routing: gateway evaluates content-filter responses early, marks providers with contentFilter: true as candidates for exclusion, reroutes requests away from those providers when enabled, and exposes per-provider and request-level content-filter metadata in logs, DB schema, actions types, and UIs.

Changes

Cohort / File(s) Summary
Gateway Core Routing Logic
apps/gateway/src/chat/chat.ts
Introduces content-filter-aware routing: detects ProviderDefinition.contentFilter, evaluates gateway content-filter early, builds preferred candidate set, reroutes when enabled, and augments routing decisions to include excluded providers and reroute flags.
API Logging
apps/api/src/routes/logs.ts
Extended logSchema.routingMetadata to validate and expose per-provider content-filter flags and top-level contentFilterMatched, contentFilterRerouted, contentFilterExcludedProviders in GET endpoints.
Type System & Actions
packages/actions/src/get-cheapest-from-available-providers.ts, packages/models/src/providers.ts
Expanded exported RoutingMetadata to include content-filter fields; added optional contentFilter?: boolean to ProviderDefinition and marked provider(s) (e.g., obsidian) accordingly.
Database Schema
packages/db/src/schema.ts
Extended log and video_job JSON routingMetadata shapes to include per-provider contentFilterProvider/excludedByContentFilter and top-level contentFilterMatched, contentFilterRerouted, contentFilterExcludedProviders.
UI – Dashboard & Admin
apps/ui/src/app/.../log-detail-client.tsx, apps/ui/src/components/dashboard/log-card.tsx, ee/admin/src/components/log-card.tsx
Added visual indicators for content-filter exclusion (amber “content filter” badge/icon) in provider score rows; admin UI updated to include optional region display and composite list keys.
Tests
apps/gateway/src/fallback.spec.ts, apps/gateway/src/lib/costs.spec.ts
Added test fixtures/ensures for providers/users; new content-filter tests for "enabled" and "monitor" modes; simplified resetTestState; minor test adjustments.
Analysis CLI & Findings
packages/scripts/src/analyze-content-filter.ts, packages/scripts/package.json, findings.md
Added CLI to evaluate content-filter detection rules against CSV datasets, new npm script entry, and a findings markdown report documenting thresholds, metrics, and diagnostics.
Worker Entrypoint Guard
apps/worker/src/index.ts
Added isDirectExecution() guard so worker startup only runs when the module is the direct entrypoint (prevents auto-start on import).

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Gateway
    participant ContentFilter
    participant ProviderSelector
    participant Provider
    participant Logger

    Client->>Gateway: send chat request
    Gateway->>ContentFilter: evaluate request
    ContentFilter-->>Gateway: content-filter result

    alt content-filter matched
        Gateway->>Gateway: identify providers with contentFilter=true
        Gateway->>ProviderSelector: request available providers excluding content-filter providers
        ProviderSelector->>ProviderSelector: score candidates (mark excluded providers)
        ProviderSelector-->>Gateway: preferred provider + excluded list
        Gateway->>Provider: forward request to preferred provider
        Provider-->>Gateway: response
        Gateway->>Logger: log routingMetadata (contentFilterMatched=true, contentFilterRerouted=true, contentFilterExcludedProviders)
    else no content-filter match
        Gateway->>ProviderSelector: request available providers (all eligible)
        ProviderSelector-->>Gateway: selected provider
        Gateway->>Provider: forward request
        Provider-->>Gateway: response
        Gateway->>Logger: log routingMetadata (contentFilterMatched=false)
    end

    Gateway-->>Client: return response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.88% 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 title 'feat: content filter routing' accurately and concisely describes the main change across the PR—adding content-filter-aware routing logic to detect content filter matches and reroute away from affected providers.

✏️ 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 codex/content-filter-routing

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds gateway routing behavior and observability around “content filter” matches, enabling rerouting away from providers marked as content-filtering when alternatives exist, and introduces tooling/docs to analyze moderation signals offline.

Changes:

  • Add contentFilter flag to provider catalog and implement content-filter-aware provider selection + routing metadata in the gateway.
  • Extend routing metadata schema/types (DB JSON, API zod, generated v1.d.ts) and update UI log views to surface content-filter exclusions.
  • Add a scripts package CLI (analyze-content-filter) and commit a findings report.

Reviewed changes

Copilot reviewed 13 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/scripts/src/analyze-content-filter.ts New CLI script to analyze moderation payloads vs content_filter finish reason and sweep thresholds.
packages/scripts/package.json Adds analyze-content-filter script entry.
packages/models/src/providers.ts Adds contentFilter?: boolean to ProviderDefinition; marks obsidian as contentFilter: true.
packages/db/src/schema.ts Extends routingMetadata JSON typing for log and videoJob with content-filter routing fields.
packages/actions/src/get-cheapest-from-available-providers.ts Extends RoutingMetadata interface to include content-filter routing fields.
findings.md Adds documented results from running the analyzer on local datasets.
apps/gateway/src/chat/chat.ts Implements content-filter routing decision and injects routing metadata for excluded providers.
apps/gateway/src/fallback.spec.ts Adds integration tests for reroute vs monitor mode behavior; refactors fixture setup/reset.
apps/gateway/src/lib/costs.spec.ts Updates discount test expectations to match seeded global OpenAI discount and uses azure as “no-discount” provider.
apps/api/src/routes/logs.ts Extends Zod schema for routing metadata fields returned by logs endpoints.
ee/admin/src/lib/api/v1.d.ts Updates generated API types for new routing metadata fields.
apps/ui/src/lib/api/v1.d.ts Updates generated API types for new routing metadata fields.
apps/playground/src/lib/api/v1.d.ts Updates generated API types for new routing metadata fields.
apps/code/src/lib/api/v1.d.ts Updates generated API types for new routing metadata fields.
ee/admin/src/components/log-card.tsx Displays content-filter exclusion badges and makes provider score keys region-safe.
apps/ui/src/components/dashboard/log-card.tsx Displays content-filter exclusion badge in provider score list.
apps/ui/src/app/dashboard/[orgId]/[projectId]/activity/[logId]/log-detail-client.tsx Displays content-filter exclusion badge in log detail provider score list.

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

Comment on lines +381 to +404
function parseGatewayResponses(value: string): ModerationResponse[] | null {
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed === "null") {
return null;
}

try {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) {
return parsed as ModerationResponse[];
}

if (typeof parsed === "string") {
const nested = JSON.parse(parsed) as unknown;
if (Array.isArray(nested)) {
return nested as ModerationResponse[];
}
}
} catch {
return null;
}

return null;
}

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.

parseGatewayResponses() only returns an array when the parsed JSON is already an array (or a JSON-encoded string containing an array). The header comment and expected CSV format say the field may be a single moderation payload object as well; in that case this function returns null and the row is treated as having no payload (breaking tuning/inspection). Consider accepting a single object by wrapping it in an array (and similarly handling a stringified object).

Copilot uses AI. Check for mistakes.
Comment thread findings.md
File:

```text
/Users/steebchen/projects/llmgateway/llmgateway/contentfiltered.csv

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.

This doc includes absolute local file paths (e.g. /Users/...); that’s machine/user-specific and can leak local usernames. Suggest replacing with a repo-relative example path or a placeholder (e.g. /abs/path/to/contentfiltered.csv) and keeping any personal paths out of committed docs.

Suggested change
/Users/steebchen/projects/llmgateway/llmgateway/contentfiltered.csv
/abs/path/to/contentfiltered.csv

Copilot uses AI. Check for mistakes.
Comment on lines +418 to +422
price: getProviderSelectionPrice(provider),
contentFilterProvider: true,
excludedByContentFilter: true,
};
}),

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/api/src/routes/logs.ts (1)

111-151: ⚠️ Potential issue | 🟠 Major

Keep routingMetadata API schema fully aligned with DB shape.

The updated schema adds content-filter fields, but it still omits existing routing metadata fields (originalProvider, originalProviderUptime, originalProviderRateLimited, noFallback) that are present in the DB type. This creates API contract drift for OpenAPI/typed consumers.

🔧 Proposed fix
 			contentFilterMatched: z.boolean().optional(),
 			contentFilterRerouted: z.boolean().optional(),
 			contentFilterExcludedProviders: z.array(z.string()).optional(),
+			originalProvider: z.string().optional(),
+			originalProviderUptime: z.number().optional(),
+			originalProviderRateLimited: z.boolean().optional(),
+			noFallback: z.boolean().optional(),
 			routing: z
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/routes/logs.ts` around lines 111 - 151, The routingMetadata Zod
schema is missing DB fields causing API drift; update the routingMetadata object
(the schema defined as routingMetadata: z.object({...})) to include the missing
fields from the DB: add originalProvider (z.string().optional()),
originalProviderUptime (z.number().optional()), originalProviderRateLimited
(z.boolean().optional()), and noFallback (z.boolean().optional()) with the same
optionality and basic types as in the DB type so the API shape matches persisted
routing metadata.
🧹 Nitpick comments (2)
ee/admin/src/components/log-card.tsx (1)

38-68: Local RoutingMetadata type is missing fields from the authoritative interface.

The relevant code snippet shows that packages/actions/src/get-cheapest-from-available-providers.ts exports a RoutingMetadata interface with additional fields not present in this local copy:

  • selectedProvider (required string)
  • originalProvider (optional string)
  • originalProviderUptime (optional number)
  • originalProviderRateLimited (optional boolean)
  • noFallback (optional boolean)

While making all fields optional is sensible for UI display of persisted data, the type should include all fields to avoid future maintenance issues when the UI needs to display them.

♻️ Suggested additions to the local interface
 interface RoutingMetadata {
+	selectedProvider?: string;
 	selectionReason?: string;
 	availableProviders?: string[];
 	providerScores?: Array<{
 		// ... existing fields ...
 	}>;
+	originalProvider?: string;
+	originalProviderUptime?: number;
+	originalProviderRateLimited?: boolean;
+	noFallback?: boolean;
 	contentFilterMatched?: boolean;
 	contentFilterRerouted?: boolean;
 	contentFilterExcludedProviders?: string[];
 	routing?: Array<{
 		// ... existing fields ...
 	}>;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ee/admin/src/components/log-card.tsx` around lines 38 - 68, The local
RoutingMetadata interface is missing fields present in the authoritative
interface (packages/actions/src/get-cheapest-from-available-providers.ts);
update the local interface (RoutingMetadata) to include selectedProvider:
string, originalProvider?: string, originalProviderUptime?: number,
originalProviderRateLimited?: boolean, and noFallback?: boolean (make them
optional except selectedProvider if the authoritative type requires it, or make
all fields optional for UI safety) so the UI type matches the source-of-truth
and avoids future mismatches.
packages/scripts/src/analyze-content-filter.ts (1)

381-404: JSON parsing silently ignores single moderation response objects.

If gateway_content_filter_response contains a single ModerationResponse object (not wrapped in an array), this function returns null rather than wrapping it in an array. This could cause valid data to be silently ignored.

♻️ Handle single object responses
 function parseGatewayResponses(value: string): ModerationResponse[] | null {
 	const trimmed = value.trim();
 	if (trimmed.length === 0 || trimmed === "null") {
 		return null;
 	}

 	try {
 		const parsed = JSON.parse(trimmed) as unknown;
 		if (Array.isArray(parsed)) {
 			return parsed as ModerationResponse[];
 		}

 		if (typeof parsed === "string") {
 			const nested = JSON.parse(parsed) as unknown;
 			if (Array.isArray(nested)) {
 				return nested as ModerationResponse[];
 			}
+			if (typeof nested === "object" && nested !== null) {
+				return [nested as ModerationResponse];
+			}
 		}
+
+		if (typeof parsed === "object" && parsed !== null) {
+			return [parsed as ModerationResponse];
+		}
 	} catch {
 		return null;
 	}

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

In `@packages/scripts/src/analyze-content-filter.ts` around lines 381 - 404, The
parseGatewayResponses function currently returns null when the parsed JSON is a
single ModerationResponse object; update parseGatewayResponses to detect when
parsed (or nested parsed from a string) is an object matching a single
ModerationResponse (i.e., typeof parsed === "object" && parsed !== null and not
an array) and return [parsed as ModerationResponse]; do the same for the nested
variable path so single-object strings are also wrapped into an array; keep
existing array handling and catch behavior intact and ensure the return type
remains ModerationResponse[] | null.
🤖 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/chat.ts`:
- Around line 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.
- Around line 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).
- Around line 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 `@apps/gateway/src/lib/costs.spec.ts`:
- Around line 136-148: The two tests call calculateCosts("gpt-4", "openai", 100,
50, null) but expect different results; fix by making expectations consistent
with seeded global 10% OpenAI discount: update the earlier test that expects
inputCost 0.001, outputCost 0.0015, totalCost 0.0025 to expect discounted values
(inputCost 0.0009, outputCost 0.00135, totalCost 0.00225 and discount 0.1) OR
modify the test setup to isolate/remove the global discount (e.g., stub
getEffectiveDiscount or set organizationId so getEffectiveDiscount returns 0) so
both calculateCosts calls produce consistent results.

In `@findings.md`:
- Around line 24-28: The file contains a workstation-specific absolute path
"/Users/steebchen/projects/llmgateway/llmgateway/contentfiltered.csv" (and
similar entries referenced later) that leaks a local username and is not
portable; replace these with a repo-relative or placeholder path such as
"./content/contentfiltered.csv" or "<REPO_ROOT>/contentfiltered.csv" throughout
the document so the example works on any machine and does not expose local user
information, updating every occurrence of the absolute path accordingly.

---

Outside diff comments:
In `@apps/api/src/routes/logs.ts`:
- Around line 111-151: The routingMetadata Zod schema is missing DB fields
causing API drift; update the routingMetadata object (the schema defined as
routingMetadata: z.object({...})) to include the missing fields from the DB: add
originalProvider (z.string().optional()), originalProviderUptime
(z.number().optional()), originalProviderRateLimited (z.boolean().optional()),
and noFallback (z.boolean().optional()) with the same optionality and basic
types as in the DB type so the API shape matches persisted routing metadata.

---

Nitpick comments:
In `@ee/admin/src/components/log-card.tsx`:
- Around line 38-68: The local RoutingMetadata interface is missing fields
present in the authoritative interface
(packages/actions/src/get-cheapest-from-available-providers.ts); update the
local interface (RoutingMetadata) to include selectedProvider: string,
originalProvider?: string, originalProviderUptime?: number,
originalProviderRateLimited?: boolean, and noFallback?: boolean (make them
optional except selectedProvider if the authoritative type requires it, or make
all fields optional for UI safety) so the UI type matches the source-of-truth
and avoids future mismatches.

In `@packages/scripts/src/analyze-content-filter.ts`:
- Around line 381-404: The parseGatewayResponses function currently returns null
when the parsed JSON is a single ModerationResponse object; update
parseGatewayResponses to detect when parsed (or nested parsed from a string) is
an object matching a single ModerationResponse (i.e., typeof parsed === "object"
&& parsed !== null and not an array) and return [parsed as ModerationResponse];
do the same for the nested variable path so single-object strings are also
wrapped into an array; keep existing array handling and catch behavior intact
and ensure the return type remains ModerationResponse[] | null.
🪄 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: 5eb01cb0-22d6-48b7-94b2-9ec08ea3bc5c

📥 Commits

Reviewing files that changed from the base of the PR and between d1ba037 and b9d3845.

⛔ Files ignored due to path filters (4)
  • apps/code/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
  • apps/playground/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
  • apps/ui/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
  • ee/admin/src/lib/api/v1.d.ts is excluded by !**/v1.d.ts
📒 Files selected for processing (13)
  • apps/api/src/routes/logs.ts
  • apps/gateway/src/chat/chat.ts
  • apps/gateway/src/fallback.spec.ts
  • apps/gateway/src/lib/costs.spec.ts
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/activity/[logId]/log-detail-client.tsx
  • apps/ui/src/components/dashboard/log-card.tsx
  • ee/admin/src/components/log-card.tsx
  • findings.md
  • packages/actions/src/get-cheapest-from-available-providers.ts
  • packages/db/src/schema.ts
  • packages/models/src/providers.ts
  • packages/scripts/package.json
  • packages/scripts/src/analyze-content-filter.ts

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

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).

Comment on lines +1961 to +1969
const contentFilterRoutingDecision = getContentFilterRoutingDecision(
availableModelProviders,
shouldRerouteContentFilter,
);
const contentFilterPreferredProviders =
contentFilterRoutingDecision.candidates;
contentFilterRoutingExcludedProviders =
contentFilterRoutingDecision.excludedProviders;
contentFilterRoutingApplied = contentFilterRoutingDecision.rerouted;

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.

Comment on lines +2629 to +2631
contentFilterMode === "enabled" &&
contentFilterMatched &&
!contentFilterRoutingApplied;

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.

Comment thread apps/gateway/src/lib/costs.spec.ts
Comment thread findings.md
Comment on lines +24 to +28
File:

```text
/Users/steebchen/projects/llmgateway/llmgateway/contentfiltered.csv
```

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 | 🟡 Minor

Replace workstation-specific paths with portable examples.

These absolute paths only work on one machine and leak a local username into the repo. Please switch them to a repo-relative example or a placeholder path so the document stays portable.

Also applies to: 133-137

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

In `@findings.md` around lines 24 - 28, The file contains a workstation-specific
absolute path
"/Users/steebchen/projects/llmgateway/llmgateway/contentfiltered.csv" (and
similar entries referenced later) that leaks a local username and is not
portable; replace these with a repo-relative or placeholder path such as
"./content/contentfiltered.csv" or "<REPO_ROOT>/contentfiltered.csv" throughout
the document so the example works on any machine and does not expose local user
information, updating every occurrence of the absolute path accordingly.

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

Copy link
Copy Markdown

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: 73d4adbf65

ℹ️ 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 +1535 to +1537
const openAIContentFilterResult =
shouldApplyGatewayContentFilter && contentFilterMethod === "openai"
? await checkOpenAIContentFilter(

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 👍 / 👎.

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

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 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/gateway/src/fallback.spec.ts (2)

227-239: ⚠️ Potential issue | 🟠 Major

Scope the “root” metrics update to the root mapping only.

When options.region is omitted, the update on Line 239 only filters by modelId + providerId, so a write intended for the root mapping also rewrites every regional row for that provider. That makes this helper unable to seed root and regional scores independently, and it amplifies the stale-fixture problem above. Add an explicit null-region condition in the no-region branch so root stats do not bleed into cn-beijing / singapore rows.

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

In `@apps/gateway/src/fallback.spec.ts` around lines 227 - 239, The update is
currently scoped only by modelId + providerId when options.region is omitted,
causing root metrics to overwrite regional rows; modify the branch where
options?.region is falsy to push a null-region condition (e.g.
push(isNull(tables.modelProviderMapping.region)) or
push(eq(tables.modelProviderMapping.region, null))) into the conditions array
before calling db.update(...).where(and(...conditions)) so the update only
targets the root mapping row and does not bleed into regional rows.

89-118: ⚠️ Potential issue | 🟠 Major

Clean up the custom model mappings between tests.

Line 92 clears routing stats, but resetTestState() still leaves the test-created model / modelProviderMapping rows behind. That is risky here because the earlier glm-4.6 cases insert an alibaba/cn-beijing mapping without maxOutput, and the later max_tokens case inserts another alibaba/cn-beijing row with maxOutput: 16384. If both rows survive, the later test can still route through the stale unbounded mapping and pass for the wrong reason. Please delete those per-test fixtures or make each case use unique model IDs.

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

In `@apps/gateway/src/fallback.spec.ts` around lines 89 - 118, The
resetTestState() helper currently clears routing stats but does not remove
test-created model or mapping rows; update resetTestState() to also delete
entries from tables.modelProviderMapping and the models table (e.g.,
tables.model) so per-test model and modelProviderMapping fixtures are removed
between tests and avoid stale mappings (refer to resetTestState(),
tables.modelProviderMapping and the models table name used in your schema).
🧹 Nitpick comments (2)
apps/worker/src/index.ts (1)

54-61: Path comparison may fail with relative invocations.

fileURLToPath(import.meta.url) returns an absolute path, but process.argv[1] can be relative (e.g., node dist/index.js). The comparison would then fail, preventing the worker from starting.

♻️ Proposed fix to normalize paths
-import { fileURLToPath } from "node:url";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
 function isDirectExecution() {
 	const entrypoint = process.argv[1];
 	if (!entrypoint) {
 		return false;
 	}
 
-	return fileURLToPath(import.meta.url) === entrypoint;
+	return fileURLToPath(import.meta.url) === resolve(entrypoint);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/worker/src/index.ts` around lines 54 - 61, The current isDirectExecution
comparison can fail when process.argv[1] is relative; change isDirectExecution
to normalize entrypoint to an absolute, normalized path before comparing to
fileURLToPath(import.meta.url). Specifically, in isDirectExecution, take the raw
entrypoint (process.argv[1]) and resolve it against process.cwd() (e.g., via
path.resolve or path.join + path.normalize) so that the comparison uses two
absolute, normalized paths; keep using fileURLToPath(import.meta.url) for the
other side and return the equality check. Ensure you reference the
isDirectExecution function, process.argv[1], and fileURLToPath(import.meta.url)
when making the change.
apps/gateway/src/fallback.spec.ts (1)

1502-1519: Isolate the provider/env overrides behind a helper.

packages/models/src/providers.ts Lines 560-564 returns the live registry entry, so togetherProvider.contentFilter = true is mutating shared module state here, and each test also patches several process.env keys. The finally blocks help, but this is still fragile and duplicates a lot of cleanup code. A small helper that snapshots/restores both the provider flag and env vars would make these cases safer and easier to maintain.

Also applies to: 1592-1609

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

In `@apps/gateway/src/fallback.spec.ts` around lines 1502 - 1519, Extract the
provider/env override logic into a helper that snapshots and restores the
provider flag and the specific env vars instead of mutating shared state inline:
create a function (e.g., withProviderEnvOverride or
snapshotAndRestoreProviderEnv) that accepts a providerName (used with
getProviderDefinition) and an overrides object ({ contentFilter: boolean, env: {
LLM_CONTENT_FILTER_MODE, LLM_CONTENT_FILTER_METHOD, LLM_CONTENT_FILTER_MODELS,
LLM_CONTENT_FILTER_KEYWORDS } }), inside the helper clone or copy the provider
entry returned by getProviderDefinition("together.ai") (or snapshot its
contentFilter), save the current process.env values for the listed keys, apply
the overrides (set provider.contentFilter and process.env values), run the test
callback, and in a finally block restore the provider.contentFilter and the
saved process.env values; replace the inline mutation of
togetherProvider.contentFilter and manual env saves/restores with calls to this
helper for both occurrences (around lines where getProviderDefinition,
togetherProvider.contentFilter, and the LLM_CONTENT_FILTER_* env keys are used).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@apps/gateway/src/fallback.spec.ts`:
- Around line 227-239: The update is currently scoped only by modelId +
providerId when options.region is omitted, causing root metrics to overwrite
regional rows; modify the branch where options?.region is falsy to push a
null-region condition (e.g. push(isNull(tables.modelProviderMapping.region)) or
push(eq(tables.modelProviderMapping.region, null))) into the conditions array
before calling db.update(...).where(and(...conditions)) so the update only
targets the root mapping row and does not bleed into regional rows.
- Around line 89-118: The resetTestState() helper currently clears routing stats
but does not remove test-created model or mapping rows; update resetTestState()
to also delete entries from tables.modelProviderMapping and the models table
(e.g., tables.model) so per-test model and modelProviderMapping fixtures are
removed between tests and avoid stale mappings (refer to resetTestState(),
tables.modelProviderMapping and the models table name used in your schema).

---

Nitpick comments:
In `@apps/gateway/src/fallback.spec.ts`:
- Around line 1502-1519: Extract the provider/env override logic into a helper
that snapshots and restores the provider flag and the specific env vars instead
of mutating shared state inline: create a function (e.g.,
withProviderEnvOverride or snapshotAndRestoreProviderEnv) that accepts a
providerName (used with getProviderDefinition) and an overrides object ({
contentFilter: boolean, env: { LLM_CONTENT_FILTER_MODE,
LLM_CONTENT_FILTER_METHOD, LLM_CONTENT_FILTER_MODELS,
LLM_CONTENT_FILTER_KEYWORDS } }), inside the helper clone or copy the provider
entry returned by getProviderDefinition("together.ai") (or snapshot its
contentFilter), save the current process.env values for the listed keys, apply
the overrides (set provider.contentFilter and process.env values), run the test
callback, and in a finally block restore the provider.contentFilter and the
saved process.env values; replace the inline mutation of
togetherProvider.contentFilter and manual env saves/restores with calls to this
helper for both occurrences (around lines where getProviderDefinition,
togetherProvider.contentFilter, and the LLM_CONTENT_FILTER_* env keys are used).

In `@apps/worker/src/index.ts`:
- Around line 54-61: The current isDirectExecution comparison can fail when
process.argv[1] is relative; change isDirectExecution to normalize entrypoint to
an absolute, normalized path before comparing to fileURLToPath(import.meta.url).
Specifically, in isDirectExecution, take the raw entrypoint (process.argv[1])
and resolve it against process.cwd() (e.g., via path.resolve or path.join +
path.normalize) so that the comparison uses two absolute, normalized paths; keep
using fileURLToPath(import.meta.url) for the other side and return the equality
check. Ensure you reference the isDirectExecution function, process.argv[1], and
fileURLToPath(import.meta.url) when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 35c1e080-8201-470b-920a-a2c34cdf2e65

📥 Commits

Reviewing files that changed from the base of the PR and between b9d3845 and 73d4adb.

📒 Files selected for processing (3)
  • apps/gateway/src/fallback.spec.ts
  • apps/gateway/src/lib/costs.spec.ts
  • apps/worker/src/index.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/gateway/src/lib/costs.spec.ts

@steebchen
steebchen merged commit dd9bdc8 into main Mar 30, 2026
31 checks passed
@steebchen
steebchen deleted the codex/content-filter-routing branch March 30, 2026 15:33
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