Skip to content

feat: per-project enterprise routing overrides - #2363

Merged
steebchen merged 40 commits into
mainfrom
enterprise-routing-overrides
May 30, 2026
Merged

steebchen merged 40 commits into
mainfrom
enterprise-routing-overrides

Conversation

@steebchen

@steebchen steebchen commented May 21, 2026 •

Copy link
Copy Markdown
Member

Summary

  • New routing_config table (per-project) lets enterprise orgs override the hardcoded routing knobs: scoring weights (price / uptime / throughput / latency / cache / image-price), thresholds (cache prompt size, uptime penalty curve, defaults for missing metrics, exploration rate), retry / low-uptime fallback, request timeouts, and per-provider priorities (set to 0 to fully disable a provider for the project).
  • Mirrors the existing guardrails pattern: SWR-cached read in the gateway hot path, enterprise + owner/admin gate in the API, dedicated "Routing" tab under the project settings sidebar with a ContactSalesCard upsell for non-enterprise orgs.
  • All knobs are optional; missing values fall back through override -> env var -> built-in default, so existing tests and deployments behave identically out of the box.

Test plan

  • pnpm test:unit — 1589 passing, including new packages/shared/src/routing-config.spec.ts and new override cases in packages/actions/src/models.spec.ts
  • pnpm build — full monorepo build passes (api openapi regenerated, UI fetch-client types regenerated)
  • pnpm format — clean
  • Manual smoke once deployed: as an enterprise admin, open /dashboard/{orgId}/{projectId}/settings/routing, set providerPriorities[openai] = 0, confirm OpenAI is excluded from routing in the gateway; non-enterprise org sees the upsell card.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added enterprise routing configuration management with a new dashboard settings page enabling custom retry policies, timeout values, provider priorities, and scoring weights for enterprise-tier projects.
    • Implemented API endpoints for retrieving, updating, and resetting routing configurations with validation and atomic database writes.
  • Refactor

    • Restructured provider metrics calculation to use historical aggregation instead of pre-computed rollups.
    • Updated retry and timeout behavior to respect project-specific routing configuration instead of fixed defaults.
    • Modified provider selection algorithm to apply custom weights and priority overrides from routing configuration.

Review Change Stack

Allow enterprise orgs to override hardcoded routing knobs per project:
scoring weights, thresholds, retry/fallback, timeouts, and per-provider
priorities. Mirrors guardrails pattern (DB row + enterprise-gated API
CRUD + UI tab + SWR-cached gateway read).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 21, 2026 19:27
@coderabbitai

coderabbitai Bot commented May 21, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR introduces a comprehensive enterprise routing configuration system that allows per-project customization of routing weights, thresholds, retry behavior, timeouts, metrics history windows, and provider priorities. Changes span the shared configuration model, database schema, API endpoints, gateway integration, provider-selection scoring, UI editor, and metrics history computation.

Changes

Enterprise routing configuration

Layer / File(s) Summary
Shared routing configuration model and resolution
packages/shared/src/routing-config.ts, packages/shared/src/routing-config.spec.ts, packages/shared/package.json, packages/shared/src/index.ts
Defines RoutingWeightsConfig, RoutingThresholdsConfig, RoutingRetryConfig, RoutingTimeoutsConfig, RoutingHistoryConfig, and ProviderPriorityOverrides types. Provides default constants, resolveRoutingConfig() resolver with validation and clamping, and cache-key helpers. Tests cover override merging, timeout ceiling enforcement, and history bounds checking.
Database schema, relations, and migrations
packages/db/src/schema.ts, packages/db/src/relations.ts, packages/db/migrations/1779793199_volatile_mercury.sql, packages/db/migrations/meta/_journal.json
New routing_config table with JSONB columns for weights/thresholds/retry/timeouts/history/provider-priorities, unique projectId foreign key, and auto-updated timestamps. Removes routing-statistic columns (routingUptime, routingLatency, routingThroughput, routingTotalRequests) from modelProviderMapping. Adds project relations.
Provider metrics history computation
packages/db/src/provider-metrics-history.ts, packages/db/src/provider-metrics.ts, packages/db/src/index.ts
New getProviderMetricsFromHistory performs SWR-cached tier-weighted aggregation from history table using configurable sliding windows. Removes legacy getProviderMetrics and getProviderMetricsForCombinations functions that fetched pre-computed metrics.
API endpoints for routing configuration
apps/api/src/routes/routing-config.ts, apps/api/src/routes/index.ts, apps/api/package.json
Five enterprise-only endpoints under /routing-config: GET/PUT/POST for storing configs, GET for resolved configs, GET for defaults. Enterprise access enforced via organization ownership and enterprise plan check. PUT performs atomic upsert preserving omitted fields while writing explicit null.
Gateway-side routing config loader
apps/gateway/src/lib/routing-config-loader.ts
Exports getResolvedRoutingConfig(projectId, orgPlan) that returns defaults for non-enterprise plans and fetches/caches per-project config via SWR for enterprise. Also provides getDefaultProviderPriorities() for UI defaults.
Gateway metrics and timeout wiring
apps/gateway/src/lib/provider-metrics-for-routing.ts, apps/gateway/src/lib/timeout-config.ts
getProviderMetricsForRouting selects history window from routing config. Timeout getters (getGatewayTimeoutMs, getStreamingTimeoutMs, getTimeoutMs) now accept optional routing config and resolve via precedence: config override → env var → defaults. Signal creators (createStreamingCombinedSignal, createCombinedSignal, etc.) wire config through.
Retry configuration parametrization
apps/gateway/src/chat/tools/retry-with-fallback.ts, packages/actions/package.json
MAX_RETRIES sourced from DEFAULT_ROUTING_RETRY.maxRetries. shouldRetryRequest accepts optional per-invocation maxRetries override.
Chat routing integration
apps/gateway/src/chat/chat.ts
Computes routingCfg per request via getResolvedRoutingConfig(project.id, organization.plan) and threads it through all routing decisions, metric fetches, timeout signals, and retry loops. Low-uptime fallback uses routingCfg.retry.lowUptimeFallbackThreshold. Streaming and non-streaming retry loops use routingCfg.retry.maxRetries.
Videos routing integration
apps/gateway/src/videos/videos.ts
Similar integration: loads routingCfg, uses it in low-uptime fallback threshold and metrics retrieval, passes config to provider selection and retry decision logic.
Provider selection scoring refactored
packages/actions/src/get-cheapest-from-available-providers.ts, packages/actions/src/models.spec.ts
New getEffectivePriority(providerId, cfg) resolves per-provider priority overrides. Exploration rate, uptime penalty, default metrics values, and weighted-score weights now use cfg.thresholds.* and cfg.weights.*. Providers filtered by effective priority. Tests verify config overrides are honored in selection.
UI routing configuration editor
apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx, apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/_components/routing-contact-sales-card.tsx, apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/page.tsx, apps/ui/src/components/dashboard/dashboard-sidebar.tsx
Enterprise-only routing editor: shows upsell card for unauthorized users, authorized editor with enable toggle, six grouped field sections (weights, thresholds, retry & fallback, timeouts, metrics history, provider priorities), Save/Reset all actions with validation and feedback. Sidebar adds "Routing" entry.
Test updates
apps/gateway/src/fallback.spec.ts, apps/gateway/src/test-utils/gateway-api-test-harness.ts, apps/worker/src/services/stats-calculator.ts, apps/worker/src/services/stats-calculator.spec.ts, packages/db/src/provider-metrics.spec.ts
Test harness updated to seed history table instead of routing columns. Worker stats calculator removes routing-metrics computation and related stale-clearing logic. Worker spec assertions relaxed to not check removed routing metrics. Fallback tests update to write history rows.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • theopenco/llmgateway#2312: Adjusts price/image-price weights in provider scoring; the main PR makes all weights routing-config driven.
  • theopenco/llmgateway#2041: Modifies calculateAggregatedStatistics() tier-weighting logic in stats calculator; the main PR switches to unweighted rollups and removes routing-metrics computation.
  • theopenco/llmgateway#2158: Overlaps provider-selection and metrics-lookup refactoring in the main routing-config-driven work.

