feat: add round robin routing and improve token binding UX

This commit is contained in:
bnvnvnv
2026-03-12 05:17:10 +00:00
parent cb93b28c71
commit 5a7531f635
20 changed files with 1112 additions and 325 deletions
@@ -190,4 +190,134 @@ describe('Models marketplace text', () => {
root?.unmount();
}
});
it('limits expanded account and pricing detail to the selected site filter', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 2,
tokenCount: 3,
avgLatency: 500,
successRate: 96,
description: 'demo model',
tags: ['chat'],
supportedEndpointTypes: ['openai'],
pricingSources: [
{
siteId: 1,
siteName: '站点 A',
accountId: 1,
username: 'user-a',
ownerBy: null,
enableGroups: [],
groupPricing: {
default: {
quotaType: 0,
inputPerMillion: 1,
outputPerMillion: 2,
},
},
},
{
siteId: 2,
siteName: '站点 B',
accountId: 2,
username: 'user-b',
ownerBy: null,
enableGroups: [],
groupPricing: {
default: {
quotaType: 0,
inputPerMillion: 3,
outputPerMillion: 4,
},
},
},
],
accounts: [
{
id: 1,
site: '站点 A',
username: 'user-a',
latency: 320,
balance: 12.5,
tokens: [
{ id: 1, name: 'token-a-1', isDefault: true },
{ id: 2, name: 'token-a-2', isDefault: false },
],
},
{
id: 2,
site: '站点 B',
username: 'user-b',
latency: 680,
balance: 8.4,
tokens: [
{ id: 3, name: 'token-b-1', isDefault: true },
],
},
],
},
],
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const siteFilterItem = root!.root.find((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('filter-item')
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('站点 A')
));
await act(async () => {
siteFilterItem.props.onClick();
});
await flushMicrotasks();
const cards = root!.root.findAll((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('model-card')
&& typeof node.props.onClick === 'function'
));
expect(cards.length).toBeGreaterThan(0);
await act(async () => {
cards[0]!.props.onClick();
});
await flushMicrotasks();
const expandedSections = root!.root.findAll((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('model-card-expand')
));
expect(expandedSections.length).toBe(1);
const expandedText = collectText(expandedSections[0]!);
expect(expandedText).toContain('站点 A');
expect(expandedText).toContain('user-a');
expect(expandedText).toContain('token-a-1');
expect(expandedText).not.toContain('站点 B');
expect(expandedText).not.toContain('user-b');
expect(expandedText).not.toContain('token-b-1');
} finally {
root?.unmount();
}
});
});
+29 -7
View File
@@ -292,26 +292,50 @@ export default function Models() {
});
}, [data.models, search, activeSite, activeBrand, sortBy, sortDir]);
// Keep expanded detail consistent with filters (especially site filter).
// The list-level filter uses "model has at least one account on this site" semantics;
// once a model is shown, its detail should honor the active site as well.
const detailModels = useMemo(() => {
if (!activeSite) return filteredModels;
return filteredModels.map((model) => {
const accounts = model.accounts.filter((account) => account.site === activeSite);
const pricingSources = model.pricingSources.filter((source) => source.siteName === activeSite);
const latencyValues = accounts
.map((account) => account.latency)
.filter((latency): latency is number => typeof latency === 'number' && Number.isFinite(latency));
return {
...model,
accounts,
pricingSources,
accountCount: accounts.length,
tokenCount: accounts.reduce((sum, account) => sum + account.tokens.length, 0),
avgLatency: latencyValues.length > 0
? Math.round(latencyValues.reduce((sum, latency) => sum + latency, 0) / latencyValues.length)
: model.avgLatency,
};
});
}, [filteredModels, activeSite]);
/* ---- pagination ---- */
const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize));
const safePageVal = Math.min(page, totalPages);
const paged = filteredModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize);
const paged = detailModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize);
useEffect(() => { setPage(1); }, [search, activeSite, activeBrand, pageSize]);
/* ---- stats ---- */
const totalCoverageSlots = filteredModels.reduce((s, m) => s + m.accountCount, 0);
const totalCoverageSlots = detailModels.reduce((s, m) => s + m.accountCount, 0);
const uniqueAccountCount = (() => {
const ids = new Set<number>();
for (const model of filteredModels) {
for (const model of detailModels) {
for (const account of model.accounts) {
ids.add(account.id);
}
}
return ids.size;
})();
const avgLatency = filteredModels.length
? Math.round(filteredModels.reduce((s, m) => s + m.avgLatency, 0) / filteredModels.length)
const avgLatency = detailModels.length
? Math.round(detailModels.reduce((s, m) => s + m.avgLatency, 0) / detailModels.length)
: 0;
/* ---- copy ---- */
@@ -898,5 +922,3 @@ export default function Models() {
</div>
);
}
+37
View File
@@ -28,6 +28,7 @@ import type {
RouteSortDir,
GroupFilter,
RouteSummaryRow,
RouteRoutingStrategy,
RouteDecision,
RouteIconOption,
MissingTokenRouteSiteActionItem,
@@ -88,6 +89,7 @@ export default function TokenRoutes() {
const [channelTokenDraft, setChannelTokenDraft] = useState<Record<number, number>>({});
const [updatingChannel, setUpdatingChannel] = useState<Record<number, boolean>>({});
const [savingPriorityByRoute, setSavingPriorityByRoute] = useState<Record<number, boolean>>({});
const [updatingRoutingStrategyByRoute, setUpdatingRoutingStrategyByRoute] = useState<Record<number, boolean>>({});
const [decisionByRoute, setDecisionByRoute] = useState<Record<number, RouteDecision | null>>({});
const [loadingDecision, setLoadingDecision] = useState(false);
@@ -349,6 +351,32 @@ export default function TokenRoutes() {
}
};
const handleRoutingStrategyChange = async (route: RouteSummaryRow, routingStrategy: RouteRoutingStrategy) => {
const currentStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
if (routingStrategy === currentStrategy) return;
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: true }));
setRouteSummaries((prev) => prev.map((item) => (
item.id === route.id
? { ...item, routingStrategy }
: item
)));
try {
await api.updateRoute(route.id, { routingStrategy });
toast.success(routingStrategy === 'round_robin' ? '已切换为轮询策略' : '已切换为权重随机策略');
await load();
} catch (e: any) {
setRouteSummaries((prev) => prev.map((item) => (
item.id === route.id
? { ...item, routingStrategy: currentStrategy }
: item
)));
toast.error(e.message || '更新路由策略失败');
} finally {
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: false }));
}
};
// Stable derived value: only changes when route patterns change (not on enabled toggle)
const routePatterns = useMemo(
() => routeSummaries.map((r) => ({ id: r.id, modelPattern: r.modelPattern })),
@@ -761,6 +789,12 @@ export default function TokenRoutes() {
const handleToggleEnabledRef = useRef(handleToggleRouteEnabled);
handleToggleEnabledRef.current = handleToggleRouteEnabled;
const stableToggleEnabled = useCallback((route: RouteSummaryRow) => { handleToggleEnabledRef.current(route); }, []);
const handleRoutingStrategyChangeRef = useRef(handleRoutingStrategyChange);
handleRoutingStrategyChangeRef.current = handleRoutingStrategyChange;
const stableRoutingStrategyChange = useCallback(
(route: RouteSummaryRow, strategy: RouteRoutingStrategy) => handleRoutingStrategyChangeRef.current(route, strategy),
[],
);
const stableTokenDraftChange = useCallback(
(channelId: number, tokenId: number) => setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })),
[],
@@ -995,6 +1029,7 @@ export default function TokenRoutes() {
>
<MobileField label="模型" value={route.modelPattern} />
<MobileField label="通道" value={route.channelCount} />
<MobileField label="策略" value={route.routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')} />
<MobileField label="状态" value={route.enabled ? tr('启用') : tr('禁用')} />
<div className="mobile-card-actions">
<button
@@ -1019,6 +1054,8 @@ export default function TokenRoutes() {
onEdit={stableEditRoute}
onDelete={stableDeleteRoute}
onToggleEnabled={stableToggleEnabled}
onRoutingStrategyChange={stableRoutingStrategyChange}
updatingRoutingStrategy={!!updatingRoutingStrategyByRoute[route.id]}
channels={channelsByRouteId[route.id]}
loadingChannels={!!loadingChannelsByRouteId[route.id]}
routeDecision={decisionByRoute[route.id] || null}
+21 -7
View File
@@ -6,6 +6,11 @@ import { useToast } from '../../components/Toast.js';
import { tr } from '../../i18n.js';
import type { RouteCandidateView, RouteAccountOption, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
import type { RouteMissingTokenHint } from '../helpers/routeMissingTokenHints.js';
import {
buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel,
describeTokenBinding,
} from './tokenBindingPresentation.js';
type ChannelSelection = {
accountId: number;
@@ -79,13 +84,10 @@ export default function AddChannelModal({
return next;
}
const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
const defaultToken = tokens.find((t) => t.isDefault) || tokens[0] || null;
return {
...prev,
[account.id]: {
accountId: account.id,
tokenId: defaultToken?.id,
sourceModel: defaultToken?.sourceModel,
},
};
});
@@ -191,6 +193,7 @@ export default function AddChannelModal({
const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
const selection = selectedAccounts[account.id];
const isExisting = existingChannelAccountIds?.has(account.id);
const tokenBinding = describeTokenBinding(tokens, selection?.tokenId || 0);
return (
<div
@@ -219,7 +222,7 @@ export default function AddChannelModal({
{isSelected && tokens.length > 0 && (
<div style={{ marginTop: 6, paddingLeft: 24 }} onClick={(e) => e.stopPropagation()}>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>{tr('令牌')}:</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4 }}>{tr('令牌绑定')}:</div>
<ModernSelect
size="sm"
value={(() => {
@@ -235,14 +238,25 @@ export default function AddChannelModal({
updateTokenForAccount(account.id, Number.parseInt(tokenRaw, 10) || 0, sourceParts.join('::'));
}}
options={[
{ value: '0', label: tr('默认令牌') },
{
value: '0',
label: tr('跟随账号默认'),
description: tokenBinding.followOptionDescription,
},
...tokens.map((token: RouteTokenOption) => ({
value: `${token.id}::${token.sourceModel || ''}`,
label: `${token.name}${token.isDefault ? '(默认)' : ''}${token.sourceModel ? ` [${token.sourceModel}]` : ''}`,
label: buildFixedTokenOptionLabel(token, {
includeDefaultTag: true,
includeSourceModel: true,
}),
description: buildFixedTokenOptionDescription(token),
})),
]}
placeholder={tr('选择令牌')}
placeholder={tr('选择绑定方式')}
/>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
{tokenBinding.helperText}
</div>
</div>
)}
</div>
+47 -1
View File
@@ -14,9 +14,17 @@ import {
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { BrandGlyph, InlineBrandIcon, type BrandInfo } from '../../components/BrandIcon.js';
import ModernSelect from '../../components/ModernSelect.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { tr } from '../../i18n.js';
import type { RouteSummaryRow, RouteChannel, RouteDecision, RouteDecisionCandidate, MissingTokenRouteSiteActionItem } from './types.js';
import type {
RouteSummaryRow,
RouteChannel,
RouteDecision,
RouteDecisionCandidate,
MissingTokenRouteSiteActionItem,
RouteRoutingStrategy,
} from './types.js';
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
import { SortableChannelRow } from './SortableChannelRow.js';
import {
@@ -34,6 +42,8 @@ type RouteCardProps = {
onEdit: (route: RouteSummaryRow) => void;
onDelete: (routeId: number) => void;
onToggleEnabled: (route: RouteSummaryRow) => void;
onRoutingStrategyChange: (route: RouteSummaryRow, strategy: RouteRoutingStrategy) => void;
updatingRoutingStrategy: boolean;
// Channel data (loaded on demand)
channels: RouteChannel[] | undefined;
loadingChannels: boolean;
@@ -79,6 +89,8 @@ function RouteCardInner({
onEdit,
onDelete,
onToggleEnabled,
onRoutingStrategyChange,
updatingRoutingStrategy,
channels,
loadingChannels,
routeDecision,
@@ -100,6 +112,19 @@ function RouteCardInner({
const routeIcon = resolveRouteIcon(route);
const exactRoute = isExactModelPattern(route.modelPattern);
const title = resolveRouteTitle(route);
const routingStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
const routingStrategyOptions = [
{
value: 'weighted',
label: tr('权重随机'),
description: tr('按优先级、权重和成本信号综合选择'),
},
{
value: 'round_robin',
label: tr('轮询'),
description: tr('按全局顺序轮流调用,忽略优先级,连续失败 3 次后进入分级冷却'),
},
] as const;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
@@ -172,6 +197,10 @@ function RouteCardInner({
{route.channelCount} {tr('通道')}
</span>
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
{routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')}
</span>
<svg
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
style={{ flexShrink: 0, color: 'var(--color-text-muted)' }}
@@ -251,6 +280,23 @@ function RouteCardInner({
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{tr('路由策略')}
</div>
<div style={{ minWidth: 220, maxWidth: 320, flex: '1 1 220px' }}>
<ModernSelect
size="sm"
value={routingStrategy}
disabled={updatingRoutingStrategy}
onChange={(nextValue) => onRoutingStrategyChange(route, nextValue as RouteRoutingStrategy)}
options={routingStrategyOptions.map((option) => ({ ...option }))}
placeholder={tr('选择路由策略')}
emptyLabel={tr('暂无可选策略')}
/>
</div>
</div>
{/* Missing token hints + Add channel button */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
{missingTokenSiteItems.length > 0 ? (
@@ -3,6 +3,11 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import ModernSelect from '../../components/ModernSelect.js';
import type { SortableChannelRowProps } from './types.js';
import {
buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel,
describeTokenBinding,
} from './tokenBindingPresentation.js';
import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
export function SortableChannelRow({
@@ -48,6 +53,7 @@ export function SortableChannelRow({
};
const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
const tokenBinding = describeTokenBinding(tokenOptions, activeTokenId, channel.token?.name ?? null);
return (
<div ref={setNodeRef} style={rowStyle}>
@@ -105,11 +111,29 @@ export function SortableChannelRow({
className="badge"
style={{
fontSize: 10,
background: 'color-mix(in srgb, var(--color-info) 15%, transparent)',
color: 'var(--color-info)',
background: tokenBinding.isFollowingAccountDefault
? 'color-mix(in srgb, var(--color-info) 15%, transparent)'
: 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
color: tokenBinding.isFollowingAccountDefault ? 'var(--color-info)' : 'var(--color-warning)',
}}
>
{channel.token?.name || '默认令牌'}
{tokenBinding.bindingModeLabel}
</span>
<span
className="badge"
style={{
fontSize: 10,
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
color: 'var(--color-primary)',
maxWidth: 220,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
data-tooltip={`当前生效:${tokenBinding.effectiveTokenName}`}
>
当前生效:{tokenBinding.effectiveTokenName}
</span>
{channel.sourceModel ? (
@@ -174,28 +198,36 @@ export function SortableChannelRow({
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ minWidth: 150, flex: 1 }}>
<div style={{ minWidth: 220, flex: 1 }}>
<ModernSelect
size="sm"
value={String(activeTokenId || 0)}
onChange={(nextValue) => onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
disabled={isUpdatingToken}
options={[
{ value: '0', label: '默认令牌' },
{
value: '0',
label: '跟随账号默认',
description: tokenBinding.followOptionDescription,
},
...tokenOptions.map((token) => ({
value: String(token.id),
label: `${token.name}${token.isDefault ? '(默认)' : ''}`,
label: buildFixedTokenOptionLabel(token, { includeDefaultTag: true }),
description: buildFixedTokenOptionDescription(token),
})),
]}
placeholder="默认令牌"
placeholder="选择令牌绑定方式"
/>
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>
{tokenBinding.helperText}
</div>
</div>
<button
onClick={onSaveToken}
disabled={isUpdatingToken}
className="btn btn-link btn-link-info"
>
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '改令牌'}
{isUpdatingToken ? <span className="spinner spinner-sm" /> : '保存'}
</button>
</div>
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
buildFixedTokenOptionDescription,
buildFixedTokenOptionLabel,
describeTokenBinding,
} from './tokenBindingPresentation.js';
describe('tokenBindingPresentation', () => {
it('describes follow-account-default mode with the current default token', () => {
const result = describeTokenBinding([
{ id: 1, name: 'token-a', isDefault: true },
{ id: 2, name: 'token-b', isDefault: false },
], 0);
expect(result.bindingModeLabel).toBe('跟随账号默认');
expect(result.effectiveTokenName).toBe('token-a');
expect(result.helperText).toContain('以后账号默认变化时会自动切换');
expect(result.followOptionDescription).toContain('当前生效:token-a');
});
it('describes fixed mode when the selected token is also the account default', () => {
const result = describeTokenBinding([
{ id: 1, name: 'default', isDefault: true },
{ id: 2, name: 'backup', isDefault: false },
], 1);
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.effectiveTokenName).toBe('default');
expect(result.helperText).toContain('它目前也是账号默认');
expect(result.helperText).toContain('不会跟着变');
});
it('formats fixed token options with clearer labels and descriptions', () => {
const token = {
id: 3,
name: 'shared-token',
isDefault: true,
sourceModel: 'gpt-4o-mini',
};
expect(buildFixedTokenOptionLabel(token, {
includeDefaultTag: true,
includeSourceModel: true,
})).toBe('固定使用:shared-token(当前账号默认) [gpt-4o-mini]');
expect(buildFixedTokenOptionDescription(token)).toContain('以后不会自动跟随');
});
});
@@ -0,0 +1,80 @@
export type TokenBindingOption = {
id: number;
name: string;
isDefault: boolean;
sourceModel?: string;
};
export type TokenBindingPresentation = {
isFollowingAccountDefault: boolean;
bindingModeLabel: string;
effectiveTokenName: string;
helperText: string;
followOptionDescription: string;
};
export function getDefaultTokenOption(options: TokenBindingOption[]): TokenBindingOption | null {
return options.find((option) => option.isDefault) || null;
}
export function describeTokenBinding(
options: TokenBindingOption[],
activeTokenId: number,
fallbackTokenName?: string | null,
): TokenBindingPresentation {
const defaultToken = getDefaultTokenOption(options);
const selectedToken = activeTokenId
? options.find((option) => option.id === activeTokenId) || null
: null;
if (!activeTokenId) {
const effectiveTokenName = defaultToken?.name || fallbackTokenName || '未设置默认令牌';
return {
isFollowingAccountDefault: true,
bindingModeLabel: '跟随账号默认',
effectiveTokenName,
helperText: defaultToken
? `跟随账号默认。当前生效的是「${defaultToken.name}」,以后账号默认变化时会自动切换。`
: '跟随账号默认。当前账号还没有默认令牌。',
followOptionDescription: defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换',
};
}
const effectiveTokenName = selectedToken?.name || fallbackTokenName || `token-${activeTokenId}`;
return {
isFollowingAccountDefault: false,
bindingModeLabel: '固定令牌',
effectiveTokenName,
helperText: selectedToken?.isDefault
? `已固定到「${effectiveTokenName}」。它目前也是账号默认,但以后账号默认变化时,这个通道不会跟着变。`
: `已固定到「${effectiveTokenName}」,不会随账号默认变化。`,
followOptionDescription: defaultToken
? `当前生效:${defaultToken.name};以后账号默认变化时会自动切换`
: '以后账号默认变化时会自动切换',
};
}
export function buildFixedTokenOptionLabel(
token: TokenBindingOption,
options: {
includeDefaultTag?: boolean;
includeSourceModel?: boolean;
} = {},
): string {
let label = `固定使用:${token.name}`;
if (options.includeDefaultTag && token.isDefault) {
label += '(当前账号默认)';
}
if (options.includeSourceModel && token.sourceModel) {
label += ` [${token.sourceModel}]`;
}
return label;
}
export function buildFixedTokenOptionDescription(token: TokenBindingOption): string {
return token.isDefault
? '固定到这条令牌;它目前也是账号默认,但以后不会自动跟随'
: '固定到这条令牌;不随账号默认变化';
}
+3
View File
@@ -4,6 +4,7 @@ import type { BrandInfo } from '../../components/BrandIcon.js';
export type RouteSortBy = 'modelPattern' | 'channelCount';
export type RouteSortDir = 'asc' | 'desc';
export type GroupFilter = null | '__all__' | number;
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
export type RouteChannelDraft = {
accountId: number;
@@ -46,6 +47,7 @@ export type RouteRow = {
displayName?: string | null;
displayIcon?: string | null;
modelMapping?: string | null;
routingStrategy?: RouteRoutingStrategy | null;
decisionSnapshot?: RouteDecision | null;
decisionRefreshedAt?: string | null;
enabled: boolean;
@@ -58,6 +60,7 @@ export type RouteSummaryRow = {
displayName: string | null;
displayIcon: string | null;
modelMapping: string | null;
routingStrategy?: RouteRoutingStrategy | null;
enabled: boolean;
channelCount: number;
enabledChannelCount: number;