[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
@@ -0,0 +1,199 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
type DbModule = typeof import('../../db/index.js');
type BackgroundTaskModule = typeof import('../../services/backgroundTaskService.js');
type TokenRouterModule = typeof import('../../services/tokenRouter.js');
describe('POST /api/routes/decision/refresh', () => {
let app: FastifyInstance;
let previousDataDir: string | undefined;
let db: DbModule['db'];
let schema: DbModule['schema'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let tokenRouter: TokenRouterModule['tokenRouter'];
let getBackgroundTask: BackgroundTaskModule['getBackgroundTask'];
let resetBackgroundTasks: BackgroundTaskModule['__resetBackgroundTasksForTests'];
let dataDir = '';
beforeAll(async () => {
previousDataDir = process.env.DATA_DIR;
vi.resetModules();
dataDir = mkdtempSync(join(tmpdir(), 'metapi-route-decision-refresh-task-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./tokens.js');
const tokenRouterModule = await import('../../services/tokenRouter.js');
const backgroundTaskModule = await import('../../services/backgroundTaskService.js');
db = dbModule.db;
schema = dbModule.schema;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
tokenRouter = tokenRouterModule.tokenRouter;
getBackgroundTask = backgroundTaskModule.getBackgroundTask;
resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests;
vi.spyOn(tokenRouter, 'refreshPricingReferenceCosts').mockResolvedValue(undefined);
vi.spyOn(tokenRouter, 'refreshRouteWidePricingReferenceCosts').mockResolvedValue(undefined);
app = Fastify();
await app.register(routesModule.tokensRoutes);
});
beforeEach(async () => {
resetBackgroundTasks();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
await db.delete(schema.settings).run();
await db.delete(schema.events).run();
invalidateTokenRouterCache();
});
afterAll(async () => {
await app.close();
invalidateTokenRouterCache();
vi.restoreAllMocks();
if (dataDir) {
try {
rmSync(dataDir, { recursive: true, force: true });
} catch {}
}
if (previousDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = previousDataDir;
}
vi.resetModules();
});
it('queues a background refresh task and persists refreshed snapshots for exact and wildcard routes', async () => {
const site = await db.insert(schema.sites).values({
name: 'refresh-task-site',
url: 'https://refresh-task-site.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'refresh-task-user',
accessToken: 'refresh-task-access',
apiToken: 'refresh-task-api',
status: 'active',
}).returning().get();
const exactRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
const duplicateExactRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-4o-mini',
enabled: true,
}).returning().get();
const wildcardRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
enabled: true,
}).returning().get();
const disabledRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'disabled-model',
enabled: false,
}).returning().get();
await db.insert(schema.routeChannels).values([
{
routeId: exactRoute.id,
accountId: account.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
},
{
routeId: duplicateExactRoute.id,
accountId: account.id,
tokenId: null,
sourceModel: 'gpt-4o-mini',
priority: 0,
weight: 5,
enabled: true,
},
{
routeId: wildcardRoute.id,
accountId: account.id,
tokenId: null,
sourceModel: 'claude-opus-4-6',
priority: 0,
weight: 10,
enabled: true,
},
{
routeId: disabledRoute.id,
accountId: account.id,
tokenId: null,
priority: 0,
weight: 10,
enabled: true,
},
]).run();
const response = await app.inject({
method: 'POST',
url: '/api/routes/decision/refresh',
payload: {},
});
expect(response.statusCode).toBe(202);
const body = response.json() as {
success: boolean;
queued: boolean;
jobId: string;
status: string;
};
expect(body.success).toBe(true);
expect(body.queued).toBe(true);
expect(body.jobId.length).toBeGreaterThan(10);
expect(body.status).toBe('pending');
for (let attempt = 0; attempt < 200; attempt += 1) {
const task = getBackgroundTask(body.jobId);
if (task?.status === 'succeeded') break;
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(getBackgroundTask(body.jobId)).toMatchObject({ status: 'succeeded' });
const routesResponse = await app.inject({
method: 'GET',
url: '/api/routes',
});
expect(routesResponse.statusCode).toBe(200);
const routes = routesResponse.json() as Array<{
id: number;
decisionSnapshot?: { matched?: boolean; routeId?: number; candidates?: Array<unknown> } | null;
}>;
const refreshedExactRoute = routes.find((route) => route.id === exactRoute.id);
const refreshedDuplicateExactRoute = routes.find((route) => route.id === duplicateExactRoute.id);
const refreshedWildcardRoute = routes.find((route) => route.id === wildcardRoute.id);
const refreshedDisabledRoute = routes.find((route) => route.id === disabledRoute.id);
expect(refreshedExactRoute?.decisionSnapshot?.matched).toBe(true);
expect(refreshedExactRoute?.decisionSnapshot?.routeId).toBe(exactRoute.id);
expect(Array.isArray(refreshedExactRoute?.decisionSnapshot?.candidates)).toBe(true);
expect(refreshedDuplicateExactRoute?.decisionSnapshot?.matched).toBe(true);
expect(refreshedDuplicateExactRoute?.decisionSnapshot?.routeId).toBe(duplicateExactRoute.id);
expect(refreshedWildcardRoute?.decisionSnapshot?.matched).toBe(true);
expect(refreshedWildcardRoute?.decisionSnapshot?.routeId).toBe(wildcardRoute.id);
expect(refreshedDisabledRoute?.decisionSnapshot).toBeNull();
});
});
+54 -10
View File
@@ -13,7 +13,7 @@ import {
type RouteRoutingStrategy, type RouteRoutingStrategy,
} from '../../services/routeRoutingStrategy.js'; } from '../../services/routeRoutingStrategy.js';
import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { appendBackgroundTaskLog, startBackgroundTask } from '../../services/backgroundTaskService.js';
import { import {
clearRouteDecisionSnapshot, clearRouteDecisionSnapshot,
clearRouteDecisionSnapshots, clearRouteDecisionSnapshots,
@@ -21,6 +21,11 @@ import {
saveRouteDecisionSnapshots, saveRouteDecisionSnapshots,
} from '../../services/routeDecisionSnapshotStore.js'; } from '../../services/routeDecisionSnapshotStore.js';
import { clearRouteCooldown } from '../../services/routeCooldownService.js'; import { clearRouteCooldown } from '../../services/routeCooldownService.js';
import {
refreshAllRouteDecisionSnapshots,
ROUTE_DECISION_REFRESH_DEDUPE_KEY,
ROUTE_DECISION_REFRESH_TASK_TYPE,
} from '../../services/routeDecisionRefreshService.js';
import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js'; import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js';
import { import {
parseRouteChannelBatchCreatePayload, parseRouteChannelBatchCreatePayload,
@@ -945,11 +950,11 @@ export async function tokensRoutes(app: FastifyInstance) {
return { success: true, decisions }; return { success: true, decisions };
}); });
app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => { app.post<{ Body: BatchRouteWideDecisionRouteIds }>('/api/routes/decision/route-wide/batch', async (request, reply) => {
const parsed = parseBatchRouteWideDecisionRouteIds(request.body); const parsed = parseBatchRouteWideDecisionRouteIds(request.body);
if (!parsed.ok) { if (!parsed.ok) {
return reply.code(400).send({ success: false, message: parsed.message }); return reply.code(400).send({ success: false, message: parsed.message });
} }
const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionRouteWide>>> = {}; const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionRouteWide>>> = {};
const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined; const refreshedKeys = parsed.refreshPricingCatalog ? new Set<string>() : undefined;
@@ -966,10 +971,49 @@ export async function tokensRoutes(app: FastifyInstance) {
snapshot: decisions[String(routeId)] ?? null, snapshot: decisions[String(routeId)] ?? null,
}))); })));
} }
return { success: true, decisions }; return { success: true, decisions };
}); });
app.post('/api/routes/decision/refresh', async (_request, reply) => {
let taskId = '';
const { task, reused } = startBackgroundTask(
{
type: ROUTE_DECISION_REFRESH_TASK_TYPE,
title: '刷新路由选中概率',
dedupeKey: ROUTE_DECISION_REFRESH_DEDUPE_KEY,
successMessage: (currentTask) => {
const result = currentTask.result as { exactModelCount?: number; wildcardRouteCount?: number } | null;
const exactModelCount = result?.exactModelCount ?? 0;
const wildcardRouteCount = result?.wildcardRouteCount ?? 0;
return `路由选中概率刷新完成:精确模型 ${exactModelCount},通配符路由 ${wildcardRouteCount}`;
},
failureMessage: (currentTask) => `路由选中概率刷新失败:${currentTask.error || 'unknown error'}`,
},
async () => {
await Promise.resolve();
return await refreshAllRouteDecisionSnapshots({
refreshPricingCatalog: true,
onProgress: (message) => {
appendBackgroundTaskLog(taskId, message);
},
});
},
);
taskId = task.id;
return reply.code(202).send({
success: true,
queued: true,
reused,
jobId: task.id,
status: task.status,
message: reused
? '路由选中概率刷新任务执行中,可稍后返回查看'
: '已开始后台刷新路由选中概率,可稍后返回查看',
});
});
// Create a route // Create a route
app.post<{ Body: unknown }>('/api/routes', async (request, reply) => { app.post<{ Body: unknown }>('/api/routes', async (request, reply) => {
const parsedBody = parseTokenRouteCreatePayload(request.body); const parsedBody = parseTokenRouteCreatePayload(request.body);
@@ -0,0 +1,103 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { saveRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
import { matchesModelPattern, tokenRouter } from './tokenRouter.js';
import { ROUTE_DECISION_REFRESH_TASK_TYPE } from '../../shared/tokenRouteContract.js';
export { ROUTE_DECISION_REFRESH_TASK_TYPE };
export const ROUTE_DECISION_REFRESH_DEDUPE_KEY = 'refresh-route-decision-snapshots';
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?]/.test(normalized);
}
function normalizeModels(models: string[]): string[] {
return Array.from(new Set(
models
.map((model) => String(model || '').trim())
.filter(Boolean),
));
}
function normalizeRouteIds(routeIds: number[]): number[] {
return Array.from(new Set(
routeIds
.map((routeId) => Math.trunc(routeId))
.filter((routeId) => routeId > 0),
));
}
type RefreshOptions = {
refreshPricingCatalog?: boolean;
onProgress?: (message: string) => void;
};
export async function refreshAllRouteDecisionSnapshots(options: RefreshOptions = {}): Promise<{
exactModelCount: number;
wildcardRouteCount: number;
}> {
const routes = await db.select({
id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
}).from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.enabled, true))
.all();
const exactModels = normalizeModels(
routes
.filter((route) => isExactModelPattern(route.modelPattern))
.map((route) => route.modelPattern),
);
const wildcardRouteIds = normalizeRouteIds(
routes
.filter((route) => !isExactModelPattern(route.modelPattern))
.map((route) => route.id),
);
const refreshedKeys = options.refreshPricingCatalog ? new Set<string>() : undefined;
options.onProgress?.(`开始刷新路由概率:精确模型 ${exactModels.length},通配符路由 ${wildcardRouteIds.length}`);
for (const [index, model] of exactModels.entries()) {
options.onProgress?.(`刷新精确模型概率 ${index + 1}/${exactModels.length}:${model}`);
const matchingRoutes = routes.filter((route) => (
isExactModelPattern(route.modelPattern) && matchesModelPattern(model, route.modelPattern)
));
const snapshotWrites: Array<{ routeId: number; snapshot: unknown }> = [];
for (const route of matchingRoutes) {
if (options.refreshPricingCatalog) {
await tokenRouter.refreshPricingReferenceCostsForRoute(route.id, model, { refreshedKeys });
}
const decision = await tokenRouter.explainSelectionForRoute(route.id, model);
snapshotWrites.push({
routeId: route.id,
snapshot: decision,
});
}
if (snapshotWrites.length > 0) {
await saveRouteDecisionSnapshots(snapshotWrites);
}
}
for (const [index, routeId] of wildcardRouteIds.entries()) {
options.onProgress?.(`刷新通配符路由概率 ${index + 1}/${wildcardRouteIds.length}:#${routeId}`);
if (options.refreshPricingCatalog) {
await tokenRouter.refreshRouteWidePricingReferenceCosts(routeId, { refreshedKeys });
}
const decision = await tokenRouter.explainSelectionRouteWide(routeId);
await saveRouteDecisionSnapshots([
{
routeId,
snapshot: decision,
},
]);
}
return {
exactModelCount: exactModels.length,
wildcardRouteCount: wildcardRouteIds.length,
};
}
+1
View File
@@ -1,3 +1,4 @@
export declare const ROUTE_DECISION_REFRESH_TASK_TYPE = "route-decision.refresh";
export type RouteMode = 'pattern' | 'explicit_group'; export type RouteMode = 'pattern' | 'explicit_group';
export type RouteDecisionCandidate = { export type RouteDecisionCandidate = {
channelId: number; channelId: number;
+2
View File
@@ -1,3 +1,5 @@
export const ROUTE_DECISION_REFRESH_TASK_TYPE = 'route-decision.refresh';
export function normalizeTokenRouteMode(routeMode) { export function normalizeTokenRouteMode(routeMode) {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
} }
+6
View File
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
describe('token route contract', () => { describe('token route contract', () => {
it('exports the route decision refresh task type', async () => {
const { ROUTE_DECISION_REFRESH_TASK_TYPE } = await import('./tokenRouteContract.js');
expect(ROUTE_DECISION_REFRESH_TASK_TYPE).toBe('route-decision.refresh');
});
it('normalizes unknown route modes to pattern', async () => { it('normalizes unknown route modes to pattern', async () => {
const { normalizeTokenRouteMode } = await import('./tokenRouteContract.js'); const { normalizeTokenRouteMode } = await import('./tokenRouteContract.js');
+4
View File
@@ -731,6 +731,10 @@ export const api = {
body: JSON.stringify({ refreshModels, ...(wait ? { wait: true } : {}) }), body: JSON.stringify({ refreshModels, ...(wait ? { wait: true } : {}) }),
timeoutMs: wait ? 150_000 : 30_000, 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)}`), getRouteDecision: (model: string) => request(`/api/routes/decision?model=${encodeURIComponent(model)}`),
getRouteDecisionsBatch: (models: string[], options?: { refreshPricingCatalog?: boolean; persistSnapshots?: boolean }) => request('/api/routes/decision/batch', { getRouteDecisionsBatch: (models: string[], options?: { refreshPricingCatalog?: boolean; persistSnapshots?: boolean }) => request('/api/routes/decision/batch', {
method: 'POST', method: 'POST',
+2
View File
@@ -455,6 +455,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'生成已取消。': 'The build was canceled.', '生成已取消。': 'The build was canceled.',
'生成已停止。': 'The build has stopped.', '生成已停止。': 'The build has stopped.',
'生成中...': 'Generating...', '生成中...': 'Generating...',
'已缓存': 'Cached',
'失败': 'fail', '失败': 'fail',
'失败 (${countBy(\'failed\')})': 'Failure (${countBy(\'failed\')})', '失败 (${countBy(\'failed\')})': 'Failure (${countBy(\'failed\')})',
'失败避让': 'failure avoidance', '失败避让': 'failure avoidance',
@@ -740,6 +741,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'自动重建': 'automatic rebuild', '自动重建': 'automatic rebuild',
'自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)': 'Automatic reconstruction completed (new ${createdRoutes} routes / ${createdChannels} channels)', '自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)': 'Automatic reconstruction completed (new ${createdRoutes} routes / ${createdChannels} channels)',
'最大 Token 数': 'Maximum number of tokens', '最大 Token 数': 'Maximum number of tokens',
'最近刷新': 'Last refreshed',
'最近健康检查失败': 'Recent health check failed', '最近健康检查失败': 'Recent health check failed',
'最近失败,优先避让(${RECENT_FAILURE_AVOID_SEC / 60} 分钟窗口)': 'Recent failure, priority avoidance (${RECENT_FAILURE_AVOID_SEC / 60} minute window)', '最近失败,优先避让(${RECENT_FAILURE_AVOID_SEC / 60} 分钟窗口)': 'Recent failure, priority avoidance (${RECENT_FAILURE_AVOID_SEC / 60} minute window)',
'最近一次检查失败': 'The latest check failed', '最近一次检查失败': '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 ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
import { useIsMobile } from '../components/useIsMobile.js'; import { useIsMobile } from '../components/useIsMobile.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
import { ROUTE_DECISION_REFRESH_TASK_TYPE } from '../../shared/tokenRouteContract.js';
import { import {
buildRouteModelCandidatesIndex, buildRouteModelCandidatesIndex,
type RouteCandidateView, type RouteCandidateView,
@@ -42,7 +43,6 @@ import type {
GroupRouteItem, GroupRouteItem,
} from './token-routes/types.js'; } from './token-routes/types.js';
import { import {
AUTO_ROUTE_DECISION_LIMIT,
ROUTE_RENDER_CHUNK, ROUTE_RENDER_CHUNK,
isExplicitGroupRoute, isExplicitGroupRoute,
isExactModelPattern, isExactModelPattern,
@@ -161,73 +161,8 @@ export default function TokenRoutes() {
const candidatesPromiseRef = useRef<Promise<void> | null>(null); const candidatesPromiseRef = useRef<Promise<void> | null>(null);
const candidatesVersionRef = useRef(0); const candidatesVersionRef = useRef(0);
const candidatesSeqRef = useRef(0); const candidatesSeqRef = useRef(0);
const decisionRefreshWatchSeqRef = useRef(0);
const loadRouteDecisions = async ( const mountedRef = useRef(true);
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 loadCandidates = (force?: boolean) => { const loadCandidates = (force?: boolean) => {
if (candidatesLoadedRef.current && !force) return; 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(() => { useEffect(() => {
mountedRef.current = true;
(async () => { (async () => {
try { try {
await resumeRouteDecisionRefreshTask();
await load(); await load();
} catch { } catch {
toast.error('加载路由配置失败'); toast.error('加载路由配置失败');
@@ -292,7 +315,11 @@ export default function TokenRoutes() {
const scheduleIdle = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb: () => void) => setTimeout(cb, 0); const scheduleIdle = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb: () => void) => setTimeout(cb, 0);
scheduleIdle(() => loadCandidates()); scheduleIdle(() => loadCandidates());
})(); })();
}, []); return () => {
mountedRef.current = false;
decisionRefreshWatchSeqRef.current += 1;
};
}, [resumeRouteDecisionRefreshTask, toast]);
const handleRebuild = async () => { const handleRebuild = async () => {
try { try {
@@ -318,10 +345,19 @@ export default function TokenRoutes() {
const handleRefreshRouteDecisions = async () => { const handleRefreshRouteDecisions = async () => {
try { try {
await loadRouteDecisions(routeSummaries, { force: true, refreshPricingCatalog: true, persistSnapshots: true }); const response = await api.refreshRouteDecisionSnapshots() as {
toast.success('路由选择概率已刷新'); message?: string;
} catch { jobId?: string;
toast.error('刷新路由选择概率失败'); };
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 ModernSelect from '../../components/ModernSelect.js';
import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js'; import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
import { tr } from '../../i18n.js'; import { tr } from '../../i18n.js';
import { formatDateTimeMinuteLocal } from '../helpers/checkinLogTime.js';
import type { import type {
RouteSummaryRow, RouteSummaryRow,
RouteChannel, RouteChannel,
@@ -240,6 +241,10 @@ function RouteCardInner({
const channelManagementDisabled = explicitGroupRoute; const channelManagementDisabled = explicitGroupRoute;
const title = resolveRouteTitle(route); const title = resolveRouteTitle(route);
const routingStrategy = normalizeRouteRoutingStrategyValue(route.routingStrategy); const routingStrategy = normalizeRouteRoutingStrategyValue(route.routingStrategy);
const hasCachedDecisionSnapshot = !!route.decisionSnapshot;
const cachedDecisionTooltip = route.decisionRefreshedAt
? `${tr('最近刷新')}: ${formatDateTimeMinuteLocal(route.decisionRefreshedAt)}`
: undefined;
const routingStrategyOptions = [ const routingStrategyOptions = [
{ {
value: 'weighted', value: 'weighted',
@@ -384,6 +389,15 @@ function RouteCardInner({
{route.channelCount} {tr('通道')} {route.channelCount} {tr('通道')}
</span> </span>
)} )}
{hasCachedDecisionSnapshot ? (
<span
className="badge badge-success"
data-tooltip={cachedDecisionTooltip}
style={{ fontSize: 10, flexShrink: 0 }}
>
{tr('已缓存')}
</span>
) : null}
{readOnlyRoute ? ( {readOnlyRoute ? (
<span className="badge badge-warning" style={{ fontSize: 10, flexShrink: 0 }}> <span className="badge badge-warning" style={{ fontSize: 10, flexShrink: 0 }}>
@@ -458,6 +472,15 @@ function RouteCardInner({
{route.channelCount} {tr('通道')} {route.channelCount} {tr('通道')}
</span> </span>
)} )}
{hasCachedDecisionSnapshot ? (
<span
className="badge badge-success"
data-tooltip={cachedDecisionTooltip}
style={{ fontSize: 10 }}
>
{tr('已缓存')}
</span>
) : null}
{readOnlyRoute && ( {readOnlyRoute && (
<span className="badge badge-warning" style={{ fontSize: 10 }}> <span className="badge badge-warning" style={{ fontSize: 10 }}>
{tr('0 通道')} {tr('0 通道')}
@@ -523,6 +546,15 @@ function RouteCardInner({
<span className="badge badge-info" style={{ fontSize: 10 }}> <span className="badge badge-info" style={{ fontSize: 10 }}>
{route.channelCount} {tr('通道')} {route.channelCount} {tr('通道')}
</span> </span>
{hasCachedDecisionSnapshot ? (
<span
className="badge badge-success"
data-tooltip={cachedDecisionTooltip}
style={{ fontSize: 10 }}
>
{tr('已缓存')}
</span>
) : null}
{explicitGroupRoute && explicitGroupSourceCount > 0 ? ( {explicitGroupRoute && explicitGroupSourceCount > 0 ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}> <span className="badge badge-muted" style={{ fontSize: 10 }}>
{explicitGroupSourceCount} {tr('来源模型')} {explicitGroupSourceCount} {tr('来源模型')}
@@ -1,13 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 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 { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js'; import { ToastProvider } from '../components/Toast.js';
import { ROUTE_DECISION_REFRESH_TASK_TYPE } from '../../shared/tokenRouteContract.js';
import TokenRoutes from './TokenRoutes.js'; import TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({ const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: { apiMock: {
getRoutesSummary: vi.fn(), getRoutesSummary: vi.fn(),
refreshRouteDecisionSnapshots: vi.fn(),
getRouteChannels: vi.fn(), getRouteChannels: vi.fn(),
getTask: vi.fn(),
getTasks: vi.fn(),
getModelTokenCandidates: vi.fn(), getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(), getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(), getRouteWideDecisionsBatch: vi.fn(),
@@ -71,6 +75,9 @@ describe('TokenRoutes refresh decision action', () => {
}, },
]); ]);
apiMock.getRouteChannels.mockResolvedValue([]); 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.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} }); apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
@@ -80,9 +87,39 @@ describe('TokenRoutes refresh decision action', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('passes refreshPricingCatalog and persistSnapshots when user clicks refresh selection probability', async () => { it('queues a background snapshot refresh task when user clicks refresh selection probability', async () => {
let root!: WebTestRenderer; let root!: ReactTestRenderer;
try { 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 () => { await act(async () => {
root = create( root = create(
<MemoryRouter initialEntries={['/routes']}> <MemoryRouter initialEntries={['/routes']}>
@@ -99,9 +136,64 @@ describe('TokenRoutes refresh decision action', () => {
await refreshButton.props.onClick(); await refreshButton.props.onClick();
}); });
await flushMicrotasks(); await flushMicrotasks();
await flushMicrotasks();
expect(apiMock.getRouteDecisionsBatch).toHaveBeenCalledWith(['gpt-4o-mini'], { refreshPricingCatalog: true, persistSnapshots: true }); expect(apiMock.refreshRouteDecisionSnapshots).toHaveBeenCalledTimes(1);
expect(apiMock.getRouteWideDecisionsBatch).toHaveBeenCalledWith([2], { refreshPricingCatalog: true, persistSnapshots: true }); 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 { } finally {
root?.unmount(); root?.unmount();
} }
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 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 { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js'; import { ToastProvider } from '../components/Toast.js';
import TokenRoutes from './TokenRoutes.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 () => { it('shows cached probabilities immediately from getRoutes snapshot data', async () => {
let root!: WebTestRenderer; let root!: ReactTestRenderer;
try { try {
await act(async () => { await act(async () => {
root = create( root = create(
@@ -126,6 +126,8 @@ describe('TokenRoutes cached snapshot load', () => {
}); });
await flushMicrotasks(); await flushMicrotasks();
expect(collectText(root.root)).toContain('已缓存');
// Expand the route card to see channel details with probability // Expand the route card to see channel details with probability
const expandBtn = root.root.find((node) => const expandBtn = root.root.find((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'), node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
@@ -142,4 +144,38 @@ describe('TokenRoutes cached snapshot load', () => {
root?.unmount(); 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();
}
});
}); });