Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 133 additions & 3 deletions web/src/components/routes/ClientTypeRoutesContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

import { useState, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, RefreshCw, Zap } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Plus, RefreshCw, Zap, Workflow, Settings2, Pin } from 'lucide-react';
import {
DndContext,
closestCenter,
Expand All @@ -32,19 +33,27 @@ import {
useUpdateRoutePositions,
useProviderStats,
useProxyRequestUpdates,
useRoutingStrategies,
routeKeys,
} from '@/hooks/queries';
import { useQueryClient } from '@tanstack/react-query';
import { useStreamingRequests } from '@/hooks/use-streaming';
import { getClientName, getClientColor } from '@/components/icons/client-icons';
import { getProviderColor, type ProviderType } from '@/lib/theme';
import type { ClientType, Provider, ProviderStats } from '@/lib/transport';
import type {
ClientType,
Provider,
ProviderStats,
RoutingStrategyType,
RoutingStickyScope,
} from '@/lib/transport';
import {
SortableProviderRow,
ProviderRowContent,
} from '@/pages/client-routes/components/provider-row';
import type { ProviderConfigItem } from '@/pages/client-routes/types';
import { Button } from '../ui';
import { Button, Badge, buttonVariants } from '../ui';
import { cn } from '@/lib/utils';
import { AntigravityQuotasProvider } from '@/contexts/antigravity-quotas-context';
import { CooldownsProvider } from '@/contexts/cooldowns-context';

Expand Down Expand Up @@ -102,6 +111,93 @@ interface ClientTypeRoutesContentProps {
searchQuery?: string; // Optional search query from parent
}

// Small banner above the routes list telling the user which routing strategy is
// actually in effect for this scope (project-specific, else inherited global,
// else the priority default) and what that means for ordering. Mirrors the
// backend resolution order in router.getRoutingStrategy.
// Render a sticky TTL as a compact, human-friendly duration (1800 → "30m",
// 3600 → "1h", 90 → "90s"). Non-positive values fall back to the 30m default
// the backend applies (sticky.TTLFromConfig).
function formatTtl(seconds: number): string {
const s = seconds > 0 ? seconds : 1800;
if (s % 3600 === 0) return `${s / 3600}h`;
if (s % 60 === 0) return `${s / 60}m`;
return `${s}s`;
}

function RoutingStrategyBanner({
type,
inherited,
isDefault,
stickyEnabled,
stickyScope,
stickyTTLSeconds,
}: {
type: RoutingStrategyType;
inherited: boolean;
isDefault: boolean;
stickyEnabled: boolean;
stickyScope: RoutingStickyScope;
stickyTTLSeconds: number;
}) {
const { t } = useTranslation();
const isWeighted = type === 'weighted_random';
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 rounded-xl border border-border/60 bg-muted/30 px-4 py-2.5">
<Workflow className="h-4 w-4 text-cyan-500 shrink-0" />
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t('routes.strategyLabel')}
</span>
<Badge variant={isWeighted ? 'warning' : 'info'}>
{isWeighted
? t('routingStrategies.weightedRandom')
: t('routingStrategies.priorityByPosition')}
</Badge>
{/* Session affinity only takes effect under weighted_random, so only
surface its state there — otherwise the badge would be misleading. */}
{isWeighted &&
(stickyEnabled ? (
<Badge variant="success" title={t('routes.affinityTooltip')}>
<Pin className="mr-1 h-3 w-3" />
{t('routes.affinityOn')}
<span className="ml-1 font-normal opacity-80">
·{' '}
{stickyScope === 'conversation'
? t('routes.affinityScopeConversation')
: t('routes.affinityScopeToken')}{' '}
· {formatTtl(stickyTTLSeconds)}
</span>
</Badge>
) : (
<Badge variant="outline" title={t('routes.affinityTooltip')}>
<Pin className="mr-1 h-3 w-3 opacity-50" />
{t('routes.affinityOff')}
</Badge>
))}
{inherited && (
<span className="text-[11px] text-muted-foreground/70">
({t('routes.strategyInherited')})
</span>
)}
{isDefault && (
<span className="text-[11px] text-muted-foreground/70">
({t('routes.strategyDefault')})
</span>
)}
<span className="text-[11px] text-muted-foreground min-w-0 flex-1 break-words">
{isWeighted ? t('routes.strategyWeightedHint') : t('routes.strategyPriorityHint')}
</span>
<Link
to="/routing-strategies"
className={cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'h-7 shrink-0 text-xs')}
>
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
{t('routes.strategyConfigure')}
</Link>
</div>
);
}

