forked from boos/BoosAPI
feat(sites): per-site model probe with streaming logs and auto-disable
- Move post-refresh probe settings from global config to per-site (sites table) - Add manual 'probe now' button in site editor with real-time SSE log stream - Use fetch+ReadableStream instead of EventSource to support Authorization header - Concurrent probe execution (default 10 workers) via worker-pool pattern - Add stop button to cancel in-flight probe while preserving completed results - Add latency threshold: auto-disable models exceeding response time limit - Treat inconclusive probe results as unsupported (auto-disable) - Show specific failure reason in logs (timeout, no token, model not found, etc.) - Refresh and display post-probe model status list (green=available, red=disabled) - Remove probe settings from global Settings page and RuntimeSettingsPayload
This commit is contained in:
@@ -702,6 +702,10 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||
config.tokenRouterFailureCooldownMaxSec = normalized;
|
||||
return;
|
||||
}
|
||||
case 'post_refresh_probe_enabled':
|
||||
case 'post_refresh_probe_model':
|
||||
case 'post_refresh_probe_scope':
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js';
|
||||
import { normalizeSiteApiEndpointBaseUrl } from '../../services/siteApiEndpointService.js';
|
||||
import { analyzePrimarySiteUrl } from '../../../shared/sitePrimaryUrl.js';
|
||||
import { probeSiteModels } from '../../services/modelService.js';
|
||||
|
||||
function sseWrite(raw: import('http').ServerResponse, event: string, data: unknown) {
|
||||
try { raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||
if (input === undefined || input === null) return null;
|
||||
@@ -683,6 +688,10 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
|
||||
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
|
||||
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
|
||||
const anyBody = body as Record<string, unknown>;
|
||||
if (anyBody.postRefreshProbeEnabled !== undefined) updates.postRefreshProbeEnabled = Boolean(anyBody.postRefreshProbeEnabled);
|
||||
if (anyBody.postRefreshProbeModel !== undefined) updates.postRefreshProbeModel = String(anyBody.postRefreshProbeModel || '').trim();
|
||||
if (anyBody.postRefreshProbeScope !== undefined) updates.postRefreshProbeScope = anyBody.postRefreshProbeScope === 'all' ? 'all' : 'single';
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
@@ -887,6 +896,58 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
return { siteId: id, models };
|
||||
});
|
||||
|
||||
// Manually probe site models now (one-shot JSON)
|
||||
app.post<{ Params: { id: string }; Body: unknown }>('/api/sites/:id/probe-now', async (request, reply) => {
|
||||
const id = parseInt(request.params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
return reply.code(400).send({ error: 'Invalid site id' });
|
||||
}
|
||||
const body = request.body as Record<string, unknown> | null;
|
||||
const scope = body?.scope === 'all' ? 'all' : body?.scope === 'single' ? 'single' : undefined;
|
||||
const modelName = typeof body?.modelName === 'string' ? body.modelName.trim() : undefined;
|
||||
const result = await probeSiteModels(id, { scope, modelName });
|
||||
if (!result.success) {
|
||||
return reply.code(422).send({ error: result.error });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Streaming probe via SSE
|
||||
app.get<{ Params: { id: string }; Querystring: { scope?: string; modelName?: string; latencyThresholdMs?: string } }>(
|
||||
'/api/sites/:id/probe-stream',
|
||||
async (request, reply) => {
|
||||
reply.hijack();
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
});
|
||||
|
||||
const id = parseInt(request.params.id);
|
||||
if (Number.isNaN(id)) {
|
||||
sseWrite(reply.raw, 'error', { message: 'Invalid site id' });
|
||||
reply.raw.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const q = request.query;
|
||||
const scope = q.scope === 'all' ? 'all' : q.scope === 'single' ? 'single' : undefined;
|
||||
const modelName = q.modelName?.trim() || undefined;
|
||||
const parsedThreshold = parseInt(q.latencyThresholdMs ?? '', 10);
|
||||
const latencyThresholdMs = Number.isFinite(parsedThreshold) && parsedThreshold > 0 ? parsedThreshold : undefined;
|
||||
|
||||
try {
|
||||
const result = await probeSiteModels(id, { scope, modelName, latencyThresholdMs }, (ev) => {
|
||||
sseWrite(reply.raw, ev.type, ev);
|
||||
});
|
||||
sseWrite(reply.raw, 'complete', result);
|
||||
} catch (err: any) {
|
||||
sseWrite(reply.raw, 'error', { message: err?.message || '探测失败' });
|
||||
}
|
||||
reply.raw.end();
|
||||
},
|
||||
);
|
||||
|
||||
// Detect platform for a URL
|
||||
app.post<{ Body: unknown }>('/api/sites/detect', async (request, reply) => {
|
||||
const parsedBody = parseSiteDetectPayload(request.body);
|
||||
|
||||
Reference in New Issue
Block a user