Suggested reviewers

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

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.28% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: per-project enterprise routing overrides' directly summarizes the main addition—a new routing-config table and UI that enables enterprise organizations to override per-project routing parameters.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch enterprise-routing-overrides

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 per-project (enterprise-only) routing configuration overrides, exposing adjustable routing weights/thresholds/retry/timeouts/provider priorities via a new DB table + API endpoints, and wiring the resolved config into gateway provider selection and timeout/retry behavior.

Changes:

  • Introduces routing_config DB table + relations + migration for storing per-project routing overrides.
  • Adds shared routing-config resolver/defaults and uses it in the routing algorithm (getCheapestFromAvailableProviders) plus gateway loaders.
  • Adds API endpoints + UI “Routing” settings page to view/update/reset routing config (enterprise owners/admins).

Reviewed changes

Copilot reviewed 21 out of 27 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
pnpm-lock.yaml Updates lockfile for new workspace dependency wiring.
packages/shared/src/routing-config.ts Adds routing config types, defaults, and resolve/merge helpers.
packages/shared/src/routing-config.spec.ts Unit tests for routing config resolution/merging behavior.
packages/shared/src/index.ts Exposes routing-config exports from shared package entrypoint.
packages/shared/package.json Adds ./routing-config export path.
packages/db/src/schema.ts Adds routing_config table schema + JSONB columns/types.
packages/db/src/relations.ts Adds project ↔ routingConfig relations.
packages/db/migrations/meta/_journal.json Registers new migration in Drizzle journal.
packages/db/migrations/1779389372_black_ghost_rider.sql Creates routing_config table + index + FK.
packages/actions/src/models.spec.ts Adds tests ensuring providerPriorities=0 excludes providers, and thresholds overrides don’t break selection.
packages/actions/src/get-cheapest-from-available-providers.ts Integrates resolved routing config into scoring, exploration rate, cache threshold, defaults, and provider priority disabling.
packages/actions/package.json Adds @llmgateway/shared dependency.
ee/admin/src/lib/api/v1.d.ts Regenerates OpenAPI client types to include routing-config endpoints.
apps/ui/src/lib/api/v1.d.ts Regenerates OpenAPI client types to include routing-config endpoints.
apps/playground/src/lib/api/v1.d.ts Regenerates OpenAPI client types to include routing-config endpoints.
apps/code/src/lib/api/v1.d.ts Regenerates OpenAPI client types to include routing-config endpoints.
apps/ui/src/components/dashboard/dashboard-sidebar.tsx Adds “Routing” item to project settings sidebar.
apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/page.tsx Adds routing settings page entrypoint.
apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx Implements routing config editor UI and save/reset flows.
apps/gateway/src/videos/videos.ts Loads resolved routing config per request and applies retry + provider selection overrides.
apps/gateway/src/lib/timeout-config.ts Applies routing-config timeouts with override → env → default precedence.
apps/gateway/src/lib/routing-config-loader.ts Adds SWR-cached loader to fetch/resolve project routing config from DB.
apps/gateway/src/chat/tools/retry-with-fallback.ts Makes max retries configurable (falls back to default routing retry maxRetries).
apps/gateway/src/chat/chat.ts Loads routing config and threads it through routing decisions, timeouts, and retry limits.
apps/api/src/routes/routing-config.ts Adds CRUD + resolved/defaults routing-config API routes with enterprise + owner/admin gating (partial).
apps/api/src/routes/index.ts Registers the routing-config routes.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

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

