feat: 将模型禁用功能从站点管理移至连接管理
- 在连接管理页面为每个账号添加独立的模型管理弹窗 - 弹窗内支持查看可用模型,进行「刷新模型」「全选」「全部禁用」「反选」等快捷操作 - 保存模型禁用配置后,系统会自动在后台触发并重建路由(rebuildRoutes),确保配置立即生效 - 从原站点编辑界面移除过时的禁用功能板块 - 修复因执行自动同步逻辑导致的 SQLite Drizzle Migration `duplicate column token_group` 报错,更新 bootstrap markers 及测试用例
This commit is contained in:
@@ -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`);
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user