forked from boos/BoosAPI
refactor: remove deprecated apiKey from site management and update related functionality
- Removed apiKey from SiteRow type and related forms in Sites component. - Updated site saving logic to handle site creation without apiKey. - Adjusted tests to ensure apiKey is not included in site editor state. - Refactored Tokens component to support embedded mode and redirect logic. - Added migration tests for site apiKey to account migration service. - Implemented siteApiKeyMigrationService to handle migration of apiKey to account tokens. - Enhanced tests for segmented connections in Accounts and redirect logic for Tokens.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import Fastify from 'fastify';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildConfig } from './config.js';
|
||||
import { buildConfig, buildFastifyOptions } from './config.js';
|
||||
|
||||
describe('buildConfig', () => {
|
||||
it('defaults to external listen host for server deployments', () => {
|
||||
@@ -30,4 +31,24 @@ describe('buildConfig', () => {
|
||||
|
||||
expect(config.listenHost).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('accepts JSON request bodies larger than Fastify default 1 MiB', async () => {
|
||||
const app = Fastify(buildFastifyOptions(buildConfig({})));
|
||||
const largeText = 'a'.repeat(2 * 1024 * 1024);
|
||||
|
||||
app.post('/echo', async (request) => {
|
||||
const body = request.body as { text?: string };
|
||||
return { textLength: body.text?.length ?? 0 };
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/echo',
|
||||
payload: { text: largeText },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ textLength: largeText.length });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dotenv/config';
|
||||
import type { FastifyServerOptions } from 'fastify';
|
||||
|
||||
const DEFAULT_REQUEST_BODY_LIMIT = 20 * 1024 * 1024;
|
||||
|
||||
function parseBoolean(value: string | undefined, fallback = false): boolean {
|
||||
if (value === undefined) return fallback;
|
||||
@@ -70,6 +73,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
|
||||
dbType: parseDbType(env.DB_TYPE),
|
||||
dbUrl: (env.DB_URL || '').trim(),
|
||||
dbSsl: parseBoolean(env.DB_SSL, false),
|
||||
requestBodyLimit: DEFAULT_REQUEST_BODY_LIMIT,
|
||||
routingFallbackUnitCost: Math.max(1e-6, parseNumber(env.ROUTING_FALLBACK_UNIT_COST, 1)),
|
||||
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
|
||||
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))),
|
||||
@@ -85,3 +89,12 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
|
||||
}
|
||||
|
||||
export const config = buildConfig(process.env);
|
||||
|
||||
export function buildFastifyOptions(
|
||||
appConfig: ReturnType<typeof buildConfig>,
|
||||
): FastifyServerOptions {
|
||||
return {
|
||||
logger: true,
|
||||
bodyLimit: appConfig.requestBodyLimit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
type MigrationJournalEntry = {
|
||||
tag: string;
|
||||
when: number;
|
||||
};
|
||||
|
||||
const migrationsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../drizzle');
|
||||
|
||||
function readMigrationJournalEntries(): MigrationJournalEntry[] {
|
||||
const journalPath = join(migrationsDir, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { entries?: MigrationJournalEntry[] };
|
||||
return journal.entries ?? [];
|
||||
}
|
||||
|
||||
function applyMigrationSql(sqlite: Database.Database, sqlText: string) {
|
||||
const statements = sqlText
|
||||
.split('--> statement-breakpoint')
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
|
||||
for (const statement of statements) {
|
||||
sqlite.exec(statement);
|
||||
}
|
||||
}
|
||||
|
||||
describe('sqlite migrate bootstrap', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.DB_URL;
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('accepts an already-synced sqlite schema with an empty drizzle journal', async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-'));
|
||||
const dbPath = join(dataDir, 'hub.db');
|
||||
const sqlite = new Database(dbPath);
|
||||
const journalEntries = readMigrationJournalEntries();
|
||||
|
||||
for (const entry of journalEntries) {
|
||||
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||
applyMigrationSql(sqlite, sqlText);
|
||||
}
|
||||
|
||||
sqlite.close();
|
||||
|
||||
process.env.DATA_DIR = dataDir;
|
||||
vi.resetModules();
|
||||
|
||||
await expect(import('./migrate.js')).resolves.toMatchObject({
|
||||
runSqliteMigrations: expect.any(Function),
|
||||
});
|
||||
|
||||
const verified = new Database(dbPath, { readonly: true });
|
||||
const appliedRows = verified
|
||||
.prepare('select created_at from __drizzle_migrations order by created_at asc')
|
||||
.all() as Array<{ created_at: number }>;
|
||||
|
||||
expect(appliedRows.map((row) => Number(row.created_at))).toEqual(
|
||||
journalEntries.map((entry) => entry.when),
|
||||
);
|
||||
|
||||
verified.close();
|
||||
});
|
||||
});
|
||||
+115
-2
@@ -2,10 +2,49 @@ import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { config } from '../config.js';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
type MigrationJournalEntry = {
|
||||
tag: string;
|
||||
when: number;
|
||||
};
|
||||
|
||||
type MigrationJournalFile = {
|
||||
entries?: MigrationJournalEntry[];
|
||||
};
|
||||
|
||||
type SchemaMarker = {
|
||||
table: string;
|
||||
column?: string;
|
||||
};
|
||||
|
||||
type MigrationRecord = {
|
||||
createdAt: number;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
const VERIFIED_BOOTSTRAP_TAG = '0004_sorting_preferences';
|
||||
const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [
|
||||
{ table: 'sites' },
|
||||
{ table: 'settings' },
|
||||
{ table: 'accounts' },
|
||||
{ table: 'checkin_logs' },
|
||||
{ table: 'model_availability' },
|
||||
{ table: 'proxy_logs' },
|
||||
{ table: 'token_routes' },
|
||||
{ table: 'route_channels', column: 'token_id' },
|
||||
{ table: 'account_tokens' },
|
||||
{ table: 'token_model_availability' },
|
||||
{ table: 'events' },
|
||||
{ table: 'sites', column: 'is_pinned' },
|
||||
{ table: 'sites', column: 'sort_order' },
|
||||
{ table: 'accounts', column: 'is_pinned' },
|
||||
{ table: 'accounts', column: 'sort_order' },
|
||||
];
|
||||
|
||||
function resolveSqliteDbPath(): string {
|
||||
const raw = (config.dbUrl || '').trim();
|
||||
if (!raw) return resolve(`${config.dataDir}/hub.db`);
|
||||
@@ -24,15 +63,89 @@ function resolveMigrationsFolder(): string {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '../../../drizzle');
|
||||
}
|
||||
|
||||
function tableExists(sqlite: Database.Database, table: string): boolean {
|
||||
const row = sqlite.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1").get(table);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
function columnExists(sqlite: Database.Database, table: string, column: string): boolean {
|
||||
if (!tableExists(sqlite, table)) return false;
|
||||
const rows = sqlite.prepare(`PRAGMA table_info("${table}")`).all() as Array<{ name?: string }>;
|
||||
return rows.some((row) => row.name === column);
|
||||
}
|
||||
|
||||
function hasRecordedDrizzleMigrations(sqlite: Database.Database): boolean {
|
||||
if (!tableExists(sqlite, '__drizzle_migrations')) return false;
|
||||
const row = sqlite.prepare('SELECT 1 FROM __drizzle_migrations LIMIT 1').get();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
function hasVerifiedLegacySchema(sqlite: Database.Database): boolean {
|
||||
return VERIFIED_SCHEMA_MARKERS.every((marker) => (
|
||||
marker.column
|
||||
? columnExists(sqlite, marker.table, marker.column)
|
||||
: tableExists(sqlite, marker.table)
|
||||
));
|
||||
}
|
||||
|
||||
function readVerifiedMigrationRecords(migrationsFolder: string): MigrationRecord[] {
|
||||
const journalPath = resolve(migrationsFolder, 'meta', '_journal.json');
|
||||
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile;
|
||||
const records: MigrationRecord[] = [];
|
||||
|
||||
for (const entry of journal.entries ?? []) {
|
||||
const migrationSql = readFileSync(resolve(migrationsFolder, `${entry.tag}.sql`), 'utf8');
|
||||
records.push({
|
||||
createdAt: Number(entry.when),
|
||||
hash: createHash('sha256').update(migrationSql).digest('hex'),
|
||||
});
|
||||
|
||||
if (entry.tag === VERIFIED_BOOTSTRAP_TAG) {
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function bootstrapLegacyDrizzleMigrations(sqlite: Database.Database, migrationsFolder: string): boolean {
|
||||
if (hasRecordedDrizzleMigrations(sqlite)) return false;
|
||||
if (!hasVerifiedLegacySchema(sqlite)) return false;
|
||||
|
||||
const records = readVerifiedMigrationRecords(migrationsFolder);
|
||||
if (records.length === 0) return false;
|
||||
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS "__drizzle_migrations" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash text NOT NULL,
|
||||
created_at numeric
|
||||
)
|
||||
`);
|
||||
|
||||
const insert = sqlite.prepare('INSERT INTO "__drizzle_migrations" ("hash", "created_at") VALUES (?, ?)');
|
||||
const applyBootstrap = sqlite.transaction((migrations: MigrationRecord[]) => {
|
||||
for (const migrationRecord of migrations) {
|
||||
insert.run(migrationRecord.hash, migrationRecord.createdAt);
|
||||
}
|
||||
});
|
||||
|
||||
applyBootstrap(records);
|
||||
console.log('[db] Bootstrapped drizzle migration journal for existing SQLite schema.');
|
||||
return true;
|
||||
}
|
||||
|
||||
export function runSqliteMigrations(): void {
|
||||
const dbPath = resolveSqliteDbPath();
|
||||
const migrationsFolder = resolveMigrationsFolder();
|
||||
if (dbPath !== ':memory:') {
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
}
|
||||
|
||||
const sqlite = new Database(dbPath);
|
||||
bootstrapLegacyDrizzleMigrations(sqlite, migrationsFolder);
|
||||
const db = drizzle(sqlite);
|
||||
migrate(db, { migrationsFolder: resolveMigrationsFolder() });
|
||||
migrate(db, { migrationsFolder });
|
||||
sqlite.close();
|
||||
console.log('Migration complete.');
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { config } from './config.js';
|
||||
import { buildFastifyOptions, config } from './config.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import { sitesRoutes } from './routes/api/sites.js';
|
||||
import { accountsRoutes } from './routes/api/accounts.js';
|
||||
@@ -22,6 +22,7 @@ import { startScheduler } from './services/checkinScheduler.js';
|
||||
import { startProxyLogRetentionService, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||
import { existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -208,13 +209,14 @@ try {
|
||||
applyRuntimeSettings(finalMap);
|
||||
await ensureProxyLogBillingDetailsColumn();
|
||||
await repairStoredCreatedAtValues();
|
||||
await migrateSiteApiKeysToAccounts();
|
||||
|
||||
console.log('Loaded runtime settings overrides');
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load runtime settings overrides: ${(error as Error)?.message || 'unknown error'}`);
|
||||
}
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
const app = Fastify(buildFastifyOptions(config));
|
||||
|
||||
await app.register(cors);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { mergeAccountExtraConfig } from '../../services/accountExtraConfig.js';
|
||||
|
||||
const getApiTokensMock = vi.fn();
|
||||
const getApiTokenMock = vi.fn();
|
||||
@@ -137,6 +138,80 @@ describe('account tokens sync routes with site status', () => {
|
||||
expect(tokenRows.length).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects sync and token management for apikey connections', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' });
|
||||
await db.update(schema.accounts)
|
||||
.set({
|
||||
apiToken: 'sk-proxy-only',
|
||||
checkinEnabled: false,
|
||||
extraConfig: mergeAccountExtraConfig(null, { credentialMode: 'apikey' }),
|
||||
})
|
||||
.where(eq(schema.accounts.id, account.id))
|
||||
.run();
|
||||
|
||||
const syncResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/account-tokens/sync/${account.id}`,
|
||||
});
|
||||
expect(syncResponse.statusCode).toBe(400);
|
||||
expect(syncResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'API Key 连接不支持同步账号令牌',
|
||||
});
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/account-tokens',
|
||||
payload: {
|
||||
accountId: account.id,
|
||||
name: 'should-fail',
|
||||
},
|
||||
});
|
||||
expect(createResponse.statusCode).toBe(400);
|
||||
expect(createResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'API Key 连接不支持创建账号令牌',
|
||||
});
|
||||
|
||||
const groupsResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/account-tokens/groups/${account.id}`,
|
||||
});
|
||||
expect(groupsResponse.statusCode).toBe(400);
|
||||
expect(groupsResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'API Key 连接不支持拉取账号令牌分组',
|
||||
});
|
||||
});
|
||||
|
||||
it('hides legacy mirrored tokens for apikey connections from list API', async () => {
|
||||
const { account } = await seedAccount({ siteStatus: 'active', accessToken: '' });
|
||||
await db.update(schema.accounts)
|
||||
.set({
|
||||
apiToken: 'sk-hidden-legacy',
|
||||
checkinEnabled: false,
|
||||
extraConfig: mergeAccountExtraConfig(null, { credentialMode: 'apikey' }),
|
||||
})
|
||||
.where(eq(schema.accounts.id, account.id))
|
||||
.run();
|
||||
|
||||
await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'default',
|
||||
token: 'sk-hidden-legacy',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).run();
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/account-tokens',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual([]);
|
||||
});
|
||||
|
||||
it('sync-all skips disabled-site accounts and syncs active-site accounts', async () => {
|
||||
const disabled = await seedAccount({ siteStatus: 'disabled' });
|
||||
const active = await seedAccount({ siteStatus: 'active' });
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
syncTokensFromUpstream,
|
||||
} from '../../services/accountTokenService.js';
|
||||
import { getAdapter } from '../../services/platforms/index.js';
|
||||
import { resolvePlatformUserId } from '../../services/accountExtraConfig.js';
|
||||
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
|
||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||
|
||||
type AccountWithSiteRow = {
|
||||
@@ -82,6 +82,12 @@ function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
|
||||
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
|
||||
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (explicit && explicit !== 'auto') return explicit === 'apikey';
|
||||
return !(account.accessToken || '').trim();
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
@@ -169,6 +175,20 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
|
||||
};
|
||||
}
|
||||
|
||||
if (isApiKeyConnection(row.accounts)) {
|
||||
return {
|
||||
...base,
|
||||
status: 'skipped',
|
||||
reason: 'apikey_connection',
|
||||
message: 'apikey connection does not support account tokens',
|
||||
synced: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
total: 0,
|
||||
defaultTokenId: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!row.accounts.accessToken) {
|
||||
if (row.accounts.apiToken) {
|
||||
ensureDefaultTokenForAccount(accountId, row.accounts.apiToken, { name: 'default', source: 'legacy' });
|
||||
@@ -341,6 +361,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ success: false, message: '账号不存在' });
|
||||
}
|
||||
|
||||
if (isApiKeyConnection(row.accounts)) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持创建账号令牌' });
|
||||
}
|
||||
|
||||
const tokenValue = (body.token || '').trim();
|
||||
if (tokenValue) {
|
||||
const now = new Date().toISOString();
|
||||
@@ -481,6 +505,14 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ success: false, message: '令牌不存在' });
|
||||
}
|
||||
|
||||
const owner = await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.accountId)).get();
|
||||
if (!owner) {
|
||||
return reply.code(404).send({ success: false, message: '账号不存在' });
|
||||
}
|
||||
if (isApiKeyConnection(owner)) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
|
||||
}
|
||||
|
||||
const body = request.body;
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
|
||||
|
||||
@@ -525,7 +557,18 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
if (Number.isNaN(tokenId)) {
|
||||
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
|
||||
}
|
||||
const success = setDefaultToken(tokenId);
|
||||
const tokenRow = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
|
||||
if (!tokenRow) {
|
||||
return reply.code(404).send({ success: false, message: '令牌不存在' });
|
||||
}
|
||||
const owner = await db.select().from(schema.accounts).where(eq(schema.accounts.id, tokenRow.accountId)).get();
|
||||
if (!owner) {
|
||||
return reply.code(404).send({ success: false, message: '账号不存在' });
|
||||
}
|
||||
if (isApiKeyConnection(owner)) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
|
||||
}
|
||||
const success = await setDefaultToken(tokenId);
|
||||
if (!success) {
|
||||
return reply.code(404).send({ success: false, message: '令牌不存在' });
|
||||
}
|
||||
@@ -548,6 +591,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ success: false, message: '令牌不存在' });
|
||||
}
|
||||
|
||||
if (isApiKeyConnection(row.accounts)) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
|
||||
}
|
||||
|
||||
const tokenValue = normalizeTokenForDisplay(row.account_tokens.token, row.sites.platform);
|
||||
return {
|
||||
success: true,
|
||||
@@ -573,6 +620,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ success: false, message: '账号不存在' });
|
||||
}
|
||||
|
||||
if (isApiKeyConnection(row.accounts)) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持拉取账号令牌分组' });
|
||||
}
|
||||
|
||||
const account = row.accounts;
|
||||
const site = row.sites;
|
||||
const adapter = getAdapter(site.platform);
|
||||
@@ -612,6 +663,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ success: false, message: '令牌不存在' });
|
||||
}
|
||||
|
||||
if (isApiKeyConnection(row.accounts)) {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
|
||||
}
|
||||
|
||||
const existing = row.account_tokens;
|
||||
const account = row.accounts;
|
||||
const site = row.sites;
|
||||
@@ -657,6 +712,9 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
|
||||
const result = await executeAccountTokenSync(row);
|
||||
appendTokenSyncEvent(result);
|
||||
if (result.status === 'skipped' && result.reason === 'apikey_connection') {
|
||||
return reply.code(400).send({ success: false, message: 'API Key 连接不支持同步账号令牌' });
|
||||
}
|
||||
if (result.status === 'failed' && result.reason === 'unsupported_platform') {
|
||||
return reply.code(400).send({ success: false, message: result.message });
|
||||
}
|
||||
@@ -727,6 +785,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
success: true,
|
||||
token: row
|
||||
? (() => {
|
||||
if (isApiKeyConnection(row.accounts)) return null;
|
||||
const { token: rawToken, ...meta } = row.account_tokens;
|
||||
return { ...meta, tokenMasked: maskToken(rawToken, row.sites.platform) };
|
||||
})()
|
||||
|
||||
@@ -122,6 +122,9 @@ describe('accounts credential mode', () => {
|
||||
expect((accounts[0]?.apiToken || '').startsWith('sk-')).toBe(true);
|
||||
expect(accounts[0]?.checkinEnabled).toBe(false);
|
||||
|
||||
const accountTokens = await db.select().from(schema.accountTokens).all();
|
||||
expect(accountTokens).toHaveLength(0);
|
||||
|
||||
const parsedExtra = JSON.parse(accounts[0]?.extraConfig || '{}') as { credentialMode?: string };
|
||||
expect(parsedExtra.credentialMode).toBe('apikey');
|
||||
});
|
||||
|
||||
@@ -1058,7 +1058,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
return reply.code(500).send({ success: false, message: '创建账号失败' });
|
||||
}
|
||||
|
||||
if (apiToken) {
|
||||
if (tokenType === 'session' && apiToken) {
|
||||
try {
|
||||
await ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' });
|
||||
} catch { }
|
||||
@@ -1161,7 +1161,15 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
|
||||
|
||||
if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
|
||||
const nextAccessToken = typeof updates.accessToken === 'string' ? updates.accessToken : account.accessToken;
|
||||
const nextExtraConfig = typeof updates.extraConfig === 'string' ? updates.extraConfig : account.extraConfig;
|
||||
const explicitNextMode = getCredentialModeFromExtraConfig(nextExtraConfig);
|
||||
const nextCredentialMode =
|
||||
explicitNextMode && explicitNextMode !== 'auto'
|
||||
? explicitNextMode
|
||||
: (hasSessionTokenValue(nextAccessToken) ? 'session' : 'apikey');
|
||||
|
||||
if (nextCredentialMode !== 'apikey' && typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
|
||||
try {
|
||||
await ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' });
|
||||
} catch { }
|
||||
@@ -1253,4 +1261,3 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,6 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
name: string;
|
||||
url: string;
|
||||
platform?: string;
|
||||
apiKey?: string;
|
||||
proxyUrl?: string | null;
|
||||
externalCheckinUrl?: string | null;
|
||||
status?: string;
|
||||
@@ -104,7 +103,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
sortOrder?: number;
|
||||
globalWeight?: number;
|
||||
} }>('/api/sites', async (request, reply) => {
|
||||
const { name, url, platform, apiKey, proxyUrl, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
|
||||
const { name, url, platform, proxyUrl, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
|
||||
const normalizedStatus = normalizeSiteStatus(status);
|
||||
if (status !== undefined && !normalizedStatus) {
|
||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||
@@ -145,7 +144,6 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
name,
|
||||
url: url.replace(/\/+$/, ''),
|
||||
platform: detectedPlatform,
|
||||
apiKey,
|
||||
proxyUrl: parsedProxyUrl.proxyUrl,
|
||||
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
||||
status: normalizedStatus ?? 'active',
|
||||
@@ -170,7 +168,6 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
name?: string;
|
||||
url?: string;
|
||||
platform?: string;
|
||||
apiKey?: string;
|
||||
proxyUrl?: string | null;
|
||||
externalCheckinUrl?: string | null;
|
||||
status?: string;
|
||||
@@ -218,7 +215,6 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (body.name !== undefined) updates.name = body.name;
|
||||
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
||||
if (body.platform !== undefined) updates.platform = body.platform;
|
||||
if (body.apiKey !== undefined) updates.apiKey = body.apiKey;
|
||||
if (parsedProxyUrl.present) updates.proxyUrl = parsedProxyUrl.proxyUrl;
|
||||
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
|
||||
if (body.status !== undefined) updates.status = normalizedStatus;
|
||||
|
||||
@@ -363,4 +363,81 @@ describe('/api/models/token-candidates', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not pass deprecated site apiKey into pricing catalog lookup', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-e',
|
||||
url: 'https://site-e.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
apiKey: 'sk-site-fallback',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'erin',
|
||||
accessToken: '',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
}).returning().get();
|
||||
|
||||
const token = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'default',
|
||||
token: 'sk-default-e',
|
||||
tokenGroup: 'default',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId: account.id,
|
||||
modelName: 'claude-opus-4-6',
|
||||
available: true,
|
||||
}).run();
|
||||
|
||||
await db.insert(schema.tokenModelAvailability).values({
|
||||
tokenId: token.id,
|
||||
modelName: 'claude-opus-4-6',
|
||||
available: true,
|
||||
}).run();
|
||||
|
||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
modelName: 'claude-opus-4-6',
|
||||
quotaType: 0,
|
||||
modelDescription: null,
|
||||
tags: [],
|
||||
supportedEndpointTypes: [],
|
||||
ownerBy: null,
|
||||
enableGroups: ['default', 'vip'],
|
||||
groupPricing: {},
|
||||
},
|
||||
],
|
||||
groupRatio: { default: 1, vip: 2 },
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/models/token-candidates',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchModelPricingCatalogMock).toHaveBeenCalled();
|
||||
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
site: {
|
||||
id: site.id,
|
||||
url: site.url,
|
||||
platform: site.platform,
|
||||
},
|
||||
account: {
|
||||
id: account.id,
|
||||
accessToken: '',
|
||||
apiToken: null,
|
||||
},
|
||||
});
|
||||
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]?.site?.apiKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -332,7 +332,6 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
id: row.sites.id,
|
||||
url: row.sites.url,
|
||||
platform: row.sites.platform,
|
||||
apiKey: row.sites.apiKey,
|
||||
},
|
||||
account: {
|
||||
id: row.accounts.id,
|
||||
@@ -663,7 +662,6 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
id: row.sites.id,
|
||||
url: row.sites.url,
|
||||
platform: row.sites.platform,
|
||||
apiKey: row.sites.apiKey,
|
||||
},
|
||||
account: {
|
||||
id: row.accounts.id,
|
||||
|
||||
@@ -955,7 +955,6 @@ export async function resolveUpstreamEndpointCandidates(
|
||||
id: context.site.id,
|
||||
url: context.site.url,
|
||||
platform: context.site.platform,
|
||||
apiKey: context.site.apiKey ?? null,
|
||||
},
|
||||
account: {
|
||||
id: context.account.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getCredentialModeFromExtraConfig } from './accountExtraConfig.js';
|
||||
|
||||
type UpstreamApiToken = {
|
||||
name?: string | null;
|
||||
@@ -66,6 +67,12 @@ async function updateAccountApiToken(accountId: number, tokenValue: string | nul
|
||||
.run();
|
||||
}
|
||||
|
||||
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
|
||||
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (explicit && explicit !== 'auto') return explicit === 'apikey';
|
||||
return normalizeTokenValue(account.accessToken) === null;
|
||||
}
|
||||
|
||||
export async function getPreferredAccountToken(accountId: number) {
|
||||
const tokens = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
@@ -267,7 +274,9 @@ export async function listTokensWithRelations(accountId?: number) {
|
||||
? await base.where(eq(schema.accountTokens.accountId, accountId)).all()
|
||||
: await base.all();
|
||||
|
||||
return rows.map((row) => {
|
||||
return rows
|
||||
.filter((row) => !isApiKeyConnection(row.accounts))
|
||||
.map((row) => {
|
||||
const { token, ...tokenMeta } = row.account_tokens;
|
||||
return {
|
||||
...tokenMeta,
|
||||
@@ -284,6 +293,6 @@ export async function listTokensWithRelations(accountId?: number) {
|
||||
platform: row.sites.platform,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -383,7 +383,6 @@ function buildTokenCandidates(input: EstimateProxyCostInput): string[] {
|
||||
const candidates = [
|
||||
input.account.accessToken,
|
||||
input.account.apiToken,
|
||||
input.site.apiKey,
|
||||
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||
|
||||
return Array.from(new Set(candidates));
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
|
||||
import { resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
|
||||
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
||||
|
||||
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
||||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||
const MODEL_REFRESH_BATCH_SIZE = 3;
|
||||
|
||||
function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
|
||||
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
|
||||
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (explicit && explicit !== 'auto') return explicit === 'apikey';
|
||||
return !(account.accessToken || '').trim();
|
||||
}
|
||||
|
||||
function normalizeModels(models: string[]): string[] {
|
||||
return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0)));
|
||||
@@ -101,10 +107,10 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
.all();
|
||||
|
||||
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
|
||||
if (enabledTokens.length === 0) {
|
||||
const fallback = discoveredApiToken || account.apiToken || null;
|
||||
if (fallback) {
|
||||
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
|
||||
if (!isApiKeyConnection(account) && enabledTokens.length === 0) {
|
||||
const fallback = discoveredApiToken || account.apiToken || null;
|
||||
if (fallback) {
|
||||
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
|
||||
enabledTokens = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { fetchMock, fetchJsonWithShieldCookieRetryMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
fetchJsonWithShieldCookieRetryMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./platforms/newApiShield.js', () => ({
|
||||
buildNewApiCookieCandidates: () => [],
|
||||
fetchJsonWithShieldCookieRetry: (...args: unknown[]) => fetchJsonWithShieldCookieRetryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./siteProxy.js', () => ({
|
||||
withExplicitProxyRequestInit: (_proxyUrl: string | null | undefined, init: RequestInit) => init,
|
||||
}));
|
||||
|
||||
import {
|
||||
resolveProxyUsageWithSelfLogFallback,
|
||||
shouldLookupSelfLog,
|
||||
} from './proxyUsageFallbackService.js';
|
||||
|
||||
describe('proxyUsageFallbackService sub2api', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchJsonWithShieldCookieRetryMock.mockReset();
|
||||
fetchJsonWithShieldCookieRetryMock.mockResolvedValue({ data: null });
|
||||
});
|
||||
|
||||
it('always enables usage lookup for sub2api to recover exact billing cost', () => {
|
||||
expect(shouldLookupSelfLog('sub2api', {
|
||||
promptTokens: 70354,
|
||||
completionTokens: 1148,
|
||||
totalTokens: 71502,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('recovers exact cost from sub2api /api/v1/usage and matches by api key value', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 1001,
|
||||
model: 'gpt-5.4',
|
||||
input_tokens: 512,
|
||||
output_tokens: 32,
|
||||
cache_creation_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
total_cost: 0.001088,
|
||||
actual_cost: 0.001088,
|
||||
duration_ms: 23000,
|
||||
created_at: '2026-03-07T06:12:30.000Z',
|
||||
api_key: {
|
||||
id: 91,
|
||||
key: 'sk-wrong',
|
||||
name: 'wrong-key',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
model: 'gpt-5.4',
|
||||
input_tokens: 70354,
|
||||
output_tokens: 1148,
|
||||
cache_creation_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
total_cost: 0.143004,
|
||||
actual_cost: 0.143004,
|
||||
duration_ms: 23000,
|
||||
created_at: '2026-03-07T06:12:29.000Z',
|
||||
api_key: {
|
||||
id: 92,
|
||||
key: 'sk-route',
|
||||
name: 'upstream-key-name',
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1,
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const result = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: {
|
||||
url: 'https://sub2api.example.com',
|
||||
platform: 'sub2api',
|
||||
},
|
||||
account: {
|
||||
accessToken: 'jwt-access-token',
|
||||
apiToken: 'sk-account-level',
|
||||
},
|
||||
tokenValue: 'sk-route',
|
||||
tokenName: 'local-token-name',
|
||||
modelName: 'gpt-5.4',
|
||||
requestStartedAtMs: Date.parse('2026-03-07T06:12:07.000Z'),
|
||||
requestEndedAtMs: Date.parse('2026-03-07T06:12:30.000Z'),
|
||||
localLatencyMs: 23000,
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/api/v1/usage?');
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('model=gpt-5.4');
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer jwt-access-token',
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
promptTokens: 70354,
|
||||
completionTokens: 1148,
|
||||
totalTokens: 71502,
|
||||
recoveredFromSelfLog: true,
|
||||
estimatedCostFromQuota: 0.143004,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,8 @@ const MATCH_LOOKAHEAD_MS = 120_000;
|
||||
const MATCH_MAX_CREATED_DELTA_MS = 90_000;
|
||||
const MATCH_MAX_LATENCY_DELTA_MS = 12_000;
|
||||
const QUOTA_PER_UNIT = 500_000;
|
||||
const SUPPORTED_USAGE_FALLBACK_PLATFORMS = new Set(['done-hub', 'one-hub', 'new-api', 'anyrouter']);
|
||||
const ALWAYS_LOOKUP_SELF_LOG_PLATFORMS = new Set(['done-hub', 'one-hub', 'anyrouter']);
|
||||
const SUPPORTED_USAGE_FALLBACK_PLATFORMS = new Set(['done-hub', 'one-hub', 'new-api', 'anyrouter', 'sub2api']);
|
||||
const ALWAYS_LOOKUP_SELF_LOG_PLATFORMS = new Set(['done-hub', 'one-hub', 'anyrouter', 'sub2api']);
|
||||
const PLATFORM_REQUIRES_USER_HEADER = new Set(['new-api', 'anyrouter']);
|
||||
|
||||
interface ProxyUsage {
|
||||
@@ -66,10 +66,12 @@ export interface SelfLogBillingMeta {
|
||||
export interface SelfLogItem {
|
||||
modelName: string;
|
||||
tokenName: string;
|
||||
tokenValue?: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
quota: number;
|
||||
recoveredCost?: number;
|
||||
createdAtMs: number;
|
||||
requestTimeMs: number;
|
||||
billingMeta: SelfLogBillingMeta | null;
|
||||
@@ -78,6 +80,7 @@ export interface SelfLogItem {
|
||||
interface SelfLogMatchInput {
|
||||
modelName: string;
|
||||
tokenName?: string | null;
|
||||
tokenValue?: string | null;
|
||||
requestStartedAtMs: number;
|
||||
requestEndedAtMs: number;
|
||||
localLatencyMs: number;
|
||||
@@ -179,6 +182,10 @@ function modelNameMatches(left: string, right: string): boolean {
|
||||
return !!leftTail && leftTail === rightTail;
|
||||
}
|
||||
|
||||
function normalizeTokenMatchValue(value: string | null | undefined): string {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function getPayloadList(payload: unknown): unknown[] {
|
||||
const candidates = [
|
||||
payload,
|
||||
@@ -200,25 +207,57 @@ function mapSelfLogItem(raw: unknown): SelfLogItem | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
|
||||
const modelName = String(row.model_name ?? row.modelName ?? '').trim();
|
||||
const modelName = String(row.model_name ?? row.modelName ?? row.model ?? '').trim();
|
||||
if (!modelName) return null;
|
||||
|
||||
const promptTokens = toPositiveInt(row.prompt_tokens ?? row.promptTokens);
|
||||
const completionTokens = toPositiveInt(row.completion_tokens ?? row.completionTokens);
|
||||
const promptTokens = toPositiveInt(
|
||||
row.prompt_tokens
|
||||
?? row.promptTokens
|
||||
?? row.input_tokens
|
||||
?? row.inputTokens,
|
||||
);
|
||||
const completionTokens = toPositiveInt(
|
||||
row.completion_tokens
|
||||
?? row.completionTokens
|
||||
?? row.output_tokens
|
||||
?? row.outputTokens,
|
||||
);
|
||||
const totalTokensRaw = toPositiveInt(row.total_tokens ?? row.totalTokens);
|
||||
const totalTokens = totalTokensRaw > 0 ? totalTokensRaw : (promptTokens + completionTokens);
|
||||
const createdAtMs = toTimestampMs(row.created_at ?? row.createdAt);
|
||||
if (createdAtMs <= 0) return null;
|
||||
const recoveredCost = roundCost(toNumber(row.actual_cost ?? row.actualCost ?? row.total_cost ?? row.totalCost, 0));
|
||||
const rawApiKey = row.api_key ?? row.apiKey;
|
||||
const apiKeyRecord = rawApiKey && typeof rawApiKey === 'object'
|
||||
? rawApiKey as Record<string, unknown>
|
||||
: null;
|
||||
const tokenName = String(
|
||||
row.token_name
|
||||
?? row.tokenName
|
||||
?? apiKeyRecord?.name
|
||||
?? '',
|
||||
).trim();
|
||||
const tokenValue = normalizeTokenMatchValue(
|
||||
typeof apiKeyRecord?.key === 'string' ? apiKeyRecord.key : '',
|
||||
);
|
||||
const requestTimeMs = toPositiveInt(
|
||||
row.request_time
|
||||
?? row.requestTime
|
||||
?? row.duration_ms
|
||||
?? row.durationMs,
|
||||
);
|
||||
|
||||
return {
|
||||
modelName,
|
||||
tokenName: String(row.token_name ?? row.tokenName ?? '').trim(),
|
||||
tokenName,
|
||||
...(tokenValue ? { tokenValue } : {}),
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
quota: toPositiveInt(row.quota),
|
||||
...(recoveredCost > 0 ? { recoveredCost } : {}),
|
||||
createdAtMs,
|
||||
requestTimeMs: toPositiveInt(row.request_time ?? row.requestTime),
|
||||
requestTimeMs,
|
||||
billingMeta: parseSelfLogBillingMeta(row.other),
|
||||
};
|
||||
}
|
||||
@@ -293,7 +332,6 @@ function buildTokenCandidates(input: ProxyUsageFallbackInput): string[] {
|
||||
input.account.accessToken,
|
||||
input.tokenValue,
|
||||
input.account.apiToken,
|
||||
input.site.apiKey,
|
||||
]
|
||||
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
||||
.filter(Boolean);
|
||||
@@ -313,6 +351,35 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
|
||||
}, SELF_LOG_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const platform = String(input.site.platform || '').toLowerCase();
|
||||
if (platform === 'sub2api') {
|
||||
const query = new URLSearchParams({
|
||||
page: '1',
|
||||
page_size: String(SELF_LOG_PAGE_SIZE),
|
||||
model: input.modelName,
|
||||
});
|
||||
const response = await fetch(`${baseUrl}/api/v1/usage?${query.toString()}`, withExplicitProxyRequestInit(input.site.proxyUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const pageSizeParam = String(input.site.platform || '').toLowerCase() === 'anyrouter'
|
||||
? 'page_size'
|
||||
: 'size';
|
||||
@@ -321,7 +388,6 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
const platform = String(input.site.platform || '').toLowerCase();
|
||||
if (PLATFORM_REQUIRES_USER_HEADER.has(platform)) {
|
||||
const userId = resolveSelfLogUserId(input);
|
||||
if (userId) {
|
||||
@@ -409,6 +475,14 @@ export function findBestSelfLogMatch(items: SelfLogItem[], input: SelfLogMatchIn
|
||||
));
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const tokenValue = normalizeTokenMatchValue(input.tokenValue);
|
||||
if (tokenValue) {
|
||||
const tokenValueMatched = candidates.filter((item) => normalizeTokenMatchValue(item.tokenValue) === tokenValue);
|
||||
if (tokenValueMatched.length > 0) {
|
||||
candidates = tokenValueMatched;
|
||||
}
|
||||
}
|
||||
|
||||
const tokenName = String(input.tokenName || '').trim().toLowerCase();
|
||||
if (tokenName) {
|
||||
const tokenMatched = candidates.filter((item) => item.tokenName.trim().toLowerCase() === tokenName);
|
||||
@@ -450,6 +524,11 @@ function toQuotaCost(quota: number): number {
|
||||
return roundCost(toPositiveInt(quota) / QUOTA_PER_UNIT);
|
||||
}
|
||||
|
||||
function toRecoveredCost(item: SelfLogItem): number {
|
||||
if (typeof item.recoveredCost === 'number' && item.recoveredCost > 0) return item.recoveredCost;
|
||||
return toQuotaCost(item.quota);
|
||||
}
|
||||
|
||||
export async function resolveProxyUsageWithSelfLogFallback(
|
||||
input: ProxyUsageFallbackInput,
|
||||
): Promise<ProxyUsageFallbackResult> {
|
||||
@@ -471,6 +550,7 @@ export async function resolveProxyUsageWithSelfLogFallback(
|
||||
const matched = findBestSelfLogMatch(items, {
|
||||
modelName: input.modelName,
|
||||
tokenName: input.tokenName,
|
||||
tokenValue: input.tokenValue,
|
||||
requestStartedAtMs: input.requestStartedAtMs,
|
||||
requestEndedAtMs: input.requestEndedAtMs,
|
||||
localLatencyMs: input.localLatencyMs,
|
||||
@@ -494,7 +574,7 @@ export async function resolveProxyUsageWithSelfLogFallback(
|
||||
completionTokens: resolvedTokens.completionTokens,
|
||||
totalTokens: resolvedTokens.totalTokens,
|
||||
recoveredFromSelfLog: true,
|
||||
estimatedCostFromQuota: toQuotaCost(matched.quota),
|
||||
estimatedCostFromQuota: toRecoveredCost(matched),
|
||||
selfLogBillingMeta: matched.billingMeta,
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type MigrationModule = typeof import('./siteApiKeyMigrationService.js');
|
||||
|
||||
describe('siteApiKeyMigrationService', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let migrateSiteApiKeysToAccounts: MigrationModule['migrateSiteApiKeysToAccounts'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-api-key-migration-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const migrationModule = await import('./siteApiKeyMigrationService.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
migrateSiteApiKeysToAccounts = migrationModule.migrateSiteApiKeysToAccounts;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await db.delete(schema.accountTokens).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('migrates site apiKey into an apikey connection, clears the site field, and removes a mirrored token row', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'legacy-site',
|
||||
url: 'https://legacy.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-legacy-site-token',
|
||||
}).returning().get();
|
||||
|
||||
const existing = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: null,
|
||||
accessToken: '',
|
||||
apiToken: 'sk-legacy-site-token',
|
||||
checkinEnabled: false,
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.accountTokens).values({
|
||||
accountId: existing.id,
|
||||
name: 'default',
|
||||
token: 'sk-legacy-site-token',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).run();
|
||||
|
||||
const summary = await migrateSiteApiKeysToAccounts();
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
migrated: 0,
|
||||
deduped: 1,
|
||||
clearedSites: 1,
|
||||
removedMirrorTokens: 1,
|
||||
warned: 0,
|
||||
});
|
||||
|
||||
const migratedSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get();
|
||||
expect(migratedSite?.apiKey).toBeNull();
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).where(eq(schema.accounts.siteId, site.id)).all();
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0]?.apiToken).toBe('sk-legacy-site-token');
|
||||
expect(accounts[0]?.accessToken).toBe('');
|
||||
expect(accounts[0]?.checkinEnabled).toBe(false);
|
||||
expect(JSON.parse(accounts[0]?.extraConfig || '{}')).toMatchObject({ credentialMode: 'apikey' });
|
||||
|
||||
const tokens = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.accountId, existing.id)).all();
|
||||
expect(tokens).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('creates a new apikey connection from site apiKey when no matching connection exists', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'new-site',
|
||||
url: 'https://new-site.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-new-site-token',
|
||||
}).returning().get();
|
||||
|
||||
const summary = await migrateSiteApiKeysToAccounts();
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
migrated: 1,
|
||||
deduped: 0,
|
||||
clearedSites: 1,
|
||||
removedMirrorTokens: 0,
|
||||
warned: 0,
|
||||
});
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).where(eq(schema.accounts.siteId, site.id)).all();
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0]?.apiToken).toBe('sk-new-site-token');
|
||||
expect(accounts[0]?.accessToken).toBe('');
|
||||
expect(accounts[0]?.status).toBe('active');
|
||||
expect(JSON.parse(accounts[0]?.extraConfig || '{}')).toMatchObject({ credentialMode: 'apikey' });
|
||||
});
|
||||
|
||||
it('warns and keeps token rows intact when an apikey connection has multiple child tokens', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'warn-site',
|
||||
url: 'https://warn.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-warn-site-token',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: null,
|
||||
accessToken: '',
|
||||
apiToken: 'sk-warn-site-token',
|
||||
checkinEnabled: false,
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.accountTokens).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
name: 'default',
|
||||
token: 'sk-warn-site-token',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
name: 'extra',
|
||||
token: 'sk-extra-token',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
},
|
||||
]).run();
|
||||
|
||||
const summary = await migrateSiteApiKeysToAccounts();
|
||||
|
||||
expect(summary.warned).toBe(1);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
|
||||
const tokens = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.accountId, account.id)).all();
|
||||
expect(tokens).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getCredentialModeFromExtraConfig, mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||
|
||||
export type SiteApiKeyMigrationSummary = {
|
||||
migrated: number;
|
||||
deduped: number;
|
||||
clearedSites: number;
|
||||
removedMirrorTokens: number;
|
||||
warned: number;
|
||||
};
|
||||
|
||||
function normalizeTokenValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
|
||||
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (explicit && explicit !== 'auto') return explicit === 'apikey';
|
||||
return normalizeTokenValue(account.accessToken).length === 0;
|
||||
}
|
||||
|
||||
async function clearSiteApiKey(siteId: number) {
|
||||
await db.update(schema.sites)
|
||||
.set({
|
||||
apiKey: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.sites.id, siteId))
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function migrateSiteApiKeysToAccounts(): Promise<SiteApiKeyMigrationSummary> {
|
||||
const summary: SiteApiKeyMigrationSummary = {
|
||||
migrated: 0,
|
||||
deduped: 0,
|
||||
clearedSites: 0,
|
||||
removedMirrorTokens: 0,
|
||||
warned: 0,
|
||||
};
|
||||
|
||||
const sites = await db.select().from(schema.sites).all();
|
||||
if (sites.length === 0) return summary;
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
let nextSortOrder = accounts.reduce((max, account) => Math.max(max, account.sortOrder || 0), -1) + 1;
|
||||
|
||||
for (const site of sites) {
|
||||
const siteApiKey = normalizeTokenValue(site.apiKey);
|
||||
if (!siteApiKey) continue;
|
||||
|
||||
let targetAccount = accounts.find((account) => (
|
||||
account.siteId === site.id
|
||||
&& isApiKeyConnection(account)
|
||||
&& normalizeTokenValue(account.apiToken) === siteApiKey
|
||||
)) || null;
|
||||
|
||||
if (targetAccount) {
|
||||
summary.deduped += 1;
|
||||
} else {
|
||||
const inserted = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: null,
|
||||
accessToken: '',
|
||||
apiToken: siteApiKey,
|
||||
checkinEnabled: false,
|
||||
status: 'active',
|
||||
extraConfig: mergeAccountExtraConfig(undefined, { credentialMode: 'apikey' }),
|
||||
isPinned: false,
|
||||
sortOrder: nextSortOrder,
|
||||
}).run();
|
||||
const accountId = Number(inserted.lastInsertRowid || 0);
|
||||
if (accountId > 0) {
|
||||
targetAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get() || null;
|
||||
if (targetAccount) {
|
||||
accounts.push(targetAccount);
|
||||
nextSortOrder += 1;
|
||||
summary.migrated += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetAccount) {
|
||||
const childTokens = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, targetAccount.id))
|
||||
.all();
|
||||
|
||||
if (childTokens.length === 1 && normalizeTokenValue(childTokens[0]?.token) === siteApiKey) {
|
||||
await db.delete(schema.accountTokens).where(eq(schema.accountTokens.id, childTokens[0]!.id)).run();
|
||||
summary.removedMirrorTokens += 1;
|
||||
} else if (childTokens.length > 1) {
|
||||
summary.warned += 1;
|
||||
console.warn(`Skipped destructive token cleanup for API key connection #${targetAccount.id}: found ${childTokens.length} child tokens`);
|
||||
}
|
||||
}
|
||||
|
||||
await clearSiteApiKey(site.id);
|
||||
summary.clearedSites += 1;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -83,4 +83,40 @@ describe('TokenRouter site status guard', () => {
|
||||
const candidate = decision.candidates.find((item) => item.channelId === channel.id);
|
||||
expect(candidate?.eligible).toBe(false);
|
||||
});
|
||||
|
||||
it('does not fallback to deprecated site apiKey when channel and account tokens are missing', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-api-key-only',
|
||||
url: 'https://site-key.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-site-only',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'site-key-user',
|
||||
accessToken: '',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
}).returning().get();
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4.1-mini',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).run();
|
||||
|
||||
const selected = await new TokenRouter().selectChannel('gpt-4.1-mini');
|
||||
expect(selected).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -827,19 +827,16 @@ export class TokenRouter {
|
||||
site?: typeof schema.sites.$inferSelect | null;
|
||||
token: typeof schema.accountTokens.$inferSelect | null;
|
||||
}): string | null {
|
||||
if (candidate.channel.tokenId) {
|
||||
if (!candidate.token) return null;
|
||||
if (!candidate.token.enabled) return null;
|
||||
const token = candidate.token.token?.trim();
|
||||
return token ? token : null;
|
||||
}
|
||||
|
||||
const fallback = candidate.account.apiToken?.trim();
|
||||
if (fallback) return fallback;
|
||||
|
||||
const siteApiKey = candidate.site?.apiKey?.trim();
|
||||
return siteApiKey ? siteApiKey : null;
|
||||
}
|
||||
if (candidate.channel.tokenId) {
|
||||
if (!candidate.token) return null;
|
||||
if (!candidate.token.enabled) return null;
|
||||
const token = candidate.token.token?.trim();
|
||||
return token ? token : null;
|
||||
}
|
||||
|
||||
const fallback = candidate.account.apiToken?.trim();
|
||||
return fallback || null;
|
||||
}
|
||||
|
||||
private weightedRandomSelect(
|
||||
candidates: RouteChannelCandidate[],
|
||||
|
||||
+767
-502
File diff suppressed because it is too large
Load Diff
+5
-18
@@ -23,7 +23,6 @@ type SiteRow = {
|
||||
externalCheckinUrl?: string | null;
|
||||
platform?: string;
|
||||
status?: string;
|
||||
apiKey?: string;
|
||||
proxyUrl?: string | null;
|
||||
globalWeight?: number;
|
||||
isPinned?: boolean;
|
||||
@@ -188,7 +187,6 @@ export default function Sites() {
|
||||
url: form.url.trim(),
|
||||
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
||||
platform: form.platform.trim(),
|
||||
apiKey: form.apiKey.trim(),
|
||||
proxyUrl: form.proxyUrl.trim(),
|
||||
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
|
||||
};
|
||||
@@ -201,8 +199,12 @@ export default function Sites() {
|
||||
try {
|
||||
const action = buildSiteSaveAction(editor, payload);
|
||||
if (action.kind === 'add') {
|
||||
await api.addSite(action.payload);
|
||||
const created = await api.addSite(action.payload);
|
||||
toast.success(`站点 "${payload.name}" 已添加`);
|
||||
const createdSiteId = Number(created?.id) || 0;
|
||||
if (createdSiteId > 0) {
|
||||
navigate(`/accounts?segment=apikey&create=1&siteId=${createdSiteId}`);
|
||||
}
|
||||
} else {
|
||||
await api.updateSite(action.id, action.payload);
|
||||
toast.success(`站点 "${payload.name}" 已更新`);
|
||||
@@ -407,21 +409,6 @@ export default function Sites() {
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="API Key(可选)"
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="出站代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
|
||||
value={form.proxyUrl}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
||||
@@ -9,6 +9,9 @@ import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
|
||||
import { tr } from '../i18n.js';
|
||||
|
||||
type SyncStatus = 'success' | 'skipped' | 'failed';
|
||||
type TokensPanelProps = {
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
type AccountTokenSyncResult = {
|
||||
status?: string;
|
||||
@@ -25,8 +28,22 @@ type AccountTokenSyncResult = {
|
||||
};
|
||||
};
|
||||
|
||||
const resolveAccountCredentialMode = (account: any): 'session' | 'apikey' => {
|
||||
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
|
||||
if (rawMode === 'apikey') return 'apikey';
|
||||
if (rawMode === 'session') return 'session';
|
||||
if (typeof account?.capabilities?.proxyOnly === 'boolean') {
|
||||
return account.capabilities.proxyOnly ? 'apikey' : 'session';
|
||||
}
|
||||
return typeof account?.accessToken === 'string' && account.accessToken.trim()
|
||||
? 'session'
|
||||
: 'apikey';
|
||||
};
|
||||
|
||||
const isAccountSyncable = (account: any) =>
|
||||
account?.status === 'active' && account?.site?.status !== 'disabled';
|
||||
resolveAccountCredentialMode(account) === 'session'
|
||||
&& account?.status === 'active'
|
||||
&& account?.site?.status !== 'disabled';
|
||||
|
||||
const resolveSyncStatus = (result: AccountTokenSyncResult | null | undefined): SyncStatus => {
|
||||
const raw = String(result?.status || '').toLowerCase();
|
||||
@@ -83,7 +100,7 @@ function isTruthyFlag(input: string | null): boolean {
|
||||
return normalized === '1' || normalized === 'true' || normalized === 'yes';
|
||||
}
|
||||
|
||||
export default function Tokens() {
|
||||
export function TokensPanel({ embedded = false }: TokensPanelProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const initialCreateForm = {
|
||||
@@ -362,9 +379,9 @@ export default function Tokens() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className={embedded ? '' : 'animate-fade-in'}>
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">{tr('令牌管理')}</h2>
|
||||
{!embedded ? <h2 className="page-title">{tr('账号令牌')}</h2> : <div />}
|
||||
<div className="page-actions">
|
||||
<div style={{ minWidth: 220, position: 'relative', zIndex: 20 }}>
|
||||
<ModernSelect
|
||||
@@ -646,3 +663,11 @@ export default function Tokens() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Tokens() {
|
||||
const location = useLocation();
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set('segment', 'tokens');
|
||||
const nextSearch = params.toString();
|
||||
return <Navigate to={`/accounts${nextSearch ? `?${nextSearch}` : ''}`} replace />;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('Accounts proxy-only expired state', () => {
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts']}>
|
||||
<MemoryRouter initialEntries={['/accounts?segment=apikey']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
getAccountTokens: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Accounts segmented connections view', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows only apikey connections in the apikey segment and labels unnamed ones as API Key 连接', async () => {
|
||||
apiMock.getAccounts.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
username: 'session-user',
|
||||
accessToken: 'session-token',
|
||||
apiToken: 'sk-session',
|
||||
status: 'active',
|
||||
credentialMode: 'session',
|
||||
capabilities: { canCheckin: true, canRefreshBalance: true, proxyOnly: false },
|
||||
site: { id: 10, name: 'Session Site', platform: 'new-api', status: 'active', url: 'https://session.example.com' },
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: '',
|
||||
accessToken: '',
|
||||
apiToken: 'sk-apikey',
|
||||
status: 'active',
|
||||
credentialMode: 'apikey',
|
||||
capabilities: { canCheckin: false, canRefreshBalance: false, proxyOnly: true },
|
||||
site: { id: 11, name: 'Key Site', platform: 'new-api', status: 'active', url: 'https://key.example.com' },
|
||||
},
|
||||
]);
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
|
||||
{ id: 11, name: 'Key Site', platform: 'new-api', status: 'active' },
|
||||
]);
|
||||
apiMock.getAccountTokens.mockResolvedValue([]);
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/accounts?segment=apikey']}>
|
||||
<ToastProvider>
|
||||
<Accounts />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('连接管理');
|
||||
expect(rendered).toContain('API Key 连接');
|
||||
expect(rendered).toContain('Key Site');
|
||||
expect(rendered).not.toContain('session-user');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildSiteSaveAction } from './sitesEditor.js';
|
||||
import { buildSiteSaveAction, emptySiteForm, siteFormFromSite } from './sitesEditor.js';
|
||||
|
||||
describe('buildSiteSaveAction', () => {
|
||||
it('returns add action in add mode', () => {
|
||||
@@ -10,7 +10,6 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://a.example.com/',
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-1',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
globalWeight: '1.2',
|
||||
},
|
||||
@@ -23,7 +22,6 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://a.example.com/',
|
||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-1',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
globalWeight: '1.2',
|
||||
},
|
||||
@@ -38,7 +36,6 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://b.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: 'one-api',
|
||||
apiKey: '',
|
||||
proxyUrl: '',
|
||||
globalWeight: '0.8',
|
||||
},
|
||||
@@ -52,7 +49,6 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://b.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: 'one-api',
|
||||
apiKey: '',
|
||||
proxyUrl: '',
|
||||
globalWeight: '0.8',
|
||||
},
|
||||
@@ -68,11 +64,23 @@ describe('buildSiteSaveAction', () => {
|
||||
url: 'https://c.example.com',
|
||||
externalCheckinUrl: '',
|
||||
platform: '',
|
||||
apiKey: '',
|
||||
proxyUrl: '',
|
||||
globalWeight: '1',
|
||||
},
|
||||
),
|
||||
).toThrow('editingSiteId is required in edit mode');
|
||||
});
|
||||
|
||||
it('does not expose deprecated apiKey in site editor state', () => {
|
||||
expect(emptySiteForm()).not.toHaveProperty('apiKey');
|
||||
expect(siteFormFromSite({
|
||||
name: 'site-d',
|
||||
url: 'https://d.example.com',
|
||||
externalCheckinUrl: null,
|
||||
platform: 'new-api',
|
||||
proxyUrl: null,
|
||||
globalWeight: 1,
|
||||
apiKey: 'sk-legacy-site-key',
|
||||
})).not.toHaveProperty('apiKey');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ export type SiteForm = {
|
||||
url: string;
|
||||
externalCheckinUrl: string;
|
||||
platform: string;
|
||||
apiKey: string;
|
||||
proxyUrl: string;
|
||||
globalWeight: string;
|
||||
};
|
||||
@@ -17,7 +16,7 @@ type SiteSaveAction =
|
||||
| { kind: 'update'; id: number; payload: SiteForm };
|
||||
|
||||
export function emptySiteForm(): SiteForm {
|
||||
return { name: '', url: '', externalCheckinUrl: '', platform: '', apiKey: '', proxyUrl: '', globalWeight: '1' };
|
||||
return { name: '', url: '', externalCheckinUrl: '', platform: '', proxyUrl: '', globalWeight: '1' };
|
||||
}
|
||||
|
||||
export function siteFormFromSite(site: Partial<SiteForm> & {
|
||||
@@ -32,7 +31,6 @@ export function siteFormFromSite(site: Partial<SiteForm> & {
|
||||
url: site.url ?? '',
|
||||
externalCheckinUrl: site.externalCheckinUrl ?? '',
|
||||
platform: site.platform ?? '',
|
||||
apiKey: site.apiKey ?? '',
|
||||
proxyUrl: site.proxyUrl ?? '',
|
||||
globalWeight,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Tokens from './Tokens.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccountTokens: vi.fn().mockResolvedValue([]),
|
||||
getAccounts: vi.fn().mockResolvedValue([]),
|
||||
getAccountTokenGroups: vi.fn().mockResolvedValue({ groups: ['default'] }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <div>{`${location.pathname}${location.search}`}</div>;
|
||||
}
|
||||
|
||||
describe('Tokens legacy route redirect', () => {
|
||||
it('redirects /tokens to the merged accounts tokens segment', async () => {
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter initialEntries={['/tokens?create=1&accountId=23&model=gpt-4.1']}>
|
||||
<Routes>
|
||||
<Route path="/tokens" element={<Tokens />} />
|
||||
<Route path="/accounts" element={<LocationProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const rendered = JSON.stringify(root?.toJSON());
|
||||
expect(rendered).toContain('/accounts?');
|
||||
expect(rendered).toContain('segment=tokens');
|
||||
expect(rendered).toContain('create=1');
|
||||
expect(rendered).toContain('accountId=23');
|
||||
expect(rendered).toContain('model=gpt-4.1');
|
||||
root?.unmount();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user