1
0
forked from boos/BoosAPI

feat: Add manual model availability management, including database schema updates, new API routes, and UI components.

This commit is contained in:
ksmaze
2026-03-14 19:26:13 -07:00
parent 5da3fbf874
commit de22fc35fa
17 changed files with 586 additions and 163 deletions
@@ -0,0 +1 @@
ALTER TABLE `model_availability` ADD `is_manual` integer DEFAULT false;
+10 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "886fa1ac-d97d-4a9d-bd5e-354568cba09d", "id": "4bdb796c-cedc-4b81-8bfb-78c5e0bde8c0",
"prevId": "71fea22a-57bf-4abb-91d6-2835353f289d", "prevId": "886fa1ac-d97d-4a9d-bd5e-354568cba09d",
"tables": { "tables": {
"account_tokens": { "account_tokens": {
"name": "account_tokens", "name": "account_tokens",
@@ -693,6 +693,14 @@
"notNull": false, "notNull": false,
"autoincrement": false "autoincrement": false
}, },
"is_manual": {
"name": "is_manual",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"latency_ms": { "latency_ms": {
"name": "latency_ms", "name": "latency_ms",
"type": "integer", "type": "integer",
+7
View File
@@ -57,6 +57,13 @@
"when": 1773500500000, "when": 1773500500000,
"tag": "0008_sqlite_schema_backfill", "tag": "0008_sqlite_schema_backfill",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1773528832339,
"tag": "0009_model_availability_is_manual",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -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 `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 `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 `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_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_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); 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);
+1 -1
View File
@@ -1 +1 @@
-- no schema changes detected for mysql ALTER TABLE `model_availability` ADD COLUMN `is_manual` BOOLEAN DEFAULT false;
@@ -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 "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 "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 "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_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_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); 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);
+1 -1
View File
@@ -1 +1 @@
-- no schema changes detected for postgres ALTER TABLE "model_availability" ADD COLUMN "is_manual" BOOLEAN DEFAULT false;
@@ -417,6 +417,12 @@
"notNull": false, "notNull": false,
"defaultValue": "datetime('now')", "defaultValue": "datetime('now')",
"primaryKey": false "primaryKey": false
},
"is_manual": {
"logicalType": "boolean",
"notNull": false,
"defaultValue": "false",
"primaryKey": false
} }
} }
}, },
+3 -2
View File
@@ -232,7 +232,7 @@ describe('sqlite migrate bootstrap', () => {
JSON.stringify({ JSON.stringify({
entries: [ entries: [
{ {
tag: '0008_sqlite_schema_backfill', tag: '0009_model_availability_is_manual',
when: 1772600000000, when: 1772600000000,
}, },
], ],
@@ -240,7 +240,7 @@ describe('sqlite migrate bootstrap', () => {
); );
writeFileSync( 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);', 'CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL);',
'ALTER TABLE `account_tokens` ADD `token_group` text;', 'ALTER TABLE `account_tokens` ADD `token_group` text;',
@@ -282,6 +282,7 @@ describe('sqlite migrate bootstrap', () => {
'0006_site_disabled_models', '0006_site_disabled_models',
'0007_account_token_group', '0007_account_token_group',
'0008_sqlite_schema_backfill', '0008_sqlite_schema_backfill',
'0009_model_availability_is_manual',
]); ]);
const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag)); const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag));
+3 -1
View File
@@ -34,7 +34,7 @@ type RecoveryMigration = RecoveryMigrationRecord & {
statements: string[]; statements: string[];
}; };
const VERIFIED_BOOTSTRAP_TAG = '0007_account_token_group'; const VERIFIED_BOOTSTRAP_TAG = '0009_model_availability_is_manual';
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [ const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
{ table: 'sites' }, { table: 'sites' },
{ table: 'settings' }, { table: 'settings' },
@@ -55,6 +55,8 @@ const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
{ table: 'site_disabled_models' }, { table: 'site_disabled_models' },
// 0007: token_group column on account_tokens // 0007: token_group column on account_tokens
{ table: 'account_tokens', column: 'token_group' }, { table: 'account_tokens', column: 'token_group' },
// 0009: is_manual column on model_availability
{ table: 'model_availability', column: 'is_manual' },
]; ];
+1
View File
@@ -92,6 +92,7 @@ export const modelAvailability = sqliteTable('model_availability', {
accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }), accountId: integer('account_id').notNull().references(() => accounts.id, { onDelete: 'cascade' }),
modelName: text('model_name').notNull(), modelName: text('model_name').notNull(),
available: integer('available', { mode: 'boolean' }), available: integer('available', { mode: 'boolean' }),
isManual: integer('is_manual', { mode: 'boolean' }).default(false),
latencyMs: integer('latency_ms'), latencyMs: integer('latency_ms'),
checkedAt: text('checked_at').default(sql`(datetime('now'))`), checkedAt: text('checked_at').default(sql`(datetime('now'))`),
}, (table) => ({ }, (table) => ({
@@ -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);
});
});
@@ -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.
});
});
+70 -1
View File
@@ -954,7 +954,7 @@ export async function accountsRoutes(app: FastifyInstance) {
); );
// Add an account (manual credential input) // 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 body = request.body;
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get(); const site = await db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get();
if (!site) { if (!site) {
@@ -979,6 +979,11 @@ export async function accountsRoutes(app: FastifyInstance) {
let verifiedModels: string[] = []; let verifiedModels: string[] = [];
if (credentialMode === 'apikey') { if (credentialMode === 'apikey') {
if (body.skipModelFetch) {
tokenType = 'apikey';
accessToken = '';
if (!apiToken) apiToken = rawAccessToken;
} else {
try { try {
const models = await adapter.getModels(site.url, rawAccessToken, body.platformUserId); const models = await adapter.getModels(site.url, rawAccessToken, body.platformUserId);
verifiedModels = Array.isArray(models) verifiedModels = Array.isArray(models)
@@ -1002,6 +1007,7 @@ export async function accountsRoutes(app: FastifyInstance) {
tokenType = 'apikey'; tokenType = 'apikey';
accessToken = ''; accessToken = '';
if (!apiToken) apiToken = rawAccessToken; if (!apiToken) apiToken = rawAccessToken;
}
} else { } else {
let verifyResult: any; let verifyResult: any;
try { try {
@@ -1367,6 +1373,7 @@ export async function accountsRoutes(app: FastifyInstance) {
modelName: schema.modelAvailability.modelName, modelName: schema.modelAvailability.modelName,
available: schema.modelAvailability.available, available: schema.modelAvailability.available,
latencyMs: schema.modelAvailability.latencyMs, latencyMs: schema.modelAvailability.latencyMs,
isManual: schema.modelAvailability.isManual,
}).from(schema.modelAvailability) }).from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, accountId)) .where(eq(schema.modelAvailability.accountId, accountId))
.all(); .all();
@@ -1386,6 +1393,7 @@ export async function accountsRoutes(app: FastifyInstance) {
name: r.modelName, name: r.modelName,
latencyMs: r.latencyMs, latencyMs: r.latencyMs,
disabled: disabledSet.has(r.modelName), disabled: disabledSet.has(r.modelName),
isManual: !!r.isManual,
})) }))
.sort((a, b) => a.name.localeCompare(b.name)); .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, 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 || '保存失败' });
}
});
} }
+6
View File
@@ -352,6 +352,12 @@ describe('/v1/models route', () => {
}, },
]).run(); ]).run();
await db.insert(schema.downstreamApiKeys).values({
name: 'search-key',
key: 'change-me-proxy-sk-token',
enabled: true,
}).run();
const response = await app.inject({ const response = await app.inject({
method: 'GET', method: 'GET',
url: '/v1/models', url: '/v1/models',
+1
View File
@@ -265,6 +265,7 @@ export const api = {
batchUpdateAccounts: (data: any) => request('/api/accounts/batch', { method: 'POST', body: JSON.stringify(data) }), batchUpdateAccounts: (data: any) => request('/api/accounts/batch', { method: 'POST', body: JSON.stringify(data) }),
refreshBalance: (id: number) => request(`/api/accounts/${id}/balance`, { method: 'POST' }), refreshBalance: (id: number) => request(`/api/accounts/${id}/balance`, { method: 'POST' }),
getAccountModels: (id: number) => request(`/api/accounts/${id}/models`), 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', { refreshAccountHealth: (data?: { accountId?: number; wait?: boolean }) => request('/api/accounts/health/refresh', {
method: 'POST', method: 'POST',
body: JSON.stringify(data || {}), body: JSON.stringify(data || {}),
+84 -16
View File
@@ -46,6 +46,7 @@ function createTokenForm(credentialMode: 'session' | 'apikey' = 'session') {
refreshToken: '', refreshToken: '',
tokenExpiresAt: '', tokenExpiresAt: '',
credentialMode, credentialMode,
skipModelFetch: false,
}; };
} }
@@ -119,11 +120,13 @@ export default function Accounts() {
const [modelModal, setModelModal] = useState<{ const [modelModal, setModelModal] = useState<{
open: boolean; open: boolean;
account: any | null; 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<string>; pendingDisabled: Set<string>;
loading: boolean; loading: boolean;
saving: boolean; saving: boolean;
siteName: string; siteName: string;
manualModelsInput: string;
addingManualModels: boolean;
}>({ }>({
open: false, open: false,
account: null, account: null,
@@ -132,6 +135,8 @@ export default function Accounts() {
loading: false, loading: false,
saving: false, saving: false,
siteName: '', siteName: '',
manualModelsInput: '',
addingManualModels: false,
}); });
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map()); const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -330,7 +335,7 @@ export default function Accounts() {
const handleTokenAdd = async () => { const handleTokenAdd = async () => {
if (!tokenForm.siteId || !tokenForm.accessToken) return; if (!tokenForm.siteId || !tokenForm.accessToken) return;
if (!verifyResult?.success) { if (!verifyResult?.success && !tokenForm.skipModelFetch) {
toast.error('请先验证 Token 成功后再添加账号'); toast.error('请先验证 Token 成功后再添加账号');
return; return;
} }
@@ -349,6 +354,7 @@ export default function Accounts() {
? Number.parseInt(tokenForm.tokenExpiresAt.trim(), 10) ? Number.parseInt(tokenForm.tokenExpiresAt.trim(), 10)
: undefined, : undefined,
credentialMode, credentialMode,
skipModelFetch: tokenForm.skipModelFetch,
}); });
closeAddPanel(); closeAddPanel();
if (result.tokenType === 'apikey') { if (result.tokenType === 'apikey') {
@@ -442,7 +448,7 @@ export default function Accounts() {
open: true, open: true,
account, account,
loading: true, loading: true,
...(options.resetBeforeLoad ? { models: [], pendingDisabled: new Set<string>(), siteName: '' } : {}), ...(options.resetBeforeLoad ? { models: [], pendingDisabled: new Set<string>(), siteName: '', manualModelsInput: '' } : {}),
})); }));
try { try {
if (options.refreshUpstream) { if (options.refreshUpstream) {
@@ -475,7 +481,7 @@ export default function Accounts() {
const closeModelModal = () => { const closeModelModal = () => {
modelModalRequestSeqRef.current += 1; modelModalRequestSeqRef.current += 1;
setModelModal(s => ({ ...s, open: false, account: null })); setModelModal(s => ({ ...s, open: false, account: null, manualModelsInput: '', addingManualModels: false }));
}; };
const toggleModelDisabled = (modelName: string) => { 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 = { const inputStyle: React.CSSProperties = {
width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)', width: '100%', padding: '10px 14px', border: '1px solid var(--color-border)',
@@ -1282,6 +1310,15 @@ export default function Accounts() {
若站点要求 New-Api-User / User-ID,请在这里提前填写。 若站点要求 New-Api-User / User-ID,请在这里提前填写。
</div> </div>
</div> </div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', alignSelf: 'flex-start' }}>
<input
type="checkbox"
checked={!!tokenForm.skipModelFetch}
onChange={(e) => setTokenForm((f) => ({ ...f, skipModelFetch: e.target.checked }))}
style={{ width: 14, height: 14 }}
/>
<span>跳过模型验证(直接添加 API Key)</span>
</label>
{verifyResult && verifyResult.success && verifyResult.tokenType === 'apikey' && ( {verifyResult && verifyResult.success && verifyResult.tokenType === 'apikey' && (
<div className="alert alert-info animate-scale-in"> <div className="alert alert-info animate-scale-in">
<div className="alert-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div className="alert-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -1325,7 +1362,7 @@ export default function Accounts() {
</button> </button>
<button <button
onClick={handleTokenAdd} onClick={handleTokenAdd}
disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || !canAddVerifiedConnection} disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || (!canAddVerifiedConnection && !tokenForm.skipModelFetch)}
className="btn btn-success" className="btn btn-success"
> >
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />添加中...</> : '添加连接'} {saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />添加中...</> : '添加连接'}
@@ -1946,11 +1983,13 @@ export default function Accounts() {
<span className="spinner" /> <span className="spinner" />
<span style={{ color: 'var(--color-text-muted)', fontSize: 14 }}>加载模型列表...</span> <span style={{ color: 'var(--color-text-muted)', fontSize: 14 }}>加载模型列表...</span>
</div> </div>
) : modelModal.models.length === 0 ? ( ) : (
<div style={{ textAlign: 'center', padding: '48px 0' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{modelModal.models.length === 0 ? (
<div style={{ textAlign: 'center', padding: '32px 0' }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🤖</div> <div style={{ fontSize: 32, marginBottom: 8 }}>🤖</div>
<div style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: 8 }}>暂无可用模型</div> <div style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: 8 }}>暂无可用模型</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>请先点击账号操作栏中的「刷新」或「模型」按钮获取模型</div> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 16 }}>请先点击账号操作栏中的「刷新」或「模型」按钮获取模型</div>
<button <button
onClick={async () => { onClick={async () => {
if (!modelModal.account) return; if (!modelModal.account) return;
@@ -1960,17 +1999,14 @@ export default function Accounts() {
errorMessage: '刷新失败', errorMessage: '刷新失败',
}); });
}} }}
style={{ marginTop: 16 }}
className="btn btn-soft-primary" className="btn btn-soft-primary"
> >
立即获取模型 立即获取模型
</button> </button>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <>
{/* Header toolbar */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
{/* Left: select-all checkbox */}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}> <label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
<input <input
type="checkbox" type="checkbox"
@@ -1987,8 +2023,8 @@ export default function Accounts() {
setModelModal(s => ({ setModelModal(s => ({
...s, ...s,
pendingDisabled: allEnabled pendingDisabled: allEnabled
? new Set(s.models.map((m) => m.name)) // 全选→全禁用 ? new Set(s.models.map((m) => m.name))
: new Set(), // 非全选→全启用 : new Set(),
})); }));
}} }}
style={{ accentColor: 'var(--color-primary)', width: 15, height: 15 }} style={{ accentColor: 'var(--color-primary)', width: 15, height: 15 }}
@@ -1997,7 +2033,6 @@ export default function Accounts() {
已启用 <strong style={{ color: 'var(--color-text-primary)' }}>{modelModal.models.length - modelModal.pendingDisabled.size}</strong> / {modelModal.models.length} 个模型 已启用 <strong style={{ color: 'var(--color-text-primary)' }}>{modelModal.models.length - modelModal.pendingDisabled.size}</strong> / {modelModal.models.length} 个模型
</span> </span>
</label> </label>
{/* Right: action buttons */}
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
<button <button
onClick={async () => { onClick={async () => {
@@ -2045,7 +2080,7 @@ export default function Accounts() {
</div> </div>
<div style={{ <div style={{
maxHeight: 380, maxHeight: 280,
overflowY: 'auto', overflowY: 'auto',
border: '1px solid var(--color-border-light)', border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-sm)', borderRadius: 'var(--radius-sm)',
@@ -2081,6 +2116,9 @@ export default function Accounts() {
{model.latencyMs}ms {model.latencyMs}ms
</span> </span>
)} )}
{model.isManual && (
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0, padding: '0 4px' }}>手动</span>
)}
{isDisabled && ( {isDisabled && (
<span className="badge badge-error" style={{ fontSize: 10, flexShrink: 0 }}>禁用</span> <span className="badge badge-error" style={{ fontSize: 10, flexShrink: 0 }}>禁用</span>
)} )}
@@ -2091,6 +2129,36 @@ export default function Accounts() {
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}> <div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
💡 禁用的模型将对整个站点生效,该站点下所有连接都不会使用这些模型进行代理。 💡 禁用的模型将对整个站点生效,该站点下所有连接都不会使用这些模型进行代理。
</div> </div>
</>
)}
<div style={{ marginTop: 16, padding: '12px', background: 'var(--color-bg)', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--color-text-primary)' }}>手动添加可用模型</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8 }}>
如果您的账号支持某些未在上方列表中显示的模型,可以在此手动添加(多个以英文逗号分隔)。
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
placeholder="例如: gpt-4-custom, claude-3-5-sonnet-20241022"
value={modelModal.manualModelsInput}
onChange={(e) => setModelModal(s => ({ ...s, manualModelsInput: e.target.value }))}
style={{ ...inputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
onKeyDown={(e) => {
if (e.key === 'Enter' && !modelModal.addingManualModels) {
handleAddManualModels();
}
}}
/>
<button
disabled={!modelModal.manualModelsInput.trim() || modelModal.addingManualModels}
onClick={handleAddManualModels}
className="btn btn-primary btn-sm"
style={{ whiteSpace: 'nowrap' }}
>
{modelModal.addingManualModels ? <span className="spinner spinner-sm" /> : '添加'}
</button>
</div>
</div>
</div> </div>
)} )}
</CenteredModal> </CenteredModal>