diff --git a/drizzle/0006_site_disabled_models.sql b/drizzle/0006_site_disabled_models.sql new file mode 100644 index 0000000..020fc91 --- /dev/null +++ b/drizzle/0006_site_disabled_models.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS `site_disabled_models` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `site_id` integer NOT NULL, + `model_name` text NOT NULL, + `created_at` text DEFAULT (datetime('now')), + FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `site_disabled_models_site_model_unique` +ON `site_disabled_models` (`site_id`, `model_name`); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `site_disabled_models_site_id_idx` +ON `site_disabled_models` (`site_id`); diff --git a/drizzle/0007_account_token_group.sql b/drizzle/0007_account_token_group.sql new file mode 100644 index 0000000..9adddae --- /dev/null +++ b/drizzle/0007_account_token_group.sql @@ -0,0 +1 @@ +ALTER TABLE `account_tokens` ADD `token_group` text; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 921bc28..586849f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,20 @@ "when": 1772320800000, "tag": "0004_sorting_preferences", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1773500331000, + "tag": "0006_site_disabled_models", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1773500400000, + "tag": "0007_account_token_group", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index 1237f4a..a702ea5 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -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`); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index c0e882f..b17c4cf 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -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' }), diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index 11ac19e..1b2abc1 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -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, + }; + }); } + diff --git a/src/server/routes/api/sites.disabledModels.test.ts b/src/server/routes/api/sites.disabledModels.test.ts new file mode 100644 index 0000000..29ee482 --- /dev/null +++ b/src/server/routes/api/sites.disabledModels.test.ts @@ -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); + }); +}); diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index a08cbf9..e073e56 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -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); diff --git a/src/server/services/modelService.siteDisable.test.ts b/src/server/services/modelService.siteDisable.test.ts new file mode 100644 index 0000000..02ffe67 --- /dev/null +++ b/src/server/services/modelService.siteDisable.test.ts @@ -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(); + }); +}); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index a40571b..b97ad79 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -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(fn: () => Promise, timeoutMs: number, timeoutMessage: string): Promise { - let timer: ReturnType | null = null; - try { - return await Promise.race([ - fn(), - new Promise((_, 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(fn: () => Promise, timeoutMs: number, timeoutMessage: string): Promise { + let timer: ReturnType | null = null; + try { + return await Promise.race([ + fn(), + new Promise((_, 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(); const modelLatency = new Map(); 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>(); + 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>(); - 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(); diff --git a/src/web/api.ts b/src/web/api.ts index 2a6df9e..22fefc6 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -10,7 +10,7 @@ async function request(url: string, options: RequestOptions = {}) { let timeoutHandle: ReturnType | null = setTimeout(() => { controller.abort(); }, timeoutMs); - let cleanupExternalSignal = () => {}; + let cleanupExternalSignal = () => { }; if (externalSignal) { if (externalSignal.aborted) { @@ -65,7 +65,7 @@ async function request(url: string, options: RequestOptions = {}) { message = `${message}: ${text.slice(0, 120)}`; } } - } catch {} + } catch { } throw new Error(message); } return res.json(); @@ -235,6 +235,8 @@ export const api = { deleteSite: (id: number) => request(`/api/sites/${id}`, { method: 'DELETE' }), batchUpdateSites: (data: any) => request('/api/sites/batch', { method: 'POST', body: JSON.stringify(data) }), detectSite: (url: string) => request('/api/sites/detect', { method: 'POST', body: JSON.stringify({ url }) }), + getSiteDisabledModels: (siteId: number) => request(`/api/sites/${siteId}/disabled-models`), + updateSiteDisabledModels: (siteId: number, models: string[]) => request(`/api/sites/${siteId}/disabled-models`, { method: 'PUT', body: JSON.stringify({ models }) }), // Accounts getAccounts: () => request('/api/accounts'), @@ -247,6 +249,7 @@ export const api = { deleteAccount: (id: number) => request(`/api/accounts/${id}`, { method: 'DELETE' }), batchUpdateAccounts: (data: any) => request('/api/accounts/batch', { method: 'POST', body: JSON.stringify(data) }), refreshBalance: (id: number) => request(`/api/accounts/${id}/balance`, { method: 'POST' }), + getAccountModels: (id: number) => request(`/api/accounts/${id}/models`), refreshAccountHealth: (data?: { accountId?: number; wait?: boolean }) => request('/api/accounts/health/refresh', { method: 'POST', body: JSON.stringify(data || {}), diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index b28584d..a572415 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -109,6 +109,23 @@ export default function Accounts() { const [rebindVerifyResult, setRebindVerifyResult] = useState(null); const [rebindVerifying, setRebindVerifying] = useState(false); const [rebindSaving, setRebindSaving] = useState(false); + const [modelModal, setModelModal] = useState<{ + open: boolean; + account: any | null; + models: Array<{ name: string; latencyMs: number | null; disabled: boolean }>; + pendingDisabled: Set; + loading: boolean; + saving: boolean; + siteName: string; + }>({ + open: false, + account: null, + models: [], + pendingDisabled: new Set(), + loading: false, + saving: false, + siteName: '', + }); const rowRefs = useRef>(new Map()); const highlightTimerRef = useRef | null>(null); const lastRebindTargetRef = useRef(null); @@ -388,6 +405,58 @@ export default function Accounts() { } }; + const openModelModal = async (account: any) => { + setModelModal(s => ({ ...s, open: true, account, loading: true, models: [], pendingDisabled: new Set(), siteName: '' })); + try { + const result = await api.getAccountModels(account.id); + const models = Array.isArray(result?.models) ? result.models : []; + const disabledSet = new Set(models.filter((m: any) => m.disabled).map((m: any) => m.name as string)); + setModelModal(s => ({ + ...s, + loading: false, + models, + pendingDisabled: disabledSet, + siteName: result?.siteName || account.site?.name || '', + })); + } catch (e: any) { + toast.error(e.message || '加载模型列表失败'); + setModelModal(s => ({ ...s, open: false, loading: false })); + } + }; + + const closeModelModal = () => { + setModelModal(s => ({ ...s, open: false, account: null })); + }; + + const toggleModelDisabled = (modelName: string) => { + setModelModal(s => { + const next = new Set(s.pendingDisabled); + if (next.has(modelName)) next.delete(modelName); + else next.add(modelName); + return { ...s, pendingDisabled: next }; + }); + }; + + const saveModelDisabled = async () => { + if (!modelModal.account) return; + const siteId = modelModal.account.siteId; + setModelModal(s => ({ ...s, saving: true })); + try { + await api.updateSiteDisabledModels(siteId, Array.from(modelModal.pendingDisabled)); + // 禁用配置变更后自动重建路由,使变更立即生效(不刷新模型,在后台异步执行) + try { + await api.rebuildRoutes(false, false); + } catch { /* 路由重建失败不阻塞保存流程 */ } + toast.success('模型禁用设置已保存,路由已重建'); + closeModelModal(); + } catch (e: any) { + toast.error(e.message || '保存失败'); + } finally { + setModelModal(s => ({ ...s, saving: false })); + } + }; + + const inputStyle: React.CSSProperties = { width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', fontSize: 13, outline: 'none', @@ -1535,8 +1604,12 @@ export default function Accounts() { {actionLoading[`refresh-${a.id}`] ? : '刷新'} )} - {capabilities.canCheckin && ( )} - {capabilities.canCheckin && ( + + + } + > + {modelModal.loading ? ( +
+ + 加载模型列表... +
+ ) : modelModal.models.length === 0 ? ( +
+
🤖
+
暂无可用模型
+
请先点击账号操作栏中的「刷新」或「模型」按钮获取模型
+ +
+ ) : ( +
+ {/* Header toolbar */} +
+ {/* Left: select-all checkbox */} + + {/* Right: action buttons */} +
+ + + + +
+
+ +
+ {modelModal.models.map((model, idx) => { + const isDisabled = modelModal.pendingDisabled.has(model.name); + return ( + + ); + })} +
+
+ 💡 禁用的模型将对整个站点生效,该站点下所有连接都不会使用这些模型进行代理。 +
+
+ )} + ); } diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index e86993c..c161a48 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -94,6 +94,10 @@ export default function Sites() { const rowRefs = useRef>(new Map()); const highlightTimerRef = useRef(null); const toast = useToast(); + const [disabledModels, setDisabledModels] = useState([]); + const [disabledModelInput, setDisabledModelInput] = useState(''); + const [disabledModelsLoading, setDisabledModelsLoading] = useState(false); + const [disabledModelsSaving, setDisabledModelsSaving] = useState(false); if (editor) lastEditorRef.current = editor; const activeEditor = editor || lastEditorRef.current; @@ -197,6 +201,43 @@ export default function Sites() { setEditor({ mode: 'edit', editingSiteId: site.id }); setForm(siteFormFromSite(site)); scrollToEditorTop(); + // Load disabled models for this site + setDisabledModelsLoading(true); + setDisabledModels([]); + setDisabledModelInput(''); + api.getSiteDisabledModels(site.id) + .then((res: any) => setDisabledModels(Array.isArray(res?.models) ? res.models : [])) + .catch(() => { }) + .finally(() => setDisabledModelsLoading(false)); + }; + + const handleAddDisabledModel = () => { + const model = disabledModelInput.trim(); + if (!model) return; + if (disabledModels.includes(model)) { + toast.info(`模型 "${model}" 已在禁用列表中`); + setDisabledModelInput(''); + return; + } + setDisabledModels((prev) => [...prev, model]); + setDisabledModelInput(''); + }; + + const handleRemoveDisabledModel = (model: string) => { + setDisabledModels((prev) => prev.filter((m) => m !== model)); + }; + + const handleSaveDisabledModels = async () => { + if (!editor || editor.mode !== 'edit') return; + setDisabledModelsSaving(true); + try { + await api.updateSiteDisabledModels(editor.editingSiteId, disabledModels); + toast.success('禁用模型列表已保存'); + } catch (e: any) { + toast.error(e.message || '保存禁用模型失败'); + } finally { + setDisabledModelsSaving(false); + } }; const handleSave = async () => { @@ -654,6 +695,71 @@ export default function Sites() {
按 key/value 逐条填写。整行留空会自动忽略;同名请求头不允许重复;请求本身显式传入的请求头优先级更高。
+ {isEditing && ( +
+
禁用模型管理
+
+ 在此站点禁用指定模型后,路由重建时将不为该站点的这些模型创建通道。 +
+ {disabledModelsLoading ? ( +
+ 加载中... +
+ ) : ( + <> +
+ {disabledModels.length === 0 && ( + 暂无禁用模型 + )} + {disabledModels.map((model) => ( + + {model} + + + ))} +
+
+ setDisabledModelInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddDisabledModel(); } }} + style={{ + flex: 1, padding: '8px 12px', border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', fontSize: 12, outline: 'none', + background: 'var(--color-bg)', color: 'var(--color-text-primary)', + }} + /> + +
+ + + )} +
+ )}