1
0
forked from boos/BoosAPI

feat: 将模型禁用功能从站点管理移至连接管理

- 在连接管理页面为每个账号添加独立的模型管理弹窗
- 弹窗内支持查看可用模型,进行「刷新模型」「全选」「全部禁用」「反选」等快捷操作
- 保存模型禁用配置后,系统会自动在后台触发并重建路由(rebuildRoutes),确保配置立即生效
- 从原站点编辑界面移除过时的禁用功能板块
- 修复因执行自动同步逻辑导致的 SQLite Drizzle Migration `duplicate column token_group` 报错,更新 bootstrap markers 及测试用例
This commit is contained in:
silent
2026-03-14 12:07:27 +08:00
parent ad791d87bb
commit 0d44d3ba10
16 changed files with 1299 additions and 357 deletions
+6 -1
View File
@@ -26,7 +26,7 @@ type MigrationRecord = {
hash: string;
};
const VERIFIED_BOOTSTRAP_TAG = '0004_sorting_preferences';
const VERIFIED_BOOTSTRAP_TAG = '0007_account_token_group';
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
{ table: 'sites' },
{ table: 'settings' },
@@ -43,8 +43,13 @@ const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
{ table: 'sites', column: 'sort_order' },
{ table: 'accounts', column: 'is_pinned' },
{ table: 'accounts', column: 'sort_order' },
// 0006: site_disabled_models table
{ table: 'site_disabled_models' },
// 0007: token_group column on account_tokens
{ table: 'account_tokens', column: 'token_group' },
];
function resolveSqliteDbPath(): string {
const raw = (config.dbUrl || '').trim();
if (!raw) return resolve(`${config.dataDir}/hub.db`);
+10
View File
@@ -21,6 +21,16 @@ export const sites = sqliteTable('sites', {
statusIdx: index('sites_status_idx').on(table.status),
}));
export const siteDisabledModels = sqliteTable('site_disabled_models', {
id: integer('id').primaryKey({ autoIncrement: true }),
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
modelName: text('model_name').notNull(),
createdAt: text('created_at').default(sql`(datetime('now'))`),
}, (table) => ({
siteModelUnique: uniqueIndex('site_disabled_models_site_model_unique').on(table.siteId, table.modelName),
siteIdIdx: index('site_disabled_models_site_id_idx').on(table.siteId),
}));
export const accounts = sqliteTable('accounts', {
id: integer('id').primaryKey({ autoIncrement: true }),
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
+55
View File
@@ -1343,4 +1343,59 @@ export async function accountsRoutes(app: FastifyInstance) {
return { message: err?.message || 'failed to fetch balance' };
}
});
// Get model list for an account (available models + disabled status at site level)
app.get<{ Params: { id: string } }>('/api/accounts/:id/models', async (request, reply) => {
const accountId = parseInt(request.params.id, 10);
if (!Number.isFinite(accountId) || accountId <= 0) {
return reply.code(400).send({ message: '账号 ID 无效' });
}
const account = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
if (!account) {
return reply.code(404).send({ message: '账号不存在' });
}
const siteId = account.accounts.siteId;
// Get available models for this account
const modelRows = await db.select({
modelName: schema.modelAvailability.modelName,
available: schema.modelAvailability.available,
latencyMs: schema.modelAvailability.latencyMs,
}).from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, accountId))
.all();
// Get disabled models for this site
const disabledRows = await db.select({
modelName: schema.siteDisabledModels.modelName,
}).from(schema.siteDisabledModels)
.where(eq(schema.siteDisabledModels.siteId, siteId))
.all();
const disabledSet = new Set(disabledRows.map((r) => r.modelName));
const models = modelRows
.filter((r) => r.available)
.map((r) => ({
name: r.modelName,
latencyMs: r.latencyMs,
disabled: disabledSet.has(r.modelName),
}))
.sort((a, b) => a.name.localeCompare(b.name));
return {
siteId,
siteName: account.sites.name,
models,
totalCount: models.length,
disabledCount: models.filter((m) => m.disabled).length,
};
});
}
@@ -0,0 +1,218 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
describe('sites disabled models API', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-disabled-models-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./sites.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.sitesRoutes);
});
beforeEach(async () => {
await db.delete(schema.siteDisabledModels).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('returns empty list for a new site', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
url: 'https://test-site.example.com',
platform: 'new-api',
}).returning().get();
const resp = await app.inject({
method: 'GET',
url: `/api/sites/${site.id}/disabled-models`,
});
expect(resp.statusCode).toBe(200);
const body = resp.json();
expect(body.siteId).toBe(site.id);
expect(body.models).toEqual([]);
});
it('sets and retrieves disabled models', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
url: 'https://test-site.example.com',
platform: 'new-api',
}).returning().get();
const putResp = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: ['gpt-4o', 'claude-sonnet-4-5-20250929'] },
});
expect(putResp.statusCode).toBe(200);
const putBody = putResp.json();
expect(putBody.models).toHaveLength(2);
expect(putBody.models).toContain('gpt-4o');
expect(putBody.models).toContain('claude-sonnet-4-5-20250929');
const getResp = await app.inject({
method: 'GET',
url: `/api/sites/${site.id}/disabled-models`,
});
expect(getResp.statusCode).toBe(200);
const getBody = getResp.json();
expect(getBody.models).toHaveLength(2);
expect(getBody.models).toContain('gpt-4o');
expect(getBody.models).toContain('claude-sonnet-4-5-20250929');
});
it('replaces disabled models on subsequent PUT', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
url: 'https://test-site.example.com',
platform: 'new-api',
}).returning().get();
await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: ['gpt-4o', 'gpt-3.5-turbo'] },
});
const putResp = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: ['claude-sonnet-4-5-20250929'] },
});
expect(putResp.statusCode).toBe(200);
const body = putResp.json();
expect(body.models).toEqual(['claude-sonnet-4-5-20250929']);
// Verify old models are removed
const getResp = await app.inject({
method: 'GET',
url: `/api/sites/${site.id}/disabled-models`,
});
const getBody = getResp.json();
expect(getBody.models).toEqual(['claude-sonnet-4-5-20250929']);
});
it('clears disabled models with empty array', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
url: 'https://test-site.example.com',
platform: 'new-api',
}).returning().get();
await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: ['gpt-4o'] },
});
const putResp = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: [] },
});
expect(putResp.statusCode).toBe(200);
expect(putResp.json().models).toEqual([]);
const getResp = await app.inject({
method: 'GET',
url: `/api/sites/${site.id}/disabled-models`,
});
expect(getResp.json().models).toEqual([]);
});
it('returns 404 for non-existent site', async () => {
const getResp = await app.inject({
method: 'GET',
url: '/api/sites/99999/disabled-models',
});
expect(getResp.statusCode).toBe(404);
const putResp = await app.inject({
method: 'PUT',
url: '/api/sites/99999/disabled-models',
payload: { models: ['gpt-4o'] },
});
expect(putResp.statusCode).toBe(404);
});
it('returns 400 when models is not an array', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
url: 'https://test-site.example.com',
platform: 'new-api',
}).returning().get();
const resp = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: 'not-an-array' },
});
expect(resp.statusCode).toBe(400);
});
it('deduplicates model names', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
url: 'https://test-site.example.com',
platform: 'new-api',
}).returning().get();
const putResp = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: ['gpt-4o', 'gpt-4o', 'gpt-4o'] },
});
expect(putResp.statusCode).toBe(200);
expect(putResp.json().models).toEqual(['gpt-4o']);
});
it('deletes disabled models when site is deleted (cascade)', async () => {
const site = await db.insert(schema.sites).values({
name: 'delete-test',
url: 'https://delete-test.example.com',
platform: 'new-api',
}).returning().get();
await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}/disabled-models`,
payload: { models: ['gpt-4o', 'claude-sonnet-4-5-20250929'] },
});
await db.delete(schema.sites).where(eq(schema.sites.id, site.id)).run();
const rows = await db.select().from(schema.siteDisabledModels)
.where(eq(schema.siteDisabledModels.siteId, site.id))
.all();
expect(rows).toHaveLength(0);
});
});
+53 -2
View File
@@ -104,7 +104,7 @@ export async function sitesRoutes(app: FastifyInstance) {
relatedType: 'site',
createdAt,
}).run();
} catch {}
} catch { }
return;
}
@@ -124,7 +124,7 @@ export async function sitesRoutes(app: FastifyInstance) {
relatedType: 'site',
createdAt,
}).run();
} catch {}
} catch { }
}
function normalizeBatchIds(input: unknown): number[] {
@@ -372,6 +372,57 @@ export async function sitesRoutes(app: FastifyInstance) {
};
});
// Get disabled models for a site
app.get<{ Params: { id: string } }>('/api/sites/:id/disabled-models', async (request, reply) => {
const id = parseInt(request.params.id);
if (Number.isNaN(id)) {
return reply.code(400).send({ error: 'Invalid site id' });
}
const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
if (!existingSite) {
return reply.code(404).send({ error: 'Site not found' });
}
const rows = await db.select({ modelName: schema.siteDisabledModels.modelName })
.from(schema.siteDisabledModels)
.where(eq(schema.siteDisabledModels.siteId, id))
.all();
return { siteId: id, models: rows.map((r) => r.modelName) };
});
// Update disabled models for a site (full replace)
app.put<{ Params: { id: string }; Body: { models?: string[] } }>('/api/sites/:id/disabled-models', async (request, reply) => {
const id = parseInt(request.params.id);
if (Number.isNaN(id)) {
return reply.code(400).send({ error: 'Invalid site id' });
}
const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
if (!existingSite) {
return reply.code(404).send({ error: 'Site not found' });
}
const rawModels = request.body?.models;
if (!Array.isArray(rawModels)) {
return reply.code(400).send({ error: 'models must be an array of strings' });
}
const models = rawModels
.filter((m): m is string => typeof m === 'string')
.map((m) => m.trim())
.filter((m) => m.length > 0);
const uniqueModels = Array.from(new Set(models));
await db.delete(schema.siteDisabledModels)
.where(eq(schema.siteDisabledModels.siteId, id))
.run();
if (uniqueModels.length > 0) {
await db.insert(schema.siteDisabledModels).values(
uniqueModels.map((modelName) => ({ siteId: id, modelName })),
).run();
}
invalidateSiteCaches();
return { siteId: id, models: uniqueModels };
});
// Detect platform for a URL
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
const result = await detectSite(request.body.url);
@@ -0,0 +1,177 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { and, eq } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
type ModelServiceModule = typeof import('./modelService.js');
describe('rebuildTokenRoutesFromAvailability with site disabled models', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let rebuildTokenRoutesFromAvailability: ModelServiceModule['rebuildTokenRoutesFromAvailability'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-disabled-models-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const modelService = await import('./modelService.js');
db = dbModule.db;
schema = dbModule.schema;
rebuildTokenRoutesFromAvailability = modelService.rebuildTokenRoutesFromAvailability;
});
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.siteDisabledModels).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(() => {
delete process.env.DATA_DIR;
});
it('does not create route/channel for a model disabled on its site', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://site-a.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-a',
accessToken: '',
apiToken: 'sk-test-disabled',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-4o',
available: true,
latencyMs: 500,
checkedAt: '2026-03-12T00:00:00.000Z',
}).run();
// Disable gpt-4o for this site
await db.insert(schema.siteDisabledModels).values({
siteId: site.id,
modelName: 'gpt-4o',
}).run();
const rebuild = await rebuildTokenRoutesFromAvailability();
expect(rebuild.models).toBe(0);
const routes = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.modelPattern, 'gpt-4o'))
.all();
expect(routes).toHaveLength(0);
});
it('only blocks the disabled site, not other sites providing the same model', async () => {
const siteA = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://site-a.example.com',
platform: 'new-api',
}).returning().get();
const siteB = await db.insert(schema.sites).values({
name: 'site-b',
url: 'https://site-b.example.com',
platform: 'new-api',
}).returning().get();
const accountA = await db.insert(schema.accounts).values({
siteId: siteA.id,
username: 'user-a',
accessToken: '',
apiToken: 'sk-site-a',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
const accountB = await db.insert(schema.accounts).values({
siteId: siteB.id,
username: 'user-b',
accessToken: '',
apiToken: 'sk-site-b',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
// Both sites have the same model
await db.insert(schema.modelAvailability).values([
{ accountId: accountA.id, modelName: 'claude-sonnet-4-5-20250929', available: true, latencyMs: 300 },
{ accountId: accountB.id, modelName: 'claude-sonnet-4-5-20250929', available: true, latencyMs: 400 },
]).run();
// Disable the model only on site A
await db.insert(schema.siteDisabledModels).values({
siteId: siteA.id,
modelName: 'claude-sonnet-4-5-20250929',
}).run();
const rebuild = await rebuildTokenRoutesFromAvailability();
expect(rebuild.models).toBe(1);
const route = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.modelPattern, 'claude-sonnet-4-5-20250929'))
.get();
expect(route).toBeDefined();
const channels = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.routeId, route!.id))
.all();
// Only site B's channel should exist
expect(channels).toHaveLength(1);
expect(channels[0]?.accountId).toBe(accountB.id);
});
it('allows model when no disabled models are configured', async () => {
const site = await db.insert(schema.sites).values({
name: 'normal-site',
url: 'https://normal.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'normal-user',
accessToken: '',
apiToken: 'sk-normal',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5',
available: true,
latencyMs: 200,
}).run();
const rebuild = await rebuildTokenRoutesFromAvailability();
expect(rebuild.models).toBe(1);
const route = await db.select().from(schema.tokenRoutes)
.where(eq(schema.tokenRoutes.modelPattern, 'gpt-5'))
.get();
expect(route).toBeDefined();
});
});
+202 -188
View File
@@ -6,7 +6,7 @@ import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accou
import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
const MODEL_REFRESH_BATCH_SIZE = 3;
@@ -38,38 +38,38 @@ function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boole
if (explicit && explicit !== 'auto') return explicit === 'apikey';
return !(account.accessToken || '').trim();
}
function normalizeModels(models: string[]): string[] {
return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0)));
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized);
}
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
fn(),
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
export async function refreshModelsForAccount(accountId: number) {
const row = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
function normalizeModels(models: string[]): string[] {
return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0)));
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized);
}
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
fn(),
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
export async function refreshModelsForAccount(accountId: number) {
const row = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(eq(schema.accounts.id, accountId))
.get();
if (!row) {
return {
accountId,
@@ -82,26 +82,26 @@ export async function refreshModelsForAccount(accountId: number) {
reason: 'account_not_found',
};
}
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
const accountTokens = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, accountId))
.all();
await db.delete(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, accountId))
.run();
for (const token of accountTokens) {
await db.delete(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, token.id))
.run();
}
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
const accountTokens = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, accountId))
.all();
await db.delete(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, accountId))
.run();
for (const token of accountTokens) {
await db.delete(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, token.id))
.run();
}
if (isSiteDisabled(site.status)) {
return {
accountId,
@@ -127,44 +127,44 @@ export async function refreshModelsForAccount(accountId: number) {
reason: 'adapter_or_status',
};
}
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
let discoveredApiToken: string | null = null;
if (!account.apiToken && account.accessToken) {
try {
discoveredApiToken = await withTimeout(
() => adapter.getApiToken(site.url, account.accessToken, platformUserId),
API_TOKEN_DISCOVERY_TIMEOUT_MS,
`api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
if (discoveredApiToken) {
ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' });
await db.update(schema.accounts).set({
apiToken: discoveredApiToken,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, account.id)).run();
}
} catch {}
}
let enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.all();
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
let discoveredApiToken: string | null = null;
if (!account.apiToken && account.accessToken) {
try {
discoveredApiToken = await withTimeout(
() => adapter.getApiToken(site.url, account.accessToken, platformUserId),
API_TOKEN_DISCOVERY_TIMEOUT_MS,
`api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
if (discoveredApiToken) {
ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' });
await db.update(schema.accounts).set({
apiToken: discoveredApiToken,
updatedAt: new Date().toISOString(),
}).where(eq(schema.accounts.id, account.id)).run();
}
} catch { }
}
let enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.all();
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
if (!isApiKeyConnection(account) && enabledTokens.length === 0) {
const fallback = discoveredApiToken || account.apiToken || null;
if (fallback) {
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.all();
}
}
enabledTokens = await db.select()
.from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
.all();
}
}
const accountModels = new Set<string>();
const modelLatency = new Map<string, number | null>();
let scannedTokenCount = 0;
@@ -175,27 +175,27 @@ export async function refreshModelsForAccount(accountId: number) {
const message = (err as { message?: string })?.message || String(err || '');
if (message) failureMessages.push(message);
};
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
for (const modelName of models) {
accountModels.add(modelName);
const prev = modelLatency.get(modelName);
if (prev === undefined || prev === null) {
modelLatency.set(modelName, latencyMs);
continue;
}
if (latencyMs === null) continue;
if (latencyMs < prev) modelLatency.set(modelName, latencyMs);
}
};
const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
const credential = (credentialRaw || '').trim();
if (!credential) return;
if (attemptedCredentials.has(credential)) return;
attemptedCredentials.add(credential);
const startedAt = Date.now();
const mergeDiscoveredModels = (models: string[], latencyMs: number | null) => {
for (const modelName of models) {
accountModels.add(modelName);
const prev = modelLatency.get(modelName);
if (prev === undefined || prev === null) {
modelLatency.set(modelName, latencyMs);
continue;
}
if (latencyMs === null) continue;
if (latencyMs < prev) modelLatency.set(modelName, latencyMs);
}
};
const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => {
const credential = (credentialRaw || '').trim();
if (!credential) return;
if (attemptedCredentials.has(credential)) return;
attemptedCredentials.add(credential);
const startedAt = Date.now();
let models: string[] = [];
try {
models = normalizeModels(
@@ -210,18 +210,18 @@ export async function refreshModelsForAccount(accountId: number) {
models = [];
}
if (models.length === 0) return;
discoveredByCredential = true;
const latencyMs = Date.now() - startedAt;
mergeDiscoveredModels(models, latencyMs);
};
// Prefer account-level credential discovery so model availability does not rely on managed tokens.
await discoverModelsWithCredential(account.apiToken);
await discoverModelsWithCredential(discoveredApiToken);
await discoverModelsWithCredential(account.accessToken);
for (const token of enabledTokens) {
const startedAt = Date.now();
discoveredByCredential = true;
const latencyMs = Date.now() - startedAt;
mergeDiscoveredModels(models, latencyMs);
};
// Prefer account-level credential discovery so model availability does not rely on managed tokens.
await discoverModelsWithCredential(account.apiToken);
await discoverModelsWithCredential(discoveredApiToken);
await discoverModelsWithCredential(account.accessToken);
for (const token of enabledTokens) {
const startedAt = Date.now();
let models: string[] = [];
try {
@@ -236,26 +236,26 @@ export async function refreshModelsForAccount(accountId: number) {
recordFailure(err);
models = [];
}
if (models.length === 0) continue;
const latencyMs = Date.now() - startedAt;
const checkedAt = new Date().toISOString();
await db.insert(schema.tokenModelAvailability).values(
models.map((modelName) => ({
tokenId: token.id,
modelName,
available: true,
latencyMs,
checkedAt,
})),
).run();
scannedTokenCount++;
mergeDiscoveredModels(models, latencyMs);
}
if (models.length === 0) continue;
const latencyMs = Date.now() - startedAt;
const checkedAt = new Date().toISOString();
await db.insert(schema.tokenModelAvailability).values(
models.map((modelName) => ({
tokenId: token.id,
modelName,
available: true,
latencyMs,
checkedAt,
})),
).run();
scannedTokenCount++;
mergeDiscoveredModels(models, latencyMs);
}
if (accountModels.size === 0) {
const firstMessage = failureMessages[0] || '';
const errorCode = firstMessage ? classifyModelDiscoveryError(firstMessage) : 'empty_models';
@@ -312,33 +312,33 @@ export async function refreshModelsForAccount(accountId: number) {
discoveredApiToken: !!discoveredApiToken,
};
}
async function refreshModelsForAllActiveAccounts() {
const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts)
.where(eq(schema.accounts.status, 'active'))
.all();
async function refreshModelsForAllActiveAccounts() {
const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts)
.where(eq(schema.accounts.status, 'active'))
.all();
const results: any[] = [];
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE);
const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));
results.push(...batchResults);
}
return results;
}
for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) {
const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE);
const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id)));
results.push(...batchResults);
}
return results;
}
export async function rebuildTokenRoutesFromAvailability() {
const tokenRows = await db.select().from(schema.tokenModelAvailability)
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
@@ -354,29 +354,43 @@ export async function rebuildTokenRoutesFromAvailability() {
)
.all();
// Load site-level disabled models
const disabledModelRows = await db.select().from(schema.siteDisabledModels).all();
const disabledModelsBySite = new Map<number, Set<string>>();
for (const row of disabledModelRows) {
if (!disabledModelsBySite.has(row.siteId)) disabledModelsBySite.set(row.siteId, new Set());
disabledModelsBySite.get(row.siteId)!.add(row.modelName);
}
function isModelDisabledForSite(siteId: number, modelName: string): boolean {
const disabled = disabledModelsBySite.get(siteId);
return !!disabled && disabled.has(modelName);
}
const modelCandidates = new Map<string, Map<string, { accountId: number; tokenId: number | null }>>();
const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null) => {
const addModelCandidate = (modelNameRaw: string | null | undefined, accountId: number, tokenId: number | null, siteId: number) => {
const modelName = (modelNameRaw || '').trim();
if (!modelName) return;
if (isModelDisabledForSite(siteId, modelName)) return;
if (!modelCandidates.has(modelName)) modelCandidates.set(modelName, new Map());
const candidateKey = `${accountId}:${tokenId ?? 'account'}`;
modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId });
};
for (const row of tokenRows) {
addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id);
addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id, row.accounts.siteId);
}
for (const row of accountRows) {
if (!isApiKeyConnection(row.accounts)) continue;
if (!(row.accounts.apiToken || '').trim()) continue;
addModelCandidate(row.model_availability.modelName, row.accounts.id, null);
addModelCandidate(row.model_availability.modelName, row.accounts.id, null, row.accounts.siteId);
}
const routes = await db.select().from(schema.tokenRoutes).all();
const channels = await db.select().from(schema.routeChannels).all();
let createdRoutes = 0;
let createdRoutes = 0;
let createdChannels = 0;
let removedChannels = 0;
let removedRoutes = 0;
@@ -439,12 +453,12 @@ export async function rebuildTokenRoutesFromAvailability() {
.where(eq(schema.routeChannels.id, channel.id))
.run();
continue;
}
}
if (!channel.manualOverride) {
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
removedChannels++;
}
}
if (!channel.manualOverride) {
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channel.id)).run();
removedChannels++;
}
}
}
@@ -454,16 +468,16 @@ export async function rebuildTokenRoutesFromAvailability() {
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) {
continue;
}
const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length;
if (routeChannelCount > 0) {
removedChannels += routeChannelCount;
}
const deleted = (await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run()).changes;
if (deleted > 0) {
removedRoutes += deleted;
}
const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length;
if (routeChannelCount > 0) {
removedChannels += routeChannelCount;
}
const deleted = (await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run()).changes;
if (deleted > 0) {
removedRoutes += deleted;
}
}
@@ -472,7 +486,7 @@ export async function rebuildTokenRoutesFromAvailability() {
}
invalidateTokenRouterCache();
return {
models: modelCandidates.size,
createdRoutes,
@@ -480,8 +494,8 @@ export async function rebuildTokenRoutesFromAvailability() {
removedChannels,
removedRoutes,
};
}
}
export async function refreshModelsAndRebuildRoutes() {
const refresh = await refreshModelsForAllActiveAccounts();
const rebuild = await rebuildTokenRoutesFromAvailability();