forked from boos/BoosAPI
Merge pull request #80 from ksmaze/main
feat: Add manual model availability management, including database sc…
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `model_availability` ADD `is_manual` integer DEFAULT false;
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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 @@
|
|||||||
-- 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 @@
|
|||||||
-- 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,156 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns validation error for empty models array', 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: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } 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();
|
||||||
|
if (dataDir) {
|
||||||
|
try {
|
||||||
|
rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
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 should NOT be called when skipModelFetch is true
|
||||||
|
expect(getModelsMock).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
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.
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema, runtimeDbDialect } from '../../db/index.js';
|
||||||
import { and, eq, gte, lt, sql } from 'drizzle-orm';
|
import { and, eq, gte, lt, sql } from 'drizzle-orm';
|
||||||
import { refreshBalance } from '../../services/balanceService.js';
|
import { refreshBalance } from '../../services/balanceService.js';
|
||||||
import { getAdapter } from '../../services/platforms/index.js';
|
import { getAdapter } from '../../services/platforms/index.js';
|
||||||
@@ -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,29 +979,35 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
let verifiedModels: string[] = [];
|
let verifiedModels: string[] = [];
|
||||||
|
|
||||||
if (credentialMode === 'apikey') {
|
if (credentialMode === 'apikey') {
|
||||||
try {
|
if (body.skipModelFetch === true) {
|
||||||
const models = await adapter.getModels(site.url, rawAccessToken, body.platformUserId);
|
tokenType = 'apikey';
|
||||||
verifiedModels = Array.isArray(models)
|
accessToken = '';
|
||||||
? models.filter((item) => typeof item === 'string' && item.trim().length > 0)
|
if (!apiToken) apiToken = rawAccessToken;
|
||||||
: [];
|
} else {
|
||||||
} catch (err: any) {
|
try {
|
||||||
return reply.code(400).send({
|
const models = await adapter.getModels(site.url, rawAccessToken, body.platformUserId);
|
||||||
success: false,
|
verifiedModels = Array.isArray(models)
|
||||||
message: err?.message || 'API Key 验证失败',
|
? 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) {
|
if (verifiedModels.length === 0) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
requiresVerification: true,
|
requiresVerification: true,
|
||||||
message: 'API Key 验证失败:未获取到可用模型',
|
message: 'API Key 验证失败:未获取到可用模型',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
tokenType = 'apikey';
|
tokenType = 'apikey';
|
||||||
accessToken = '';
|
accessToken = '';
|
||||||
if (!apiToken) apiToken = rawAccessToken;
|
if (!apiToken) apiToken = rawAccessToken;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let verifyResult: any;
|
let verifyResult: any;
|
||||||
try {
|
try {
|
||||||
@@ -1098,10 +1104,14 @@ export async function accountsRoutes(app: FastifyInstance) {
|
|||||||
if (tokenType === 'session') {
|
if (tokenType === 'session') {
|
||||||
try { await refreshBalance(result.id); } catch { }
|
try { await refreshBalance(result.id); } catch { }
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
await refreshModelsForAccount(result.id);
|
// Try to refresh models only if not skipping.
|
||||||
await rebuildTokenRoutesFromAvailability();
|
if (body.skipModelFetch !== true) {
|
||||||
} catch { }
|
try {
|
||||||
|
await refreshModelsForAccount(result.id);
|
||||||
|
await rebuildTokenRoutesFromAvailability();
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
|
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
|
||||||
const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode;
|
const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode;
|
||||||
@@ -1367,6 +1377,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 +1397,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 +1409,90 @@ 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 = Array.from(new Set(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 {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
for (const modelName of normalizedModels) {
|
||||||
|
if (runtimeDbDialect === 'mysql') {
|
||||||
|
const existing = await tx.select()
|
||||||
|
.from(schema.modelAvailability)
|
||||||
|
.where(and(eq(schema.modelAvailability.accountId, accountId), eq(schema.modelAvailability.modelName, modelName)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await tx.update(schema.modelAvailability)
|
||||||
|
.set({ available: true, latencyMs: null, isManual: true, checkedAt })
|
||||||
|
.where(eq(schema.modelAvailability.id, existing.id))
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
await tx.insert(schema.modelAvailability).values({
|
||||||
|
accountId,
|
||||||
|
modelName,
|
||||||
|
available: true,
|
||||||
|
isManual: true,
|
||||||
|
latencyMs: null,
|
||||||
|
checkedAt,
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// SQLite / PostgreSQL path
|
||||||
|
await (tx.insert(schema.modelAvailability)
|
||||||
|
.values({
|
||||||
|
accountId,
|
||||||
|
modelName,
|
||||||
|
available: true,
|
||||||
|
isManual: true,
|
||||||
|
latencyMs: null,
|
||||||
|
checkedAt,
|
||||||
|
}) as any)
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [schema.modelAvailability.accountId, schema.modelAvailability.modelName],
|
||||||
|
set: {
|
||||||
|
available: true,
|
||||||
|
isManual: true,
|
||||||
|
latencyMs: null,
|
||||||
|
checkedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rebuildTokenRoutesFromAvailability();
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: any) {
|
||||||
|
return reply.code(500).send({ success: false, message: err?.message || '保存失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -352,11 +352,17 @@ describe('/v1/models route', () => {
|
|||||||
},
|
},
|
||||||
]).run();
|
]).run();
|
||||||
|
|
||||||
|
await db.insert(schema.downstreamApiKeys).values({
|
||||||
|
name: 'search-key',
|
||||||
|
key: 'sk-search-key',
|
||||||
|
enabled: true,
|
||||||
|
}).run();
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
url: '/v1/models',
|
url: '/v1/models',
|
||||||
headers: {
|
headers: {
|
||||||
authorization: 'Bearer change-me-proxy-sk-token',
|
authorization: 'Bearer sk-search-key',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 || {}),
|
||||||
|
|||||||
+198
-130
@@ -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,59 +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={{ fontSize: 32, 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>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
if (!modelModal.account) return;
|
|
||||||
await loadModelModalModels(modelModal.account, {
|
|
||||||
refreshUpstream: true,
|
|
||||||
successMessage: '模型列表已刷新',
|
|
||||||
errorMessage: '刷新失败',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
className="btn btn-soft-primary"
|
|
||||||
>
|
|
||||||
立即获取模型
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{/* Header toolbar */}
|
{modelModal.models.length === 0 ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
<div style={{ textAlign: 'center', padding: '32px 0' }}>
|
||||||
{/* Left: select-all checkbox */}
|
<div style={{ fontSize: 32, marginBottom: 8 }}>🤖</div>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
|
<div style={{ fontSize: 14, color: 'var(--color-text-muted)', marginBottom: 8 }}>暂无可用模型</div>
|
||||||
<input
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 16 }}>请先点击账号操作栏中的「刷新」或「模型」按钮获取模型</div>
|
||||||
type="checkbox"
|
|
||||||
checked={modelModal.pendingDisabled.size === 0}
|
|
||||||
ref={(el) => {
|
|
||||||
if (el) {
|
|
||||||
const total = modelModal.models.length;
|
|
||||||
const disabled = modelModal.pendingDisabled.size;
|
|
||||||
el.indeterminate = disabled > 0 && disabled < total;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onChange={() => {
|
|
||||||
const allEnabled = modelModal.pendingDisabled.size === 0;
|
|
||||||
setModelModal(s => ({
|
|
||||||
...s,
|
|
||||||
pendingDisabled: allEnabled
|
|
||||||
? new Set(s.models.map((m) => m.name)) // 全选→全禁用
|
|
||||||
: new Set(), // 非全选→全启用
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
style={{ accentColor: 'var(--color-primary)', width: 15, height: 15 }}
|
|
||||||
/>
|
|
||||||
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
|
|
||||||
已启用 <strong style={{ color: 'var(--color-text-primary)' }}>{modelModal.models.length - modelModal.pendingDisabled.size}</strong> / {modelModal.models.length} 个模型
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
{/* Right: action buttons */}
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!modelModal.account) return;
|
if (!modelModal.account) return;
|
||||||
@@ -2008,88 +1999,165 @@ export default function Accounts() {
|
|||||||
errorMessage: '刷新失败',
|
errorMessage: '刷新失败',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
disabled={modelModal.saving}
|
className="btn btn-soft-primary"
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
|
||||||
>
|
>
|
||||||
刷新模型
|
立即获取模型
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setModelModal(s => {
|
|
||||||
const next = new Set<string>();
|
|
||||||
for (const m of s.models) {
|
|
||||||
if (!s.pendingDisabled.has(m.name)) next.add(m.name);
|
|
||||||
}
|
|
||||||
return { ...s, pendingDisabled: next };
|
|
||||||
})}
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
|
||||||
>
|
|
||||||
反选
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setModelModal(s => ({ ...s, pendingDisabled: new Set(s.models.map((m) => m.name)) }))}
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
|
||||||
>
|
|
||||||
全部禁用
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setModelModal(s => ({ ...s, pendingDisabled: new Set() }))}
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ fontSize: 12, padding: '4px 10px' }}
|
|
||||||
>
|
|
||||||
全部启用
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<>
|
||||||
<div style={{
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||||
maxHeight: 380,
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', userSelect: 'none' }}>
|
||||||
overflowY: 'auto',
|
|
||||||
border: '1px solid var(--color-border-light)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
}}>
|
|
||||||
{modelModal.models.map((model, idx) => {
|
|
||||||
const isDisabled = modelModal.pendingDisabled.has(model.name);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={model.name}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 10,
|
|
||||||
padding: '9px 14px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: isDisabled ? 'var(--color-bg)' : undefined,
|
|
||||||
borderBottom: idx < modelModal.models.length - 1 ? '1px solid var(--color-border-light)' : undefined,
|
|
||||||
opacity: isDisabled ? 0.55 : 1,
|
|
||||||
transition: 'opacity 0.15s, background 0.15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!isDisabled}
|
checked={modelModal.pendingDisabled.size === 0}
|
||||||
onChange={() => toggleModelDisabled(model.name)}
|
ref={(el) => {
|
||||||
style={{ accentColor: 'var(--color-primary)', width: 15, height: 15, flexShrink: 0 }}
|
if (el) {
|
||||||
|
const total = modelModal.models.length;
|
||||||
|
const disabled = modelModal.pendingDisabled.size;
|
||||||
|
el.indeterminate = disabled > 0 && disabled < total;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChange={() => {
|
||||||
|
const allEnabled = modelModal.pendingDisabled.size === 0;
|
||||||
|
setModelModal(s => ({
|
||||||
|
...s,
|
||||||
|
pendingDisabled: allEnabled
|
||||||
|
? new Set(s.models.map((m) => m.name))
|
||||||
|
: new Set(),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
style={{ accentColor: 'var(--color-primary)', width: 15, height: 15 }}
|
||||||
/>
|
/>
|
||||||
<span style={{ flex: 1, fontSize: 13, fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>
|
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
|
||||||
{model.name}
|
已启用 <strong style={{ color: 'var(--color-text-primary)' }}>{modelModal.models.length - modelModal.pendingDisabled.size}</strong> / {modelModal.models.length} 个模型
|
||||||
</span>
|
</span>
|
||||||
{model.latencyMs != null && (
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
|
||||||
{model.latencyMs}ms
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{isDisabled && (
|
|
||||||
<span className="badge badge-error" style={{ fontSize: 10, flexShrink: 0 }}>禁用</span>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
);
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
})}
|
<button
|
||||||
</div>
|
onClick={async () => {
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
if (!modelModal.account) return;
|
||||||
💡 禁用的模型将对整个站点生效,该站点下所有连接都不会使用这些模型进行代理。
|
await loadModelModalModels(modelModal.account, {
|
||||||
|
refreshUpstream: true,
|
||||||
|
successMessage: '模型列表已刷新',
|
||||||
|
errorMessage: '刷新失败',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={modelModal.saving}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||||
|
>
|
||||||
|
刷新模型
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setModelModal(s => {
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const m of s.models) {
|
||||||
|
if (!s.pendingDisabled.has(m.name)) next.add(m.name);
|
||||||
|
}
|
||||||
|
return { ...s, pendingDisabled: next };
|
||||||
|
})}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||||
|
>
|
||||||
|
反选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setModelModal(s => ({ ...s, pendingDisabled: new Set(s.models.map((m) => m.name)) }))}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||||
|
>
|
||||||
|
全部禁用
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setModelModal(s => ({ ...s, pendingDisabled: new Set() }))}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 12, padding: '4px 10px' }}
|
||||||
|
>
|
||||||
|
全部启用
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
maxHeight: 280,
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: '1px solid var(--color-border-light)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
}}>
|
||||||
|
{modelModal.models.map((model, idx) => {
|
||||||
|
const isDisabled = modelModal.pendingDisabled.has(model.name);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={model.name}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: '9px 14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: isDisabled ? 'var(--color-bg)' : undefined,
|
||||||
|
borderBottom: idx < modelModal.models.length - 1 ? '1px solid var(--color-border-light)' : undefined,
|
||||||
|
opacity: isDisabled ? 0.55 : 1,
|
||||||
|
transition: 'opacity 0.15s, background 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!isDisabled}
|
||||||
|
onChange={() => toggleModelDisabled(model.name)}
|
||||||
|
style={{ accentColor: 'var(--color-primary)', width: 15, height: 15, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<span style={{ flex: 1, fontSize: 13, fontFamily: 'var(--font-mono)', wordBreak: 'break-all' }}>
|
||||||
|
{model.name}
|
||||||
|
</span>
|
||||||
|
{model.latencyMs != null && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
||||||
|
{model.latencyMs}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{model.isManual && (
|
||||||
|
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0, padding: '0 4px' }}>手动</span>
|
||||||
|
)}
|
||||||
|
{isDisabled && (
|
||||||
|
<span className="badge badge-error" style={{ fontSize: 10, flexShrink: 0 }}>禁用</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||||
|
💡 禁用的模型将对整个站点生效,该站点下所有连接都不会使用这些模型进行代理。
|
||||||
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user