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:
cita
2026-03-07 15:03:30 +08:00
parent a98a6f2c18
commit c31bbcbb51
30 changed files with 1960 additions and 592 deletions
+22 -1
View File
@@ -1,5 +1,6 @@
import Fastify from 'fastify';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildConfig } from './config.js'; import { buildConfig, buildFastifyOptions } from './config.js';
describe('buildConfig', () => { describe('buildConfig', () => {
it('defaults to external listen host for server deployments', () => { it('defaults to external listen host for server deployments', () => {
@@ -30,4 +31,24 @@ describe('buildConfig', () => {
expect(config.listenHost).toBe('127.0.0.1'); 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();
});
}); });
+13
View File
@@ -1,4 +1,7 @@
import 'dotenv/config'; import 'dotenv/config';
import type { FastifyServerOptions } from 'fastify';
const DEFAULT_REQUEST_BODY_LIMIT = 20 * 1024 * 1024;
function parseBoolean(value: string | undefined, fallback = false): boolean { function parseBoolean(value: string | undefined, fallback = false): boolean {
if (value === undefined) return fallback; if (value === undefined) return fallback;
@@ -70,6 +73,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
dbType: parseDbType(env.DB_TYPE), dbType: parseDbType(env.DB_TYPE),
dbUrl: (env.DB_URL || '').trim(), dbUrl: (env.DB_URL || '').trim(),
dbSsl: parseBoolean(env.DB_SSL, false), dbSsl: parseBoolean(env.DB_SSL, false),
requestBodyLimit: DEFAULT_REQUEST_BODY_LIMIT,
routingFallbackUnitCost: Math.max(1e-6, parseNumber(env.ROUTING_FALLBACK_UNIT_COST, 1)), 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))), 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))), 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 const config = buildConfig(process.env);
export function buildFastifyOptions(
appConfig: ReturnType<typeof buildConfig>,
): FastifyServerOptions {
return {
logger: true,
bodyLimit: appConfig.requestBodyLimit,
};
}
+70
View File
@@ -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
View File
@@ -2,10 +2,49 @@ import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { config } from '../config.js'; 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 { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; 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 { function resolveSqliteDbPath(): string {
const raw = (config.dbUrl || '').trim(); const raw = (config.dbUrl || '').trim();
if (!raw) return resolve(`${config.dataDir}/hub.db`); if (!raw) return resolve(`${config.dataDir}/hub.db`);
@@ -24,15 +63,89 @@ function resolveMigrationsFolder(): string {
return resolve(dirname(fileURLToPath(import.meta.url)), '../../../drizzle'); 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 { export function runSqliteMigrations(): void {
const dbPath = resolveSqliteDbPath(); const dbPath = resolveSqliteDbPath();
const migrationsFolder = resolveMigrationsFolder();
if (dbPath !== ':memory:') { if (dbPath !== ':memory:') {
mkdirSync(dirname(dbPath), { recursive: true }); mkdirSync(dirname(dbPath), { recursive: true });
} }
const sqlite = new Database(dbPath); const sqlite = new Database(dbPath);
bootstrapLegacyDrizzleMigrations(sqlite, migrationsFolder);
const db = drizzle(sqlite); const db = drizzle(sqlite);
migrate(db, { migrationsFolder: resolveMigrationsFolder() }); migrate(db, { migrationsFolder });
sqlite.close(); sqlite.close();
console.log('Migration complete.'); console.log('Migration complete.');
} }
+4 -2
View File
@@ -1,7 +1,7 @@
import Fastify from 'fastify'; import Fastify from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import { config } from './config.js'; import { buildFastifyOptions, config } from './config.js';
import { authMiddleware } from './middleware/auth.js'; import { authMiddleware } from './middleware/auth.js';
import { sitesRoutes } from './routes/api/sites.js'; import { sitesRoutes } from './routes/api/sites.js';
import { accountsRoutes } from './routes/api/accounts.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 { startProxyLogRetentionService, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
import { buildStartupSummaryLines } from './services/startupInfo.js'; import { buildStartupSummaryLines } from './services/startupInfo.js';
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js'; import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@@ -208,13 +209,14 @@ try {
applyRuntimeSettings(finalMap); applyRuntimeSettings(finalMap);
await ensureProxyLogBillingDetailsColumn(); await ensureProxyLogBillingDetailsColumn();
await repairStoredCreatedAtValues(); await repairStoredCreatedAtValues();
await migrateSiteApiKeysToAccounts();
console.log('Loaded runtime settings overrides'); console.log('Loaded runtime settings overrides');
} catch (error) { } catch (error) {
console.warn(`Failed to load runtime settings overrides: ${(error as Error)?.message || 'unknown 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); await app.register(cors);
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { mkdtempSync } from 'node:fs'; import { mkdtempSync } from 'node:fs';
import { and, eq, sql } from 'drizzle-orm'; import { and, eq, sql } from 'drizzle-orm';
import { mergeAccountExtraConfig } from '../../services/accountExtraConfig.js';
const getApiTokensMock = vi.fn(); const getApiTokensMock = vi.fn();
const getApiTokenMock = vi.fn(); const getApiTokenMock = vi.fn();
@@ -137,6 +138,80 @@ describe('account tokens sync routes with site status', () => {
expect(tokenRows.length).toBe(0); 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 () => { it('sync-all skips disabled-site accounts and syncs active-site accounts', async () => {
const disabled = await seedAccount({ siteStatus: 'disabled' }); const disabled = await seedAccount({ siteStatus: 'disabled' });
const active = await seedAccount({ siteStatus: 'active' }); const active = await seedAccount({ siteStatus: 'active' });
+61 -2
View File
@@ -11,7 +11,7 @@ import {
syncTokensFromUpstream, syncTokensFromUpstream,
} from '../../services/accountTokenService.js'; } from '../../services/accountTokenService.js';
import { getAdapter } from '../../services/platforms/index.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'; import { startBackgroundTask } from '../../services/backgroundTaskService.js';
type AccountWithSiteRow = { type AccountWithSiteRow = {
@@ -82,6 +82,12 @@ function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled'; 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 { function asTrimmedString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined; if (typeof value !== 'string') return undefined;
const trimmed = value.trim(); 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.accessToken) {
if (row.accounts.apiToken) { if (row.accounts.apiToken) {
ensureDefaultTokenForAccount(accountId, row.accounts.apiToken, { name: 'default', source: 'legacy' }); 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: '账号不存在' }); 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(); const tokenValue = (body.token || '').trim();
if (tokenValue) { if (tokenValue) {
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -481,6 +505,14 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(404).send({ success: false, message: '令牌不存在' }); 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 body = request.body;
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() }; const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
@@ -525,7 +557,18 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (Number.isNaN(tokenId)) { if (Number.isNaN(tokenId)) {
return reply.code(400).send({ success: false, message: '令牌 ID 无效' }); 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) { if (!success) {
return reply.code(404).send({ success: false, message: '令牌不存在' }); 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: '令牌不存在' }); 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); const tokenValue = normalizeTokenForDisplay(row.account_tokens.token, row.sites.platform);
return { return {
success: true, success: true,
@@ -573,6 +620,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(404).send({ success: false, message: '账号不存在' }); 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 account = row.accounts;
const site = row.sites; const site = row.sites;
const adapter = getAdapter(site.platform); const adapter = getAdapter(site.platform);
@@ -612,6 +663,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(404).send({ success: false, message: '令牌不存在' }); 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 existing = row.account_tokens;
const account = row.accounts; const account = row.accounts;
const site = row.sites; const site = row.sites;
@@ -657,6 +712,9 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const result = await executeAccountTokenSync(row); const result = await executeAccountTokenSync(row);
appendTokenSyncEvent(result); 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') { if (result.status === 'failed' && result.reason === 'unsupported_platform') {
return reply.code(400).send({ success: false, message: result.message }); return reply.code(400).send({ success: false, message: result.message });
} }
@@ -727,6 +785,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
success: true, success: true,
token: row token: row
? (() => { ? (() => {
if (isApiKeyConnection(row.accounts)) return null;
const { token: rawToken, ...meta } = row.account_tokens; const { token: rawToken, ...meta } = row.account_tokens;
return { ...meta, tokenMasked: maskToken(rawToken, row.sites.platform) }; 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]?.apiToken || '').startsWith('sk-')).toBe(true);
expect(accounts[0]?.checkinEnabled).toBe(false); 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 }; const parsedExtra = JSON.parse(accounts[0]?.extraConfig || '{}') as { credentialMode?: string };
expect(parsedExtra.credentialMode).toBe('apikey'); expect(parsedExtra.credentialMode).toBe('apikey');
}); });
+10 -3
View File
@@ -1058,7 +1058,7 @@ export async function accountsRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '创建账号失败' }); return reply.code(500).send({ success: false, message: '创建账号失败' });
} }
if (apiToken) { if (tokenType === 'session' && apiToken) {
try { try {
await ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' }); await ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' });
} catch { } } catch { }
@@ -1161,7 +1161,15 @@ export async function accountsRoutes(app: FastifyInstance) {
updates.updatedAt = new Date().toISOString(); updates.updatedAt = new Date().toISOString();
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run(); 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 { try {
await ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' }); await ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' });
} catch { } } catch { }
@@ -1253,4 +1261,3 @@ export async function accountsRoutes(app: FastifyInstance) {
} }
}); });
} }
+1 -5
View File
@@ -96,7 +96,6 @@ export async function sitesRoutes(app: FastifyInstance) {
name: string; name: string;
url: string; url: string;
platform?: string; platform?: string;
apiKey?: string;
proxyUrl?: string | null; proxyUrl?: string | null;
externalCheckinUrl?: string | null; externalCheckinUrl?: string | null;
status?: string; status?: string;
@@ -104,7 +103,7 @@ export async function sitesRoutes(app: FastifyInstance) {
sortOrder?: number; sortOrder?: number;
globalWeight?: number; globalWeight?: number;
} }>('/api/sites', async (request, reply) => { } }>('/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); const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) { if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' }); return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
@@ -145,7 +144,6 @@ export async function sitesRoutes(app: FastifyInstance) {
name, name,
url: url.replace(/\/+$/, ''), url: url.replace(/\/+$/, ''),
platform: detectedPlatform, platform: detectedPlatform,
apiKey,
proxyUrl: parsedProxyUrl.proxyUrl, proxyUrl: parsedProxyUrl.proxyUrl,
externalCheckinUrl: normalizedExternalCheckinUrl.url, externalCheckinUrl: normalizedExternalCheckinUrl.url,
status: normalizedStatus ?? 'active', status: normalizedStatus ?? 'active',
@@ -170,7 +168,6 @@ export async function sitesRoutes(app: FastifyInstance) {
name?: string; name?: string;
url?: string; url?: string;
platform?: string; platform?: string;
apiKey?: string;
proxyUrl?: string | null; proxyUrl?: string | null;
externalCheckinUrl?: string | null; externalCheckinUrl?: string | null;
status?: string; status?: string;
@@ -218,7 +215,6 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.name !== undefined) updates.name = body.name; if (body.name !== undefined) updates.name = body.name;
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, ''); if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
if (body.platform !== undefined) updates.platform = body.platform; if (body.platform !== undefined) updates.platform = body.platform;
if (body.apiKey !== undefined) updates.apiKey = body.apiKey;
if (parsedProxyUrl.present) updates.proxyUrl = parsedProxyUrl.proxyUrl; if (parsedProxyUrl.present) updates.proxyUrl = parsedProxyUrl.proxyUrl;
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url; if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
if (body.status !== undefined) updates.status = normalizedStatus; 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();
});
}); });
-2
View File
@@ -332,7 +332,6 @@ export async function statsRoutes(app: FastifyInstance) {
id: row.sites.id, id: row.sites.id,
url: row.sites.url, url: row.sites.url,
platform: row.sites.platform, platform: row.sites.platform,
apiKey: row.sites.apiKey,
}, },
account: { account: {
id: row.accounts.id, id: row.accounts.id,
@@ -663,7 +662,6 @@ export async function statsRoutes(app: FastifyInstance) {
id: row.sites.id, id: row.sites.id,
url: row.sites.url, url: row.sites.url,
platform: row.sites.platform, platform: row.sites.platform,
apiKey: row.sites.apiKey,
}, },
account: { account: {
id: row.accounts.id, id: row.accounts.id,
@@ -955,7 +955,6 @@ export async function resolveUpstreamEndpointCandidates(
id: context.site.id, id: context.site.id,
url: context.site.url, url: context.site.url,
platform: context.site.platform, platform: context.site.platform,
apiKey: context.site.apiKey ?? null,
}, },
account: { account: {
id: context.account.id, id: context.account.id,
+11 -2
View File
@@ -1,5 +1,6 @@
import { and, eq, ne } from 'drizzle-orm'; import { and, eq, ne } from 'drizzle-orm';
import { db, schema } from '../db/index.js'; import { db, schema } from '../db/index.js';
import { getCredentialModeFromExtraConfig } from './accountExtraConfig.js';
type UpstreamApiToken = { type UpstreamApiToken = {
name?: string | null; name?: string | null;
@@ -66,6 +67,12 @@ async function updateAccountApiToken(accountId: number, tokenValue: string | nul
.run(); .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) { export async function getPreferredAccountToken(accountId: number) {
const tokens = await db.select() const tokens = await db.select()
.from(schema.accountTokens) .from(schema.accountTokens)
@@ -267,7 +274,9 @@ export async function listTokensWithRelations(accountId?: number) {
? await base.where(eq(schema.accountTokens.accountId, accountId)).all() ? await base.where(eq(schema.accountTokens.accountId, accountId)).all()
: await base.all(); : await base.all();
return rows.map((row) => { return rows
.filter((row) => !isApiKeyConnection(row.accounts))
.map((row) => {
const { token, ...tokenMeta } = row.account_tokens; const { token, ...tokenMeta } = row.account_tokens;
return { return {
...tokenMeta, ...tokenMeta,
@@ -284,6 +293,6 @@ export async function listTokensWithRelations(accountId?: number) {
platform: row.sites.platform, platform: row.sites.platform,
}, },
}; };
}); });
} }
@@ -383,7 +383,6 @@ function buildTokenCandidates(input: EstimateProxyCostInput): string[] {
const candidates = [ const candidates = [
input.account.accessToken, input.account.accessToken,
input.account.apiToken, input.account.apiToken,
input.site.apiKey,
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
return Array.from(new Set(candidates)); return Array.from(new Set(candidates));
+8 -2
View File
@@ -2,7 +2,7 @@ import { and, eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js'; import { db, schema } from '../db/index.js';
import { getAdapter } from './platforms/index.js'; import { getAdapter } from './platforms/index.js';
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js'; import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
import { resolvePlatformUserId } from './accountExtraConfig.js'; import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
import { invalidateTokenRouterCache } from './tokenRouter.js'; import { invalidateTokenRouterCache } from './tokenRouter.js';
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000; const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
@@ -13,6 +13,12 @@ function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled'; 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[] { function normalizeModels(models: string[]): string[] {
return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0))); return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0)));
} }
@@ -101,7 +107,7 @@ export async function refreshModelsForAccount(accountId: number) {
.all(); .all();
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table. // Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
if (enabledTokens.length === 0) { if (!isApiKeyConnection(account) && enabledTokens.length === 0) {
const fallback = discoveredApiToken || account.apiToken || null; const fallback = discoveredApiToken || account.apiToken || null;
if (fallback) { if (fallback) {
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' }); ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
@@ -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_CREATED_DELTA_MS = 90_000;
const MATCH_MAX_LATENCY_DELTA_MS = 12_000; const MATCH_MAX_LATENCY_DELTA_MS = 12_000;
const QUOTA_PER_UNIT = 500_000; const QUOTA_PER_UNIT = 500_000;
const SUPPORTED_USAGE_FALLBACK_PLATFORMS = new Set(['done-hub', 'one-hub', 'new-api', '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']); const ALWAYS_LOOKUP_SELF_LOG_PLATFORMS = new Set(['done-hub', 'one-hub', 'anyrouter', 'sub2api']);
const PLATFORM_REQUIRES_USER_HEADER = new Set(['new-api', 'anyrouter']); const PLATFORM_REQUIRES_USER_HEADER = new Set(['new-api', 'anyrouter']);
interface ProxyUsage { interface ProxyUsage {
@@ -66,10 +66,12 @@ export interface SelfLogBillingMeta {
export interface SelfLogItem { export interface SelfLogItem {
modelName: string; modelName: string;
tokenName: string; tokenName: string;
tokenValue?: string;
promptTokens: number; promptTokens: number;
completionTokens: number; completionTokens: number;
totalTokens: number; totalTokens: number;
quota: number; quota: number;
recoveredCost?: number;
createdAtMs: number; createdAtMs: number;
requestTimeMs: number; requestTimeMs: number;
billingMeta: SelfLogBillingMeta | null; billingMeta: SelfLogBillingMeta | null;
@@ -78,6 +80,7 @@ export interface SelfLogItem {
interface SelfLogMatchInput { interface SelfLogMatchInput {
modelName: string; modelName: string;
tokenName?: string | null; tokenName?: string | null;
tokenValue?: string | null;
requestStartedAtMs: number; requestStartedAtMs: number;
requestEndedAtMs: number; requestEndedAtMs: number;
localLatencyMs: number; localLatencyMs: number;
@@ -179,6 +182,10 @@ function modelNameMatches(left: string, right: string): boolean {
return !!leftTail && leftTail === rightTail; return !!leftTail && leftTail === rightTail;
} }
function normalizeTokenMatchValue(value: string | null | undefined): string {
return String(value || '').trim();
}
function getPayloadList(payload: unknown): unknown[] { function getPayloadList(payload: unknown): unknown[] {
const candidates = [ const candidates = [
payload, payload,
@@ -200,25 +207,57 @@ function mapSelfLogItem(raw: unknown): SelfLogItem | null {
if (!raw || typeof raw !== 'object') return null; if (!raw || typeof raw !== 'object') return null;
const row = raw as Record<string, unknown>; 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; if (!modelName) return null;
const promptTokens = toPositiveInt(row.prompt_tokens ?? row.promptTokens); const promptTokens = toPositiveInt(
const completionTokens = toPositiveInt(row.completion_tokens ?? row.completionTokens); 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 totalTokensRaw = toPositiveInt(row.total_tokens ?? row.totalTokens);
const totalTokens = totalTokensRaw > 0 ? totalTokensRaw : (promptTokens + completionTokens); const totalTokens = totalTokensRaw > 0 ? totalTokensRaw : (promptTokens + completionTokens);
const createdAtMs = toTimestampMs(row.created_at ?? row.createdAt); const createdAtMs = toTimestampMs(row.created_at ?? row.createdAt);
if (createdAtMs <= 0) return null; 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 { return {
modelName, modelName,
tokenName: String(row.token_name ?? row.tokenName ?? '').trim(), tokenName,
...(tokenValue ? { tokenValue } : {}),
promptTokens, promptTokens,
completionTokens, completionTokens,
totalTokens, totalTokens,
quota: toPositiveInt(row.quota), quota: toPositiveInt(row.quota),
...(recoveredCost > 0 ? { recoveredCost } : {}),
createdAtMs, createdAtMs,
requestTimeMs: toPositiveInt(row.request_time ?? row.requestTime), requestTimeMs,
billingMeta: parseSelfLogBillingMeta(row.other), billingMeta: parseSelfLogBillingMeta(row.other),
}; };
} }
@@ -293,7 +332,6 @@ function buildTokenCandidates(input: ProxyUsageFallbackInput): string[] {
input.account.accessToken, input.account.accessToken,
input.tokenValue, input.tokenValue,
input.account.apiToken, input.account.apiToken,
input.site.apiKey,
] ]
.map((value) => (typeof value === 'string' ? value.trim() : '')) .map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean); .filter(Boolean);
@@ -313,6 +351,35 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
}, SELF_LOG_FETCH_TIMEOUT_MS); }, SELF_LOG_FETCH_TIMEOUT_MS);
try { 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' const pageSizeParam = String(input.site.platform || '').toLowerCase() === 'anyrouter'
? 'page_size' ? 'page_size'
: 'size'; : 'size';
@@ -321,7 +388,6 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
const headers: Record<string, string> = { const headers: Record<string, string> = {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}; };
const platform = String(input.site.platform || '').toLowerCase();
if (PLATFORM_REQUIRES_USER_HEADER.has(platform)) { if (PLATFORM_REQUIRES_USER_HEADER.has(platform)) {
const userId = resolveSelfLogUserId(input); const userId = resolveSelfLogUserId(input);
if (userId) { if (userId) {
@@ -409,6 +475,14 @@ export function findBestSelfLogMatch(items: SelfLogItem[], input: SelfLogMatchIn
)); ));
if (candidates.length === 0) return null; 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(); const tokenName = String(input.tokenName || '').trim().toLowerCase();
if (tokenName) { if (tokenName) {
const tokenMatched = candidates.filter((item) => item.tokenName.trim().toLowerCase() === 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); 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( export async function resolveProxyUsageWithSelfLogFallback(
input: ProxyUsageFallbackInput, input: ProxyUsageFallbackInput,
): Promise<ProxyUsageFallbackResult> { ): Promise<ProxyUsageFallbackResult> {
@@ -471,6 +550,7 @@ export async function resolveProxyUsageWithSelfLogFallback(
const matched = findBestSelfLogMatch(items, { const matched = findBestSelfLogMatch(items, {
modelName: input.modelName, modelName: input.modelName,
tokenName: input.tokenName, tokenName: input.tokenName,
tokenValue: input.tokenValue,
requestStartedAtMs: input.requestStartedAtMs, requestStartedAtMs: input.requestStartedAtMs,
requestEndedAtMs: input.requestEndedAtMs, requestEndedAtMs: input.requestEndedAtMs,
localLatencyMs: input.localLatencyMs, localLatencyMs: input.localLatencyMs,
@@ -494,7 +574,7 @@ export async function resolveProxyUsageWithSelfLogFallback(
completionTokens: resolvedTokens.completionTokens, completionTokens: resolvedTokens.completionTokens,
totalTokens: resolvedTokens.totalTokens, totalTokens: resolvedTokens.totalTokens,
recoveredFromSelfLog: true, recoveredFromSelfLog: true,
estimatedCostFromQuota: toQuotaCost(matched.quota), estimatedCostFromQuota: toRecoveredCost(matched),
selfLogBillingMeta: matched.billingMeta, selfLogBillingMeta: matched.billingMeta,
}; };
} catch { } 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); const candidate = decision.candidates.find((item) => item.channelId === channel.id);
expect(candidate?.eligible).toBe(false); 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();
});
}); });
+1 -4
View File
@@ -835,10 +835,7 @@ export class TokenRouter {
} }
const fallback = candidate.account.apiToken?.trim(); const fallback = candidate.account.apiToken?.trim();
if (fallback) return fallback; return fallback || null;
const siteApiKey = candidate.site?.apiKey?.trim();
return siteApiKey ? siteApiKey : null;
} }
private weightedRandomSelect( private weightedRandomSelect(
+767 -502
View File
File diff suppressed because it is too large Load Diff
+5 -18
View File
@@ -23,7 +23,6 @@ type SiteRow = {
externalCheckinUrl?: string | null; externalCheckinUrl?: string | null;
platform?: string; platform?: string;
status?: string; status?: string;
apiKey?: string;
proxyUrl?: string | null; proxyUrl?: string | null;
globalWeight?: number; globalWeight?: number;
isPinned?: boolean; isPinned?: boolean;
@@ -188,7 +187,6 @@ export default function Sites() {
url: form.url.trim(), url: form.url.trim(),
externalCheckinUrl: form.externalCheckinUrl.trim(), externalCheckinUrl: form.externalCheckinUrl.trim(),
platform: form.platform.trim(), platform: form.platform.trim(),
apiKey: form.apiKey.trim(),
proxyUrl: form.proxyUrl.trim(), proxyUrl: form.proxyUrl.trim(),
globalWeight: Number(parsedGlobalWeight.toFixed(3)), globalWeight: Number(parsedGlobalWeight.toFixed(3)),
}; };
@@ -201,8 +199,12 @@ export default function Sites() {
try { try {
const action = buildSiteSaveAction(editor, payload); const action = buildSiteSaveAction(editor, payload);
if (action.kind === 'add') { if (action.kind === 'add') {
await api.addSite(action.payload); const created = await api.addSite(action.payload);
toast.success(`站点 "${payload.name}" 已添加`); toast.success(`站点 "${payload.name}" 已添加`);
const createdSiteId = Number(created?.id) || 0;
if (createdSiteId > 0) {
navigate(`/accounts?segment=apikey&create=1&siteId=${createdSiteId}`);
}
} else { } else {
await api.updateSite(action.id, action.payload); await api.updateSite(action.id, action.payload);
toast.success(`站点 "${payload.name}" 已更新`); toast.success(`站点 "${payload.name}" 已更新`);
@@ -407,21 +409,6 @@ export default function Sites() {
color: 'var(--color-text-primary)', 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 <input
placeholder="出站代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)" placeholder="出站代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
value={form.proxyUrl} value={form.proxyUrl}
+30 -5
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'; 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 { api } from '../api.js';
import { useToast } from '../components/Toast.js'; import { useToast } from '../components/Toast.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
@@ -9,6 +9,9 @@ import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
import { tr } from '../i18n.js'; import { tr } from '../i18n.js';
type SyncStatus = 'success' | 'skipped' | 'failed'; type SyncStatus = 'success' | 'skipped' | 'failed';
type TokensPanelProps = {
embedded?: boolean;
};
type AccountTokenSyncResult = { type AccountTokenSyncResult = {
status?: string; 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) => 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 resolveSyncStatus = (result: AccountTokenSyncResult | null | undefined): SyncStatus => {
const raw = String(result?.status || '').toLowerCase(); const raw = String(result?.status || '').toLowerCase();
@@ -83,7 +100,7 @@ function isTruthyFlag(input: string | null): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes'; return normalized === '1' || normalized === 'true' || normalized === 'yes';
} }
export default function Tokens() { export function TokensPanel({ embedded = false }: TokensPanelProps) {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const initialCreateForm = { const initialCreateForm = {
@@ -362,9 +379,9 @@ export default function Tokens() {
}; };
return ( return (
<div className="animate-fade-in"> <div className={embedded ? '' : 'animate-fade-in'}>
<div className="page-header"> <div className="page-header">
<h2 className="page-title">{tr('令牌管理')}</h2> {!embedded ? <h2 className="page-title">{tr('账号令牌')}</h2> : <div />}
<div className="page-actions"> <div className="page-actions">
<div style={{ minWidth: 220, position: 'relative', zIndex: 20 }}> <div style={{ minWidth: 220, position: 'relative', zIndex: 20 }}>
<ModernSelect <ModernSelect
@@ -646,3 +663,11 @@ export default function Tokens() {
</div> </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 { try {
await act(async () => { await act(async () => {
root = create( root = create(
<MemoryRouter initialEntries={['/accounts']}> <MemoryRouter initialEntries={['/accounts?segment=apikey']}>
<ToastProvider> <ToastProvider>
<Accounts /> <Accounts />
</ToastProvider> </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();
}
});
});
+14 -6
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildSiteSaveAction } from './sitesEditor.js'; import { buildSiteSaveAction, emptySiteForm, siteFormFromSite } from './sitesEditor.js';
describe('buildSiteSaveAction', () => { describe('buildSiteSaveAction', () => {
it('returns add action in add mode', () => { it('returns add action in add mode', () => {
@@ -10,7 +10,6 @@ describe('buildSiteSaveAction', () => {
url: 'https://a.example.com/', url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com', externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api', platform: 'new-api',
apiKey: 'sk-1',
proxyUrl: 'http://127.0.0.1:7890', proxyUrl: 'http://127.0.0.1:7890',
globalWeight: '1.2', globalWeight: '1.2',
}, },
@@ -23,7 +22,6 @@ describe('buildSiteSaveAction', () => {
url: 'https://a.example.com/', url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com', externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api', platform: 'new-api',
apiKey: 'sk-1',
proxyUrl: 'http://127.0.0.1:7890', proxyUrl: 'http://127.0.0.1:7890',
globalWeight: '1.2', globalWeight: '1.2',
}, },
@@ -38,7 +36,6 @@ describe('buildSiteSaveAction', () => {
url: 'https://b.example.com', url: 'https://b.example.com',
externalCheckinUrl: '', externalCheckinUrl: '',
platform: 'one-api', platform: 'one-api',
apiKey: '',
proxyUrl: '', proxyUrl: '',
globalWeight: '0.8', globalWeight: '0.8',
}, },
@@ -52,7 +49,6 @@ describe('buildSiteSaveAction', () => {
url: 'https://b.example.com', url: 'https://b.example.com',
externalCheckinUrl: '', externalCheckinUrl: '',
platform: 'one-api', platform: 'one-api',
apiKey: '',
proxyUrl: '', proxyUrl: '',
globalWeight: '0.8', globalWeight: '0.8',
}, },
@@ -68,11 +64,23 @@ describe('buildSiteSaveAction', () => {
url: 'https://c.example.com', url: 'https://c.example.com',
externalCheckinUrl: '', externalCheckinUrl: '',
platform: '', platform: '',
apiKey: '',
proxyUrl: '', proxyUrl: '',
globalWeight: '1', globalWeight: '1',
}, },
), ),
).toThrow('editingSiteId is required in edit mode'); ).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');
});
}); });
+1 -3
View File
@@ -3,7 +3,6 @@ export type SiteForm = {
url: string; url: string;
externalCheckinUrl: string; externalCheckinUrl: string;
platform: string; platform: string;
apiKey: string;
proxyUrl: string; proxyUrl: string;
globalWeight: string; globalWeight: string;
}; };
@@ -17,7 +16,7 @@ type SiteSaveAction =
| { kind: 'update'; id: number; payload: SiteForm }; | { kind: 'update'; id: number; payload: SiteForm };
export function emptySiteForm(): 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> & { export function siteFormFromSite(site: Partial<SiteForm> & {
@@ -32,7 +31,6 @@ export function siteFormFromSite(site: Partial<SiteForm> & {
url: site.url ?? '', url: site.url ?? '',
externalCheckinUrl: site.externalCheckinUrl ?? '', externalCheckinUrl: site.externalCheckinUrl ?? '',
platform: site.platform ?? '', platform: site.platform ?? '',
apiKey: site.apiKey ?? '',
proxyUrl: site.proxyUrl ?? '', proxyUrl: site.proxyUrl ?? '',
globalWeight, globalWeight,
}; };
+48
View File
@@ -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();
});
});