feat: Add normalized types and utility functions for handling chat formats

- Introduced `NormalizedContentBlock`, `NormalizedUsage`, and related types for structured data handling.
- Implemented utility functions for creating and merging normalized usage statistics.
- Added a new module for shared transformers to manage chat formats.

feat: Implement app local state management

- Created functions to clear application installation state and manage local storage keys.
- Added constants for theme and user profile storage keys.

test: Add tests for BrandIcon component rendering

- Implemented tests to ensure BrandIcon does not force a colored background for recognized brands.

test: Enhance ModelAnalysisPanel tests

- Added tests for rendering token summaries with compact units in ModelAnalysisPanel.

test: Add tests for ModernSelect component

- Implemented tests to verify icon rendering for selected options in ModernSelect.

test: Add number formatting tests

- Created tests for `formatCompactTokenMetric` to validate token formatting logic.

feat: Implement account creation intent handling

- Added logic to handle session and API key creation intents in the Accounts page.

test: Add tests for dashboard performance card

- Implemented tests to verify rendering of performance metrics in the dashboard.

feat: Implement default connection segment resolution

- Added logic to determine initial connection segments based on platform types.

test: Add tests for settings factory reset functionality

- Implemented tests to verify the factory reset process and local state clearing.

test: Add tests for site creation redirect logic

- Implemented tests to verify redirection based on site capabilities during creation.

test: Add tests for token routes refresh decision action

- Implemented tests to verify refresh decision actions in TokenRoutes.

test: Add tests for token routes snapshot load

