From c31bbcbb51299e6d5de703b2fbecae6838d695c5 Mon Sep 17 00:00:00 2001 From: cita Date: Sat, 7 Mar 2026 15:03:30 +0800 Subject: [PATCH] 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. --- src/server/config.test.ts | 23 +- src/server/config.ts | 13 + src/server/db/migrate.test.ts | 70 + src/server/db/migrate.ts | 117 +- src/server/index.ts | 6 +- .../routes/api/accountTokens.sync.test.ts | 75 + src/server/routes/api/accountTokens.ts | 63 +- .../api/accounts.credentialMode.test.ts | 3 + src/server/routes/api/accounts.ts | 13 +- src/server/routes/api/sites.ts | 6 +- .../routes/api/stats.token-candidates.test.ts | 77 + src/server/routes/api/stats.ts | 2 - src/server/routes/proxy/upstreamEndpoint.ts | 1 - src/server/services/accountTokenService.ts | 13 +- src/server/services/modelPricingService.ts | 1 - src/server/services/modelService.ts | 32 +- .../proxyUsageFallbackService.sub2api.test.ts | 131 ++ .../services/proxyUsageFallbackService.ts | 100 +- .../siteApiKeyMigrationService.test.ts | 157 ++ .../services/siteApiKeyMigrationService.ts | 103 ++ .../services/tokenRouter.siteStatus.test.ts | 36 + src/server/services/tokenRouter.ts | 23 +- src/web/pages/Accounts.tsx | 1269 ++++++++++------- src/web/pages/Sites.tsx | 23 +- src/web/pages/Tokens.tsx | 35 +- .../accounts.proxy-only-expired.test.tsx | 2 +- .../accounts.segmented-connections.test.tsx | 86 ++ src/web/pages/helpers/sitesEditor.test.ts | 20 +- src/web/pages/helpers/sitesEditor.ts | 4 +- src/web/pages/tokens.redirect.test.tsx | 48 + 30 files changed, 1960 insertions(+), 592 deletions(-) create mode 100644 src/server/db/migrate.test.ts create mode 100644 src/server/services/proxyUsageFallbackService.sub2api.test.ts create mode 100644 src/server/services/siteApiKeyMigrationService.test.ts create mode 100644 src/server/services/siteApiKeyMigrationService.ts create mode 100644 src/web/pages/accounts.segmented-connections.test.tsx create mode 100644 src/web/pages/tokens.redirect.test.tsx diff --git a/src/server/config.test.ts b/src/server/config.test.ts index a785256..515759a 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -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(); + }); }); diff --git a/src/server/config.ts b/src/server/config.ts index 54ffd95..16aa9b1 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -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, +): FastifyServerOptions { + return { + logger: true, + bodyLimit: appConfig.requestBodyLimit, + }; +} diff --git a/src/server/db/migrate.test.ts b/src/server/db/migrate.test.ts new file mode 100644 index 0000000..419257a --- /dev/null +++ b/src/server/db/migrate.test.ts @@ -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(); + }); +}); diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index e725d09..1237f4a 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -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.'); } diff --git a/src/server/index.ts b/src/server/index.ts index 0398cdb..93e2919 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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); diff --git a/src/server/routes/api/accountTokens.sync.test.ts b/src/server/routes/api/accountTokens.sync.test.ts index 56bd4ae..b493655 100644 --- a/src/server/routes/api/accountTokens.sync.test.ts +++ b/src/server/routes/api/accountTokens.sync.test.ts @@ -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' }); diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index a48aaea..4b2b7d9 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -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 = { 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) }; })() diff --git a/src/server/routes/api/accounts.credentialMode.test.ts b/src/server/routes/api/accounts.credentialMode.test.ts index 55b8408..2d9c4fe 100644 --- a/src/server/routes/api/accounts.credentialMode.test.ts +++ b/src/server/routes/api/accounts.credentialMode.test.ts @@ -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'); }); diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index 0e87aa6..2248ac7 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -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) { } }); } - diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index 3fc7545..8d60ef3 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -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; diff --git a/src/server/routes/api/stats.token-candidates.test.ts b/src/server/routes/api/stats.token-candidates.test.ts index b950c35..2fb67d8 100644 --- a/src/server/routes/api/stats.token-candidates.test.ts +++ b/src/server/routes/api/stats.token-candidates.test.ts @@ -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(); + }); }); diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index fc3ac24..322e2f1 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -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, diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 058f02f..3ccfe2e 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -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, diff --git a/src/server/services/accountTokenService.ts b/src/server/services/accountTokenService.ts index c5bbe3b..57cf713 100644 --- a/src/server/services/accountTokenService.ts +++ b/src/server/services/accountTokenService.ts @@ -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, }, }; - }); + }); } diff --git a/src/server/services/modelPricingService.ts b/src/server/services/modelPricingService.ts index a9a83b4..3bf010e 100644 --- a/src/server/services/modelPricingService.ts +++ b/src/server/services/modelPricingService.ts @@ -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)); diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 426c2e5..6d5a2e5 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -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))) diff --git a/src/server/services/proxyUsageFallbackService.sub2api.test.ts b/src/server/services/proxyUsageFallbackService.sub2api.test.ts new file mode 100644 index 0000000..6f5f522 --- /dev/null +++ b/src/server/services/proxyUsageFallbackService.sub2api.test.ts @@ -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, + }); + }); +}); diff --git a/src/server/services/proxyUsageFallbackService.ts b/src/server/services/proxyUsageFallbackService.ts index c180964..4063388 100644 --- a/src/server/services/proxyUsageFallbackService.ts +++ b/src/server/services/proxyUsageFallbackService.ts @@ -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; - 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 + : 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 = { 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 { @@ -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 { diff --git a/src/server/services/siteApiKeyMigrationService.test.ts b/src/server/services/siteApiKeyMigrationService.test.ts new file mode 100644 index 0000000..eb48e1e --- /dev/null +++ b/src/server/services/siteApiKeyMigrationService.test.ts @@ -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); + }); +}); diff --git a/src/server/services/siteApiKeyMigrationService.ts b/src/server/services/siteApiKeyMigrationService.ts new file mode 100644 index 0000000..767c955 --- /dev/null +++ b/src/server/services/siteApiKeyMigrationService.ts @@ -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 { + 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; +} diff --git a/src/server/services/tokenRouter.siteStatus.test.ts b/src/server/services/tokenRouter.siteStatus.test.ts index dd99fed..3b07506 100644 --- a/src/server/services/tokenRouter.siteStatus.test.ts +++ b/src/server/services/tokenRouter.siteStatus.test.ts @@ -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(); + }); }); diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 5bf5f34..d2d4a96 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -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[], diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 2cb7add..c0d1161 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -11,13 +11,34 @@ import { normalizeVerifyFailureMessage, } from './helpers/accountVerifyFeedback.js'; import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFocus.js'; +import { TokensPanel } from './Tokens.js'; import { tr } from '../i18n.js'; import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js'; import { SITE_DOCS_URL } from '../docsLink.js'; +type ConnectionsSegment = 'session' | 'apikey' | 'tokens'; + +function isTruthyFlag(value: string | null): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes'; +} + +function parsePositiveInt(value: string | null): number { + const parsed = Number.parseInt(String(value || '').trim(), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +function resolveConnectionsSegment(search: string): ConnectionsSegment { + const rawSegment = new URLSearchParams(search).get('segment'); + if (rawSegment === 'apikey' || rawSegment === 'tokens') return rawSegment; + return 'session'; +} + export default function Accounts() { const location = useLocation(); const navigate = useNavigate(); + const activeSegment = useMemo(() => resolveConnectionsSegment(location.search), [location.search]); const [accounts, setAccounts] = useState([]); const [sites, setSites] = useState([]); const [loaded, setLoaded] = useState(false); @@ -33,7 +54,7 @@ export default function Accounts() { platformUserId: '', refreshToken: '', tokenExpiresAt: '', - credentialMode: 'auto' as 'auto' | 'session' | 'apikey', + credentialMode: 'session' as 'auto' | 'session' | 'apikey', }); const [verifyResult, setVerifyResult] = useState(null); const [verifying, setVerifying] = useState(false); @@ -81,13 +102,89 @@ export default function Accounts() { ); const isSub2ApiSelected = (selectedTokenSite?.platform || '').toLowerCase() === 'sub2api'; + const resolveAccountCredentialMode = (account: any): 'session' | 'apikey' => { + const rawMode = String(account?.credentialMode || '').trim().toLowerCase(); + if (rawMode === 'apikey') return 'apikey'; + if (rawMode === 'session') return 'session'; + const fromServer = account?.capabilities; + if (fromServer && typeof fromServer.proxyOnly === 'boolean') { + return fromServer.proxyOnly ? 'apikey' : 'session'; + } + const hasSession = typeof account?.accessToken === 'string' && account.accessToken.trim().length > 0; + return hasSession ? 'session' : 'apikey'; + }; + + const resolveAccountDisplayName = (account: any) => { + const username = typeof account?.username === 'string' ? account.username.trim() : ''; + if (username) return username; + return resolveAccountCredentialMode(account) === 'apikey' ? 'API Key 连接' : '未命名'; + }; + const sortedAccounts = useMemo( () => sortItemsForDisplay(accounts, sortMode, (account) => account.balance || 0), [accounts, sortMode], ); + const visibleAccounts = useMemo(() => { + if (activeSegment === 'tokens') return []; + return sortedAccounts.filter((account) => resolveAccountCredentialMode(account) === activeSegment); + }, [activeSegment, sortedAccounts]); const verifyFailureHint = buildVerifyFailureHint(verifyResult); const addAccountPrereqHint = buildAddAccountPrereqHint(verifyResult); + const setSegment = (nextSegment: ConnectionsSegment) => { + const params = new URLSearchParams(location.search); + if (nextSegment === 'session') params.delete('segment'); + else params.set('segment', nextSegment); + const nextSearch = params.toString(); + navigate( + { + pathname: location.pathname, + search: nextSearch ? `?${nextSearch}` : '', + }, + { replace: false }, + ); + }; + + useEffect(() => { + if (activeSegment !== 'tokens') return; + setShowAdd(false); + setVerifyResult(null); + if (rebindTarget) closeRebindPanel(); + }, [activeSegment]); + + useEffect(() => { + if (activeSegment !== 'apikey' || !loaded) return; + const params = new URLSearchParams(location.search); + const shouldOpenCreate = isTruthyFlag(params.get('create')); + const requestedSiteId = parsePositiveInt(params.get('siteId')); + if (!shouldOpenCreate || !requestedSiteId) return; + + setShowAdd(true); + setAddMode('token'); + setVerifyResult(null); + setTokenForm({ + siteId: requestedSiteId, + username: '', + accessToken: '', + platformUserId: '', + refreshToken: '', + tokenExpiresAt: '', + credentialMode: 'apikey', + }); + + params.delete('create'); + params.delete('siteId'); + params.delete('from'); + const nextSearch = params.toString(); + navigate( + { + pathname: location.pathname, + search: nextSearch ? `?${nextSearch}` : '', + }, + { replace: true }, + ); + }, [activeSegment, loaded, location.pathname, location.search, navigate]); + useEffect(() => { return () => { if (highlightTimerRef.current) { @@ -124,6 +221,7 @@ export default function Accounts() { const handleVerifyToken = async () => { if (!tokenForm.siteId || !tokenForm.accessToken) return; + const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session'; setVerifying(true); setVerifyResult(null); try { @@ -131,7 +229,7 @@ export default function Accounts() { siteId: tokenForm.siteId, accessToken: tokenForm.accessToken, platformUserId: tokenForm.platformUserId ? parseInt(tokenForm.platformUserId) : undefined, - credentialMode: tokenForm.credentialMode, + credentialMode, }); setVerifyResult(result); if (result.success) { @@ -157,6 +255,7 @@ export default function Accounts() { toast.error('请先验证 Token 成功后再添加账号'); return; } + const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session'; setSaving(true); try { const result = await api.addAccount({ @@ -170,7 +269,7 @@ export default function Accounts() { tokenExpiresAt: isSub2ApiSelected && tokenForm.tokenExpiresAt.trim() ? Number.parseInt(tokenForm.tokenExpiresAt.trim(), 10) : undefined, - credentialMode: tokenForm.credentialMode, + credentialMode, }); setShowAdd(false); setTokenForm({ @@ -180,7 +279,7 @@ export default function Accounts() { platformUserId: '', refreshToken: '', tokenExpiresAt: '', - credentialMode: 'auto', + credentialMode: 'session', }); setVerifyResult(null); if (result.tokenType === 'apikey') { @@ -427,9 +526,9 @@ export default function Accounts() { useEffect(() => { const { accountId, openRebind } = readFocusAccountIntent(location.search); - if (!accountId || !loaded) return; + if (!accountId || !loaded || activeSegment === 'tokens') return; - const target = sortedAccounts.find((account) => account.id === accountId); + const target = visibleAccounts.find((account) => account.id === accountId); const row = rowRefs.current.get(accountId); const cleanedSearch = clearFocusParams(location.search); if (!target || !row) { @@ -452,540 +551,706 @@ export default function Accounts() { } navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true }); - }, [loaded, location.pathname, location.search, navigate, openRebindPanel, rebindTarget, sortedAccounts]); + }, [activeSegment, loaded, location.pathname, location.search, navigate, openRebindPanel, rebindTarget, visibleAccounts]); + + const canAddVerifiedConnection = Boolean( + verifyResult?.success + && ( + (activeSegment === 'apikey' && verifyResult.tokenType === 'apikey') + || (activeSegment === 'session' && verifyResult.tokenType === 'session') + ), + ); return (
-

{tr('账号管理')}

-
-
- setSortMode(nextValue as SortMode)} - options={[ - { value: 'custom', label: '自定义排序' }, - { value: 'balance-desc', label: '余额高到低' }, - { value: 'balance-asc', label: '余额低到高' }, - ]} - placeholder="自定义排序" - /> +

{tr('连接管理')}

+ {activeSegment !== 'tokens' && ( +
+
+ setSortMode(nextValue as SortMode)} + options={[ + { value: 'custom', label: '自定义排序' }, + { value: 'balance-desc', label: '余额高到低' }, + { value: 'balance-asc', label: '余额低到高' }, + ]} + placeholder="自定义排序" + /> +
+ {activeSegment === 'session' && ( + + )} + +
- - - -
+ )}
- {/* Add Panel */} - {addPanelPresence.shouldRender && ( -
- {/* Mode tabs */} -
- - -
+
+ {[ + { value: 'session' as ConnectionsSegment, label: 'Session 连接' }, + { value: 'apikey' as ConnectionsSegment, label: 'API Key 连接' }, + { value: 'tokens' as ConnectionsSegment, label: '账号令牌' }, + ].map((segment) => ( + + ))} +
- {addMode === 'token' ? ( -
-
-
-
凭证模式:Session / API Key(可切换,默认自动识别)
-
API Key(在站点「令牌」页面生成)→ 仅代理转发
-
Session 模式支持两种凭证:系统访问令牌(Access Token,推荐) 或 浏览器 Cookie
-
以 NewAPI 为例:控制台 → 个人设置 → 安全设置 → 生成「系统访问令牌」
-
- 推荐优先使用 Access Token(更稳定、可多账号);Cookie 仅用于兼容场景。获取 Cookie: F12 → Application → Cookie
- -
-
- { - const nextSiteId = Number.parseInt(nextValue, 10) || 0; - setTokenForm((f) => ({ ...f, siteId: nextSiteId })); - setVerifyResult(null); - }} - options={[ - { value: '0', label: '选择站点' }, - ...sites.map((s: any) => ({ - value: String(s.id), - label: `${s.name} (${s.platform})`, - })), - ]} - placeholder="选择站点" - /> - setTokenForm((f) => ({ ...f, username: e.target.value }))} - style={inputStyle} - /> - { - const nextMode = (nextValue as 'auto' | 'session' | 'apikey') || 'auto'; - setTokenForm((f) => ({ ...f, credentialMode: nextMode })); - setVerifyResult(null); - }} - options={[ - { value: 'auto', label: '自动识别(推荐)' }, - { value: 'session', label: 'Session 模式(Access Token / Cookie)' }, - { value: 'apikey', label: 'API Key 模式' }, - ]} - placeholder="选择凭证模式" - /> -