Merge pull request #68 from grassroadsZ/feature/site-model-disable

feat: 将模型禁用功能从站点移至连接管理
This commit is contained in:
Cita
2026-03-14 17:26:57 +08:00
committed by GitHub
17 changed files with 1601 additions and 357 deletions
+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);