[codex] background route decision refresh (#370)

* background route decision refresh

* address route decision refresh review

* fix route decision refresh typing
This commit is contained in:
Cita
2026-04-01 18:24:14 +08:00
committed by GitHub
parent 6e21f911ea
commit 37a86d1380
12 changed files with 647 additions and 90 deletions
+4
View File
@@ -731,6 +731,10 @@ export const api = {
body: JSON.stringify({ refreshModels, ...(wait ? { wait: true } : {}) }),
timeoutMs: wait ? 150_000 : 30_000,
}),
refreshRouteDecisionSnapshots: () => request('/api/routes/decision/refresh', {
method: 'POST',
body: JSON.stringify({}),
}),
getRouteDecision: (model: string) => request(`/api/routes/decision?model=${encodeURIComponent(model)}`),
getRouteDecisionsBatch: (models: string[], options?: { refreshPricingCatalog?: boolean; persistSnapshots?: boolean }) => request('/api/routes/decision/batch', {
method: 'POST',
+2
View File
@@ -455,6 +455,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'生成已取消。': 'The build was canceled.',
'生成已停止。': 'The build has stopped.',
'生成中...': 'Generating...',
'已缓存': 'Cached',
'失败': 'fail',
'失败 (${countBy(\'failed\')})': 'Failure (${countBy(\'failed\')})',
'失败避让': 'failure avoidance',
@@ -740,6 +741,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'自动重建': 'automatic rebuild',
'自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)': 'Automatic reconstruction completed (new ${createdRoutes} routes / ${createdChannels} channels)',
'最大 Token 数': 'Maximum number of tokens',
'最近刷新': 'Last refreshed',
'最近健康检查失败': 'Recent health check failed',
'最近失败,优先避让(${RECENT_FAILURE_AVOID_SEC / 60} 分钟窗口)': 'Recent failure, priority avoidance (${RECENT_FAILURE_AVOID_SEC / 60} minute window)',
'最近一次检查失败': 'The latest check failed',
+109 -73
View File
@@ -9,6 +9,7 @@ import { MobileCard, MobileField } from '../components/MobileCard.js';
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { tr } from '../i18n.js';
import { ROUTE_DECISION_REFRESH_TASK_TYPE } from '../../shared/tokenRouteContract.js';
import {
buildRouteModelCandidatesIndex,
type RouteCandidateView,
@@ -42,7 +43,6 @@ import type {
GroupRouteItem,
} from './token-routes/types.js';
import {
AUTO_ROUTE_DECISION_LIMIT,
ROUTE_RENDER_CHUNK,
isExplicitGroupRoute,
isExactModelPattern,
@@ -161,73 +161,8 @@ export default function TokenRoutes() {
const candidatesPromiseRef = useRef<Promise<void> | null>(null);
const candidatesVersionRef = useRef(0);
const candidatesSeqRef = useRef(0);
const loadRouteDecisions = async (
routeRows: RouteSummaryRow[],
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
) => {
const rows = routeRows || [];
const exactRoutes = rows.filter((route) => isRouteExactModel(route));
const wildcardRouteIds = rows
.filter((route) => !isRouteExactModel(route))
.map((route) => route.id);
const requestedModels = Array.from(new Set<string>(exactRoutes.map((route) => route.modelPattern)));
const defaultState: Record<number, RouteDecision | null> = {};
for (const route of rows) defaultState[route.id] = null;
if (requestedModels.length === 0 && wildcardRouteIds.length === 0) {
setDecisionByRoute(defaultState);
setDecisionAutoSkipped(false);
return;
}
const totalDecisionRequests = requestedModels.length + wildcardRouteIds.length;
if (!options?.force && totalDecisionRequests > AUTO_ROUTE_DECISION_LIMIT) {
setDecisionByRoute(defaultState);
setDecisionAutoSkipped(true);
return;
}
setLoadingDecision(true);
try {
setDecisionAutoSkipped(false);
const decisionRequestOptions = options?.refreshPricingCatalog
? {
refreshPricingCatalog: true as const,
...(options?.persistSnapshots ? { persistSnapshots: true as const } : {}),
}
: options?.persistSnapshots
? { persistSnapshots: true as const }
: undefined;
const [exactRes, wildcardRes] = await Promise.all([
requestedModels.length > 0
? api.getRouteDecisionsBatch(requestedModels, decisionRequestOptions)
: Promise.resolve({ decisions: {} }),
wildcardRouteIds.length > 0
? api.getRouteWideDecisionsBatch(wildcardRouteIds, decisionRequestOptions)
: Promise.resolve({ decisions: {} }),
]);
const decisionMap = (exactRes?.decisions || {}) as Record<string, RouteDecision | null>;
const wildcardDecisionMap = (wildcardRes?.decisions || {}) as Record<string, RouteDecision | null>;
const next = { ...defaultState };
for (const route of exactRoutes) {
next[route.id] = decisionMap[route.modelPattern] || null;
}
for (const routeId of wildcardRouteIds) {
next[routeId] = wildcardDecisionMap[String(routeId)] || null;
}
setDecisionByRoute(next);
} catch {
setDecisionByRoute(defaultState);
setDecisionAutoSkipped(false);
} finally {
setLoadingDecision(false);
}
};
const decisionRefreshWatchSeqRef = useRef(0);
const mountedRef = useRef(true);
const loadCandidates = (force?: boolean) => {
if (candidatesLoadedRef.current && !force) return;
@@ -281,9 +216,97 @@ export default function TokenRoutes() {
}
};
const loadRef = useRef(load);
loadRef.current = load;
const toastRef = useRef(toast);
toastRef.current = toast;
const monitorRouteDecisionRefreshTask = useCallback((taskId: string) => {
const normalizedTaskId = String(taskId || '').trim();
if (!normalizedTaskId) return;
const taskFetcher = (api as { getTask?: (id: string) => Promise<unknown> }).getTask;
if (typeof taskFetcher !== 'function') {
setLoadingDecision(false);
return;
}
const watchSeq = ++decisionRefreshWatchSeqRef.current;
setLoadingDecision(true);
setDecisionAutoSkipped(false);
void (async () => {
while (mountedRef.current && decisionRefreshWatchSeqRef.current === watchSeq) {
try {
const taskResponse = await taskFetcher(normalizedTaskId) as {
task?: { status?: string; message?: string; error?: string | null };
};
const task = taskResponse?.task;
if (!task) {
throw new Error('路由选中概率任务不存在');
}
const status = String(task.status || '').trim();
if (status === 'pending' || status === 'running') {
await new Promise((resolve) => setTimeout(resolve, 1200));
continue;
}
if (!mountedRef.current || decisionRefreshWatchSeqRef.current !== watchSeq) return;
await loadRef.current();
if (!mountedRef.current || decisionRefreshWatchSeqRef.current !== watchSeq) return;
setLoadingDecision(false);
if (status === 'succeeded') {
toastRef.current.success('路由选择概率已刷新');
} else {
toastRef.current.error(String(task.message || task.error || '刷新路由选择概率失败'));
}
return;
} catch (error: any) {
if (!mountedRef.current || decisionRefreshWatchSeqRef.current !== watchSeq) return;
setLoadingDecision(false);
toastRef.current.error(error?.message || '刷新路由选择概率失败');
return;
}
}
})();
}, []);
const resumeRouteDecisionRefreshTask = useCallback(async () => {
const tasksFetcher = (api as { getTasks?: (limit?: number) => Promise<unknown> }).getTasks;
if (typeof tasksFetcher !== 'function') {
setLoadingDecision(false);
return;
}
try {
const tasksResponse = await tasksFetcher(50) as {
tasks?: Array<{ id?: string; type?: string; status?: string }>;
};
const runningTask = Array.isArray(tasksResponse?.tasks)
? tasksResponse.tasks.find((task) => (
String(task?.type || '').trim() === ROUTE_DECISION_REFRESH_TASK_TYPE
&& (task?.status === 'pending' || task?.status === 'running')
))
: null;
const taskId = String(runningTask?.id || '').trim();
if (!taskId) {
setLoadingDecision(false);
return;
}
monitorRouteDecisionRefreshTask(taskId);
} catch {
setLoadingDecision(false);
}
}, [monitorRouteDecisionRefreshTask]);
useEffect(() => {
mountedRef.current = true;
(async () => {
try {
await resumeRouteDecisionRefreshTask();
await load();
} catch {
toast.error('加载路由配置失败');
@@ -292,7 +315,11 @@ export default function TokenRoutes() {
const scheduleIdle = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb: () => void) => setTimeout(cb, 0);
scheduleIdle(() => loadCandidates());
})();
}, []);
return () => {
mountedRef.current = false;
decisionRefreshWatchSeqRef.current += 1;
};
}, [resumeRouteDecisionRefreshTask, toast]);
const handleRebuild = async () => {
try {
@@ -318,10 +345,19 @@ export default function TokenRoutes() {
const handleRefreshRouteDecisions = async () => {
try {
await loadRouteDecisions(routeSummaries, { force: true, refreshPricingCatalog: true, persistSnapshots: true });
toast.success('路由选择概率已刷新');
} catch {
toast.error('刷新路由选择概率失败');
const response = await api.refreshRouteDecisionSnapshots() as {
message?: string;
jobId?: string;
};
const taskId = String(response?.jobId || '').trim();
if (!taskId) {
throw new Error('刷新任务未返回 taskId');
}
toast.info(response?.message || '已开始后台刷新路由选中概率,可稍后返回查看');
monitorRouteDecisionRefreshTask(taskId);
} catch (error: any) {
toast.error(error?.message || '刷新路由选择概率失败');
}
};
+32
View File
@@ -21,6 +21,7 @@ import { BrandGlyph, InlineBrandIcon, type BrandInfo } from '../../components/Br
import ModernSelect from '../../components/ModernSelect.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { tr } from '../../i18n.js';
import { formatDateTimeMinuteLocal } from '../helpers/checkinLogTime.js';
import type {
RouteSummaryRow,
RouteChannel,
@@ -240,6 +241,10 @@ function RouteCardInner({
const channelManagementDisabled = explicitGroupRoute;
const title = resolveRouteTitle(route);
const routingStrategy = normalizeRouteRoutingStrategyValue(route.routingStrategy);
const hasCachedDecisionSnapshot = !!route.decisionSnapshot;
const cachedDecisionTooltip = route.decisionRefreshedAt
? `${tr('最近刷新')}: ${formatDateTimeMinuteLocal(route.decisionRefreshedAt)}`
: undefined;
const routingStrategyOptions = [
{
value: 'weighted',
@@ -384,6 +389,15 @@ function RouteCardInner({
{route.channelCount} {tr('通道')}
</span>
)}
{hasCachedDecisionSnapshot ? (
<span
className="badge badge-success"
data-tooltip={cachedDecisionTooltip}
style={{ fontSize: 10, flexShrink: 0 }}
>
{tr('已缓存')}
</span>
) : null}
{readOnlyRoute ? (
<span className="badge badge-warning" style={{ fontSize: 10, flexShrink: 0 }}>
@@ -458,6 +472,15 @@ function RouteCardInner({
{route.channelCount} {tr('通道')}
</span>
)}
{hasCachedDecisionSnapshot ? (
<span
className="badge badge-success"
data-tooltip={cachedDecisionTooltip}
style={{ fontSize: 10 }}
>
{tr('已缓存')}
</span>
) : null}
{readOnlyRoute && (
<span className="badge badge-warning" style={{ fontSize: 10 }}>
{tr('0 通道')}
@@ -523,6 +546,15 @@ function RouteCardInner({
<span className="badge badge-info" style={{ fontSize: 10 }}>
{route.channelCount} {tr('通道')}
</span>
{hasCachedDecisionSnapshot ? (
<span
className="badge badge-success"
data-tooltip={cachedDecisionTooltip}
style={{ fontSize: 10 }}
>
{tr('已缓存')}
</span>
) : null}
{explicitGroupRoute && explicitGroupSourceCount > 0 ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{explicitGroupSourceCount} {tr('来源模型')}
@@ -1,13 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import { ROUTE_DECISION_REFRESH_TASK_TYPE } from '../../shared/tokenRouteContract.js';
import TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
getRoutesSummary: vi.fn(),
refreshRouteDecisionSnapshots: vi.fn(),
getRouteChannels: vi.fn(),
getTask: vi.fn(),
getTasks: vi.fn(),
getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
@@ -71,6 +75,9 @@ describe('TokenRoutes refresh decision action', () => {
},
]);
apiMock.getRouteChannels.mockResolvedValue([]);
apiMock.refreshRouteDecisionSnapshots.mockResolvedValue({ queued: true, jobId: 'task-1', status: 'pending' });
apiMock.getTasks.mockResolvedValue({ tasks: [] });
apiMock.getTask.mockResolvedValue({ task: { id: 'task-1', status: 'succeeded' } });
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
@@ -80,9 +87,39 @@ describe('TokenRoutes refresh decision action', () => {
vi.clearAllMocks();
});
it('passes refreshPricingCatalog and persistSnapshots when user clicks refresh selection probability', async () => {
let root!: WebTestRenderer;
it('queues a background snapshot refresh task when user clicks refresh selection probability', async () => {
let root!: ReactTestRenderer;
try {
apiMock.getRoutesSummary
.mockResolvedValueOnce([
{
id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 2, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-group',
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
])
.mockResolvedValueOnce([
{
id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: { matched: true, candidates: [] }, decisionRefreshedAt: '2026-04-01T00:00:00.000Z',
},
{
id: 2, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-group',
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: { matched: true, candidates: [] }, decisionRefreshedAt: '2026-04-01T00:00:00.000Z',
},
]);
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
@@ -99,9 +136,64 @@ describe('TokenRoutes refresh decision action', () => {
await refreshButton.props.onClick();
});
await flushMicrotasks();
await flushMicrotasks();
expect(apiMock.getRouteDecisionsBatch).toHaveBeenCalledWith(['gpt-4o-mini'], { refreshPricingCatalog: true, persistSnapshots: true });
expect(apiMock.getRouteWideDecisionsBatch).toHaveBeenCalledWith([2], { refreshPricingCatalog: true, persistSnapshots: true });
expect(apiMock.refreshRouteDecisionSnapshots).toHaveBeenCalledTimes(1);
expect(apiMock.getTask).toHaveBeenCalledWith('task-1');
expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
expect(apiMock.getRouteDecisionsBatch).not.toHaveBeenCalled();
expect(apiMock.getRouteWideDecisionsBatch).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
it('resumes a running background probability refresh task when revisiting the page', async () => {
let root!: ReactTestRenderer;
try {
apiMock.getRoutesSummary
.mockResolvedValueOnce([
{
id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
])
.mockResolvedValueOnce([
{
id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: { matched: true, candidates: [] }, decisionRefreshedAt: '2026-04-01T00:00:00.000Z',
},
]);
apiMock.getTasks.mockResolvedValue({
tasks: [
{
id: 'task-restore',
type: ROUTE_DECISION_REFRESH_TASK_TYPE,
status: 'running',
},
],
});
apiMock.getTask.mockResolvedValue({ task: { id: 'task-restore', status: 'succeeded' } });
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
await flushMicrotasks();
expect(apiMock.getTasks).toHaveBeenCalled();
expect(apiMock.getTask).toHaveBeenCalledWith('task-restore');
expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
} finally {
root?.unmount();
}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import TokenRoutes from './TokenRoutes.js';
@@ -113,7 +113,7 @@ describe('TokenRoutes cached snapshot load', () => {
});
it('shows cached probabilities immediately from getRoutes snapshot data', async () => {
let root!: WebTestRenderer;
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
@@ -126,6 +126,8 @@ describe('TokenRoutes cached snapshot load', () => {
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('已缓存');
// Expand the route card to see channel details with probability
const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
@@ -142,4 +144,38 @@ describe('TokenRoutes cached snapshot load', () => {
root?.unmount();
}
});
it('keeps showing the cached snapshot marker after revisiting the page', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('已缓存');
root.unmount();
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('已缓存');
} finally {
root?.unmount();
}
});
});