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 { and, eq, ne } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getCredentialModeFromExtraConfig } from './accountExtraConfig.js';
|
||||
|
||||
type UpstreamApiToken = {
|
||||
name?: string | null;
|
||||
@@ -66,6 +67,12 @@ async function updateAccountApiToken(accountId: number, tokenValue: string | nul
|
||||
.run();
|
||||
}
|
||||
|
||||
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
|
||||
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (explicit && explicit !== 'auto') return explicit === 'apikey';
|
||||
return normalizeTokenValue(account.accessToken) === null;
|
||||
}
|
||||
|
||||
export async function getPreferredAccountToken(accountId: number) {
|
||||
const tokens = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
@@ -267,7 +274,9 @@ export async function listTokensWithRelations(accountId?: number) {
|
||||
? await base.where(eq(schema.accountTokens.accountId, accountId)).all()
|
||||
: await base.all();
|
||||
|
||||
return rows.map((row) => {
|
||||
return rows
|
||||
.filter((row) => !isApiKeyConnection(row.accounts))
|
||||
.map((row) => {
|
||||
const { token, ...tokenMeta } = row.account_tokens;
|
||||
return {
|
||||
...tokenMeta,
|
||||
@@ -284,6 +293,6 @@ export async function listTokensWithRelations(accountId?: number) {
|
||||
platform: row.sites.platform,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -383,7 +383,6 @@ function buildTokenCandidates(input: EstimateProxyCostInput): string[] {
|
||||
const candidates = [
|
||||
input.account.accessToken,
|
||||
input.account.apiToken,
|
||||
input.site.apiKey,
|
||||
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||
|
||||
return Array.from(new Set(candidates));
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
|
||||
import { resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getAdapter } from './platforms/index.js';
|
||||
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
|
||||
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
|
||||
import { invalidateTokenRouterCache } from './tokenRouter.js';
|
||||
|
||||
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
|
||||
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
|
||||
const MODEL_REFRESH_BATCH_SIZE = 3;
|
||||
|
||||
function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
function isSiteDisabled(status?: string | null): boolean {
|
||||
return (status || 'active') === 'disabled';
|
||||
}
|
||||
|
||||
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
|
||||
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (explicit && explicit !== 'auto') return explicit === 'apikey';
|
||||
return !(account.accessToken || '').trim();
|
||||
}
|
||||
|
||||
function normalizeModels(models: string[]): string[] {
|
||||
return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0)));
|
||||
@@ -101,10 +107,10 @@ export async function refreshModelsForAccount(accountId: number) {
|
||||
.all();
|
||||
|
||||
// Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table.
|
||||
if (enabledTokens.length === 0) {
|
||||
const fallback = discoveredApiToken || account.apiToken || null;
|
||||
if (fallback) {
|
||||
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
|
||||
if (!isApiKeyConnection(account) && enabledTokens.length === 0) {
|
||||
const fallback = discoveredApiToken || account.apiToken || null;
|
||||
if (fallback) {
|
||||
ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' });
|
||||
enabledTokens = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true)))
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { fetchMock, fetchJsonWithShieldCookieRetryMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
fetchJsonWithShieldCookieRetryMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('undici', () => ({
|
||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./platforms/newApiShield.js', () => ({
|
||||
buildNewApiCookieCandidates: () => [],
|
||||
fetchJsonWithShieldCookieRetry: (...args: unknown[]) => fetchJsonWithShieldCookieRetryMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./siteProxy.js', () => ({
|
||||
withExplicitProxyRequestInit: (_proxyUrl: string | null | undefined, init: RequestInit) => init,
|
||||
}));
|
||||
|
||||
import {
|
||||
resolveProxyUsageWithSelfLogFallback,
|
||||
shouldLookupSelfLog,
|
||||
} from './proxyUsageFallbackService.js';
|
||||
|
||||
describe('proxyUsageFallbackService sub2api', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
fetchJsonWithShieldCookieRetryMock.mockReset();
|
||||
fetchJsonWithShieldCookieRetryMock.mockResolvedValue({ data: null });
|
||||
});
|
||||
|
||||
it('always enables usage lookup for sub2api to recover exact billing cost', () => {
|
||||
expect(shouldLookupSelfLog('sub2api', {
|
||||
promptTokens: 70354,
|
||||
completionTokens: 1148,
|
||||
totalTokens: 71502,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('recovers exact cost from sub2api /api/v1/usage and matches by api key value', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
code: 0,
|
||||
message: 'success',
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 1001,
|
||||
model: 'gpt-5.4',
|
||||
input_tokens: 512,
|
||||
output_tokens: 32,
|
||||
cache_creation_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
total_cost: 0.001088,
|
||||
actual_cost: 0.001088,
|
||||
duration_ms: 23000,
|
||||
created_at: '2026-03-07T06:12:30.000Z',
|
||||
api_key: {
|
||||
id: 91,
|
||||
key: 'sk-wrong',
|
||||
name: 'wrong-key',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
model: 'gpt-5.4',
|
||||
input_tokens: 70354,
|
||||
output_tokens: 1148,
|
||||
cache_creation_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
total_cost: 0.143004,
|
||||
actual_cost: 0.143004,
|
||||
duration_ms: 23000,
|
||||
created_at: '2026-03-07T06:12:29.000Z',
|
||||
api_key: {
|
||||
id: 92,
|
||||
key: 'sk-route',
|
||||
name: 'upstream-key-name',
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
pages: 1,
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const result = await resolveProxyUsageWithSelfLogFallback({
|
||||
site: {
|
||||
url: 'https://sub2api.example.com',
|
||||
platform: 'sub2api',
|
||||
},
|
||||
account: {
|
||||
accessToken: 'jwt-access-token',
|
||||
apiToken: 'sk-account-level',
|
||||
},
|
||||
tokenValue: 'sk-route',
|
||||
tokenName: 'local-token-name',
|
||||
modelName: 'gpt-5.4',
|
||||
requestStartedAtMs: Date.parse('2026-03-07T06:12:07.000Z'),
|
||||
requestEndedAtMs: Date.parse('2026-03-07T06:12:30.000Z'),
|
||||
localLatencyMs: 23000,
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('/api/v1/usage?');
|
||||
expect(String(fetchMock.mock.calls[0]?.[0] || '')).toContain('model=gpt-5.4');
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer jwt-access-token',
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
promptTokens: 70354,
|
||||
completionTokens: 1148,
|
||||
totalTokens: 71502,
|
||||
recoveredFromSelfLog: true,
|
||||
estimatedCostFromQuota: 0.143004,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,8 @@ const MATCH_LOOKAHEAD_MS = 120_000;
|
||||
const MATCH_MAX_CREATED_DELTA_MS = 90_000;
|
||||
const MATCH_MAX_LATENCY_DELTA_MS = 12_000;
|
||||
const QUOTA_PER_UNIT = 500_000;
|
||||
const SUPPORTED_USAGE_FALLBACK_PLATFORMS = new Set(['done-hub', 'one-hub', 'new-api', 'anyrouter']);
|
||||
const ALWAYS_LOOKUP_SELF_LOG_PLATFORMS = new Set(['done-hub', 'one-hub', 'anyrouter']);
|
||||
const SUPPORTED_USAGE_FALLBACK_PLATFORMS = new Set(['done-hub', 'one-hub', 'new-api', 'anyrouter', 'sub2api']);
|
||||
const ALWAYS_LOOKUP_SELF_LOG_PLATFORMS = new Set(['done-hub', 'one-hub', 'anyrouter', 'sub2api']);
|
||||
const PLATFORM_REQUIRES_USER_HEADER = new Set(['new-api', 'anyrouter']);
|
||||
|
||||
interface ProxyUsage {
|
||||
@@ -66,10 +66,12 @@ export interface SelfLogBillingMeta {
|
||||
export interface SelfLogItem {
|
||||
modelName: string;
|
||||
tokenName: string;
|
||||
tokenValue?: string;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
quota: number;
|
||||
recoveredCost?: number;
|
||||
createdAtMs: number;
|
||||
requestTimeMs: number;
|
||||
billingMeta: SelfLogBillingMeta | null;
|
||||
@@ -78,6 +80,7 @@ export interface SelfLogItem {
|
||||
interface SelfLogMatchInput {
|
||||
modelName: string;
|
||||
tokenName?: string | null;
|
||||
tokenValue?: string | null;
|
||||
requestStartedAtMs: number;
|
||||
requestEndedAtMs: number;
|
||||
localLatencyMs: number;
|
||||
@@ -179,6 +182,10 @@ function modelNameMatches(left: string, right: string): boolean {
|
||||
return !!leftTail && leftTail === rightTail;
|
||||
}
|
||||
|
||||
function normalizeTokenMatchValue(value: string | null | undefined): string {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function getPayloadList(payload: unknown): unknown[] {
|
||||
const candidates = [
|
||||
payload,
|
||||
@@ -200,25 +207,57 @@ function mapSelfLogItem(raw: unknown): SelfLogItem | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const row = raw as Record<string, unknown>;
|
||||
|
||||
const modelName = String(row.model_name ?? row.modelName ?? '').trim();
|
||||
const modelName = String(row.model_name ?? row.modelName ?? row.model ?? '').trim();
|
||||
if (!modelName) return null;
|
||||
|
||||
const promptTokens = toPositiveInt(row.prompt_tokens ?? row.promptTokens);
|
||||
const completionTokens = toPositiveInt(row.completion_tokens ?? row.completionTokens);
|
||||
const promptTokens = toPositiveInt(
|
||||
row.prompt_tokens
|
||||
?? row.promptTokens
|
||||
?? row.input_tokens
|
||||
?? row.inputTokens,
|
||||
);
|
||||
const completionTokens = toPositiveInt(
|
||||
row.completion_tokens
|
||||
?? row.completionTokens
|
||||
?? row.output_tokens
|
||||
?? row.outputTokens,
|
||||
);
|
||||
const totalTokensRaw = toPositiveInt(row.total_tokens ?? row.totalTokens);
|
||||
const totalTokens = totalTokensRaw > 0 ? totalTokensRaw : (promptTokens + completionTokens);
|
||||
const createdAtMs = toTimestampMs(row.created_at ?? row.createdAt);
|
||||
if (createdAtMs <= 0) return null;
|
||||
const recoveredCost = roundCost(toNumber(row.actual_cost ?? row.actualCost ?? row.total_cost ?? row.totalCost, 0));
|
||||
const rawApiKey = row.api_key ?? row.apiKey;
|
||||
const apiKeyRecord = rawApiKey && typeof rawApiKey === 'object'
|
||||
? rawApiKey as Record<string, unknown>
|
||||
: null;
|
||||
const tokenName = String(
|
||||
row.token_name
|
||||
?? row.tokenName
|
||||
?? apiKeyRecord?.name
|
||||
?? '',
|
||||
).trim();
|
||||
const tokenValue = normalizeTokenMatchValue(
|
||||
typeof apiKeyRecord?.key === 'string' ? apiKeyRecord.key : '',
|
||||
);
|
||||
const requestTimeMs = toPositiveInt(
|
||||
row.request_time
|
||||
?? row.requestTime
|
||||
?? row.duration_ms
|
||||
?? row.durationMs,
|
||||
);
|
||||
|
||||
return {
|
||||
modelName,
|
||||
tokenName: String(row.token_name ?? row.tokenName ?? '').trim(),
|
||||
tokenName,
|
||||
...(tokenValue ? { tokenValue } : {}),
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
totalTokens,
|
||||
quota: toPositiveInt(row.quota),
|
||||
...(recoveredCost > 0 ? { recoveredCost } : {}),
|
||||
createdAtMs,
|
||||
requestTimeMs: toPositiveInt(row.request_time ?? row.requestTime),
|
||||
requestTimeMs,
|
||||
billingMeta: parseSelfLogBillingMeta(row.other),
|
||||
};
|
||||
}
|
||||
@@ -293,7 +332,6 @@ function buildTokenCandidates(input: ProxyUsageFallbackInput): string[] {
|
||||
input.account.accessToken,
|
||||
input.tokenValue,
|
||||
input.account.apiToken,
|
||||
input.site.apiKey,
|
||||
]
|
||||
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
||||
.filter(Boolean);
|
||||
@@ -313,6 +351,35 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
|
||||
}, SELF_LOG_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const platform = String(input.site.platform || '').toLowerCase();
|
||||
if (platform === 'sub2api') {
|
||||
const query = new URLSearchParams({
|
||||
page: '1',
|
||||
page_size: String(SELF_LOG_PAGE_SIZE),
|
||||
model: input.modelName,
|
||||
});
|
||||
const response = await fetch(`${baseUrl}/api/v1/usage?${query.toString()}`, withExplicitProxyRequestInit(input.site.proxyUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const pageSizeParam = String(input.site.platform || '').toLowerCase() === 'anyrouter'
|
||||
? 'page_size'
|
||||
: 'size';
|
||||
@@ -321,7 +388,6 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
const platform = String(input.site.platform || '').toLowerCase();
|
||||
if (PLATFORM_REQUIRES_USER_HEADER.has(platform)) {
|
||||
const userId = resolveSelfLogUserId(input);
|
||||
if (userId) {
|
||||
@@ -409,6 +475,14 @@ export function findBestSelfLogMatch(items: SelfLogItem[], input: SelfLogMatchIn
|
||||
));
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const tokenValue = normalizeTokenMatchValue(input.tokenValue);
|
||||
if (tokenValue) {
|
||||
const tokenValueMatched = candidates.filter((item) => normalizeTokenMatchValue(item.tokenValue) === tokenValue);
|
||||
if (tokenValueMatched.length > 0) {
|
||||
candidates = tokenValueMatched;
|
||||
}
|
||||
}
|
||||
|
||||
const tokenName = String(input.tokenName || '').trim().toLowerCase();
|
||||
if (tokenName) {
|
||||
const tokenMatched = candidates.filter((item) => item.tokenName.trim().toLowerCase() === tokenName);
|
||||
@@ -450,6 +524,11 @@ function toQuotaCost(quota: number): number {
|
||||
return roundCost(toPositiveInt(quota) / QUOTA_PER_UNIT);
|
||||
}
|
||||
|
||||
function toRecoveredCost(item: SelfLogItem): number {
|
||||
if (typeof item.recoveredCost === 'number' && item.recoveredCost > 0) return item.recoveredCost;
|
||||
return toQuotaCost(item.quota);
|
||||
}
|
||||
|
||||
export async function resolveProxyUsageWithSelfLogFallback(
|
||||
input: ProxyUsageFallbackInput,
|
||||
): Promise<ProxyUsageFallbackResult> {
|
||||
@@ -471,6 +550,7 @@ export async function resolveProxyUsageWithSelfLogFallback(
|
||||
const matched = findBestSelfLogMatch(items, {
|
||||
modelName: input.modelName,
|
||||
tokenName: input.tokenName,
|
||||
tokenValue: input.tokenValue,
|
||||
requestStartedAtMs: input.requestStartedAtMs,
|
||||
requestEndedAtMs: input.requestEndedAtMs,
|
||||
localLatencyMs: input.localLatencyMs,
|
||||
@@ -494,7 +574,7 @@ export async function resolveProxyUsageWithSelfLogFallback(
|
||||
completionTokens: resolvedTokens.completionTokens,
|
||||
totalTokens: resolvedTokens.totalTokens,
|
||||
recoveredFromSelfLog: true,
|
||||
estimatedCostFromQuota: toQuotaCost(matched.quota),
|
||||
estimatedCostFromQuota: toRecoveredCost(matched),
|
||||
selfLogBillingMeta: matched.billingMeta,
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type MigrationModule = typeof import('./siteApiKeyMigrationService.js');
|
||||
|
||||
describe('siteApiKeyMigrationService', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let migrateSiteApiKeysToAccounts: MigrationModule['migrateSiteApiKeysToAccounts'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-api-key-migration-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const migrationModule = await import('./siteApiKeyMigrationService.js');
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
migrateSiteApiKeysToAccounts = migrationModule.migrateSiteApiKeysToAccounts;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await db.delete(schema.accountTokens).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('migrates site apiKey into an apikey connection, clears the site field, and removes a mirrored token row', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'legacy-site',
|
||||
url: 'https://legacy.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-legacy-site-token',
|
||||
}).returning().get();
|
||||
|
||||
const existing = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: null,
|
||||
accessToken: '',
|
||||
apiToken: 'sk-legacy-site-token',
|
||||
checkinEnabled: false,
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.accountTokens).values({
|
||||
accountId: existing.id,
|
||||
name: 'default',
|
||||
token: 'sk-legacy-site-token',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
}).run();
|
||||
|
||||
const summary = await migrateSiteApiKeysToAccounts();
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
migrated: 0,
|
||||
deduped: 1,
|
||||
clearedSites: 1,
|
||||
removedMirrorTokens: 1,
|
||||
warned: 0,
|
||||
});
|
||||
|
||||
const migratedSite = await db.select().from(schema.sites).where(eq(schema.sites.id, site.id)).get();
|
||||
expect(migratedSite?.apiKey).toBeNull();
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).where(eq(schema.accounts.siteId, site.id)).all();
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0]?.apiToken).toBe('sk-legacy-site-token');
|
||||
expect(accounts[0]?.accessToken).toBe('');
|
||||
expect(accounts[0]?.checkinEnabled).toBe(false);
|
||||
expect(JSON.parse(accounts[0]?.extraConfig || '{}')).toMatchObject({ credentialMode: 'apikey' });
|
||||
|
||||
const tokens = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.accountId, existing.id)).all();
|
||||
expect(tokens).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('creates a new apikey connection from site apiKey when no matching connection exists', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'new-site',
|
||||
url: 'https://new-site.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-new-site-token',
|
||||
}).returning().get();
|
||||
|
||||
const summary = await migrateSiteApiKeysToAccounts();
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
migrated: 1,
|
||||
deduped: 0,
|
||||
clearedSites: 1,
|
||||
removedMirrorTokens: 0,
|
||||
warned: 0,
|
||||
});
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).where(eq(schema.accounts.siteId, site.id)).all();
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0]?.apiToken).toBe('sk-new-site-token');
|
||||
expect(accounts[0]?.accessToken).toBe('');
|
||||
expect(accounts[0]?.status).toBe('active');
|
||||
expect(JSON.parse(accounts[0]?.extraConfig || '{}')).toMatchObject({ credentialMode: 'apikey' });
|
||||
});
|
||||
|
||||
it('warns and keeps token rows intact when an apikey connection has multiple child tokens', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'warn-site',
|
||||
url: 'https://warn.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-warn-site-token',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: null,
|
||||
accessToken: '',
|
||||
apiToken: 'sk-warn-site-token',
|
||||
checkinEnabled: false,
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.accountTokens).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
name: 'default',
|
||||
token: 'sk-warn-site-token',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
accountId: account.id,
|
||||
name: 'extra',
|
||||
token: 'sk-extra-token',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
},
|
||||
]).run();
|
||||
|
||||
const summary = await migrateSiteApiKeysToAccounts();
|
||||
|
||||
expect(summary.warned).toBe(1);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
|
||||
const tokens = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.accountId, account.id)).all();
|
||||
expect(tokens).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { getCredentialModeFromExtraConfig, mergeAccountExtraConfig } from './accountExtraConfig.js';
|
||||
|
||||
export type SiteApiKeyMigrationSummary = {
|
||||
migrated: number;
|
||||
deduped: number;
|
||||
clearedSites: number;
|
||||
removedMirrorTokens: number;
|
||||
warned: number;
|
||||
};
|
||||
|
||||
function normalizeTokenValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
|
||||
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
|
||||
if (explicit && explicit !== 'auto') return explicit === 'apikey';
|
||||
return normalizeTokenValue(account.accessToken).length === 0;
|
||||
}
|
||||
|
||||
async function clearSiteApiKey(siteId: number) {
|
||||
await db.update(schema.sites)
|
||||
.set({
|
||||
apiKey: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.sites.id, siteId))
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function migrateSiteApiKeysToAccounts(): Promise<SiteApiKeyMigrationSummary> {
|
||||
const summary: SiteApiKeyMigrationSummary = {
|
||||
migrated: 0,
|
||||
deduped: 0,
|
||||
clearedSites: 0,
|
||||
removedMirrorTokens: 0,
|
||||
warned: 0,
|
||||
};
|
||||
|
||||
const sites = await db.select().from(schema.sites).all();
|
||||
if (sites.length === 0) return summary;
|
||||
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
let nextSortOrder = accounts.reduce((max, account) => Math.max(max, account.sortOrder || 0), -1) + 1;
|
||||
|
||||
for (const site of sites) {
|
||||
const siteApiKey = normalizeTokenValue(site.apiKey);
|
||||
if (!siteApiKey) continue;
|
||||
|
||||
let targetAccount = accounts.find((account) => (
|
||||
account.siteId === site.id
|
||||
&& isApiKeyConnection(account)
|
||||
&& normalizeTokenValue(account.apiToken) === siteApiKey
|
||||
)) || null;
|
||||
|
||||
if (targetAccount) {
|
||||
summary.deduped += 1;
|
||||
} else {
|
||||
const inserted = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: null,
|
||||
accessToken: '',
|
||||
apiToken: siteApiKey,
|
||||
checkinEnabled: false,
|
||||
status: 'active',
|
||||
extraConfig: mergeAccountExtraConfig(undefined, { credentialMode: 'apikey' }),
|
||||
isPinned: false,
|
||||
sortOrder: nextSortOrder,
|
||||
}).run();
|
||||
const accountId = Number(inserted.lastInsertRowid || 0);
|
||||
if (accountId > 0) {
|
||||
targetAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get() || null;
|
||||
if (targetAccount) {
|
||||
accounts.push(targetAccount);
|
||||
nextSortOrder += 1;
|
||||
summary.migrated += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetAccount) {
|
||||
const childTokens = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, targetAccount.id))
|
||||
.all();
|
||||
|
||||
if (childTokens.length === 1 && normalizeTokenValue(childTokens[0]?.token) === siteApiKey) {
|
||||
await db.delete(schema.accountTokens).where(eq(schema.accountTokens.id, childTokens[0]!.id)).run();
|
||||
summary.removedMirrorTokens += 1;
|
||||
} else if (childTokens.length > 1) {
|
||||
summary.warned += 1;
|
||||
console.warn(`Skipped destructive token cleanup for API key connection #${targetAccount.id}: found ${childTokens.length} child tokens`);
|
||||
}
|
||||
}
|
||||
|
||||
await clearSiteApiKey(site.id);
|
||||
summary.clearedSites += 1;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -83,4 +83,40 @@ describe('TokenRouter site status guard', () => {
|
||||
const candidate = decision.candidates.find((item) => item.channelId === channel.id);
|
||||
expect(candidate?.eligible).toBe(false);
|
||||
});
|
||||
|
||||
it('does not fallback to deprecated site apiKey when channel and account tokens are missing', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-api-key-only',
|
||||
url: 'https://site-key.example.com',
|
||||
platform: 'new-api',
|
||||
apiKey: 'sk-site-only',
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'site-key-user',
|
||||
accessToken: '',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
|
||||
}).returning().get();
|
||||
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4.1-mini',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
priority: 0,
|
||||
weight: 10,
|
||||
enabled: true,
|
||||
manualOverride: false,
|
||||
}).run();
|
||||
|
||||
const selected = await new TokenRouter().selectChannel('gpt-4.1-mini');
|
||||
expect(selected).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -827,19 +827,16 @@ export class TokenRouter {
|
||||
site?: typeof schema.sites.$inferSelect | null;
|
||||
token: typeof schema.accountTokens.$inferSelect | null;
|
||||
}): string | null {
|
||||
if (candidate.channel.tokenId) {
|
||||
if (!candidate.token) return null;
|
||||
if (!candidate.token.enabled) return null;
|
||||
const token = candidate.token.token?.trim();
|
||||
return token ? token : null;
|
||||
}
|
||||
|
||||
const fallback = candidate.account.apiToken?.trim();
|
||||
if (fallback) return fallback;
|
||||
|
||||
const siteApiKey = candidate.site?.apiKey?.trim();
|
||||
return siteApiKey ? siteApiKey : null;
|
||||
}
|
||||
if (candidate.channel.tokenId) {
|
||||
if (!candidate.token) return null;
|
||||
if (!candidate.token.enabled) return null;
|
||||
const token = candidate.token.token?.trim();
|
||||
return token ? token : null;
|
||||
}
|
||||
|
||||
const fallback = candidate.account.apiToken?.trim();
|
||||
return fallback || null;
|
||||
}
|
||||
|
||||
private weightedRandomSelect(
|
||||
candidates: RouteChannelCandidate[],
|
||||
|
||||
Reference in New Issue
Block a user