[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:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
+1
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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 || '刷新路由选择概率失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user