- Implemented tests to verify loading of cached snapshot data in TokenRoutes.
This commit is contained in:
cita
2026-03-08 12:08:57 +08:00
parent ed05b37626
commit 23922a5965
115 changed files with 18597 additions and 4411 deletions
@@ -388,9 +388,10 @@ async function ensureSchema(client: SqlClient): Promise<void> {
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY 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, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "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" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" BOOLEAN DEFAULT TRUE, "expires_at" TEXT, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" BOOLEAN DEFAULT FALSE, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
@@ -403,9 +404,10 @@ async function ensureSchema(client: SqlClient): Promise<void> {
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` TEXT NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`downstream_api_keys\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`key\` VARCHAR(191) NOT NULL UNIQUE, \`description\` TEXT NULL, \`enabled\` BOOLEAN DEFAULT TRUE, \`expires_at\` TEXT NULL, \`max_cost\` DOUBLE NULL, \`used_cost\` DOUBLE DEFAULT 0, \`max_requests\` INT NULL, \`used_requests\` INT DEFAULT 0, \`supported_models\` TEXT NULL, \`allowed_route_ids\` TEXT NULL, \`site_weight_multipliers\` TEXT NULL, \`last_used_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`events\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`type\` VARCHAR(32) NOT NULL, \`title\` TEXT NOT NULL, \`message\` TEXT NULL, \`level\` VARCHAR(16) DEFAULT 'info', \`read\` BOOLEAN DEFAULT FALSE, \`related_id\` INT NULL, \`related_type\` VARCHAR(32) NULL, \`created_at\` TEXT NULL)`,
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
@@ -417,9 +419,10 @@ async function ensureSchema(client: SqlClient): Promise<void> {
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_fail_at" TEXT, "cooldown_until" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "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" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "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" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "key" TEXT NOT NULL UNIQUE, "description" TEXT, "enabled" INTEGER DEFAULT 1, "expires_at" TEXT, "max_cost" REAL, "used_cost" REAL DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" TEXT, "allowed_route_ids" TEXT, "site_weight_multipliers" TEXT, "last_used_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT DEFAULT 'info', "read" INTEGER DEFAULT 0, "related_id" INTEGER, "related_type" TEXT, "created_at" TEXT)`,
`CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT PRIMARY KEY, "value" TEXT)`,
@@ -581,13 +584,15 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
for (const row of snapshot.accounts.tokenRoutes) {
statements.push({
table: 'token_routes',
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'enabled', 'created_at', 'updated_at'],
columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'enabled', 'created_at', 'updated_at'],
values: [
asNumber(row.id, 0),
asNullableString(row.modelPattern),
asNullableString(row.displayName),
asNullableString(row.displayIcon),
asNullableString(row.modelMapping),
asNullableString(row.decisionSnapshot),
asNullableString(row.decisionRefreshedAt),
asBoolean(row.enabled, true),
asNullableString(row.createdAt),
asNullableString(row.updatedAt),
@@ -0,0 +1,129 @@
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');
type SeedModule = typeof import('./defaultSiteSeedService.js');
describe('defaultSiteSeedService', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let ensureDefaultSitesSeeded: SeedModule['ensureDefaultSitesSeeded'];
let DEFAULT_SITE_SEED_SETTING_KEY: SeedModule['DEFAULT_SITE_SEED_SETTING_KEY'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-default-site-seed-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const seedModule = await import('./defaultSiteSeedService.js');
db = dbModule.db;
schema = dbModule.schema;
ensureDefaultSitesSeeded = seedModule.ensureDefaultSitesSeeded;
DEFAULT_SITE_SEED_SETTING_KEY = seedModule.DEFAULT_SITE_SEED_SETTING_KEY;
});
beforeEach(async () => {
await db.delete(schema.settings).run();
await db.delete(schema.sites).run();
});
afterAll(() => {
delete process.env.DATA_DIR;
});
it('seeds default official sites once when no site exists yet', async () => {
await ensureDefaultSitesSeeded();
const sites = await db.select().from(schema.sites).all();
const sorted = [...sites].sort((left, right) => (left.sortOrder || 0) - (right.sortOrder || 0));
expect(sorted.map((site) => ({
name: site.name,
url: site.url,
platform: site.platform,
status: site.status,
useSystemProxy: site.useSystemProxy,
isPinned: site.isPinned,
globalWeight: site.globalWeight,
sortOrder: site.sortOrder,
}))).toEqual([
{
name: 'OpenAI 官方',
url: 'https://api.openai.com',
platform: 'openai',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 0,
},
{
name: 'Claude 官方',
url: 'https://api.anthropic.com',
platform: 'claude',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 1,
},
{
name: 'Gemini 官方',
url: 'https://generativelanguage.googleapis.com',
platform: 'gemini',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 2,
},
{
name: 'CLIProxyAPI',
url: 'http://127.0.0.1:8317',
platform: 'cliproxyapi',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 3,
},
]);
const setting = await db.select().from(schema.settings).where(eq(schema.settings.key, DEFAULT_SITE_SEED_SETTING_KEY)).get();
expect(JSON.parse(setting?.value || 'false')).toBe(true);
});
it('marks first-run evaluation without seeding when sites already exist', async () => {
await db.insert(schema.sites).values({
name: 'Existing Site',
url: 'https://existing.example.com',
platform: 'new-api',
}).run();
await ensureDefaultSitesSeeded();
const sites = await db.select().from(schema.sites).all();
expect(sites).toHaveLength(1);
expect(sites[0]?.name).toBe('Existing Site');
const setting = await db.select().from(schema.settings).where(eq(schema.settings.key, DEFAULT_SITE_SEED_SETTING_KEY)).get();
expect(JSON.parse(setting?.value || 'false')).toBe(true);
});
it('does not seed again after the evaluation marker exists', async () => {
await db.insert(schema.settings).values({
key: DEFAULT_SITE_SEED_SETTING_KEY,
value: JSON.stringify(true),
}).run();
await ensureDefaultSitesSeeded();
const sites = await db.select().from(schema.sites).all();
expect(sites).toHaveLength(0);
});
});
@@ -0,0 +1,105 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
export const DEFAULT_SITE_SEED_SETTING_KEY = 'default_site_seed_v1';
const DEFAULT_SITE_ROWS: Array<typeof schema.sites.$inferInsert> = [
{
name: 'OpenAI 官方',
url: 'https://api.openai.com',
platform: 'openai',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 0,
},
{
name: 'Claude 官方',
url: 'https://api.anthropic.com',
platform: 'claude',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 1,
},
{
name: 'Gemini 官方',
url: 'https://generativelanguage.googleapis.com',
platform: 'gemini',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 2,
},
{
name: 'CLIProxyAPI',
url: 'http://127.0.0.1:8317',
platform: 'cliproxyapi',
status: 'active',
useSystemProxy: false,
isPinned: false,
globalWeight: 1,
sortOrder: 3,
},
];
type SeedSummary = {
seeded: number;
alreadyMarked: boolean;
hadExistingSites: boolean;
};
async function writeSeedMarker(tx: typeof db) {
await tx.insert(schema.settings)
.values({
key: DEFAULT_SITE_SEED_SETTING_KEY,
value: JSON.stringify(true),
})
.onConflictDoUpdate({
target: schema.settings.key,
set: { value: JSON.stringify(true) },
})
.run();
}
export async function ensureDefaultSitesSeeded(): Promise<SeedSummary> {
return db.transaction(async (tx) => {
const marker = await tx.select({ key: schema.settings.key })
.from(schema.settings)
.where(eq(schema.settings.key, DEFAULT_SITE_SEED_SETTING_KEY))
.get();
if (marker) {
return {
seeded: 0,
alreadyMarked: true,
hadExistingSites: false,
};
}
const existingSite = await tx.select({ id: schema.sites.id })
.from(schema.sites)
.limit(1)
.get();
if (existingSite) {
await writeSeedMarker(tx);
return {
seeded: 0,
alreadyMarked: false,
hadExistingSites: true,
};
}
await tx.insert(schema.sites).values(DEFAULT_SITE_ROWS).run();
await writeSeedMarker(tx);
return {
seeded: DEFAULT_SITE_ROWS.length,
alreadyMarked: false,
hadExistingSites: false,
};
});
}
@@ -0,0 +1,90 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
type DbModule = typeof import('../db/index.js');
type ConfigModule = typeof import('../config.js');
type ServiceModule = typeof import('./factoryResetService.js');
describe('factoryResetService', () => {
let db: DbModule['db'];
let schema: DbModule['schema'];
let config: ConfigModule['config'];
let performFactoryReset: ServiceModule['performFactoryReset'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-factory-reset-service-'));
process.env.DATA_DIR = dataDir;
await import('../db/migrate.js');
const dbModule = await import('../db/index.js');
const configModule = await import('../config.js');
const serviceModule = await import('./factoryResetService.js');
db = dbModule.db;
schema = dbModule.schema;
config = configModule.config;
performFactoryReset = serviceModule.performFactoryReset;
});
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.proxyVideoTasks).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.sites).run();
await db.delete(schema.downstreamApiKeys).run();
await db.delete(schema.events).run();
await db.delete(schema.settings).run();
config.authToken = 'external-reset-token';
config.dbType = 'postgres';
config.dbUrl = 'postgres://user:pass@127.0.0.1:5432/metapi';
config.dbSsl = true;
});
afterAll(() => {
delete process.env.DATA_DIR;
});
it('clears current active data before switching runtime back to sqlite', async () => {
await db.insert(schema.sites).values({
name: 'External Runtime Site',
url: 'https://external.example.com',
platform: 'new-api',
}).run();
await db.insert(schema.settings).values([
{ key: 'auth_token', value: JSON.stringify('external-reset-token') },
{ key: 'db_type', value: JSON.stringify('postgres') },
{ key: 'db_url', value: JSON.stringify('postgres://user:pass@127.0.0.1:5432/metapi') },
{ key: 'db_ssl', value: JSON.stringify(true) },
]).run();
const switchRuntimeDatabase = vi.fn(async () => undefined);
const runSqliteMigrations = vi.fn(() => undefined);
const ensureDefaultSitesSeeded = vi.fn(async () => ({
seeded: 0,
alreadyMarked: false,
hadExistingSites: false,
}));
await performFactoryReset({
switchRuntimeDatabase,
runSqliteMigrations,
ensureDefaultSitesSeeded,
});
expect(switchRuntimeDatabase).toHaveBeenCalledWith('sqlite', '', false);
expect(runSqliteMigrations).toHaveBeenCalledTimes(1);
expect(ensureDefaultSitesSeeded).toHaveBeenCalledTimes(1);
expect(await db.select().from(schema.sites).all()).toHaveLength(0);
expect(await db.select().from(schema.settings).all()).toHaveLength(0);
});
});
@@ -0,0 +1,61 @@
import { buildConfig, config } from '../config.js';
import { db, schema, switchRuntimeDatabase } from '../db/index.js';
import { updateBalanceRefreshCron, updateCheckinCron } from './checkinScheduler.js';
import { ensureDefaultSitesSeeded } from './defaultSiteSeedService.js';
import { invalidateSiteProxyCache } from './siteProxy.js';
export const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
type FactoryResetDependencies = {
switchRuntimeDatabase?: typeof switchRuntimeDatabase;
runSqliteMigrations?: () => Promise<void> | void;
ensureDefaultSitesSeeded?: typeof ensureDefaultSitesSeeded;
};
async function clearAllBusinessData() {
await db.transaction(async (tx) => {
await tx.delete(schema.routeChannels).run();
await tx.delete(schema.tokenModelAvailability).run();
await tx.delete(schema.modelAvailability).run();
await tx.delete(schema.proxyLogs).run();
await tx.delete(schema.proxyVideoTasks).run();
await tx.delete(schema.checkinLogs).run();
await tx.delete(schema.accountTokens).run();
await tx.delete(schema.accounts).run();
await tx.delete(schema.tokenRoutes).run();
await tx.delete(schema.sites).run();
await tx.delete(schema.downstreamApiKeys).run();
await tx.delete(schema.events).run();
await tx.delete(schema.settings).run();
});
}
function resetRuntimeConfigToInitialState() {
const baseline = buildConfig(process.env);
Object.assign(config, baseline);
config.authToken = FACTORY_RESET_ADMIN_TOKEN;
config.dbType = 'sqlite';
config.dbUrl = '';
config.dbSsl = false;
updateCheckinCron(config.checkinCron);
updateBalanceRefreshCron(config.balanceRefreshCron);
invalidateSiteProxyCache();
}
async function runDefaultSqliteMigrations() {
const migrateModule = await import('../db/migrate.js');
migrateModule.runSqliteMigrations();
}
export async function performFactoryReset(deps: FactoryResetDependencies = {}): Promise<void> {
const switchRuntimeDatabaseImpl = deps.switchRuntimeDatabase ?? switchRuntimeDatabase;
const runSqliteMigrationsImpl = deps.runSqliteMigrations ?? runDefaultSqliteMigrations;
const ensureDefaultSitesSeededImpl = deps.ensureDefaultSitesSeeded ?? ensureDefaultSitesSeeded;
await clearAllBusinessData();
resetRuntimeConfigToInitialState();
await switchRuntimeDatabaseImpl('sqlite', '', false);
await runSqliteMigrationsImpl();
await clearAllBusinessData();
await ensureDefaultSitesSeededImpl();
}
+28 -5
View File
@@ -57,7 +57,7 @@ interface RoutingReferenceCostCacheEntry {
costs: Map<string, number>;
}
interface EstimateProxyCostInput {
export interface EstimateProxyCostInput {
site: {
id: number;
url: string;
@@ -497,6 +497,20 @@ async function getPricingDataCached(input: EstimateProxyCostInput): Promise<Pric
return data;
}
async function refreshPricingDataCache(input: EstimateProxyCostInput): Promise<PricingData | null> {
const key = getCacheKey(input);
const now = Date.now();
const data = await fetchPricingData(input);
const ttlMs = data ? PRICE_CACHE_TTL_MS : PRICE_CACHE_FAILURE_TTL_MS;
pricingCache.set(key, {
fetchedAt: now,
ttlMs,
data,
});
syncRoutingReferenceCostCache(key, now, ttlMs, data);
return data;
}
export function getCachedModelRoutingReferenceCost(input: {
siteId: number;
accountId: number;
@@ -708,10 +722,7 @@ export function calculateModelUsageCost(
return calculateModelUsageBreakdown(model, usage, groupRatio)?.breakdown.totalCost ?? 0;
}
export async function fetchModelPricingCatalog(input: EstimateProxyCostInput): Promise<ModelPricingCatalog | null> {
const pricingData = await getPricingDataCached(input);
if (!pricingData) return null;
function buildModelPricingCatalogFromData(pricingData: PricingData): ModelPricingCatalog {
const groups = Array.from(new Set([DEFAULT_GROUP, ...Object.keys(pricingData.groupRatio)]));
const defaultMultiplier = pricingData.groupRatio[DEFAULT_GROUP] || 1;
@@ -763,6 +774,18 @@ export async function fetchModelPricingCatalog(input: EstimateProxyCostInput): P
};
}
export async function fetchModelPricingCatalog(input: EstimateProxyCostInput): Promise<ModelPricingCatalog | null> {
const pricingData = await getPricingDataCached(input);
if (!pricingData) return null;
return buildModelPricingCatalogFromData(pricingData);
}
export async function refreshModelPricingCatalog(input: EstimateProxyCostInput): Promise<ModelPricingCatalog | null> {
const pricingData = await refreshPricingDataCache(input);
if (!pricingData) return null;
return buildModelPricingCatalogFromData(pricingData);
}
export function fallbackTokenCost(totalTokens: number, platform: string): number {
const divisor = platform === 'veloera' ? 1_000_000 : 500_000;
return roundCost(toPositiveInt(totalTokens) / divisor);
+13 -8
View File
@@ -5,6 +5,7 @@ import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accoun
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js';
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
@@ -343,10 +344,10 @@ export async function rebuildTokenRoutesFromAvailability() {
}
const latestModelNames = new Set<string>(Array.from(modelTokens.keys()));
for (const route of routes) {
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) {
continue;
for (const route of routes) {
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) {
continue;
}
const routeChannelCount = channels.filter((channel) => channel.routeId === route.id).length;
@@ -357,10 +358,14 @@ export async function rebuildTokenRoutesFromAvailability() {
const deleted = (await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).run()).changes;
if (deleted > 0) {
removedRoutes += deleted;
}
}
invalidateTokenRouterCache();
}
}
if (createdRoutes > 0 || createdChannels > 0 || removedChannels > 0 || removedRoutes > 0) {
await clearAllRouteDecisionSnapshots();
}
invalidateTokenRouterCache();
return {
models: modelTokens.size,
+127
View File
@@ -0,0 +1,127 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { formatUtcSqlDateTime } from './localTimeService.js';
export type SaveProxyVideoTaskInput = {
publicId?: string;
upstreamVideoId: string;
siteUrl: string;
tokenValue: string;
requestedModel: string;
actualModel: string;
channelId: number | null;
accountId: number | null;
statusSnapshot?: unknown;
upstreamResponseMeta?: unknown;
lastUpstreamStatus?: number | null;
lastPolledAt?: string | null;
};
export type ProxyVideoTaskRecord = {
publicId: string;
upstreamVideoId: string;
siteUrl: string;
tokenValue: string;
requestedModel: string | null;
actualModel: string | null;
channelId: number | null;
accountId: number | null;
statusSnapshot: unknown | null;
upstreamResponseMeta: unknown | null;
lastUpstreamStatus: number | null;
lastPolledAt: string | null;
};
function buildPublicVideoId(): string {
const timePart = Date.now().toString(36);
const randomPart = Math.random().toString(36).slice(2, 10);
return `vid_${timePart}_${randomPart}`;
}
export async function saveProxyVideoTask(input: SaveProxyVideoTaskInput): Promise<{
publicId: string;
upstreamVideoId: string;
}> {
const publicId = input.publicId?.trim() || buildPublicVideoId();
const now = formatUtcSqlDateTime(new Date());
await db.insert(schema.proxyVideoTasks).values({
publicId,
upstreamVideoId: input.upstreamVideoId,
siteUrl: input.siteUrl,
tokenValue: input.tokenValue,
requestedModel: input.requestedModel,
actualModel: input.actualModel,
channelId: input.channelId,
accountId: input.accountId,
statusSnapshot: input.statusSnapshot === undefined ? null : JSON.stringify(input.statusSnapshot),
upstreamResponseMeta: input.upstreamResponseMeta === undefined ? null : JSON.stringify(input.upstreamResponseMeta),
lastUpstreamStatus: input.lastUpstreamStatus ?? null,
lastPolledAt: input.lastPolledAt ?? (input.lastUpstreamStatus == null ? null : now),
createdAt: now,
updatedAt: now,
}).run();
return {
publicId,
upstreamVideoId: input.upstreamVideoId,
};
}
export async function getProxyVideoTaskByPublicId(publicId: string): Promise<ProxyVideoTaskRecord | null> {
const row = await db.select().from(schema.proxyVideoTasks)
.where(eq(schema.proxyVideoTasks.publicId, publicId))
.get();
if (!row) return null;
return {
publicId: row.publicId,
upstreamVideoId: row.upstreamVideoId,
siteUrl: row.siteUrl,
tokenValue: row.tokenValue,
requestedModel: row.requestedModel,
actualModel: row.actualModel,
channelId: row.channelId,
accountId: row.accountId,
statusSnapshot: parseJsonColumn(row.statusSnapshot),
upstreamResponseMeta: parseJsonColumn(row.upstreamResponseMeta),
lastUpstreamStatus: row.lastUpstreamStatus,
lastPolledAt: row.lastPolledAt,
};
}
export async function deleteProxyVideoTaskByPublicId(publicId: string): Promise<void> {
await db.delete(schema.proxyVideoTasks)
.where(eq(schema.proxyVideoTasks.publicId, publicId))
.run();
}
export async function refreshProxyVideoTaskSnapshot(
publicId: string,
input: {
statusSnapshot?: unknown;
upstreamResponseMeta?: unknown;
lastUpstreamStatus?: number | null;
lastPolledAt?: string | null;
},
): Promise<void> {
const now = formatUtcSqlDateTime(new Date());
await db.update(schema.proxyVideoTasks)
.set({
statusSnapshot: input.statusSnapshot === undefined ? null : JSON.stringify(input.statusSnapshot),
upstreamResponseMeta: input.upstreamResponseMeta === undefined ? null : JSON.stringify(input.upstreamResponseMeta),
lastUpstreamStatus: input.lastUpstreamStatus ?? null,
lastPolledAt: input.lastPolledAt ?? now,
updatedAt: now,
})
.where(eq(schema.proxyVideoTasks.publicId, publicId))
.run();
}
function parseJsonColumn(value: string | null | undefined): unknown | null {
if (typeof value !== 'string' || value.trim().length === 0) return null;
try {
return JSON.parse(value);
} catch {
return value;
}
}
@@ -0,0 +1,64 @@
import { eq, inArray } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { formatUtcSqlDateTime } from './localTimeService.js';
function serializeSnapshot(snapshot: unknown): string | null {
if (snapshot == null) return null;
return JSON.stringify(snapshot);
}
export function parseRouteDecisionSnapshot(value: string | null | undefined): unknown | null {
if (typeof value !== 'string' || value.trim().length === 0) return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}
export async function saveRouteDecisionSnapshot(routeId: number, snapshot: unknown): Promise<void> {
const refreshedAt = formatUtcSqlDateTime(new Date());
await db.update(schema.tokenRoutes)
.set({
decisionSnapshot: serializeSnapshot(snapshot),
decisionRefreshedAt: refreshedAt,
})
.where(eq(schema.tokenRoutes.id, routeId))
.run();
}
export async function saveRouteDecisionSnapshots(entries: Array<{ routeId: number; snapshot: unknown }>): Promise<void> {
for (const entry of entries) {
await saveRouteDecisionSnapshot(entry.routeId, entry.snapshot);
}
}
export async function clearRouteDecisionSnapshot(routeId: number): Promise<void> {
await clearRouteDecisionSnapshots([routeId]);
}
export async function clearRouteDecisionSnapshots(routeIds: number[]): Promise<void> {
const normalizedRouteIds = Array.from(new Set(
routeIds
.map((routeId) => Math.trunc(routeId))
.filter((routeId) => routeId > 0),
));
if (normalizedRouteIds.length === 0) return;
await db.update(schema.tokenRoutes)
.set({
decisionSnapshot: null,
decisionRefreshedAt: null,
})
.where(inArray(schema.tokenRoutes.id, normalizedRouteIds))
.run();
}
export async function clearAllRouteDecisionSnapshots(): Promise<void> {
await db.update(schema.tokenRoutes)
.set({
decisionSnapshot: null,
decisionRefreshedAt: null,
})
.run();
}
+100 -23
View File
@@ -2,7 +2,7 @@
import { minimatch } from 'minimatch';
import { db, schema } from '../db/index.js';
import { config } from '../config.js';
import { getCachedModelRoutingReferenceCost } from './modelPricingService.js';
import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js';
import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js';
interface RouteMatch {
@@ -195,6 +195,12 @@ type ExplainSelectionOptions = {
downstreamPolicy?: DownstreamRoutingPolicy;
};
type PricingReferenceRefreshOptions = {
useChannelSourceModelForCost?: boolean;
downstreamPolicy?: DownstreamRoutingPolicy;
refreshedKeys?: Set<string>;
};
type CandidateEligibilityOptions = {
requestedModel: string;
bypassSourceModelCheck?: boolean;
@@ -546,15 +552,47 @@ export class TokenRouter {
const fallbackRequestedModel = match?.route.modelPattern || `route:${routeId}`;
return this.explainSelectionFromMatch(match, fallbackRequestedModel, {
bypassSourceModelCheck: true,
useChannelSourceModelForCost: true,
downstreamPolicy,
});
}
private explainSelectionFromMatch(
match: RouteMatch | null,
requestedModel: string,
options: ExplainSelectionOptions = {},
useChannelSourceModelForCost: true,
downstreamPolicy,
});
}
async refreshPricingReferenceCosts(
requestedModel: string,
options: PricingReferenceRefreshOptions = {},
): Promise<void> {
const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY;
const match = await this.findRoute(requestedModel, downstreamPolicy);
await this.refreshPricingReferenceCostsForMatch(match, requestedModel, options);
}
async refreshPricingReferenceCostsForRoute(
routeId: number,
requestedModel: string,
options: PricingReferenceRefreshOptions = {},
): Promise<void> {
const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY;
const match = await this.findRouteById(routeId, downstreamPolicy);
await this.refreshPricingReferenceCostsForMatch(match, requestedModel, options);
}
async refreshRouteWidePricingReferenceCosts(
routeId: number,
options: Omit<PricingReferenceRefreshOptions, 'useChannelSourceModelForCost'> = {},
): Promise<void> {
const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY;
const match = await this.findRouteById(routeId, downstreamPolicy);
const requestedModel = match?.route.modelPattern || `route:${routeId}`;
await this.refreshPricingReferenceCostsForMatch(match, requestedModel, {
...options,
useChannelSourceModelForCost: true,
});
}
private explainSelectionFromMatch(
match: RouteMatch | null,
requestedModel: string,
options: ExplainSelectionOptions = {},
): RouteDecisionExplanation {
const excludeChannelIds = options.excludeChannelIds ?? [];
const downstreamPolicy = options.downstreamPolicy ?? DEFAULT_DOWNSTREAM_POLICY;
@@ -706,23 +744,62 @@ export class TokenRouter {
summary.push(`实际转发模型:${actualModel}`);
}
return {
requestedModel,
actualModel,
matched: true,
routeId: match.route.id,
return {
requestedModel,
actualModel,
matched: true,
routeId: match.route.id,
modelPattern: match.route.modelPattern,
selectedChannelId: selected.channel.id,
selectedAccountId: selected.account.id,
selectedLabel,
summary,
candidates,
};
}
/**
* Record success for a channel.
*/
summary,
candidates,
};
}
private async refreshPricingReferenceCostsForMatch(
match: RouteMatch | null,
requestedModel: string,
options: PricingReferenceRefreshOptions = {},
): Promise<void> {
if (!match) return;
const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName);
const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName;
const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping);
const refreshedKeys = options.refreshedKeys ?? new Set<string>();
await Promise.allSettled(match.channels.map(async (candidate) => {
const refreshKey = `${candidate.site.id}:${candidate.account.id}`;
if (refreshedKeys.has(refreshKey)) return;
refreshedKeys.add(refreshKey);
const modelName = useChannelSourceModelForCost
? (normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel)
: mappedModel;
if (!modelName) return;
await refreshModelPricingCatalog({
site: {
id: candidate.site.id,
url: candidate.site.url,
platform: candidate.site.platform,
apiKey: candidate.site.apiKey,
},
account: {
id: candidate.account.id,
accessToken: candidate.account.accessToken,
apiToken: candidate.account.apiToken,
},
modelName,
});
}));
}
/**
* Record success for a channel.
*/
async recordSuccess(channelId: number, latencyMs: number, cost: number) {
const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!ch) return;