// Wrapper component that provides the AntigravityQuotasProvider and CooldownsProvider
export function ClientTypeRoutesContent(props: ClientTypeRoutesContentProps) {
return (
Expand Down Expand Up @@ -141,6 +237,29 @@ function ClientTypeRoutesContentInner({

const { data: allRoutes, isLoading: routesLoading } = useRoutes();
const { data: providers = [], isLoading: providersLoading } = useProviders();
const { data: strategies = [] } = useRoutingStrategies();

// Resolve the effective strategy for this scope, mirroring the backend's
// order: project-specific first, then the global (projectID 0) strategy,
// then the built-in priority default.
const strategyInfo = useMemo(() => {
const own = strategies.find((s) => s.projectID === projectID);
const global = strategies.find((s) => s.projectID === 0);
const resolved = own ?? global;
const cfg = resolved?.config ?? null;
return {
type: (resolved?.type ?? 'priority') as RoutingStrategyType,
inherited: !own && !!global && projectID !== 0,
isDefault: !resolved,
// Sticky / session-affinity is only honoured under weighted_random
// (priority is already deterministic — see router.go). Surface it so the
// effect of the routes' weights is understood in context.
stickyEnabled: !!cfg?.stickyEnabled,
stickyScope: cfg?.stickyScope ?? 'token',
stickyTTLSeconds: cfg?.stickyTTLSeconds ?? 1800,
};
}, [strategies, projectID]);
const isWeighted = strategyInfo.type === 'weighted_random';

const createRoute = useCreateRoute();
const toggleRoute = useToggleRoute();
Expand Down Expand Up @@ -373,6 +492,16 @@ function ClientTypeRoutesContentInner({
<div className="flex flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto px-6 py-6">
<div className="mx-auto max-w-[1400px] space-y-6">
{/* Effective routing strategy for this scope */}
<RoutingStrategyBanner
type={strategyInfo.type}
inherited={strategyInfo.inherited}
isDefault={strategyInfo.isDefault}
stickyEnabled={strategyInfo.stickyEnabled}
stickyScope={strategyInfo.stickyScope}
stickyTTLSeconds={strategyInfo.stickyTTLSeconds}
/>

{/* Routes List */}
{items.length > 0 ? (
<DndContext
Expand All @@ -394,6 +523,7 @@ function ClientTypeRoutesContentInner({
}
stats={stableProviderStats[item.provider.id]}
isToggling={toggleRoute.isPending || createRoute.isPending}
showWeight={isWeighted}
onToggle={() => handleToggle(item)}
onDelete={item.route ? () => handleDeleteRoute(item.route!.id) : undefined}
/>
Expand Down
15 changes: 14 additions & 1 deletion web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,20 @@
},
"noRoutesForClient": "No routes configured for {{client}}",
"addRouteToGetStarted": "Add a route below to get started",
"availableProviders": "Available Providers"
"availableProviders": "Available Providers",
"strategyLabel": "Routing strategy",
"strategyInherited": "inherited from Global",
"strategyDefault": "default",
"strategyConfigure": "Configure",
"strategyPriorityHint": "Drag rows to set the priority order.",
"strategyWeightedHint": "Order is weighted-random — adjust each route's weight.",
"weight": "Weight",
"weightTooltip": "Higher weight = higher chance of being picked (weighted_random strategy).",
"affinityOn": "Affinity on",
"affinityOff": "Affinity off",
"affinityScopeToken": "by token",
"affinityScopeConversation": "by conversation",
"affinityTooltip": "Session affinity (sticky): under weighted_random, a session keeps hitting the same provider within the TTL to maximize prompt-cache hits. Configure it on the Routing strategies page."
},
"console": {
"title": "Console",
Expand Down
15 changes: 14 additions & 1 deletion web/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,20 @@
},
"noRoutesForClient": "未为 {{client}} 配置路由",
"addRouteToGetStarted": "在下方添加路由以开始使用",
"availableProviders": "可用提供方"
"availableProviders": "可用提供方",
"strategyLabel": "路由策略",
"strategyInherited": "继承自全局",
"strategyDefault": "默认",
"strategyConfigure": "配置",
"strategyPriorityHint": "拖动各行以设置优先级顺序。",
"strategyWeightedHint": "按权重随机排序——可调整每条路由的权重。",
"weight": "权重",
"weightTooltip": "权重越大被选中的概率越高(加权随机策略)。",
"affinityOn": "已开启亲和",
"affinityOff": "亲和关闭",
"affinityScopeToken": "按 token",
"affinityScopeConversation": "按会话",
"affinityTooltip": "会话亲和(sticky):加权随机下,同一会话在 TTL 内持续命中同一 provider,以最大化 prompt 缓存命中。可在「路由策略」页配置。"
},
"console": {
"title": "控制台",
Expand Down
Loading
Loading