fix: case-insensitive model merging and disabled model matching (#251)

* fix: MySQL-compatible case-insensitive model merge and disabled model matching

Root cause: MySQL uses utf8mb4_general_ci collation by default, making the
unique index on (account_id, model_name) case-insensitive. When different
tokens discover the same model with different casing (e.g., 'GPT-4o' vs
'gpt-4o'), the case-sensitive JavaScript Set allowed both, but the MySQL
batch INSERT failed with a duplicate key error — causing the entire model
refresh to silently fail and leaving stale routes (including disabled models).

Fixes:
1. mergeDiscoveredModels: change Set<string> to Map<lowercase, original>
   for cross-scan dedup, matching normalizeModels' existing behavior.
2. isModelDisabledForSite: compare with toLowerCase() so disabled models
   are filtered regardless of case variants across discovery sources.
3. tokens.ts /api/routes/rebuild: add missing await on
   rebuildTokenRoutesFromAvailability() so errors propagate correctly
   instead of being silently swallowed.

* fix: add missing await on rebuildTokenRoutesFromAvailability in stats.ts

The POST handler for /api/models/check/:accountId called
rebuildTokenRoutesFromAvailability() without await, returning a Promise
object in the response instead of the actual rebuild result.
This commit is contained in:
ZhengYiTao
2026-03-25 11:22:37 +08:00
committed by GitHub
parent 709f528bd2
commit dc40edf8e8
2 changed files with 689 additions and 691 deletions
-3
View File
@@ -20,7 +20,6 @@ import {
saveRouteDecisionSnapshots, saveRouteDecisionSnapshots,
} from '../../services/routeDecisionSnapshotStore.js'; } from '../../services/routeDecisionSnapshotStore.js';
import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js'; import { normalizeTokenRouteMode, type RouteMode } from '../../../shared/tokenRouteContract.js';
function isExactModelPattern(modelPattern: string): boolean { function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim(); const normalized = modelPattern.trim();
if (!normalized) return false; if (!normalized) return false;
@@ -1263,7 +1262,6 @@ export async function tokensRoutes(app: FastifyInstance) {
invalidateTokenRouterCache(); invalidateTokenRouterCache();
return { success: true }; return { success: true };
}); });
// Rebuild routes/channels from model availability. // Rebuild routes/channels from model availability.
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => { app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {
const body = (request.body || {}) as { refreshModels?: boolean }; const body = (request.body || {}) as { refreshModels?: boolean };
@@ -1305,4 +1303,3 @@ export async function tokensRoutes(app: FastifyInstance) {
}); });
}); });
} }
+16 -15
View File
@@ -361,7 +361,7 @@ export async function refreshModelsForAccount(
eq(schema.modelAvailability.isManual, true), eq(schema.modelAvailability.isManual, true),
)) ))
.all() .all()
).map((r) => r.modelName), ).map((r) => r.modelName.toLowerCase()),
); );
if (isSiteDisabled(site.status)) { if (isSiteDisabled(site.status)) {
@@ -386,7 +386,7 @@ export async function refreshModelsForAccount(
throw new Error('未获取到可用模型'); throw new Error('未获取到可用模型');
} }
const newCodexModels = codexModels.filter((m) => !manualModelNames.has(m)); const newCodexModels = codexModels.filter((m) => !manualModelNames.has(m.toLowerCase()));
if (newCodexModels.length > 0) { if (newCodexModels.length > 0) {
await db.insert(schema.modelAvailability).values( await db.insert(schema.modelAvailability).values(
newCodexModels.map((modelName) => ({ newCodexModels.map((modelName) => ({
@@ -460,7 +460,7 @@ export async function refreshModelsForAccount(
if (claudeModels.length === 0) { if (claudeModels.length === 0) {
throw new Error('未获取到可用模型'); throw new Error('未获取到可用模型');
} }
const newClaudeModels = claudeModels.filter((m) => !manualModelNames.has(m)); const newClaudeModels = claudeModels.filter((m) => !manualModelNames.has(m.toLowerCase()));
if (newClaudeModels.length > 0) { if (newClaudeModels.length > 0) {
await db.insert(schema.modelAvailability).values( await db.insert(schema.modelAvailability).values(
newClaudeModels.map((modelName) => ({ newClaudeModels.map((modelName) => ({
@@ -553,7 +553,7 @@ export async function refreshModelsForAccount(
`gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, `gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
); );
} }
const newGeminiModels = GEMINI_CLI_STATIC_MODELS.filter((m) => !manualModelNames.has(m)); const newGeminiModels = GEMINI_CLI_STATIC_MODELS.filter((m) => !manualModelNames.has(m.toLowerCase()));
if (newGeminiModels.length > 0) { if (newGeminiModels.length > 0) {
await db.insert(schema.modelAvailability).values( await db.insert(schema.modelAvailability).values(
newGeminiModels.map((modelName) => ({ newGeminiModels.map((modelName) => ({
@@ -628,7 +628,7 @@ export async function refreshModelsForAccount(
throw new Error('未获取到可用模型'); throw new Error('未获取到可用模型');
} }
const newAntigravityModels = antigravityModels.filter((m) => !manualModelNames.has(m)); const newAntigravityModels = antigravityModels.filter((m) => !manualModelNames.has(m.toLowerCase()));
if (newAntigravityModels.length > 0) { if (newAntigravityModels.length > 0) {
await db.insert(schema.modelAvailability).values( await db.insert(schema.modelAvailability).values(
newAntigravityModels.map((modelName) => ({ newAntigravityModels.map((modelName) => ({
@@ -746,7 +746,7 @@ export async function refreshModelsForAccount(
} }
} }
const accountModels = new Set<string>(); const accountModels = new Map<string, string>(); // lowercase key → original name (first-wins)
const modelLatency = new Map<string, number | null>(); const modelLatency = new Map<string, number | null>();
let scannedTokenCount = 0; let scannedTokenCount = 0;
let discoveredByCredential = false; let discoveredByCredential = false;
@@ -759,14 +759,15 @@ export async function refreshModelsForAccount(
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => { const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
for (const modelName of models) { for (const modelName of models) {
accountModels.add(modelName); const key = modelName.toLowerCase();
const prev = modelLatency.get(modelName); if (!accountModels.has(key)) accountModels.set(key, modelName);
const prev = modelLatency.get(key);
if (prev === undefined || prev === null) { if (prev === undefined || prev === null) {
modelLatency.set(modelName, latencyMs); modelLatency.set(key, latencyMs);
continue; continue;
} }
if (latencyMs === null) continue; if (latencyMs === null) continue;
if (latencyMs < prev) modelLatency.set(modelName, latencyMs); if (latencyMs < prev) modelLatency.set(key, latencyMs);
} }
}; };
@@ -862,14 +863,14 @@ export async function refreshModelsForAccount(
} }
const checkedAt = new Date().toISOString(); const checkedAt = new Date().toISOString();
const newAccountModels = Array.from(accountModels).filter((m) => !manualModelNames.has(m)); const newAccountModels = Array.from(accountModels.values()).filter((m) => !manualModelNames.has(m.toLowerCase()));
if (newAccountModels.length > 0) { if (newAccountModels.length > 0) {
await db.insert(schema.modelAvailability).values( await db.insert(schema.modelAvailability).values(
newAccountModels.map((modelName) => ({ newAccountModels.map((modelName) => ({
accountId: account.id, accountId: account.id,
modelName, modelName,
available: true, available: true,
latencyMs: modelLatency.get(modelName) ?? null, latencyMs: modelLatency.get(modelName.toLowerCase()) ?? null,
checkedAt, checkedAt,
})), })),
).run(); ).run();
@@ -882,7 +883,7 @@ export async function refreshModelsForAccount(
checkedAt, checkedAt,
}); });
const modelsPreview = Array.from(accountModels).slice(0, 10); const modelsPreview = Array.from(accountModels.values()).slice(0, 10);
return buildSuccessfulRefreshResult({ return buildSuccessfulRefreshResult({
accountId, accountId,
modelCount: accountModels.size, modelCount: accountModels.size,
@@ -944,12 +945,12 @@ export async function rebuildTokenRoutesFromAvailability() {
const disabledModelsBySite = new Map<number, Set<string>>(); const disabledModelsBySite = new Map<number, Set<string>>();
for (const row of disabledModelRows) { for (const row of disabledModelRows) {
if (!disabledModelsBySite.has(row.siteId)) disabledModelsBySite.set(row.siteId, new Set()); if (!disabledModelsBySite.has(row.siteId)) disabledModelsBySite.set(row.siteId, new Set());
disabledModelsBySite.get(row.siteId)!.add(row.modelName); disabledModelsBySite.get(row.siteId)!.add(row.modelName.toLowerCase());
} }
function isModelDisabledForSite(siteId: number, modelName: string): boolean { function isModelDisabledForSite(siteId: number, modelName: string): boolean {
const disabled = disabledModelsBySite.get(siteId); const disabled = disabledModelsBySite.get(siteId);
return !!disabled && disabled.has(modelName); return !!disabled && disabled.has(modelName.toLowerCase());
} }
// Load global brand filter // Load global brand filter