Merge pull request #68 from grassroadsZ/feature/site-model-disable
feat: 将模型禁用功能从站点移至连接管理
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user