feat(server): add proactive channel probes and load-aware stable routing / 服务端新增主动探活与负载感知稳定路由 (#330)

* wip: preserve local changes before upstream merge

* Improve proxy routing stability and add stable-first strategy

* Sync explicit-group routing strategy to eligible source routes

* Fix proxy retry and runtime health review issues

* fix: make proxy channel retry attempts configurable

* fix: harden proxy retry recovery and bookkeeping

* fix: address pr 257 review comments

* feat(proxy): add sticky session channel coordination

* feat(server): add proactive channel probes and load-aware stable routing

* test(server): stabilize session load routing selection assertion

* fix(proxy): harden codex forwarding stability

* fix(router): rotate stable-first channels by healthy order

* fix(server): address probe review feedback

* fix(server): address probe review follow-ups

* fix(ci): address CodeQL and core test failures

* feat(server): refine stable-first routing recovery and observation

* Finish PR 330 settings and review fixes

* Fix chat site endpoint CI mock

* Address latest PR 330 review comments

---------

Co-authored-by: Cita <juricek.chen@gmail.com>
This commit is contained in:
Bainily
2026-04-02 09:49:04 +08:00
committed by GitHub
parent 1133846998
commit 596d1b93cb
75 changed files with 6365 additions and 507 deletions
@@ -0,0 +1,106 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
const { startModelAvailabilityProbeSchedulerMock, stopModelAvailabilityProbeSchedulerMock } = vi.hoisted(() => ({
startModelAvailabilityProbeSchedulerMock: vi.fn(),
stopModelAvailabilityProbeSchedulerMock: vi.fn(),
}));
vi.mock('../../services/modelAvailabilityProbeService.js', () => ({
startModelAvailabilityProbeScheduler: startModelAvailabilityProbeSchedulerMock,
stopModelAvailabilityProbeScheduler: stopModelAvailabilityProbeSchedulerMock,
}));
type ConfigModule = typeof import('../../config.js');
type DbModule = typeof import('../../db/index.js');
describe('settings model availability probe runtime setting', () => {
let app: FastifyInstance;
let config: ConfigModule['config'];
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-model-probe-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const configModule = await import('../../config.js');
const settingsRoutesModule = await import('./settings.js');
db = dbModule.db;
schema = dbModule.schema;
config = configModule.config;
app = Fastify();
await app.register(settingsRoutesModule.settingsRoutes);
});
beforeEach(async () => {
await db.delete(schema.settings).run();
config.modelAvailabilityProbeEnabled = false;
startModelAvailabilityProbeSchedulerMock.mockReset();
stopModelAvailabilityProbeSchedulerMock.mockReset();
});
afterAll(async () => {
await app.close();
rmSync(dataDir, { recursive: true, force: true });
delete process.env.DATA_DIR;
});
it('persists enabling the model availability probe and starts the scheduler', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
modelAvailabilityProbeEnabled: true,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { modelAvailabilityProbeEnabled?: boolean };
expect(updated.modelAvailabilityProbeEnabled).toBe(true);
expect(config.modelAvailabilityProbeEnabled).toBe(true);
expect(startModelAvailabilityProbeSchedulerMock).toHaveBeenCalledTimes(1);
expect(stopModelAvailabilityProbeSchedulerMock).not.toHaveBeenCalled();
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'model_availability_probe_enabled')).get();
expect(saved?.value).toBe(JSON.stringify(true));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
expect((getResponse.json() as { modelAvailabilityProbeEnabled?: boolean }).modelAvailabilityProbeEnabled).toBe(true);
});
it('persists disabling the model availability probe and stops the scheduler', async () => {
config.modelAvailabilityProbeEnabled = true;
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
modelAvailabilityProbeEnabled: false,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { modelAvailabilityProbeEnabled?: boolean };
expect(updated.modelAvailabilityProbeEnabled).toBe(false);
expect(config.modelAvailabilityProbeEnabled).toBe(false);
expect(stopModelAvailabilityProbeSchedulerMock).toHaveBeenCalledTimes(1);
expect(startModelAvailabilityProbeSchedulerMock).not.toHaveBeenCalled();
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'model_availability_probe_enabled')).get();
expect(saved?.value).toBe(JSON.stringify(false));
});
});
+39
View File
@@ -40,12 +40,17 @@ import { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyReque
import { performFactoryReset } from '../../services/factoryResetService.js';
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js';
import {
startModelAvailabilityProbeScheduler,
stopModelAvailabilityProbeScheduler,
} from '../../services/modelAvailabilityProbeService.js';
type RoutingWeights = typeof config.routingWeights;
interface RuntimeSettingsBody {
proxyToken?: string;
systemProxyUrl?: string;
modelAvailabilityProbeEnabled?: boolean;
codexUpstreamWebsocketEnabled?: boolean;
disableCrossProtocolFallback?: boolean;
proxySessionChannelConcurrencyLimit?: number;
@@ -404,6 +409,16 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.systemProxyUrl = normalizeSiteProxyUrl(value) || '';
return;
}
case 'model_availability_probe_enabled': {
if (typeof value !== 'boolean') return;
config.modelAvailabilityProbeEnabled = value;
if (value) {
startModelAvailabilityProbeScheduler();
} else {
stopModelAvailabilityProbeScheduler();
}
return;
}
case 'codex_upstream_websocket_enabled': {
if (typeof value !== 'boolean') return;
config.codexUpstreamWebsocketEnabled = value;
@@ -687,6 +702,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled,
logCleanupRetentionDays: config.logCleanupRetentionDays,
modelAvailabilityProbeEnabled: config.modelAvailabilityProbeEnabled,
codexUpstreamWebsocketEnabled: config.codexUpstreamWebsocketEnabled,
disableCrossProtocolFallback: config.disableCrossProtocolFallback,
proxySessionChannelConcurrencyLimit: config.proxySessionChannelConcurrencyLimit,
@@ -1101,6 +1117,29 @@ export async function settingsRoutes(app: FastifyInstance) {
invalidateSiteProxyCache();
}
if (body.modelAvailabilityProbeEnabled !== undefined) {
let nextValue = false;
try {
nextValue = parseBooleanFlag(body.modelAvailabilityProbeEnabled, '批量测活开关');
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || '批量测活开关格式无效',
});
}
if (nextValue !== config.modelAvailabilityProbeEnabled) {
changedLabels.push(nextValue ? '开启批量测活' : '关闭批量测活');
}
await upsertSetting('model_availability_probe_enabled', nextValue);
config.modelAvailabilityProbeEnabled = nextValue;
if (nextValue) {
startModelAvailabilityProbeScheduler();
} else {
stopModelAvailabilityProbeScheduler();
}
}
if (body.codexUpstreamWebsocketEnabled !== undefined) {
let nextValue = false;
try {
@@ -0,0 +1,157 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const buildModelAvailabilityProbeTaskDedupeKeyMock = vi.fn();
const queueModelAvailabilityProbeTaskMock = vi.fn();
const getBackgroundTaskMock = vi.fn();
const getRunningTaskByDedupeKeyMock = vi.fn();
const waitForBackgroundTaskCompletionMock = vi.fn();
vi.mock('../../services/modelAvailabilityProbeService.js', async () => {
const actual = await vi.importActual<typeof import('../../services/modelAvailabilityProbeService.js')>('../../services/modelAvailabilityProbeService.js');
return {
...actual,
buildModelAvailabilityProbeTaskDedupeKey: (...args: unknown[]) => buildModelAvailabilityProbeTaskDedupeKeyMock(...args),
queueModelAvailabilityProbeTask: (...args: unknown[]) => queueModelAvailabilityProbeTaskMock(...args),
};
});
vi.mock('../../services/backgroundTaskService.js', async () => {
const actual = await vi.importActual<typeof import('../../services/backgroundTaskService.js')>('../../services/backgroundTaskService.js');
return {
...actual,
getBackgroundTask: (...args: unknown[]) => getBackgroundTaskMock(...args),
getRunningTaskByDedupeKey: (...args: unknown[]) => getRunningTaskByDedupeKeyMock(...args),
waitForBackgroundTaskCompletion: (...args: unknown[]) => waitForBackgroundTaskCompletionMock(...args),
};
});
describe('/api/models/probe', () => {
let app: FastifyInstance;
let dataDir = '';
let originalDataDir: string | undefined;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-stats-model-probe-'));
originalDataDir = process.env.DATA_DIR;
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const routesModule = await import('./stats.js');
app = Fastify();
await app.register(routesModule.statsRoutes);
});
beforeEach(() => {
buildModelAvailabilityProbeTaskDedupeKeyMock.mockReset();
queueModelAvailabilityProbeTaskMock.mockReset();
getBackgroundTaskMock.mockReset();
getRunningTaskByDedupeKeyMock.mockReset();
waitForBackgroundTaskCompletionMock.mockReset();
});
afterAll(async () => {
await app.close();
if (originalDataDir === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = originalDataDir;
}
});
it('rejects non-object request bodies instead of defaulting to a full probe', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/models/probe',
headers: {
'content-type': 'application/json',
},
payload: '"oops"',
});
expect(response.statusCode).toBe(400);
expect(queueModelAvailabilityProbeTaskMock).not.toHaveBeenCalled();
});
it('rejects loosely formatted account ids instead of truncating them', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/models/probe',
payload: {
accountId: '1e2',
},
});
expect(response.statusCode).toBe(400);
expect(queueModelAvailabilityProbeTaskMock).not.toHaveBeenCalled();
});
it('rejects account ids that exceed the safe integer range', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/models/probe',
payload: {
accountId: '9007199254740993',
},
});
expect(response.statusCode).toBe(400);
expect(queueModelAvailabilityProbeTaskMock).not.toHaveBeenCalled();
});
it('reuses the running deduped task for wait=true requests', async () => {
buildModelAvailabilityProbeTaskDedupeKeyMock.mockReturnValue('model-availability-probe-7');
getRunningTaskByDedupeKeyMock.mockReturnValue({
id: 'task-7',
status: 'running',
});
const completedTask = {
id: 'task-7',
status: 'succeeded',
result: {
results: [],
summary: {
totalAccounts: 1,
success: 1,
failed: 0,
skipped: 0,
scanned: 3,
supported: 2,
unsupported: 1,
inconclusive: 0,
skippedModels: 0,
updatedRows: 1,
rebuiltRoutes: true,
},
},
};
waitForBackgroundTaskCompletionMock.mockResolvedValue(completedTask);
const response = await app.inject({
method: 'POST',
url: '/api/models/probe',
payload: {
accountId: 7,
wait: true,
},
});
expect(response.statusCode).toBe(200);
expect(queueModelAvailabilityProbeTaskMock).not.toHaveBeenCalled();
expect(waitForBackgroundTaskCompletionMock).toHaveBeenCalledWith('task-7');
expect(response.json()).toMatchObject({
success: true,
reused: true,
jobId: 'task-7',
summary: {
totalAccounts: 1,
scanned: 3,
supported: 2,
unsupported: 1,
},
});
});
});
+101 -1
View File
@@ -6,8 +6,18 @@ import { refreshModelsForAccount } from '../../services/modelService.js';
import * as routeRefreshWorkflow from '../../services/routeRefreshWorkflow.js';
import { buildModelAnalysis } from '../../services/modelAnalysisService.js';
import { fallbackTokenCost, fetchModelPricingCatalog } from '../../services/modelPricingService.js';
import {
buildModelAvailabilityProbeTaskDedupeKey,
queueModelAvailabilityProbeTask,
type ModelAvailabilityProbeExecutionResult,
} from '../../services/modelAvailabilityProbeService.js';
import { getUpstreamModelDescriptionsCached } from '../../services/upstreamModelDescriptionService.js';
import { getRunningTaskByDedupeKey, startBackgroundTask } from '../../services/backgroundTaskService.js';
import {
getBackgroundTask,
getRunningTaskByDedupeKey,
startBackgroundTask,
waitForBackgroundTaskCompletion,
} from '../../services/backgroundTaskService.js';
import { parseCheckinRewardAmount } from '../../services/checkinRewardParser.js';
import { estimateRewardWithTodayIncomeFallback } from '../../services/todayIncomeRewardService.js';
import {
@@ -39,6 +49,10 @@ function parseBooleanFlag(raw?: string): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
const limitModelTokenCandidatesRead = createRateLimitGuard({
@@ -1462,6 +1476,92 @@ export async function statsRoutes(app: FastifyInstance) {
return { success: true, refresh, rebuild };
});
app.post<{ Body?: { accountId?: number; wait?: boolean } }>('/api/models/probe', async (request, reply) => {
const requestBody = request.body;
if (requestBody !== undefined && !isRecord(requestBody)) {
return reply.code(400).send({ success: false, message: '请求体必须是对象' });
}
const rawAccountId = requestBody?.accountId as unknown;
const normalizedAccountId = rawAccountId === undefined || rawAccountId === null
? ''
: String(rawAccountId).trim();
const hasAccountId = normalizedAccountId !== '';
const parsedAccountId = hasAccountId && /^[1-9]\d*$/.test(normalizedAccountId)
? Number(normalizedAccountId)
: undefined;
const accountId = parsedAccountId !== undefined && Number.isSafeInteger(parsedAccountId)
? parsedAccountId
: undefined;
const wait = requestBody?.wait === true;
if (hasAccountId && accountId === undefined) {
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
}
if (wait) {
const taskTitle = accountId ? `探测模型可用性 #${accountId}` : '探测全部模型可用性';
const dedupeKey = buildModelAvailabilityProbeTaskDedupeKey(accountId);
const runningTask = getRunningTaskByDedupeKey(dedupeKey);
const { task, reused } = runningTask
? { task: runningTask, reused: true }
: queueModelAvailabilityProbeTask({
accountId,
title: taskTitle,
});
const completedTask = await waitForBackgroundTaskCompletion(task.id);
if (!completedTask) {
return reply.code(500).send({ success: false, message: '模型可用性探测任务不存在或已过期' });
}
if (completedTask.status === 'failed') {
return reply.code(500).send({
success: false,
reused,
jobId: completedTask.id,
status: completedTask.status,
message: completedTask.error || '模型可用性探测失败',
});
}
const result = completedTask.result as ModelAvailabilityProbeExecutionResult | null;
if (!result) {
return reply.code(500).send({
success: false,
reused,
jobId: completedTask.id,
status: completedTask.status,
message: '模型可用性探测结果为空',
});
}
if (accountId && result.summary.totalAccounts === 0) {
return reply.code(404).send({ success: false, message: '账号不存在' });
}
return {
success: true,
reused,
jobId: completedTask.id,
status: completedTask.status,
...result,
};
}
const taskTitle = accountId ? `探测模型可用性 #${accountId}` : '探测全部模型可用性';
const { task, reused } = queueModelAvailabilityProbeTask({
accountId,
title: taskTitle,
});
return reply.code(202).send({
success: true,
queued: true,
reused,
jobId: task.id,
status: task.status,
message: reused
? '模型可用性探测任务进行中,请稍后查看任务列表'
: '已开始模型可用性探测,请稍后查看任务列表',
});
});
// Site distribution – per-site aggregate data
app.get('/api/stats/site-distribution', async () => {
const accountRows = await db.select({