Comment on lines +33 to +37
function getExplorationRate(cfg: ResolvedRoutingConfig): number {
const rawExplorationRate = process.env.EXPLORATION_RATE;

if (rawExplorationRate === undefined || rawExplorationRate.trim() === "") {
return DEFAULT_EXPLORATION_RATE;
return cfg.thresholds.explorationRate;
Comment on lines 508 to 512
const weightSum = new Decimal(effectivePriceWeight)
.plus(UPTIME_WEIGHT)
.plus(THROUGHPUT_WEIGHT)
.plus(weights.uptime)
.plus(weights.throughput)
.plus(effectiveLatencyWeight)
.plus(effectiveCacheWeight);
Comment thread apps/gateway/src/lib/timeout-config.ts Outdated
Comment on lines +27 to +29
return (
Number(process.env.GATEWAY_TIMEOUT_MS) || DEFAULT_ROUTING_TIMEOUTS.gatewayMs
);
Comment on lines +74 to +78
.object({
price: z.number().min(0).optional(),
imagePrice: z.number().min(0).optional(),
uptime: z.number().min(0).optional(),
throughput: z.number().min(0).optional(),
},
});

routingConfig.openapi(getDefaults, async (c) => {
Comment on lines +201 to +205
const role = teamData?.members.find((m) => m.userId === user?.id)?.role;
const canManage =
selectedOrganization?.plan === "enterprise" &&
(role === "owner" || role === "admin");

Comment on lines +197 to +200
const { selectedOrganization } = useDashboardNavigation();
const { user } = useUser();
const { data: teamData } = useTeamMembers(selectedOrganization?.id ?? "");

return Object.keys(defaults.providerPriorities).sort();
}, [defaults]);

if (!canManage) {

@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 (3)
apps/gateway/src/chat/chat.ts (1)

2122-2129: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject explicitly requested providers that are disabled for the project.

This branch only uses routingCfg to pick a region inside usedProvider. If providerPriorities[usedProvider] is 0, the request still succeeds because we never filter sameProviderMappings, and bestRegionResult?.provider ?? eligibleMappings[0] falls back to the disabled mapping anyway. That lets direct provider requests bypass the per-project disable knob.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gateway/src/chat/chat.ts` around lines 2122 - 2129, The code currently
can select a provider even when project-specific
providerPriorities[usedProvider] === 0 because
eligibleMappings/sameProviderMappings aren't filtered; update the selection to
explicitly reject providers disabled for the project by filtering out mappings
whose mapping.provider has priority 0 before computing
sameProviderMappings/eligibleMappings (or, when computing selectedMapping from
bestRegionResult/eligibleMappings, ensure bestRegionResult?.provider and
eligibleMappings[0] both have non-zero providerPriorities entries), and if no
mapping remains for the requested usedProvider return an explicit error (or fall
back to another allowed provider) so direct provider requests cannot bypass the
per-project disable knob. Include references to routingCfg, usedProvider,
providerPriorities, sameProviderMappings, eligibleMappings, bestRegionResult,
and selectedMapping when making the change.
packages/actions/src/get-cheapest-from-available-providers.ts (2)

506-524: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against zero total weight before score normalization.

If routing overrides set all effective weights to 0, weightSum becomes 0 and the subsequent divisions on Lines 514/516/518/521/523 can produce invalid scores.

Suggested fix
 		const weightSum = new Decimal(effectivePriceWeight)
 			.plus(weights.uptime)
 			.plus(weights.throughput)
 			.plus(effectiveLatencyWeight)
 			.plus(effectiveCacheWeight);
+		if (weightSum.lte(0)) {
+			// Safe fallback: prioritize by effective price when weights are all zero/invalid
+			const priority = getEffectivePriority(providerScore.provider.providerId, cfg);
+			const priorityPenalty = new Decimal(1).minus(priority);
+			providerScore.score = priceScore.plus(priorityPenalty);
+			continue;
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/actions/src/get-cheapest-from-available-providers.ts` around lines
506 - 524, Guard against weightSum being zero before normalization: after
computing weightSum (which uses effectivePriceWeight, weights.uptime,
weights.throughput, effectiveLatencyWeight, effectiveCacheWeight) check if
weightSum.isZero() and if so set weightSum to new Decimal(1) (or another safe
nonzero fallback) before computing baseScore so the subsequent .div(weightSum)
calls (used in baseScore calculation) never divide by zero; update the block
that computes weightSum and baseScore (referencing weightSum and baseScore) to
perform this guard.

33-52: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Per-project exploration override is being bypassed by env precedence.

On Line 34-51, EXPLORATION_RATE is always applied before cfg.thresholds.explorationRate. That makes a global env var override explicit project config, which conflicts with the documented precedence (override → env → default).

Suggested fix
-function getExplorationRate(cfg: ResolvedRoutingConfig): number {
+function getExplorationRate(
+	cfg: ResolvedRoutingConfig,
+	hasProjectRoutingConfig: boolean,
+): number {
+	if (hasProjectRoutingConfig) {
+		return cfg.thresholds.explorationRate;
+	}
 	const rawExplorationRate = process.env.EXPLORATION_RATE;
-	if (!isTestProcess() && Math.random() < getExplorationRate(cfg)) {
+	if (
+		!isTestProcess() &&
+		Math.random() <
+			getExplorationRate(cfg, options?.routingConfig !== undefined)
+	) {

Also applies to: 351-351

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/actions/src/get-cheapest-from-available-providers.ts` around lines
33 - 52, The getExplorationRate function currently applies EXPLORATION_RATE env
var before the project config; change the precedence so
cfg.thresholds.explorationRate (per-project override) is checked first, then
process.env.EXPLORATION_RATE, then the default; validate whichever source is
used (ensure Number.isFinite and between 0 and 1) and throw the same error
format for invalid values, and apply the same fix to the duplicate logic noted
around line ~351.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/routes/routing-config.ts`:
- Around line 220-267: The current read-then-write flow using
db.query.routingConfig.findFirst followed by db.update(...) or db.insert(...)
can race on concurrent PUTs and cause unique project_id insert failures; replace
it with a single atomic upsert (or a transaction with retry) keyed on projectId.
Concretely, remove the separate findFirst + conditional insert logic and use
your ORM's upsert mechanism (e.g.,
db.insert(tables.routingConfig).values({...}).onConflictDoUpdate({ target:
tables.routingConfig.projectId, set: { enabled: body.enabled ??
existingEquivalent, weights: body.weights ?? null, thresholds: body.thresholds
?? null, retry: body.retry ?? null, timeouts: body.timeouts ?? null,
providerPriorities: body.providerPriorities ?? null } }).returning()) so the
update-or-insert happens in one atomic statement (or wrap the read/update/insert
in a transaction with retry if upsert isn't available); keep the same field
mappings used in the current db.update and db.insert blocks.

In `@apps/gateway/src/chat/chat.ts`:
- Line 4864: The retry loops currently cap iterations with the constant
MAX_RETRIES which ignores the resolved per-project override passed as
routingCfg.retry.maxRetries; change the loop bounds to use the resolved cap
(e.g., use resolvedRetryCap or routingCfg.retry.maxRetries) instead of
MAX_RETRIES so the condition becomes retryAttempt <= routingCfg.retry.maxRetries
(or the local resolved variable) wherever the loop uses retryAttempt and
MAX_RETRIES (including the loops around retryAttempt checks at the locations
that call maxRetries: routingCfg.retry.maxRetries); ensure any local variable
used to store the resolved cap replaces references to MAX_RETRIES consistently.

In `@apps/gateway/src/lib/timeout-config.ts`:
- Around line 27-29: The getter currently returns
Number(process.env.GATEWAY_TIMEOUT_MS) || DEFAULT_ROUTING_TIMEOUTS.gatewayMs
which treats negative numbers as truthy; update the logic in timeout-config.ts
to parse GATEWAY_TIMEOUT_MS into a Number, check that it is > 0, and only return
it when positive, otherwise return DEFAULT_ROUTING_TIMEOUTS.gatewayMs (use the
same validation pattern as the other timeout getters). Reference
process.env.GATEWAY_TIMEOUT_MS and DEFAULT_ROUTING_TIMEOUTS.gatewayMs when
making the change.

In `@packages/actions/src/models.spec.ts`:
- Around line 1087-1088: Replace the dynamic imports of
"`@llmgateway/shared/routing-config`" with top-level static imports: add a
file-scope import for resolveRoutingConfig and buildProviderPriorityDefaults and
remove the await import(...) occurrences; update any test references using
resolveRoutingConfig or buildProviderPriorityDefaults (the other dynamic import
occurrence that mirrors this one) to use the top-level imported symbols instead
so TypeScript rules are satisfied.

In `@packages/shared/src/routing-config.ts`:
- Around line 157-165: getDefaultRoutingConfig currently returns the shared
mutable cachedDefaults object; change it to return a defensive copy so callers
can't mutate shared state. After computing or reading cachedDefaults in
getDefaultRoutingConfig, return a deep/shallow clone as appropriate (e.g.,
structuredClone or a deep-clone util) rather than the cached reference; keep
caching logic using cachedDefaults but always return the cloned object. Update
references to getDefaultRoutingConfig, cachedDefaults, resolveRoutingConfig, and
buildProviderPriorityDefaults accordingly so callers get an immutable copy.

---

Outside diff comments:
In `@apps/gateway/src/chat/chat.ts`:
- Around line 2122-2129: The code currently can select a provider even when
project-specific providerPriorities[usedProvider] === 0 because
eligibleMappings/sameProviderMappings aren't filtered; update the selection to
explicitly reject providers disabled for the project by filtering out mappings
whose mapping.provider has priority 0 before computing
sameProviderMappings/eligibleMappings (or, when computing selectedMapping from
bestRegionResult/eligibleMappings, ensure bestRegionResult?.provider and
eligibleMappings[0] both have non-zero providerPriorities entries), and if no
mapping remains for the requested usedProvider return an explicit error (or fall
back to another allowed provider) so direct provider requests cannot bypass the
per-project disable knob. Include references to routingCfg, usedProvider,
providerPriorities, sameProviderMappings, eligibleMappings, bestRegionResult,
and selectedMapping when making the change.

In `@packages/actions/src/get-cheapest-from-available-providers.ts`:
- Around line 506-524: Guard against weightSum being zero before normalization:
after computing weightSum (which uses effectivePriceWeight, weights.uptime,
weights.throughput, effectiveLatencyWeight, effectiveCacheWeight) check if
weightSum.isZero() and if so set weightSum to new Decimal(1) (or another safe
nonzero fallback) before computing baseScore so the subsequent .div(weightSum)
calls (used in baseScore calculation) never divide by zero; update the block
that computes weightSum and baseScore (referencing weightSum and baseScore) to
perform this guard.
- Around line 33-52: The getExplorationRate function currently applies
EXPLORATION_RATE env var before the project config; change the precedence so
cfg.thresholds.explorationRate (per-project override) is checked first, then
process.env.EXPLORATION_RATE, then the default; validate whichever source is
used (ensure Number.isFinite and between 0 and 1) and throw the same error
format for invalid values, and apply the same fix to the duplicate logic noted
around line ~351.
🪄 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: 34aef7e3-9455-42f4-a69e-a89523c648d9

📥 Commits

Reviewing files that changed from the base of the PR and between a3ff1d9 and a955948.

⛔ Files ignored due to path filters (5)
  • 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
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (22)
  • apps/api/src/routes/index.ts
  • apps/api/src/routes/routing-config.ts
  • apps/gateway/src/chat/chat.ts
  • apps/gateway/src/chat/tools/retry-with-fallback.ts
  • apps/gateway/src/lib/routing-config-loader.ts
  • apps/gateway/src/lib/timeout-config.ts
  • apps/gateway/src/videos/videos.ts
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/page.tsx
  • apps/ui/src/components/dashboard/dashboard-sidebar.tsx
  • packages/actions/package.json
  • packages/actions/src/get-cheapest-from-available-providers.ts
  • packages/actions/src/models.spec.ts
  • packages/db/migrations/1779389372_black_ghost_rider.sql
  • packages/db/migrations/meta/1779389372_snapshot.json
  • packages/db/migrations/meta/_journal.json
  • packages/db/src/relations.ts
  • packages/db/src/schema.ts
  • packages/shared/package.json
  • packages/shared/src/index.ts
  • packages/shared/src/routing-config.spec.ts
  • packages/shared/src/routing-config.ts

Comment thread apps/api/src/routes/routing-config.ts Outdated
Comment thread apps/gateway/src/chat/chat.ts
Comment thread apps/gateway/src/lib/timeout-config.ts Outdated
Comment thread packages/actions/src/models.spec.ts Outdated
Comment thread packages/shared/src/routing-config.ts
steebchen and others added 6 commits May 22, 2026 11:31
- New RoutingContactSalesCard so non-enterprise orgs see routing copy
  instead of the guardrails copy that was being reused by mistake.
- Default the per-project routing override row to enabled=false (DB
  default + API insert default + UI initial state) so that creating the
  row does not silently change routing behavior until the toggle is
  explicitly flipped on.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator

Merge conflict resolution verified. Conflicts were in packages/db/migrations/meta/_journal.json and pnpm-lock.yaml. The journal conflict was resolved by resetting migrations to origin/main and regenerating (producing 1779631433_amusing_manta.sql to capture the branch's routing_config table additions). The lockfile conflict was resolved with pnpm install. PR is now MERGEABLE.

steebchen and others added 15 commits May 24, 2026 17:04
The gateway reads routing config through swrWrap with a 4-hour stale TTL,
which keeps routing working when the DB is down. Writes via the API go
through the uncached `db` client (matching the guardrails pattern), so
they did not bust the SWR mirror — the gateway would have served the
prior value for up to 4 hours after a save.

Explicitly invalidate by table name after each update / insert / reset.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The provider-metrics aggregation in apps/worker (60-minute window with
10x/3x/1x tier weights) was hardcoded. Make these configurable per
project as part of the routing config.

When a project's history config matches the defaults, the gateway keeps
using the cheap pre-aggregated columns the worker writes onto
modelProviderMapping. When it differs, the gateway runs a per-request
aggregation against modelProviderMappingHistory using the project's
tier weights, cached in SWR by (history-config-hash, model-set) with
a 30s TTL so concurrent requests with the same shape share one DB hit
and the gateway stays warm if Postgres falls over.

Bounds: windowMinutes <= 120, weights and tier minutes >= 0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… from history

The gateway now reads routing metrics directly from
model_provider_mapping_history on every request (cached by
(history-config-hash, model-set) for 30s and SWR-mirrored to survive
DB outages), so the precomputed columns on model_provider_mapping
that the worker was writing are dead weight.

Drop them:
- routingUptime / routingLatency / routingThroughput / routingTotalRequests

The worker stats job still writes the unweighted logsCount /
errorsCount / cachedCount / clientErrorsCount / gatewayErrorsCount /
upstreamErrorsCount / avgTimeToFirstToken{,ReasoningToken} columns
because those feed admin/UI displays, but its weighted aggregation
block is gone — that work is now done on-demand with per-project
tier weights.

Test harnesses (setRoutingMetrics) seed a single recent history row
that, when aggregated, reproduces the requested
uptime/latency/throughput. provider-metrics.spec was deleted (it
tested the now-removed pre-computed getters) and the worker spec's
routing-metric assertions were dropped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The API zod schema allows individual scoring weights to be set to 0
(z.number().min(0)), but the weighted-score path in
getCheapestFromAvailableProviders then computes weightSum and divides
by it. A project that zeroed out every weight would crash with
DecimalError: Division by zero on the next request.

Short-circuit before the loop: when the effective weight total is
zero (which also covers the not-streaming case where latencyWeight
is dropped, and the short-prompt case where cacheWeight is dropped),
fall back to the existing price-only selection path. Per-provider
priority overrides and priority=0 exclusion still apply via
selectByPriceOnly.

Adds a regression test in models.spec.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move GET /routing-config/defaults to GET /routing-config/config/{projectId}/defaults
so the same checkProjectEnterpriseAccess gate that protects the other
routing-config endpoints applies here too — enterprise plan + owner/admin
role on the project's organization. The endpoint was previously only
behind the global session-auth middleware, so any authenticated user
could fetch it; consistency with the rest of the feature is worth the
URL change.

UI fetches updated; openapi + fetch-client types regenerated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the findFirst-then-update-or-insert pattern with a single
onConflictDoUpdate keyed on the unique projectId column. Concurrent
PUTs from the same admin (double-click Save, multiple tabs, retrying
scripts) used to race between the read and the insert and trip the
unique constraint on project_id with a 500.

Preserve the previous semantics:
- Fields omitted from the body keep their existing value on update —
  done by only adding those keys to the conflict-update set when the
  body explicitly provided them, so Postgres leaves them untouched.
- Fields explicitly set to null in the body clear the column.
- A no-op body (no fields at all) maps to onConflictDoNothing and we
  refetch the existing row so the response shape stays identical.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The streaming retry loop at chat.ts:4814 and the non-streaming loop at
chat.ts:8481 were capped at the constant MAX_RETRIES (2), so projects
that lowered or raised retry.maxRetries via routing config saw
shouldRetryRequest correctly skip later attempts but the outer loop
still spun up to two before exiting. Use routingCfg.retry.maxRetries
for both loop bounds. MAX_RETRIES import is dropped from chat.ts since
nothing else references it.

videos.ts has no MAX_RETRIES-bounded loop — it relies on
shouldRetryRequest (already maxRetries-aware via the option added
earlier), so no change there.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
getGatewayTimeoutMs used `Number(env) || default`, which falls back
for NaN but happily returns a negative number (`-5000` is truthy),
which would then feed `AbortSignal.timeout(-5000)`. Match the
streaming/plain getters: parse to a number and only return it if > 0,
otherwise fall back to DEFAULT_ROUTING_TIMEOUTS.gatewayMs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The three new tests in `routing config overrides` were using
dynamic `await import("@llmgateway/shared/routing-config")` (one of
them with a nested dynamic import of `@llmgateway/db` for metricsKey).
Move resolveRoutingConfig and buildProviderPriorityDefaults to
top-level imports and drop the dynamic await blocks. metricsKey is
already imported at the top.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The cache memoized the resolved defaults but handed the live reference
to every caller — a single misbehaving caller mutating
cfg.providerPriorities or cfg.weights would have poisoned every
subsequent routing decision in the process. structuredClone the cached
value before returning. Only caller today (the scoring function) just
reads, so this is hardening, not a fix for a live bug.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@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: 1

Caution

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

⚠️ Outside diff range comments (4)
apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx (2)

238-242: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid transient upsell rendering before role data resolves.

Line 307 can show RoutingContactSalesCard for authorized enterprise admins while team membership is still loading (role is initially undefined). Gate the upsell behind an explicit membership-loading check so authorized users only see loading until access is known.

Suggested fix
-	const role = teamData?.members.find((m) => m.userId === user?.id)?.role;
+	const isTeamLoading = Boolean(selectedOrganization?.id) && !teamData;
+	const role = teamData?.members.find((m) => m.userId === user?.id)?.role;
 	const canManage =
 		selectedOrganization?.plan === "enterprise" &&
 		(role === "owner" || role === "admin");
@@
-	if (!canManage) {
+	if (isTeamLoading || isLoading) {
+		return (
+			<div className="flex flex-col">
+				<div className="flex-1 space-y-4 p-4 pt-6 md:p-8">
+					<div className="max-w-3xl mx-auto">Loading…</div>
+				</div>
+			</div>
+		);
+	}
+
+	if (!canManage) {
 		return <RoutingContactSalesCard />;
 	}

Also applies to: 307-309

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx
around lines 238 - 242, The upsell can render transiently because role is
undefined while membership data loads; introduce an explicit membership-loading
guard (e.g., isMembershipLoading = teamData === undefined || teamData.members
=== undefined) and only evaluate canManage or render RoutingContactSalesCard
when loading is complete. Update the existing canManage logic (which currently
uses role and selectedOrganization) to include !isMembershipLoading (or move the
check to the JSX where RoutingContactSalesCard is rendered) so authorized
enterprise admins see a loading state until teamData.members and role are
resolved.

250-299: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Replace effect-based loading with TanStack Query.

Line 250 performs API data fetching in useEffect with manual loading/cancellation state. This violates the UI data-fetching contract and bypasses query-layer behavior (cache/retry/invalidation consistency).

As per coding guidelines, apps/{ui,playground,code}/**/*.{ts,tsx}: "Do not use useEffect for data fetching in the UI; use TanStack Query for all data fetching and state management".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx
around lines 250 - 299, Replace the useEffect-based fetch with a TanStack Query:
create a useQuery (or two queries) that uses fetchClient.GET to load
"/routing-config/config/{projectId}/defaults" and
"/routing-config/config/{projectId}" (use projectId in the query key), set the
query's enabled flag to canManage, and move the side-effects currently in the
effect into the query's onSuccess and onError handlers—call setDefaults with
defaultsRes.data and setState with the mapped row (enabled, weights, thresholds,
retry, timeouts, history, providerPriorities) on success, and setError on
failure; remove the cancelled flag and manual setIsLoading and instead derive
loading state from the query status/isFetching and wire invalidation/retries to
the query layer so fetchClient.GET is only used inside the query function(s).
apps/worker/src/services/stats-calculator.ts (1)

993-1007: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Rollup leaves stale counters for entities with no rows in the current 60-minute window.

These loops only update IDs present in mappingAggregates. If a provider/model/mapping had prior non-zero counters but no current-window history, its counters are never reset and remain stale, which breaks the “last hour” semantics.

💡 Suggested direction
+// Before/while applying aggregate updates, ensure entities missing from the
+// current rollup are explicitly zeroed out (providers, models, mappings).
+// Then apply non-zero/non-empty aggregate values.

A practical implementation is to iterate all target IDs and use agg ?? zeroAgg when writing updates, instead of updating only keys present in the aggregate map.

Also applies to: 1011-1025, 1031-1057

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/worker/src/services/stats-calculator.ts` around lines 993 - 1007, The
rollup code only updates entries present in providerMap (and similarly for
mappingAggregates/modelMap blocks), leaving previously non-zero counters
unchanged when there are no rows in the current window; change the update loops
(the one iterating providerMap and the similar loops handling
mappingAggregates/modelMap) to iterate the full set of target IDs (all
providers/models/mappings you intend to update) and use a default zero
aggregation (e.g., zeroAgg) when agg is undefined (agg ?? zeroAgg) so you write
zeros for counters when there is no current-window data; update the calls to
database.update(...) where you currently use agg.* to instead derive values from
(agg ?? zeroAgg) and ensure statsUpdatedAt/updatedAt are still set.
apps/gateway/src/chat/chat.ts (1)

1998-2037: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix: fail closed when routing config disables providers (priority=0)

getCheapestFromAvailableProviders filters out providers with providerPriorities[providerId] <= 0 (priority 0), and returns null when no eligible providers remain. The gateway then re-selects the first candidate anyway, bypassing that disable contract:

  • In auto-routing: if (cheapestResult) ... else { usedProvider = selectedProviders[0] ... } (≈1998-2037).
  • In direct-provider region selection: selectedMapping = bestRegionResult?.provider ?? eligibleMappings[0] (≈2213-2231).
  • In generic routing fallback: else { usedProvider = routingCandidates[0] ... } (≈2783-2853).

Change these null branches to fail closed (or prefilter candidates to priority > 0 before scoring), so priority 0 providers can’t be re-enabled via [0] fallbacks.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gateway/src/chat/chat.ts` around lines 1998 - 2037, The routing logic
currently re-enables providers that get filtered out by
getCheapestFromAvailableProviders (priority <= 0) by falling back to first-array
entries; update the branches that handle a null/undefined cheapest/best result
to fail closed: where you see the pattern "if (cheapestResult) ... else {
usedProvider = selectedProviders[0] ... }" (and analogous fallbacks using
eligibleMappings[0] or routingCandidates[0] and the expression selectedMapping =
bestRegionResult?.provider ?? eligibleMappings[0]), either pre-filter the
candidate arrays (selectedProviders, eligibleMappings, routingCandidates) to
remove any providerId with providerPriorities[providerId] <= 0 before scoring,
or change the else/null branch to return an error/abort path (or set
usedProvider/usedModel to undefined and propagate a failure) so a null result
from getCheapestFromAvailableProviders/getBestRegionResult cannot be overridden
by array[0]; locate uses around getCheapestFromAvailableProviders,
collapseProvidersToBestRegionPerProvider, bestRegionResult, and
routingCandidates to apply this change.
🧹 Nitpick comments (3)
packages/db/src/schema.ts (1)

1771-1778: ⚡ Quick win

Duplicate RoutingHistoryConfig interface.

This interface is identical to the one exported from packages/shared/src/routing-config.ts. Consider importing from the shared package to avoid drift if the interface changes.

+import type { RoutingHistoryConfig } from "`@llmgateway/shared/routing-config`";
+
-export interface RoutingHistoryConfig {
-	windowMinutes?: number;
-	tier1Minutes?: number;
-	tier2Minutes?: number;
-	tier1Weight?: number;
-	tier2Weight?: number;
-	tier3Weight?: number;
-}

Note: If intentional (to avoid circular dependencies between db and shared packages), consider adding a comment explaining the duplication.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/schema.ts` around lines 1771 - 1778, The RoutingHistoryConfig
interface in packages/db/src/schema.ts is a duplicate of the one in
packages/shared/src/routing-config.ts; remove the duplicated declaration and
import the shared type instead (replace the local export of RoutingHistoryConfig
with an import from the shared package and re-export if needed) so changes stay
centralized; if this duplication is intentional to avoid a circular dependency,
add a clear comment above the interface explaining why it must remain duplicated
and reference the original location (packages/shared/src/routing-config.ts) to
prevent future accidental merges.
packages/db/src/provider-metrics-history.ts (1)

97-165: 💤 Low value

Revisit stacking swrWrap with Drizzle .$withCache
.$withCache and swrWrap cache the same SELECT result in two independent layers (Drizzle’s query cache + SWR’s fetcher/result cache). This isn’t inherently incorrect, but it can increase staleness and complexity if their TTLs/invalidation/revalidation behaviors aren’t aligned—consider using one as the source of truth or intentionally keeping both with matching expectations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/provider-metrics-history.ts` around lines 97 - 165, The query
is being cached twice (swrWrap around the async fetcher and Drizzle’s
.$withCache on the cdb.select), which can cause divergent staleness; choose one
caching layer and remove the other. Either remove the `.$withCache({ config: {
ex: HISTORY_SWR_TTL_SECONDS } })` on the
`cdb.select(...).from(modelProviderMappingHistory)...` so SWR (`swrWrap`) is the
single source of truth, or remove `swrWrap` and let Drizzle’s `.$withCache`
control TTLs (ensuring `HISTORY_SWR_TTL_SECONDS` is used consistently if you
keep `.$withCache`); adjust code around `swrWrap`, `.$withCache`,
`HISTORY_SWR_TTL_SECONDS`, and the `cdb.select`/`modelProviderMappingHistory`
block accordingly.
apps/gateway/src/lib/provider-metrics-for-routing.ts (1)

10-17: ⚡ Quick win

Remove the long implementation-detail comment block.

This block restates code behavior and is likely to drift; the function name and types are already self-explanatory.

♻️ Suggested cleanup
-/**
- * Returns the metrics map for the candidate (model, provider, region)
- * combinations using the project's history window + tier weights when
- * configured; otherwise the built-in defaults. Either way this runs the
- * same on-demand weighted aggregation against
- * model_provider_mapping_history, cached in SWR by (history-config-hash,
- * model-set) for resilience and to keep concurrent requests cheap.
- */
 export async function getProviderMetricsForRouting(

As per coding guidelines: **/*.{ts,tsx}: No unnecessary code comments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gateway/src/lib/provider-metrics-for-routing.ts` around lines 10 - 17,
Remove the long implementation-detail JSDoc block that begins with "/** Returns
the metrics map for the candidate (model, provider, region) ..." — delete that
verbose comment and either leave no comment or replace it with a single-line
comment that matches the function name and signature (the function that produces
the metrics map / weighted aggregation over model_provider_mapping_history).
Ensure you do not change the function implementation or types, only remove the
unnecessary explanatory block.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/gateway/src/test-utils/gateway-api-test-harness.ts`:
- Around line 188-240: The test harness seed function setRoutingMetrics() now
inserts into modelProviderMappingHistory but the harness reset path doesn't
clear that table; modify the harness reset function (the reset function in
apps/gateway/src/test-utils/gateway-api-test-harness.ts) to remove persisted
routing history by issuing a delete on tables.modelProviderMappingHistory (or
otherwise truncating it) so seeded rows from setRoutingMetrics() are cleared
between tests.

---

Outside diff comments:
In `@apps/gateway/src/chat/chat.ts`:
- Around line 1998-2037: The routing logic currently re-enables providers that
get filtered out by getCheapestFromAvailableProviders (priority <= 0) by falling
back to first-array entries; update the branches that handle a null/undefined
cheapest/best result to fail closed: where you see the pattern "if
(cheapestResult) ... else { usedProvider = selectedProviders[0] ... }" (and
analogous fallbacks using eligibleMappings[0] or routingCandidates[0] and the
expression selectedMapping = bestRegionResult?.provider ?? eligibleMappings[0]),
either pre-filter the candidate arrays (selectedProviders, eligibleMappings,
routingCandidates) to remove any providerId with providerPriorities[providerId]
<= 0 before scoring, or change the else/null branch to return an error/abort
path (or set usedProvider/usedModel to undefined and propagate a failure) so a
null result from getCheapestFromAvailableProviders/getBestRegionResult cannot be
overridden by array[0]; locate uses around getCheapestFromAvailableProviders,
collapseProvidersToBestRegionPerProvider, bestRegionResult, and
routingCandidates to apply this change.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx:
- Around line 238-242: The upsell can render transiently because role is
undefined while membership data loads; introduce an explicit membership-loading
guard (e.g., isMembershipLoading = teamData === undefined || teamData.members
=== undefined) and only evaluate canManage or render RoutingContactSalesCard
when loading is complete. Update the existing canManage logic (which currently
uses role and selectedOrganization) to include !isMembershipLoading (or move the
check to the JSX where RoutingContactSalesCard is rendered) so authorized
enterprise admins see a loading state until teamData.members and role are
resolved.
- Around line 250-299: Replace the useEffect-based fetch with a TanStack Query:
create a useQuery (or two queries) that uses fetchClient.GET to load
"/routing-config/config/{projectId}/defaults" and
"/routing-config/config/{projectId}" (use projectId in the query key), set the
query's enabled flag to canManage, and move the side-effects currently in the
effect into the query's onSuccess and onError handlers—call setDefaults with
defaultsRes.data and setState with the mapped row (enabled, weights, thresholds,
retry, timeouts, history, providerPriorities) on success, and setError on
failure; remove the cancelled flag and manual setIsLoading and instead derive
loading state from the query status/isFetching and wire invalidation/retries to
the query layer so fetchClient.GET is only used inside the query function(s).

In `@apps/worker/src/services/stats-calculator.ts`:
- Around line 993-1007: The rollup code only updates entries present in
providerMap (and similarly for mappingAggregates/modelMap blocks), leaving
previously non-zero counters unchanged when there are no rows in the current
window; change the update loops (the one iterating providerMap and the similar
loops handling mappingAggregates/modelMap) to iterate the full set of target IDs
(all providers/models/mappings you intend to update) and use a default zero
aggregation (e.g., zeroAgg) when agg is undefined (agg ?? zeroAgg) so you write
zeros for counters when there is no current-window data; update the calls to
database.update(...) where you currently use agg.* to instead derive values from
(agg ?? zeroAgg) and ensure statsUpdatedAt/updatedAt are still set.

---

Nitpick comments:
In `@apps/gateway/src/lib/provider-metrics-for-routing.ts`:
- Around line 10-17: Remove the long implementation-detail JSDoc block that
begins with "/** Returns the metrics map for the candidate (model, provider,
region) ..." — delete that verbose comment and either leave no comment or
replace it with a single-line comment that matches the function name and
signature (the function that produces the metrics map / weighted aggregation
over model_provider_mapping_history). Ensure you do not change the function
implementation or types, only remove the unnecessary explanatory block.

In `@packages/db/src/provider-metrics-history.ts`:
- Around line 97-165: The query is being cached twice (swrWrap around the async
fetcher and Drizzle’s .$withCache on the cdb.select), which can cause divergent
staleness; choose one caching layer and remove the other. Either remove the
`.$withCache({ config: { ex: HISTORY_SWR_TTL_SECONDS } })` on the
`cdb.select(...).from(modelProviderMappingHistory)...` so SWR (`swrWrap`) is the
single source of truth, or remove `swrWrap` and let Drizzle’s `.$withCache`
control TTLs (ensuring `HISTORY_SWR_TTL_SECONDS` is used consistently if you
keep `.$withCache`); adjust code around `swrWrap`, `.$withCache`,
`HISTORY_SWR_TTL_SECONDS`, and the `cdb.select`/`modelProviderMappingHistory`
block accordingly.

In `@packages/db/src/schema.ts`:
- Around line 1771-1778: The RoutingHistoryConfig interface in
packages/db/src/schema.ts is a duplicate of the one in
packages/shared/src/routing-config.ts; remove the duplicated declaration and
import the shared type instead (replace the local export of RoutingHistoryConfig
with an import from the shared package and re-export if needed) so changes stay
centralized; if this duplication is intentional to avoid a circular dependency,
add a clear comment above the interface explaining why it must remain duplicated
and reference the original location (packages/shared/src/routing-config.ts) to
prevent future accidental merges.
🪄 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: 3864e599-0d40-4e75-85b1-9b7efc5195db

📥 Commits

Reviewing files that changed from the base of the PR and between a955948 and fbe7b07.

⛔ Files ignored due to path filters (5)
  • 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
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (27)
  • apps/api/package.json
  • apps/api/src/routes/routing-config.ts
  • apps/gateway/src/chat/chat.ts
  • apps/gateway/src/fallback.spec.ts
  • apps/gateway/src/lib/provider-metrics-for-routing.ts
  • apps/gateway/src/lib/routing-config-loader.ts
  • apps/gateway/src/lib/timeout-config.ts
  • apps/gateway/src/test-utils/gateway-api-test-harness.ts
  • apps/gateway/src/videos/videos.ts
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/_components/routing-contact-sales-card.tsx
  • apps/worker/src/services/stats-calculator.spec.ts
  • apps/worker/src/services/stats-calculator.ts
  • packages/actions/src/get-cheapest-from-available-providers.ts
  • packages/actions/src/models.spec.ts
  • packages/db/migrations/1779716905_dizzy_tigra.sql
  • packages/db/migrations/meta/1779716905_snapshot.json
  • packages/db/migrations/meta/_journal.json
  • packages/db/src/index.ts
  • packages/db/src/provider-metrics-history.ts
  • packages/db/src/provider-metrics.spec.ts
  • packages/db/src/provider-metrics.ts
  • packages/db/src/relations.ts
  • packages/db/src/schema.ts
  • packages/shared/src/index.ts
  • packages/shared/src/routing-config.spec.ts
  • packages/shared/src/routing-config.ts
💤 Files with no reviewable changes (2)
  • packages/db/src/provider-metrics.spec.ts
  • packages/db/src/provider-metrics.ts

Comment on lines +188 to +240
// Routing now reads metrics on-demand from
// model_provider_mapping_history (see packages/db/src/provider-metrics-history.ts).
// Seed a single recent history row whose unweighted aggregates
// produce the requested uptime/latency/throughput.
const totalRequests = metrics.totalRequests ?? 100;
const latency = metrics.latency ?? 100;
const throughput = metrics.throughput ?? 100;
const uptimeFraction = metrics.uptime / 100;
const errorRate = 1 - uptimeFraction;
const errorsCount = Math.round(totalRequests * errorRate);
const totalDurationMs = 1000; // arbitrary
const totalOutputTokens = Math.round(
(throughput * totalDurationMs) / 1000,
);
const totalTimeToFirstToken = latency * totalRequests;
const minuteTimestamp = new Date(Math.floor(Date.now() / 60000) * 60000);

await db
.update(tables.modelProviderMapping)
.set({
status: "active",
routingUptime: metrics.uptime,
routingLatency: metrics.latency ?? 100,
routingThroughput: metrics.throughput ?? 100,
routingTotalRequests: metrics.totalRequests ?? 100,
.insert(tables.modelProviderMappingHistory)
.values({
modelId,
providerId,
modelProviderMappingId: `${modelId}::${providerId}`,
minuteTimestamp,
logsCount: totalRequests,
errorsCount,
clientErrorsCount: 0,
gatewayErrorsCount: 0,
upstreamErrorsCount: errorsCount,
cachedCount: 0,
totalOutputTokens,
totalDuration: totalDurationMs,
totalTimeToFirstToken,
totalTimeToFirstReasoningToken: 0,
})
.where(
and(
eq(tables.modelProviderMapping.modelId, modelId),
eq(tables.modelProviderMapping.providerId, providerId),
),
);
.onConflictDoUpdate({
target: [
tables.modelProviderMappingHistory.modelProviderMappingId,
tables.modelProviderMappingHistory.minuteTimestamp,
],
set: {
logsCount: totalRequests,
errorsCount,
clientErrorsCount: 0,
gatewayErrorsCount: 0,
upstreamErrorsCount: errorsCount,
cachedCount: 0,
totalOutputTokens,
totalDuration: totalDurationMs,
totalTimeToFirstToken,
totalTimeToFirstReasoningToken: 0,
},
});

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 | ⚡ Quick win

Clear seeded routing history in harness reset to prevent cross-test state bleed.

setRoutingMetrics() now persists rows in modelProviderMappingHistory, but the harness reset path does not clear that table. This can make routing-sensitive tests order-dependent.

💡 Suggested fix
 async function resetGatewayTestData() {
 	await db.delete(tables.log);
+	await db.delete(tables.modelProviderMappingHistory);
 	await db.delete(tables.webhookDeliveryLog);
 	await db.delete(tables.videoJob);
 	await db.delete(tables.apiKey);
 	await db.delete(tables.providerKey);
 	await db.delete(tables.userOrganization);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/gateway/src/test-utils/gateway-api-test-harness.ts` around lines 188 -
240, The test harness seed function setRoutingMetrics() now inserts into
modelProviderMappingHistory but the harness reset path doesn't clear that table;
modify the harness reset function (the reset function in
apps/gateway/src/test-utils/gateway-api-test-harness.ts) to remove persisted
routing history by issuing a delete on tables.modelProviderMappingHistory (or
otherwise truncating it) so seeded rows from setRoutingMetrics() are cleared
between tests.

The Metrics History Window description still said "Non-default values
run a per-project aggregation against the 1-minute history table
instead of the worker-rolled-up global values." The worker-rolled-up
columns are gone — every request always aggregates from history now.
Trim the second sentence so the copy matches what the gateway
actually does.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

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

🧹 Nitpick comments (1)
apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx (1)

250-298: ⚡ Quick win

Prefer TanStack Query over useEffect for data fetching.

This manual fetch pattern in useEffect handles cancellation but bypasses benefits of TanStack Query (caching, automatic refetching, loading/error states, deduplication). Additionally, the empty catch block on line 287 swallows error details.

♻️ Suggested refactor using TanStack Query
import { useQuery } from "`@tanstack/react-query`";

// Replace the useEffect with:
const { data: defaults, isLoading: defaultsLoading } = useQuery({
  queryKey: ["routing-config-defaults", projectId],
  queryFn: () => fetchClient.GET("/routing-config/config/{projectId}/defaults", {
    params: { path: { projectId } },
  }).then(res => res.data as DefaultsResponse),
  enabled: canManage,
});

const { data: configData, isLoading: configLoading } = useQuery({
  queryKey: ["routing-config", projectId],
  queryFn: () => fetchClient.GET("/routing-config/config/{projectId}", {
    params: { path: { projectId } },
  }).then(res => res.data),
  enabled: canManage,
});

const isLoading = defaultsLoading || configLoading;

As per coding guidelines: "Do not use useEffect for data fetching in the UI; use TanStack Query for all data fetching and state management".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx
around lines 250 - 298, The current useEffect data-fetching block (the async
IIFE that calls fetchClient.GET for
"/routing-config/config/{projectId}/defaults" and
"/routing-config/config/{projectId}" and then calls setDefaults and setState)
should be replaced with TanStack Query useQuery hooks: create one useQuery for
the defaults (key ["routing-config-defaults", projectId]) and one for the config
(key ["routing-config", projectId]), each using fetchClient.GET(...).then(res =>
res.data) as the queryFn and enabled: canManage; remove the cancelled flag and
the try/catch in the effect, derive isLoading as the OR of both queries'
isLoading and error from their error values, and when query data is available
map it into setDefaults and setState (casting to DefaultsResponse and
RoutingConfigState shapes) instead of manual fetch logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx:
- Around line 250-298: The current useEffect data-fetching block (the async IIFE
that calls fetchClient.GET for "/routing-config/config/{projectId}/defaults" and
"/routing-config/config/{projectId}" and then calls setDefaults and setState)
should be replaced with TanStack Query useQuery hooks: create one useQuery for
the defaults (key ["routing-config-defaults", projectId]) and one for the config
(key ["routing-config", projectId]), each using fetchClient.GET(...).then(res =>
res.data) as the queryFn and enabled: canManage; remove the cancelled flag and
the try/catch in the effect, derive isLoading as the OR of both queries'
isLoading and error from their error values, and when query data is available
map it into setDefaults and setState (casting to DefaultsResponse and
RoutingConfigState shapes) instead of manual fetch logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 7311ccf0-3567-4d31-9bbd-52b68b11a335

📥 Commits

Reviewing files that changed from the base of the PR and between fbe7b07 and 4b74347.

📒 Files selected for processing (1)
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx

The built-in DEFAULT_ROUTING_TIMEOUTS values are also the hard ceiling
the infra layer enforces (upstream proxies, load balancers). A project
override only makes sense as a *shorter* timeout — pushing it higher
would silently get capped at the infra layer anyway and create
confusing diagnostics.

Three-layer guard:
- Shared resolver: Math.min(value, DEFAULT_ROUTING_TIMEOUTS[key]) so a
  hand-edited DB row can't escape the ceiling either.
- API: zod .max(DEFAULT_ROUTING_TIMEOUTS.{gatewayMs,streamingMs,plainMs})
  on each field for early 400 with a clear message.
- UI: NumericFieldRow gets min/max props, the input shows a red error
  message inline, and handleSave refuses to PUT if any value exceeds
  its ceiling. Card copy updated to explain the relationship.

Added a routing-config.spec test that confirms the resolver clamps a
deliberately-too-large override down to the default.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

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

🧹 Nitpick comments (2)
apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx (1)

269-317: ⚖️ Poor tradeoff

Data fetching uses useEffect instead of TanStack Query.

This component fetches data via useEffect with manual cancellation handling. Per coding guidelines, TanStack Query should be used for all data fetching and state management in frontend apps. TanStack Query would provide automatic caching, deduplication, background refetching, and cleaner error/loading states.

Since this appears to be the initial implementation of this component, consider refactoring to use useQuery from TanStack Query.

♻️ Conceptual refactor with TanStack Query
import { useQuery } from "`@tanstack/react-query`";

// Replace the useEffect with:
const { data: defaults, isLoading: defaultsLoading } = useQuery({
  queryKey: ["routing-config-defaults", projectId],
  queryFn: () => fetchClient.GET("/routing-config/config/{projectId}/defaults", {
    params: { path: { projectId } },
  }).then(res => res.data as DefaultsResponse),
  enabled: canManage,
});

const { data: configRow, isLoading: configLoading } = useQuery({
  queryKey: ["routing-config", projectId],
  queryFn: () => fetchClient.GET("/routing-config/config/{projectId}", {
    params: { path: { projectId } },
  }).then(res => res.data),
  enabled: canManage,
});

const isLoading = defaultsLoading || configLoading;

As per coding guidelines: "Do not use useEffect for data fetching in the UI; use TanStack Query for all data fetching and state management."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx
around lines 269 - 317, The current useEffect data-fetching block should be
replaced with TanStack Query useQuery calls: create two queries using useQuery({
queryKey: ["routing-config-defaults", projectId], queryFn: () =>
fetchClient.GET("/routing-config/config/{projectId}/defaults", { params: { path:
{ projectId } } }).then(r => r.data as DefaultsResponse), enabled: canManage })
and useQuery({ queryKey: ["routing-config", projectId], queryFn: () =>
fetchClient.GET("/routing-config/config/{projectId}", { params: { path: {
projectId } } }).then(r => r.data), enabled: canManage }); then derive isLoading
from the queries' isLoading flags, setDefaults from the defaults query data and
setState from the config query data (mapping to enabled, weights, thresholds,
retry, timeouts, history, providerPriorities), and use the queries' error states
instead of the manual try/catch, removing the cancelled flag and the useEffect
block that references setIsLoading, setDefaults, setState, and setError.
apps/api/src/routes/routing-config.ts (1)

330-337: 💤 Low value

Potential undefined response when row doesn't exist and body is empty.

If onConflictDoNothing is used (empty conflictSet) and the row already exists, it correctly falls back. However, if somehow the fallback query also returns nothing (e.g., concurrent delete), result would be undefined and c.json(result) would return null. This is an unlikely edge case but the response type declares routingConfigRowSchema (non-nullable).

Consider adding a guard or adjusting the response schema to allow null.

🛡️ Optional defensive guard
 	const result =
 		row ??
 		(await db.query.routingConfig.findFirst({
 			where: { projectId: { eq: projectId } },
 		}));
+
+	if (!result) {
+		throw new HTTPException(500, { message: "Failed to persist routing configuration" });
+	}

 	await invalidateRoutingConfigCache();
 	return c.json(result);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/routes/routing-config.ts` around lines 330 - 337, The code may
return undefined for result (from row ?? db.query.routingConfig.findFirst(...))
causing c.json(result) to violate the non-null routingConfigRowSchema; add a
defensive guard after the query: check if result is falsy and respond with an
explicit not-found or error (for example use c.status(404).json({ message:
'Routing config not found' }) or throw a typed HTTP error) before calling
invalidateRoutingConfigCache() and c.json(result); alternatively, if null is
acceptable, update the response schema (routingConfigRowSchema) to allow null —
modify the logic around result, db.query.routingConfig.findFirst,
invalidateRoutingConfigCache, and c.json accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@apps/api/src/routes/routing-config.ts`:
- Around line 330-337: The code may return undefined for result (from row ??
db.query.routingConfig.findFirst(...)) causing c.json(result) to violate the
non-null routingConfigRowSchema; add a defensive guard after the query: check if
result is falsy and respond with an explicit not-found or error (for example use
c.status(404).json({ message: 'Routing config not found' }) or throw a typed
HTTP error) before calling invalidateRoutingConfigCache() and c.json(result);
alternatively, if null is acceptable, update the response schema
(routingConfigRowSchema) to allow null — modify the logic around result,
db.query.routingConfig.findFirst, invalidateRoutingConfigCache, and c.json
accordingly.

In
`@apps/ui/src/app/dashboard/`[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx:
- Around line 269-317: The current useEffect data-fetching block should be
replaced with TanStack Query useQuery calls: create two queries using useQuery({
queryKey: ["routing-config-defaults", projectId], queryFn: () =>
fetchClient.GET("/routing-config/config/{projectId}/defaults", { params: { path:
{ projectId } } }).then(r => r.data as DefaultsResponse), enabled: canManage })
and useQuery({ queryKey: ["routing-config", projectId], queryFn: () =>
fetchClient.GET("/routing-config/config/{projectId}", { params: { path: {
projectId } } }).then(r => r.data), enabled: canManage }); then derive isLoading
from the queries' isLoading flags, setDefaults from the defaults query data and
setState from the config query data (mapping to enabled, weights, thresholds,
retry, timeouts, history, providerPriorities), and use the queries' error states
instead of the manual try/catch, removing the cancelled flag and the useEffect
block that references setIsLoading, setDefaults, setState, and setError.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 55e90705-5493-4d36-8369-5576cf2e16d4

📥 Commits

Reviewing files that changed from the base of the PR and between 4b74347 and a2706e3.

📒 Files selected for processing (4)
  • apps/api/src/routes/routing-config.ts
  • apps/ui/src/app/dashboard/[orgId]/[projectId]/settings/routing/_components/routing-config-client.tsx
  • packages/shared/src/routing-config.spec.ts
  • packages/shared/src/routing-config.ts

steebchen and others added 16 commits May 26, 2026 13:02
Main added stable preferred-provider routing (PR #2351) that keeps a
project routed to the same provider for a model as long as it stays
healthy and competitive. The three knobs that drive it (TTL, uptime
threshold, score margin) plus a global on/off were env-var only.

Surface them as a new "sticky" group on routing_config, the same way
the other knobs are exposed:
- DB jsonb column on routing_config
- shared RoutingStickyConfig type + DEFAULT_ROUTING_STICKY (mirror
  preferred-provider.ts defaults exactly) + clampSticky (uptime
  threshold clamped to 0-100, ttl >= 1s, scoreMargin >= 0)
- preferred-provider.ts now accepts an optional cfg arg; when given,
  it bypasses the env lookups and uses cfg.{ttlSeconds,uptimeThreshold,
  scoreMargin} directly. cfg-less callers still hit the existing env
  fallbacks so nothing changes for non-enterprise orgs.
- chat.ts hysteresis block now skips entirely when routingCfg.sticky.enabled
  is false and threads cfg through to set/resolve calls.
- API: new stickySchema (ttlSeconds <= 24h, uptime 0-100, scoreMargin
  0-10), wired into PUT body + resolved + defaults endpoints.
- UI: new "Sticky Routing" card with the same Enabled switch pattern as
  the top-level toggle, plus three NumericFieldRow rows.
- Spec adds clamping + defaults coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@steebchen
steebchen merged commit befaa9d into main May 30, 2026
30 of 32 checks passed
@steebchen
steebchen deleted the enterprise-routing-overrides branch May 30, 2026 20:18
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.

3 participants