1
0
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:
cita
2026-03-07 15:03:30 +08:00
parent a98a6f2c18
commit c31bbcbb51
30 changed files with 1960 additions and 592 deletions
+11 -2
View File
@@ -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));
+19 -13
View File
@@ -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();
});
});
+10 -13
View File
@@ -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[],