add zero-channel route placeholders

This commit is contained in:
cita
2026-03-16 19:25:02 +08:00
parent 83c6b3460a
commit 52441f2634
6 changed files with 405 additions and 57 deletions
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { buildZeroChannelPlaceholderRoutes } from './zeroChannelRoutes.js';
import type { MissingTokenModelsByName } from './routeMissingTokenHints.js';
import type { RouteSummaryRow } from '../token-routes/types.js';
describe('buildZeroChannelPlaceholderRoutes', () => {
it('merges missing-token and missing-group models into exact zero-channel placeholders', () => {
const routes: RouteSummaryRow[] = [
{
id: 1,
modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini',
displayIcon: null,
modelMapping: null,
routingStrategy: 'weighted',
enabled: true,
channelCount: 1,
enabledChannelCount: 1,
siteNames: ['site-a'],
decisionSnapshot: null,
decisionRefreshedAt: null,
},
];
const modelsWithoutToken: MissingTokenModelsByName = {
'gpt-4o-mini': [
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
],
'gpt-5.2-codex': [
{ accountId: 2, username: 'bob', siteId: 12, siteName: 'site-b' },
],
};
const modelsMissingTokenGroups: MissingTokenModelsByName = {
'gpt-5.2-codex': [
{
accountId: 3,
username: 'cici',
siteId: 13,
siteName: 'site-c',
missingGroups: ['opus'],
requiredGroups: ['default', 'opus'],
availableGroups: ['default'],
},
],
're:^claude-.*$': [
{ accountId: 4, username: 'dan', siteId: 14, siteName: 'site-d' },
],
};
const placeholders = buildZeroChannelPlaceholderRoutes(routes, modelsWithoutToken, modelsMissingTokenGroups);
expect(placeholders).toHaveLength(1);
expect(placeholders[0]).toMatchObject({
modelPattern: 'gpt-5.2-codex',
channelCount: 0,
enabledChannelCount: 0,
enabled: false,
decisionSnapshot: null,
kind: 'zero_channel',
readOnly: true,
isVirtual: true,
});
expect(placeholders[0].siteNames).toEqual(['site-b', 'site-c']);
expect(placeholders[0].id).toBeLessThan(0);
});
});
@@ -0,0 +1,67 @@
import type { MissingTokenModelsByName } from './routeMissingTokenHints.js';
import type { RouteSummaryRow } from '../token-routes/types.js';
import { isExactModelPattern } from '../token-routes/utils.js';
function buildStableVirtualRouteId(modelName: string): number {
const normalized = modelName.trim().toLowerCase();
let hash = 0;
for (let index = 0; index < normalized.length; index += 1) {
hash = ((hash * 131) + normalized.charCodeAt(index)) % 2_147_483_647;
}
return -Math.max(1, hash || normalized.length || 1);
}
export function buildZeroChannelPlaceholderRoutes(
routes: RouteSummaryRow[],
modelsWithoutToken: MissingTokenModelsByName,
modelsMissingTokenGroups: MissingTokenModelsByName,
): RouteSummaryRow[] {
const exactRouteNames = new Set(
(routes || [])
.filter((route) => isExactModelPattern(route.modelPattern))
.map((route) => (route.modelPattern || '').trim().toLowerCase())
.filter(Boolean),
);
const placeholderByModel = new Map<string, RouteSummaryRow>();
const mergeMissingHints = (missingByModel: MissingTokenModelsByName) => {
for (const [rawModelName, accounts] of Object.entries(missingByModel || {})) {
const modelName = String(rawModelName || '').trim();
if (!modelName) continue;
if (!isExactModelPattern(modelName)) continue;
if (exactRouteNames.has(modelName.toLowerCase())) continue;
const routeKey = modelName.toLowerCase();
const existing = placeholderByModel.get(routeKey);
const siteNames = new Set<string>(existing?.siteNames || []);
for (const account of accounts || []) {
const siteName = String(account?.siteName || '').trim();
if (siteName) siteNames.add(siteName);
}
placeholderByModel.set(routeKey, {
id: buildStableVirtualRouteId(modelName),
modelPattern: modelName,
displayName: null,
displayIcon: null,
modelMapping: null,
routingStrategy: null,
enabled: false,
channelCount: 0,
enabledChannelCount: 0,
siteNames: Array.from(siteNames).sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' })),
decisionSnapshot: null,
decisionRefreshedAt: null,
kind: 'zero_channel',
readOnly: true,
isVirtual: true,
});
}
};
mergeMissingHints(modelsWithoutToken);
mergeMissingHints(modelsMissingTokenGroups);
return Array.from(placeholderByModel.values())
.sort((left, right) => left.modelPattern.localeCompare(right.modelPattern, undefined, { sensitivity: 'base' }));
}