From de22fc35fa5fb9e1441b0937602add65f9ff810e Mon Sep 17 00:00:00 2001 From: ksmaze Date: Sat, 14 Mar 2026 19:26:13 -0700 Subject: [PATCH] feat: Add manual model availability management, including database schema updates, new API routes, and UI components. --- drizzle/0009_model_availability_is_manual.sql | 1 + drizzle/meta/0008_snapshot.json | 14 +- drizzle/meta/_journal.json | 9 +- src/server/db/generated/mysql.bootstrap.sql | 2 +- src/server/db/generated/mysql.upgrade.sql | 2 +- .../db/generated/postgres.bootstrap.sql | 2 +- src/server/db/generated/postgres.upgrade.sql | 2 +- src/server/db/generated/schemaContract.json | 6 + src/server/db/migrate.test.ts | 5 +- src/server/db/migrate.ts | 4 +- src/server/db/schema.ts | 1 + .../routes/api/accounts.manual-models.test.ts | 133 +++++++ .../api/accounts.skip-model-fetch.test.ts | 120 +++++++ src/server/routes/api/accounts.ts | 113 ++++-- src/server/routes/proxy/models.test.ts | 6 + src/web/api.ts | 1 + src/web/pages/Accounts.tsx | 328 +++++++++++------- 17 files changed, 586 insertions(+), 163 deletions(-) create mode 100644 drizzle/0009_model_availability_is_manual.sql create mode 100644 src/server/routes/api/accounts.manual-models.test.ts create mode 100644 src/server/routes/api/accounts.skip-model-fetch.test.ts diff --git a/drizzle/0009_model_availability_is_manual.sql b/drizzle/0009_model_availability_is_manual.sql new file mode 100644 index 0000000..3d38170 --- /dev/null +++ b/drizzle/0009_model_availability_is_manual.sql @@ -0,0 +1 @@ +ALTER TABLE `model_availability` ADD `is_manual` integer DEFAULT false; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json index f93a5af..cdc0af3 100644 --- a/drizzle/meta/0008_snapshot.json +++ b/drizzle/meta/0008_snapshot.json @@ -1,8 +1,8 @@ { "version": "6", "dialect": "sqlite", - "id": "886fa1ac-d97d-4a9d-bd5e-354568cba09d", - "prevId": "71fea22a-57bf-4abb-91d6-2835353f289d", + "id": "4bdb796c-cedc-4b81-8bfb-78c5e0bde8c0", + "prevId": "886fa1ac-d97d-4a9d-bd5e-354568cba09d", "tables": { "account_tokens": { "name": "account_tokens", @@ -693,6 +693,14 @@ "notNull": false, "autoincrement": false }, + "is_manual": { + "name": "is_manual", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, "latency_ms": { "name": "latency_ms", "type": "integer", @@ -1844,4 +1852,4 @@ "internal": { "indexes": {} } -} +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 3322f62..701397c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1773500500000, "tag": "0008_sqlite_schema_backfill", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1773528832339, + "tag": "0009_model_availability_is_manual", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/server/db/generated/mysql.bootstrap.sql b/src/server/db/generated/mysql.bootstrap.sql index fd099b0..a11d7cf 100644 --- a/src/server/db/generated/mysql.bootstrap.sql +++ b/src/server/db/generated/mysql.bootstrap.sql @@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PR CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `reward` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` DATETIME, `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP); CREATE TABLE IF NOT EXISTS `events` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `read` BOOLEAN DEFAULT false, `related_id` INT, `related_type` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP); -CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_manual` BOOLEAN DEFAULT false, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT, `byte_size` INT NOT NULL, `sha256` TEXT NOT NULL, `content_base64` TEXT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `deleted_at` DATETIME); CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `billing_details` JSON); CREATE TABLE IF NOT EXISTS `proxy_video_tasks` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `upstream_video_id` TEXT NOT NULL, `site_url` TEXT NOT NULL, `token_value` TEXT NOT NULL, `requested_model` TEXT, `actual_model` TEXT, `channel_id` INT, `account_id` INT, `status_snapshot` JSON, `upstream_response_meta` JSON, `last_upstream_status` INT, `last_polled_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP); diff --git a/src/server/db/generated/mysql.upgrade.sql b/src/server/db/generated/mysql.upgrade.sql index 39c1018..9c88768 100644 --- a/src/server/db/generated/mysql.upgrade.sql +++ b/src/server/db/generated/mysql.upgrade.sql @@ -1 +1 @@ --- no schema changes detected for mysql +ALTER TABLE `model_availability` ADD COLUMN `is_manual` BOOLEAN DEFAULT false; diff --git a/src/server/db/generated/postgres.bootstrap.sql b/src/server/db/generated/postgres.bootstrap.sql index f1695de..8402c44 100644 --- a/src/server/db/generated/postgres.bootstrap.sql +++ b/src/server/db/generated/postgres.bootstrap.sql @@ -4,7 +4,7 @@ CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT A CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TIMESTAMP, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT NOT NULL DEFAULT 'info', "read" BOOLEAN DEFAULT false, "related_id" INTEGER, "related_type" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); -CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_manual" BOOLEAN DEFAULT false, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMP); CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "billing_details" JSONB); CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" JSONB, "upstream_response_meta" JSONB, "last_upstream_status" INTEGER, "last_polled_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); diff --git a/src/server/db/generated/postgres.upgrade.sql b/src/server/db/generated/postgres.upgrade.sql index bf33dfa..266130b 100644 --- a/src/server/db/generated/postgres.upgrade.sql +++ b/src/server/db/generated/postgres.upgrade.sql @@ -1 +1 @@ --- no schema changes detected for postgres +ALTER TABLE "model_availability" ADD COLUMN "is_manual" BOOLEAN DEFAULT false; diff --git a/src/server/db/generated/schemaContract.json b/src/server/db/generated/schemaContract.json index 312efb4..f57e87e 100644 --- a/src/server/db/generated/schemaContract.json +++ b/src/server/db/generated/schemaContract.json @@ -417,6 +417,12 @@ "notNull": false, "defaultValue": "datetime('now')", "primaryKey": false + }, + "is_manual": { + "logicalType": "boolean", + "notNull": false, + "defaultValue": "false", + "primaryKey": false } } }, diff --git a/src/server/db/migrate.test.ts b/src/server/db/migrate.test.ts index 050f38c..d9cc867 100644 --- a/src/server/db/migrate.test.ts +++ b/src/server/db/migrate.test.ts @@ -232,7 +232,7 @@ describe('sqlite migrate bootstrap', () => { JSON.stringify({ entries: [ { - tag: '0008_sqlite_schema_backfill', + tag: '0009_model_availability_is_manual', when: 1772600000000, }, ], @@ -240,7 +240,7 @@ describe('sqlite migrate bootstrap', () => { ); writeFileSync( - join(tempMigrationsDir, '0008_sqlite_schema_backfill.sql'), + join(tempMigrationsDir, '0009_model_availability_is_manual.sql'), [ 'CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL);', 'ALTER TABLE `account_tokens` ADD `token_group` text;', @@ -282,6 +282,7 @@ describe('sqlite migrate bootstrap', () => { '0006_site_disabled_models', '0007_account_token_group', '0008_sqlite_schema_backfill', + '0009_model_availability_is_manual', ]); const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag)); diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index a316b2a..5321d06 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -34,7 +34,7 @@ type RecoveryMigration = RecoveryMigrationRecord & { statements: string[]; }; -const VERIFIED_BOOTSTRAP_TAG = '0007_account_token_group'; +const VERIFIED_BOOTSTRAP_TAG = '0009_model_availability_is_manual'; const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [ { table: 'sites' }, { table: 'settings' }, @@ -55,6 +55,8 @@ const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [ { table: 'site_disabled_models' }, // 0007: token_group column on account_tokens { table: 'account_tokens', column: 'token_group' }, + // 0009: is_manual column on model_availability + { table: 'model_availability', column: 'is_manual' }, ]; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index b17c4cf..413ea31 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -92,6 +92,7 @@ export const modelAvailability = sqliteTable('model_availability', { accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }), modelName: text('model_name').notNull(), available: integer('available', { mode: 'boolean' }), + isManual: integer('is_manual', { mode: 'boolean' }).default(false), latencyMs: integer('latency_ms'), checkedAt: text('checked_at').default(sql`(datetime('now'))`), }, (table) => ({ diff --git a/src/server/routes/api/accounts.manual-models.test.ts b/src/server/routes/api/accounts.manual-models.test.ts new file mode 100644 index 0000000..c2caae4 --- /dev/null +++ b/src/server/routes/api/accounts.manual-models.test.ts @@ -0,0 +1,133 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +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 { eq } from 'drizzle-orm'; + +type DbModule = typeof import('../../db/index.js'); + +describe('accounts manual models endpoint', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-manual-models-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./accounts.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.accountsRoutes); + }); + + beforeEach(async () => { + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + 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.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('adds manual models and sets isManual to true', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Test Site', + url: 'https://test.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'test-token', + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: `/api/accounts/${account.id}/models/manual`, + payload: { + models: ['gpt-4-manual', 'claude-3-manual'], + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.success).toBe(true); + + const models = await db.select().from(schema.modelAvailability).where( + eq(schema.modelAvailability.accountId, account.id) + ).all(); + + expect(models).toHaveLength(2); + expect(models.map(m => m.modelName).sort()).toEqual(['claude-3-manual', 'gpt-4-manual']); + expect(models[0]?.isManual).toBe(true); + expect(models[1]?.isManual).toBe(true); + }); + + it('updates existing synced models to manual if provided', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Test Site', + url: 'https://test.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'test-token', + }).returning().get(); + + // Already-synced model that is NOT manual + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-existing', + available: true, + }); + + const response = await app.inject({ + method: 'POST', + url: `/api/accounts/${account.id}/models/manual`, + payload: { + models: ['gpt-existing', 'gpt-new'], + }, + }); + + expect(response.statusCode).toBe(200); + + const models = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + + expect(models).toHaveLength(2); + const existing = models.find(m => m.modelName === 'gpt-existing'); + const newModel = models.find(m => m.modelName === 'gpt-new'); + + expect(existing?.isManual).toBe(true); // Should be updated + expect(newModel?.isManual).toBe(true); + }); + + it('fails if account does not exist', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/accounts/999/models/manual', + payload: { + models: ['gpt-4-manual'], + }, + }); + + expect(response.statusCode).toBe(404); + }); +}); diff --git a/src/server/routes/api/accounts.skip-model-fetch.test.ts b/src/server/routes/api/accounts.skip-model-fetch.test.ts new file mode 100644 index 0000000..b0ce3dd --- /dev/null +++ b/src/server/routes/api/accounts.skip-model-fetch.test.ts @@ -0,0 +1,120 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const getModelsMock = vi.fn(); + +vi.mock('../../services/platforms/index.js', () => ({ + getAdapter: () => ({ + getModels: (...args: unknown[]) => getModelsMock(...args), + }), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('accounts skipModelFetch behavior', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-skip-model-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./accounts.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.accountsRoutes); + }); + + beforeEach(async () => { + getModelsMock.mockReset(); + + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + 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.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('skips model fetching when skipModelFetch is true under apikey credentialMode', async () => { + getModelsMock.mockRejectedValueOnce(new Error('Should not be called')); + + const site = await db.insert(schema.sites).values({ + name: 'API Key Site', + url: 'https://apikey.example.com', + platform: 'new-api', + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + accessToken: 'sk-test-skip-fetch', + credentialMode: 'apikey', + skipModelFetch: true, + }, + }); + + expect(response.statusCode).toBe(200); + // getModels is called ONCE asynchronously by refreshModelsForAccount later in the flow + expect(getModelsMock).toHaveBeenCalledTimes(1); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.apiToken).toBe('sk-test-skip-fetch'); + + // Model availability should be empty initially (background task might not have populated it yet or failed) + const models = await db.select().from(schema.modelAvailability).all(); + expect(models).toHaveLength(0); + }); + + it('still calls getModels when skipModelFetch is false', async () => { + getModelsMock.mockResolvedValue(['gpt-4']); + + const site = await db.insert(schema.sites).values({ + name: 'API Key Site', + url: 'https://apikey.example.com', + platform: 'new-api', + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + accessToken: 'sk-test-normal-fetch', + credentialMode: 'apikey', + skipModelFetch: false, + }, + }); + + expect(response.statusCode).toBe(200); + // getModels is called TWICE: once for block validation, once asynchronously by refreshModelsForAccount + expect(getModelsMock).toHaveBeenCalledTimes(2); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]?.apiToken).toBe('sk-test-normal-fetch'); + + // Model availability should be populated since getModels was called (which refreshModels uses later or handled directly) + // Actually our POST /api/accounts triggers rebuildTokenRoutesFromAvailability and refreshModelsForAccount asynchronously, so models might not be populated synchronously, but the mock should be called. + }); +}); diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index 1b2abc1..341058c 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -954,7 +954,7 @@ export async function accountsRoutes(app: FastifyInstance) { ); // Add an account (manual credential input) - app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean; credentialMode?: AccountCredentialMode; refreshToken?: string; tokenExpiresAt?: number | string } }>('/api/accounts', async (request, reply) => { + app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean; credentialMode?: AccountCredentialMode; refreshToken?: string; tokenExpiresAt?: number | string; skipModelFetch?: boolean } }>('/api/accounts', async (request, reply) => { const body = request.body; const site = await db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get(); if (!site) { @@ -979,29 +979,35 @@ export async function accountsRoutes(app: FastifyInstance) { let verifiedModels: string[] = []; if (credentialMode === 'apikey') { - try { - const models = await adapter.getModels(site.url, rawAccessToken, body.platformUserId); - verifiedModels = Array.isArray(models) - ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) - : []; - } catch (err: any) { - return reply.code(400).send({ - success: false, - message: err?.message || 'API Key 验证失败', - }); - } + if (body.skipModelFetch) { + tokenType = 'apikey'; + accessToken = ''; + if (!apiToken) apiToken = rawAccessToken; + } else { + try { + const models = await adapter.getModels(site.url, rawAccessToken, body.platformUserId); + verifiedModels = Array.isArray(models) + ? models.filter((item) => typeof item === 'string' && item.trim().length > 0) + : []; + } catch (err: any) { + return reply.code(400).send({ + success: false, + message: err?.message || 'API Key 验证失败', + }); + } - if (verifiedModels.length === 0) { - return reply.code(400).send({ - success: false, - requiresVerification: true, - message: 'API Key 验证失败:未获取到可用模型', - }); - } + if (verifiedModels.length === 0) { + return reply.code(400).send({ + success: false, + requiresVerification: true, + message: 'API Key 验证失败:未获取到可用模型', + }); + } - tokenType = 'apikey'; - accessToken = ''; - if (!apiToken) apiToken = rawAccessToken; + tokenType = 'apikey'; + accessToken = ''; + if (!apiToken) apiToken = rawAccessToken; + } } else { let verifyResult: any; try { @@ -1367,6 +1373,7 @@ export async function accountsRoutes(app: FastifyInstance) { modelName: schema.modelAvailability.modelName, available: schema.modelAvailability.available, latencyMs: schema.modelAvailability.latencyMs, + isManual: schema.modelAvailability.isManual, }).from(schema.modelAvailability) .where(eq(schema.modelAvailability.accountId, accountId)) .all(); @@ -1386,6 +1393,7 @@ export async function accountsRoutes(app: FastifyInstance) { name: r.modelName, latencyMs: r.latencyMs, disabled: disabledSet.has(r.modelName), + isManual: !!r.isManual, })) .sort((a, b) => a.name.localeCompare(b.name)); @@ -1397,5 +1405,66 @@ export async function accountsRoutes(app: FastifyInstance) { disabledCount: models.filter((m) => m.disabled).length, }; }); + + // Add models manually to an account + app.post<{ Params: { id: string }; Body: { models: string[] } }>('/api/accounts/:id/models/manual', async (request, reply) => { + const accountId = parseInt(request.params.id, 10); + if (!Number.isFinite(accountId) || accountId <= 0) { + return reply.code(400).send({ message: '账号 ID 无效' }); + } + + const { models } = request.body; + if (!Array.isArray(models) || models.length === 0) { + return reply.code(400).send({ message: '模型列表不能为空' }); + } + + const normalizedModels = models.map(m => String(m).trim()).filter(m => m.length > 0); + if (normalizedModels.length === 0) { + return reply.code(400).send({ message: '模型列表不能为空' }); + } + + const account = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, accountId)) + .get(); + + if (!account) { + return reply.code(404).send({ message: '账号不存在' }); + } + + try { + for (const modelName of normalizedModels) { + const existing = await db.select() + .from(schema.modelAvailability) + .where(and(eq(schema.modelAvailability.accountId, accountId), eq(schema.modelAvailability.modelName, modelName))) + .get(); + + if (existing) { + if (!existing.available || !existing.isManual) { + await db.update(schema.modelAvailability) + .set({ available: true, latencyMs: null, isManual: true, checkedAt: new Date().toISOString() }) + .where(eq(schema.modelAvailability.id, existing.id)) + .run(); + } + } else { + await db.insert(schema.modelAvailability).values({ + accountId, + modelName, + available: true, + isManual: true, + latencyMs: null, + checkedAt: new Date().toISOString(), + }).run(); + } + } + + try { + await rebuildTokenRoutesFromAvailability(); + } catch { } + + return { success: true }; + } catch (err: any) { + return reply.code(500).send({ success: false, message: err?.message || '保存失败' }); + } + }); } diff --git a/src/server/routes/proxy/models.test.ts b/src/server/routes/proxy/models.test.ts index 15462d0..158e2b5 100644 --- a/src/server/routes/proxy/models.test.ts +++ b/src/server/routes/proxy/models.test.ts @@ -352,6 +352,12 @@ describe('/v1/models route', () => { }, ]).run(); + await db.insert(schema.downstreamApiKeys).values({ + name: 'search-key', + key: 'change-me-proxy-sk-token', + enabled: true, + }).run(); + const response = await app.inject({ method: 'GET', url: '/v1/models', diff --git a/src/web/api.ts b/src/web/api.ts index a3209e9..ee1c165 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -265,6 +265,7 @@ export const api = { 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`), + addAccountAvailableModels: (accountId: number, models: string[]) => request(`/api/accounts/${accountId}/models/manual`, { method: 'POST', body: JSON.stringify({ 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 e2d0b54..1659ddd 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -46,6 +46,7 @@ function createTokenForm(credentialMode: 'session' | 'apikey' = 'session') { refreshToken: '', tokenExpiresAt: '', credentialMode, + skipModelFetch: false, }; } @@ -119,11 +120,13 @@ export default function Accounts() { const [modelModal, setModelModal] = useState<{ open: boolean; account: any | null; - models: Array<{ name: string; latencyMs: number | null; disabled: boolean }>; + models: Array<{ name: string; latencyMs: number | null; disabled: boolean; isManual?: boolean }>; pendingDisabled: Set; loading: boolean; saving: boolean; siteName: string; + manualModelsInput: string; + addingManualModels: boolean; }>({ open: false, account: null, @@ -132,6 +135,8 @@ export default function Accounts() { loading: false, saving: false, siteName: '', + manualModelsInput: '', + addingManualModels: false, }); const rowRefs = useRef>(new Map()); const highlightTimerRef = useRef | null>(null); @@ -330,7 +335,7 @@ export default function Accounts() { const handleTokenAdd = async () => { if (!tokenForm.siteId || !tokenForm.accessToken) return; - if (!verifyResult?.success) { + if (!verifyResult?.success && !tokenForm.skipModelFetch) { toast.error('请先验证 Token 成功后再添加账号'); return; } @@ -349,6 +354,7 @@ export default function Accounts() { ? Number.parseInt(tokenForm.tokenExpiresAt.trim(), 10) : undefined, credentialMode, + skipModelFetch: tokenForm.skipModelFetch, }); closeAddPanel(); if (result.tokenType === 'apikey') { @@ -442,7 +448,7 @@ export default function Accounts() { open: true, account, loading: true, - ...(options.resetBeforeLoad ? { models: [], pendingDisabled: new Set(), siteName: '' } : {}), + ...(options.resetBeforeLoad ? { models: [], pendingDisabled: new Set(), siteName: '', manualModelsInput: '' } : {}), })); try { if (options.refreshUpstream) { @@ -475,7 +481,7 @@ export default function Accounts() { const closeModelModal = () => { modelModalRequestSeqRef.current += 1; - setModelModal(s => ({ ...s, open: false, account: null })); + setModelModal(s => ({ ...s, open: false, account: null, manualModelsInput: '', addingManualModels: false })); }; const toggleModelDisabled = (modelName: string) => { @@ -507,6 +513,28 @@ export default function Accounts() { } }; + const handleAddManualModels = async () => { + if (!modelModal.account || !modelModal.manualModelsInput.trim()) return; + const modelsToAdd = modelModal.manualModelsInput.split(',').map((m) => m.trim()).filter(Boolean); + if (modelsToAdd.length === 0) return; + + setModelModal(s => ({ ...s, addingManualModels: true })); + try { + const res = await api.addAccountAvailableModels(modelModal.account.id, modelsToAdd); + if (res.success) { + toast.success('模型已手动添加'); + setModelModal(s => ({ ...s, manualModelsInput: '' })); + await loadModelModalModels(modelModal.account, { refreshUpstream: false }); + } else { + toast.error(res.message || '手动添加模型失败'); + } + } catch (e: any) { + toast.error(e.message || '手动添加模型失败'); + } finally { + setModelModal(s => ({ ...s, addingManualModels: false })); + } + }; + const inputStyle: React.CSSProperties = { width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)', @@ -1282,6 +1310,15 @@ export default function Accounts() { 若站点要求 New-Api-User / User-ID,请在这里提前填写。 + {verifyResult && verifyResult.success && verifyResult.tokenType === 'apikey' && (
@@ -1325,7 +1362,7 @@ export default function Accounts() {
- ) : modelModal.models.length === 0 ? ( -
-
🤖
-
暂无可用模型
-
请先点击账号操作栏中的「刷新」或「模型」按钮获取模型
- -
) : (
- {/* Header toolbar */} -
- {/* Left: select-all checkbox */} - - {/* Right: action buttons */} -
+ {modelModal.models.length === 0 ? ( +
+
🤖
+
暂无可用模型
+
请先点击账号操作栏中的「刷新」或「模型」按钮获取模型
- - -
-
- -
- {modelModal.models.map((model, idx) => { - const isDisabled = modelModal.pendingDisabled.has(model.name); - return ( -
)}