forked from boos/BoosAPI
refactor: remove deprecated apiKey from site management and update related functionality
- Removed apiKey from SiteRow type and related forms in Sites component. - Updated site saving logic to handle site creation without apiKey. - Adjusted tests to ensure apiKey is not included in site editor state. - Refactored Tokens component to support embedded mode and redirect logic. - Added migration tests for site apiKey to account migration service. - Implemented siteApiKeyMigrationService to handle migration of apiKey to account tokens. - Enhanced tests for segmented connections in Accounts and redirect logic for Tokens.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mkdtempSync, readFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
type MigrationJournalEntry = {
|
||||||
|
tag: string;
|
||||||
|
when: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const migrationsDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../drizzle');
|
||||||
|
|
||||||
|
function readMigrationJournalEntries(): MigrationJournalEntry[] {
|
||||||
|
const journalPath = join(migrationsDir, 'meta', '_journal.json');
|
||||||
|
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { entries?: MigrationJournalEntry[] };
|
||||||
|
return journal.entries ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMigrationSql(sqlite: Database.Database, sqlText: string) {
|
||||||
|
const statements = sqlText
|
||||||
|
.split('--> statement-breakpoint')
|
||||||
|
.map((statement) => statement.trim())
|
||||||
|
.filter((statement) => statement.length > 0);
|
||||||
|
|
||||||
|
for (const statement of statements) {
|
||||||
|
sqlite.exec(statement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sqlite migrate bootstrap', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
delete process.env.DB_URL;
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an already-synced sqlite schema with an empty drizzle journal', async () => {
|
||||||
|
const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-'));
|
||||||
|
const dbPath = join(dataDir, 'hub.db');
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
const journalEntries = readMigrationJournalEntries();
|
||||||
|
|
||||||
|
for (const entry of journalEntries) {
|
||||||
|
const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8');
|
||||||
|
applyMigrationSql(sqlite, sqlText);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
await expect(import('./migrate.js')).resolves.toMatchObject({
|
||||||
|
runSqliteMigrations: expect.any(Function),
|
||||||
|
});
|
||||||
|
|
||||||
|
const verified = new Database(dbPath, { readonly: true });
|
||||||
|
const appliedRows = verified
|
||||||
|
.prepare('select created_at from __drizzle_migrations order by created_at asc')
|
||||||
|
.all() as Array<{ created_at: number }>;
|
||||||
|
|
||||||
|
expect(appliedRows.map((row) => Number(row.created_at))).toEqual(
|
||||||
|
journalEntries.map((entry) => entry.when),
|
||||||
|
);
|
||||||
|
|
||||||
|
verified.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
+115
-2
@@ -2,10 +2,49 @@ import Database from 'better-sqlite3';
|
|||||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
import { 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
@@ -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' });
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { and, eq } from 'drizzle-orm';
|
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;
|
||||||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||||
const MODEL_REFRESH_BATCH_SIZE = 3;
|
const MODEL_REFRESH_BATCH_SIZE = 3;
|
||||||
|
|
||||||
function isSiteDisabled(status?: string | null): boolean {
|
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,10 +107,10 @@ 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' });
|
||||||
enabledTokens = await db.select()
|
enabledTokens = await db.select()
|
||||||
.from(schema.accountTokens)
|
.from(schema.accountTokens)
|
||||||
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
|
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { fetchMock, fetchJsonWithShieldCookieRetryMock } = vi.hoisted(() => ({
|
||||||
|
fetchMock: vi.fn(),
|
||||||
|
fetchJsonWithShieldCookieRetryMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('undici', () => ({
|
||||||
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./platforms/newApiShield.js', () => ({
|
||||||
|
buildNewApiCookieCandidates: () => [],
|
||||||
|
fetchJsonWithShieldCookieRetry: (...args: unknown[]) => fetchJsonWithShieldCookieRetryMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./siteProxy.js', () => ({
|
||||||
|
withExplicitProxyRequestInit: (_proxyUrl: string | null | undefined, init: RequestInit) => init,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveProxyUsageWithSelfLogFallback,
|
||||||
|
shouldLookupSelfLog,
|
||||||
|
} from './proxyUsageFallbackService.js';
|
||||||
|
|
||||||
|
describe('proxyUsageFallbackService sub2api', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.mockReset();
|
||||||
|
fetchJsonWithShieldCookieRetryMock.mockReset();
|
||||||
|
fetchJsonWithShieldCookieRetryMock.mockResolvedValue({ data: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always enables usage lookup for sub2api to recover exact billing cost', () => {
|
||||||
|
expect(shouldLookupSelfLog('sub2api', {
|
||||||
|
promptTokens: 70354,
|
||||||
|
completionTokens: 1148,
|
||||||
|
totalTokens: 71502,
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers exact cost from sub2api /api/v1/usage and matches by api key value', async () => {
|
||||||
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
message: 'success',
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1001,
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
input_tokens: 512,
|
||||||
|
output_tokens: 32,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
total_cost: 0.001088,
|
||||||
|
actual_cost: 0.001088,
|
||||||
|
duration_ms: 23000,
|
||||||
|
created_at: '2026-03-07T06:12:30.000Z',
|
||||||
|
api_key: {
|
||||||
|
id: 91,
|
||||||
|
key: 'sk-wrong',
|
||||||
|
name: 'wrong-key',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 1002,
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
input_tokens: 70354,
|
||||||
|
output_tokens: 1148,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
total_cost: 0.143004,
|
||||||
|
actual_cost: 0.143004,
|
||||||
|
duration_ms: 23000,
|
||||||
|
created_at: '2026-03-07T06:12:29.000Z',
|
||||||
|
api_key: {
|
||||||
|
id: 92,
|
||||||
|
key: 'sk-route',
|
||||||
|
name: 'upstream-key-name',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
pages: 1,
|
||||||
|
},
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await resolveProxyUsageWithSelfLogFallback({
|
||||||
|
site: {
|
||||||
|
url: 'https://sub2api.example.com',
|
||||||
|
platform: 'sub2api',
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
accessToken: 'jwt-access-token',
|
||||||
|
apiToken: 'sk-account-level',
|
||||||
|
},
|
||||||
|
tokenValue: 'sk-route',
|
||||||
|
tokenName: 'local-token-name',
|
||||||
|
modelName: 'gpt-5.4',
|
||||||
|
requestStartedAtMs: Date.parse('2026-03-07T06:12:07.000Z'),
|
||||||
|
requestEndedAtMs: Date.parse('2026-03-07T06:12:30.000Z'),
|
||||||
|
localLatencyMs: 23000,
|
||||||
|
usage: {
|
||||||
|
promptTokens: 0,
|
||||||
|
completionTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/api/v1/usage?');
|
||||||
|
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('model=gpt-5.4');
|
||||||
|
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer jwt-access-token',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
promptTokens: 70354,
|
||||||
|
completionTokens: 1148,
|
||||||
|
totalTokens: 71502,
|
||||||
|
recoveredFromSelfLog: true,
|
||||||
|
estimatedCostFromQuota: 0.143004,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,8 +13,8 @@ const MATCH_LOOKAHEAD_MS = 120_000;
|
|||||||
const MATCH_MAX_CREATED_DELTA_MS = 90_000;
|
const MATCH_MAX_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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -827,19 +827,16 @@ export class TokenRouter {
|
|||||||
site?: typeof schema.sites.$inferSelect | null;
|
site?: typeof schema.sites.$inferSelect | null;
|
||||||
token: typeof schema.accountTokens.$inferSelect | null;
|
token: typeof schema.accountTokens.$inferSelect | null;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
if (candidate.channel.tokenId) {
|
if (candidate.channel.tokenId) {
|
||||||
if (!candidate.token) return null;
|
if (!candidate.token) return null;
|
||||||
if (!candidate.token.enabled) return null;
|
if (!candidate.token.enabled) return null;
|
||||||
const token = candidate.token.token?.trim();
|
const token = candidate.token.token?.trim();
|
||||||
return token ? token : null;
|
return token ? token : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
||||||
candidates: RouteChannelCandidate[],
|
candidates: RouteChannelCandidate[],
|
||||||
|
|||||||
+767
-502
File diff suppressed because it is too large
Load Diff
+5
-18
@@ -23,7 +23,6 @@ type SiteRow = {
|
|||||||
externalCheckinUrl?: string | null;
|
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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, create } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
|
import Tokens from './Tokens.js';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getAccountTokens: vi.fn().mockResolvedValue([]),
|
||||||
|
getAccounts: vi.fn().mockResolvedValue([]),
|
||||||
|
getAccountTokenGroups: vi.fn().mockResolvedValue({ groups: ['default'] }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function LocationProbe() {
|
||||||
|
const location = useLocation();
|
||||||
|
return <div>{`${location.pathname}${location.search}`}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Tokens legacy route redirect', () => {
|
||||||
|
it('redirects /tokens to the merged accounts tokens segment', async () => {
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter initialEntries={['/tokens?create=1&accountId=23&model=gpt-4.1']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/tokens" element={<Tokens />} />
|
||||||
|
<Route path="/accounts" element={<LocationProbe />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rendered = JSON.stringify(root?.toJSON());
|
||||||
|
expect(rendered).toContain('/accounts?');
|
||||||
|
expect(rendered).toContain('segment=tokens');
|
||||||
|
expect(rendered).toContain('create=1');
|
||||||
|
expect(rendered).toContain('accountId=23');
|
||||||
|
expect(rendered).toContain('model=gpt-4.1');
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user