Merge branch 'main' into feat/account-level-proxy

# Conflicts:
#	src/server/routes/api/accountTokens.ts
#	src/server/routes/proxy/chat.ts
#	src/server/routes/proxy/completions.ts
#	src/server/routes/proxy/embeddings.ts
#	src/server/routes/proxy/endpointFlow.ts
#	src/server/routes/proxy/images.ts
#	src/server/routes/proxy/responses.ts
#	src/server/services/accountExtraConfig.test.ts
#	src/server/services/modelService.ts
This commit is contained in:
Hureru
2026-03-18 23:42:21 +08:00
199 changed files with 27415 additions and 1901 deletions
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
const { deleteApiTokenMock } = vi.hoisted(() => ({
deleteApiTokenMock: vi.fn(),
@@ -105,6 +106,43 @@ describe('account token batch routes', () => {
expect(rows.every((row) => row.enabled === true)).toBe(true);
});
it('rejects enabling masked_pending placeholders until they are completed', async () => {
await db.update(schema.accountTokens)
.set({
enabled: false,
valueStatus: 'masked_pending' as any,
token: 'sk-mask***tail',
})
.where(eq(schema.accountTokens.id, 1))
.run();
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens/batch',
payload: {
ids: [1, 2],
action: 'enable',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
successIds?: number[];
failedItems?: Array<{ id: number; message: string }>;
};
expect(body.successIds).toEqual([2]);
expect(body.failedItems).toEqual([
expect.objectContaining({
id: 1,
message: expect.stringContaining('待补全令牌'),
}),
]);
const rows = await db.select().from(schema.accountTokens).all();
expect(rows.find((row) => row.id === 1)?.enabled).toBe(false);
expect(rows.find((row) => row.id === 2)?.enabled).toBe(true);
});
it('deletes selected account tokens through the upstream adapter', async () => {
const response = await app.inject({
method: 'POST',
@@ -0,0 +1,115 @@
import Fastify, { type FastifyInstance } from 'fastify';
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';
const refreshModelsForAccountMock = vi.fn();
const rebuildTokenRoutesFromAvailabilityMock = vi.fn();
vi.mock('../../services/modelService.js', () => ({
refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args),
rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args),
}));
type DbModule = typeof import('../../db/index.js');
describe('account token coverage refresh failure handling', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-token-coverage-failure-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const accountTokenRoutesModule = await import('./accountTokens.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(accountTokenRoutesModule.accountTokensRoutes);
});
beforeEach(async () => {
refreshModelsForAccountMock.mockReset();
rebuildTokenRoutesFromAvailabilityMock.mockReset();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('returns success after token persistence even when coverage rebuild fails', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-1',
url: 'https://site-1.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'user-1',
accessToken: 'acc-token-1',
status: 'active',
}).returning().get();
refreshModelsForAccountMock.mockResolvedValue({
accountId: account.id,
refreshed: true,
status: 'success',
errorCode: null,
errorMessage: '',
modelCount: 1,
modelsPreview: ['gpt-4o-mini'],
tokenScanned: 1,
discoveredByCredential: false,
discoveredApiToken: false,
});
rebuildTokenRoutesFromAvailabilityMock.mockRejectedValue(new Error('rebuild failed'));
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens',
payload: {
accountId: account.id,
name: 'manual-token',
token: 'sk-manual-token',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
coverageRefresh: {
rebuild: {
success: false,
error: 'rebuild failed',
},
},
});
const tokens = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokens).toHaveLength(1);
expect(tokens[0]?.token).toBe('sk-manual-token');
});
});
@@ -0,0 +1,203 @@
import Fastify, { type FastifyInstance } from 'fastify';
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';
const getApiTokensMock = vi.fn();
const getApiTokenMock = vi.fn();
const createApiTokenMock = vi.fn();
const getUserGroupsMock = vi.fn();
const deleteApiTokenMock = vi.fn();
const getModelsMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
getApiToken: (...args: unknown[]) => getApiTokenMock(...args),
createApiToken: (...args: unknown[]) => createApiTokenMock(...args),
getUserGroups: (...args: unknown[]) => getUserGroupsMock(...args),
deleteApiToken: (...args: unknown[]) => deleteApiTokenMock(...args),
getModels: (...args: unknown[]) => getModelsMock(...args),
}),
}));
type DbModule = typeof import('../../db/index.js');
describe('account token coverage refresh', { timeout: 15_000 }, () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
let seedId = 0;
const nextSeed = () => {
seedId += 1;
return seedId;
};
const seedAccount = async (modelName: string) => {
const id = nextSeed();
const site = await db.insert(schema.sites).values({
name: `site-${id}`,
url: `https://site-${id}.example.com`,
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: `user-${id}`,
accessToken: `acc-token-${id}`,
status: 'active',
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName,
available: true,
}).run();
return { site, account };
};
const readTokenCandidates = async () => {
const response = await app.inject({
method: 'GET',
url: '/api/models/token-candidates',
});
expect(response.statusCode).toBe(200);
return response.json() as {
models: Record<string, Array<{ tokenId: number; accountId: number }>>;
modelsWithoutToken: Record<string, Array<{ accountId: number }>>;
};
};
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-token-coverage-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const accountTokenRoutesModule = await import('./accountTokens.js');
const statsRoutesModule = await import('./stats.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(accountTokenRoutesModule.accountTokensRoutes);
await app.register(statsRoutesModule.statsRoutes);
});
beforeEach(async () => {
getApiTokensMock.mockReset();
getApiTokenMock.mockReset();
createApiTokenMock.mockReset();
getUserGroupsMock.mockReset();
deleteApiTokenMock.mockReset();
getModelsMock.mockReset();
seedId = 0;
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('refreshes token coverage after manually adding an account token', async () => {
const modelName = 'gpt-4o-mini';
const { account } = await seedAccount(modelName);
getModelsMock.mockImplementation(async (_url: string, credential: string) => {
if (credential === account.accessToken || credential === 'sk-manual-token') {
return [modelName];
}
return [];
});
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens',
payload: {
accountId: account.id,
name: 'manual-token',
token: 'sk-manual-token',
},
});
expect(response.statusCode).toBe(200);
const token = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.get();
expect(token).toBeDefined();
const tokenAvailability = await db.select()
.from(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, token!.id))
.all();
expect(tokenAvailability.map((row) => row.modelName)).toContain(modelName);
const candidates = await readTokenCandidates();
expect(candidates.modelsWithoutToken[modelName]).toBeUndefined();
expect(candidates.models[modelName]).toEqual([
expect.objectContaining({
accountId: account.id,
tokenId: token!.id,
}),
]);
});
it('refreshes token coverage after syncing account tokens from upstream', async () => {
const modelName = 'claude-sonnet-4-5-20250929';
const { account } = await seedAccount(modelName);
getApiTokensMock.mockResolvedValue([
{ name: 'default', key: 'sk-synced-token', enabled: true },
]);
getModelsMock.mockImplementation(async (_url: string, credential: string) => {
if (credential === account.accessToken || credential === 'sk-synced-token') {
return [modelName];
}
return [];
});
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
const token = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.get();
expect(token).toBeDefined();
const tokenAvailability = await db.select()
.from(schema.tokenModelAvailability)
.where(eq(schema.tokenModelAvailability.tokenId, token!.id))
.all();
expect(tokenAvailability.map((row) => row.modelName)).toContain(modelName);
const candidates = await readTokenCandidates();
expect(candidates.modelsWithoutToken[modelName]).toBeUndefined();
expect(candidates.models[modelName]).toEqual([
expect.objectContaining({
accountId: account.id,
tokenId: token!.id,
}),
]);
});
});
@@ -138,7 +138,7 @@ describe('account tokens sync routes with site status', () => {
expect(tokenRows.length).toBe(0);
});
it('skips masked upstream token values and does not store them', async () => {
it('stores masked upstream token values as masked_pending placeholders', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: 'sk-abc***xyz', enabled: true },
@@ -153,12 +153,13 @@ describe('account tokens sync routes with site status', () => {
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: false,
status: 'skipped',
synced: true,
status: 'synced',
reason: 'upstream_masked_tokens',
maskedSkipped: 1,
total: 0,
created: 0,
maskedPending: 1,
pendingTokenIds: [expect.any(Number)],
total: 1,
created: 1,
updated: 0,
});
@@ -166,7 +167,33 @@ describe('account tokens sync routes with site status', () => {
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows.length).toBe(0);
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: 'sk-abc***xyz',
source: 'sync',
enabled: false,
isDefault: false,
});
expect((tokenRows[0] as any).valueStatus).toBe('masked_pending');
const owner = await db.select()
.from(schema.accounts)
.where(eq(schema.accounts.id, account.id))
.get();
expect(owner?.apiToken ?? null).toBeNull();
const listResponse = await app.inject({
method: 'GET',
url: '/api/account-tokens',
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.json()).toMatchObject([
expect.objectContaining({
id: tokenRows[0].id,
valueStatus: 'masked_pending',
}),
]);
});
it('rejects sync and token management for apikey connections', async () => {
@@ -491,6 +518,7 @@ describe('account tokens sync routes with site status', () => {
source: 'sync',
enabled: true,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
@@ -503,4 +531,139 @@ describe('account tokens sync routes with site status', () => {
success: false,
});
});
it('upgrades an existing masked_pending placeholder when upstream later returns the full token', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-only',
token: 'sk-abc***xyz',
source: 'sync',
enabled: false,
isDefault: false,
tokenGroup: 'default',
valueStatus: 'masked_pending' as any,
}).run();
getApiTokensMock.mockResolvedValue([
{ name: 'masked-only', key: 'sk-real-token-1234', enabled: true, tokenGroup: 'default' },
]);
getApiTokenMock.mockResolvedValue(null);
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/sync/${account.id}`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
synced: true,
status: 'synced',
total: 1,
created: 0,
updated: 1,
});
const tokenRows = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, account.id))
.all();
expect(tokenRows).toHaveLength(1);
expect(tokenRows[0]).toMatchObject({
name: 'masked-only',
token: 'sk-real-token-1234',
enabled: true,
});
expect((tokenRows[0] as any).valueStatus).toBe('ready');
});
it('does not allow setting a masked_pending placeholder as default', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: false,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
method: 'POST',
url: `/api/account-tokens/${token.id}/default`,
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
message: expect.stringContaining('待补全令牌'),
});
});
it('promotes a masked_pending placeholder to ready when a full token is saved', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: false,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
method: 'PUT',
url: `/api/account-tokens/${token.id}`,
payload: {
token: 'sk-real-token-updated',
enabled: true,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
token: expect.objectContaining({
id: token.id,
enabled: true,
valueStatus: 'ready',
}),
});
const latest = await db.select()
.from(schema.accountTokens)
.where(eq(schema.accountTokens.id, token.id))
.get();
expect(latest).toMatchObject({
token: 'sk-real-token-updated',
enabled: true,
});
expect((latest as any)?.valueStatus).toBe('ready');
});
it('deletes masked_pending placeholders locally without calling upstream delete', async () => {
const { account } = await seedAccount({ siteStatus: 'active' });
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'masked-token',
token: 'sk-mask***tail',
source: 'sync',
enabled: false,
isDefault: false,
valueStatus: 'masked_pending' as any,
}).returning().get();
const response = await app.inject({
method: 'DELETE',
url: `/api/account-tokens/${token.id}`,
});
expect(response.statusCode).toBe(200);
expect(deleteApiTokenMock).not.toHaveBeenCalled();
const removed = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
expect(removed).toBeUndefined();
});
});
+155 -21
View File
@@ -2,12 +2,17 @@
import { and, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING,
ACCOUNT_TOKEN_VALUE_STATUS_READY,
ensureDefaultTokenForAccount,
isMaskedPendingAccountToken,
isMaskedTokenValue,
isUsableAccountToken,
listTokensWithRelations,
normalizeTokenForDisplay,
maskToken,
repairDefaultToken,
resolveAccountTokenValueStatus,
setDefaultToken,
syncTokensFromUpstream,
} from '../../services/accountTokenService.js';
@@ -15,6 +20,11 @@ import { getAdapter } from '../../services/platforms/index.js';
import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { withAccountProxyOverride } from '../../services/siteProxy.js';
import {
rebuildTokenRoutesFromAvailability,
refreshModelsForAccount,
type ModelRefreshResult,
} from '../../services/modelService.js';
type AccountWithSiteRow = {
accounts: typeof schema.accounts.$inferSelect;
@@ -34,11 +44,31 @@ type SyncExecutionResult = {
synced: boolean;
created: number;
updated: number;
maskedSkipped?: number;
maskedPending?: number;
pendingTokenIds?: number[];
total: number;
defaultTokenId?: number | null;
};
type CoverageRefreshFailureItem = {
accountId: number;
refreshed: false;
status: 'failed';
errorCode: 'coverage_refresh_failed';
errorMessage: string;
modelCount: 0;
modelsPreview: string[];
reason: 'coverage_refresh_failed';
tokenScanned: 0;
discoveredByCredential: false;
discoveredApiToken: false;
};
type CoverageRefreshItem = ModelRefreshResult | CoverageRefreshFailureItem;
type CoverageRefreshRebuildResult =
| { success: true; result: Awaited<ReturnType<typeof rebuildTokenRoutesFromAvailability>> }
| { success: false; error: string };
const TOKEN_SYNC_TIMEOUT_MS = 15_000;
const SYNC_ALL_BATCH_SIZE = 3;
@@ -267,13 +297,13 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
}
const synced = await syncTokensFromUpstream(accountId, tokens);
if ((synced.created + synced.updated) === 0 && synced.maskedSkipped > 0) {
if ((synced.maskedPending || 0) > 0) {
return {
...base,
status: 'skipped',
status: 'synced',
reason: 'upstream_masked_tokens',
message: `上游仅返回脱敏令牌(含*),跳过 ${synced.maskedSkipped} 条,无法同步明文令牌`,
synced: false,
message: `上游返回 ${synced.maskedPending} 条脱敏令牌,已保存为待补全记录,请手动补全明文 token。`,
synced: true,
...synced,
};
}
@@ -306,7 +336,7 @@ async function appendTokenSyncEvent(result: SyncExecutionResult) {
? 'info'
: (result.status === 'skipped' ? 'warning' : 'error');
const detail = result.status === 'synced'
? `新增 ${result.created},更新 ${result.updated},总数 ${result.total}`
? `新增 ${result.created},更新 ${result.updated},待补全 ${result.maskedPending || 0},总数 ${result.total}`
: (result.message || result.reason || 'sync skipped');
try {
@@ -341,6 +371,12 @@ async function executeSyncAllAccountTokens() {
results.push(...batchResults);
}
const coverageRefresh = await refreshCoverageForAccounts(
results
.filter((item) => item.status === 'synced')
.map((item) => item.accountId),
);
const summary = {
total: results.length,
synced: results.filter((item) => item.status === 'synced').length,
@@ -350,7 +386,72 @@ async function executeSyncAllAccountTokens() {
updated: results.reduce((acc, item) => acc + item.updated, 0),
};
return { summary, results };
return { summary, results, coverageRefresh };
}
async function refreshCoverageForAccounts(accountIds: number[]) {
const uniqueAccountIds = Array.from(new Set(
accountIds.filter((id) => Number.isFinite(id) && id > 0),
));
if (uniqueAccountIds.length === 0) {
return { refresh: [], rebuild: null };
}
const refresh: CoverageRefreshItem[] = [];
for (let offset = 0; offset < uniqueAccountIds.length; offset += SYNC_ALL_BATCH_SIZE) {
const batch = uniqueAccountIds.slice(offset, offset + SYNC_ALL_BATCH_SIZE);
const settled = await Promise.allSettled(
batch.map(async (accountId) => refreshModelsForAccount(accountId)),
);
settled.forEach((result, index) => {
if (result.status === 'fulfilled') {
refresh.push(result.value);
return;
}
const accountId = batch[index] || 0;
const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason || 'coverage refresh failed');
refresh.push(buildCoverageRefreshFailureItem(accountId, errorMessage));
console.warn(`[account-tokens] coverage refresh failed for account ${accountId}: ${errorMessage}`);
});
}
let rebuild: CoverageRefreshRebuildResult | null = null;
try {
rebuild = {
success: true,
result: await rebuildTokenRoutesFromAvailability(),
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error || 'route rebuild failed');
rebuild = {
success: false,
error: errorMessage,
};
console.warn(`[account-tokens] token route rebuild failed after coverage refresh: ${errorMessage}`);
}
return { refresh, rebuild };
}
function buildCoverageRefreshFailureItem(
accountId: number,
errorMessage: string,
): CoverageRefreshFailureItem {
return {
accountId,
refreshed: false,
status: 'failed',
errorCode: 'coverage_refresh_failed',
errorMessage,
modelCount: 0,
modelsPreview: [],
reason: 'coverage_refresh_failed',
tokenScanned: 0,
discoveredByCredential: false,
discoveredApiToken: false,
};
}
export async function accountTokensRoutes(app: FastifyInstance) {
@@ -394,15 +495,25 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const existing = await db.select().from(schema.accountTokens)
.where(eq(schema.accountTokens.accountId, body.accountId))
.all();
const valueStatus = isMaskedTokenValue(tokenValue)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
const enabled = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY
? (body.enabled ?? true)
: false;
const isDefault = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY
? (body.isDefault ?? false)
: false;
const inserted = await db.insert(schema.accountTokens).values({
accountId: body.accountId,
name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`),
token: tokenValue,
tokenGroup: (body.group || '').trim() || null,
valueStatus,
source: body.source || 'manual',
enabled: body.enabled ?? true,
isDefault: body.isDefault ?? false,
enabled,
isDefault,
createdAt: now,
updatedAt: now,
}).run();
@@ -415,13 +526,13 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '创建令牌失败' });
}
if (body.isDefault || (existing.length === 0 && (body.enabled ?? true))) {
if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && (body.isDefault || (existing.length === 0 && enabled))) {
await setDefaultToken(created.id);
} else if (existing.every((token) => !token.isDefault) && (body.enabled ?? true)) {
} else if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && existing.every((token) => !token.isDefault) && enabled) {
await setDefaultToken(created.id);
}
return { success: true, token: created };
const coverageRefresh = await refreshCoverageForAccounts([body.accountId]);
return { success: true, token: created, coverageRefresh };
}
const account = row.accounts;
@@ -503,6 +614,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (syncResult.status === 'skipped') {
return reply.code(502).send({ success: false, message: syncResult.message || '站点未返回可用令牌' });
}
const coverageRefresh = await refreshCoverageForAccounts([account.id]);
const preferred = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.isDefault, true)))
@@ -516,6 +628,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
success: true,
createdViaUpstream: true,
...syncResult,
coverageRefresh,
token,
};
});
@@ -539,7 +652,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
const shouldDeleteUpstream = !isSiteDisabled(site.status) && !!account.accessToken?.trim() && !!adapter;
const shouldDeleteUpstream = !isMaskedPendingAccountToken(existing)
&& !isSiteDisabled(site.status)
&& !!account.accessToken?.trim()
&& !!adapter;
if (shouldDeleteUpstream) {
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
@@ -603,6 +719,10 @@ export async function accountTokensRoutes(app: FastifyInstance) {
continue;
}
} else {
if (isMaskedPendingAccountToken(existing)) {
failedItems.push({ id, message: '待补全令牌不能修改启用状态,请先补全明文 token' });
continue;
}
await db.update(schema.accountTokens)
.set({
enabled: action === 'enable',
@@ -649,6 +769,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
const body = request.body;
const updates: Record<string, unknown> = { updatedAt: new Date().toISOString() };
let nextValueStatus = resolveAccountTokenValueStatus(existing);
if (body.name !== undefined) {
updates.name = (body.name || '').trim() || existing.name;
@@ -660,15 +781,24 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: '令牌不能为空' });
}
updates.token = tokenValue;
nextValueStatus = isMaskedTokenValue(tokenValue)
? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING
: ACCOUNT_TOKEN_VALUE_STATUS_READY;
updates.valueStatus = nextValueStatus;
}
if (body.group !== undefined) {
updates.tokenGroup = (body.group || '').trim() || null;
}
if (body.enabled !== undefined) updates.enabled = body.enabled;
if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) {
updates.enabled = false;
updates.isDefault = false;
} else {
if (body.enabled !== undefined) updates.enabled = body.enabled;
if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
}
if (body.source !== undefined) updates.source = body.source;
if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
await db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run();
@@ -677,11 +807,11 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '更新失败' });
}
if (body.isDefault === true) {
if (body.isDefault === true && isUsableAccountToken(latest)) {
setDefaultToken(tokenId);
} else if (latest.isDefault && latest.enabled) {
} else if (latest.isDefault && isUsableAccountToken(latest)) {
setDefaultToken(tokenId);
} else if (existing.isDefault && !latest.enabled) {
} else if (existing.isDefault && !isUsableAccountToken(latest)) {
repairDefaultToken(existing.accountId);
} else if (body.isDefault === false && existing.isDefault) {
repairDefaultToken(existing.accountId);
@@ -706,6 +836,9 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (isApiKeyConnection(owner)) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
if (isMaskedPendingAccountToken(tokenRow)) {
return reply.code(400).send({ success: false, message: '待补全令牌不能设为默认,请先补全明文 token' });
}
const success = await setDefaultToken(tokenId);
if (!success) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
@@ -733,7 +866,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
if (isMaskedTokenValue(row.account_tokens.token)) {
if (isMaskedPendingAccountToken(row.account_tokens) || isMaskedTokenValue(row.account_tokens.token)) {
return reply.code(409).send({
success: false,
message: '当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。',
@@ -836,7 +969,8 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (result.status === 'failed') {
return reply.code(502).send({ success: false, message: result.message || '同步失败' });
}
return { success: true, ...result };
const coverageRefresh = await refreshCoverageForAccounts([accountId]);
return { success: true, ...result, coverageRefresh };
});
app.post<{ Body?: { wait?: boolean } }>('/api/account-tokens/sync-all', async (request, reply) => {
@@ -0,0 +1,178 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const verifyTokenMock = vi.fn();
const getApiTokensMock = vi.fn();
const refreshBalanceMock = vi.fn();
const refreshModelsForAccountMock = vi.fn();
const rebuildTokenRoutesFromAvailabilityMock = vi.fn();
const ensureDefaultTokenForAccountMock = vi.fn();
const syncTokensFromUpstreamMock = vi.fn();
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: () => ({
verifyToken: (...args: unknown[]) => verifyTokenMock(...args),
getApiTokens: (...args: unknown[]) => getApiTokensMock(...args),
}),
}));
vi.mock('../../services/balanceService.js', () => ({
refreshBalance: (...args: unknown[]) => refreshBalanceMock(...args),
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args),
rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args),
}));
vi.mock('../../services/accountTokenService.js', () => ({
ensureDefaultTokenForAccount: (...args: unknown[]) => ensureDefaultTokenForAccountMock(...args),
syncTokensFromUpstream: (...args: unknown[]) => syncTokensFromUpstreamMock(...args),
}));
type DbModule = typeof import('../../db/index.js');
describe('accounts background initialization', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
let resetBackgroundTasks: (() => void) | null = null;
let getBackgroundTask: ((taskId: string) => { status: string } | null) | null = null;
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-background-init-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
const backgroundTaskModule = await import('../../services/backgroundTaskService.js');
db = dbModule.db;
schema = dbModule.schema;
resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests;
getBackgroundTask = backgroundTaskModule.getBackgroundTask;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(async () => {
verifyTokenMock.mockReset();
getApiTokensMock.mockReset();
refreshBalanceMock.mockReset();
refreshModelsForAccountMock.mockReset();
rebuildTokenRoutesFromAvailabilityMock.mockReset();
ensureDefaultTokenForAccountMock.mockReset();
syncTokensFromUpstreamMock.mockReset();
resetBackgroundTasks?.();
await db.delete(schema.proxyLogs).run();
await db.delete(schema.checkinLogs).run();
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.tokenModelAvailability).run();
await db.delete(schema.modelAvailability).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.events).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
if (dataDir) {
try {
rmSync(dataDir, { recursive: true, force: true });
} catch {}
}
delete process.env.DATA_DIR;
});
it('returns immediately and queues background initialization when token sync is slow', async () => {
const site = await db.insert(schema.sites).values({
name: 'Session Site',
url: 'https://session.example.com',
platform: 'new-api',
}).returning().get();
verifyTokenMock.mockResolvedValue({
tokenType: 'session',
userInfo: { username: 'demo-user' },
apiToken: 'sk-demo',
});
ensureDefaultTokenForAccountMock.mockResolvedValue(undefined);
refreshBalanceMock.mockResolvedValue({ balance: 1, used: 0, quota: 1 });
refreshModelsForAccountMock.mockResolvedValue(undefined);
rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue(undefined);
syncTokensFromUpstreamMock.mockResolvedValue(undefined);
let releaseTokens: ((value: Array<{ name: string; value: string }>) => void) | null = null;
const pendingTokens = new Promise<Array<{ name: string; value: string }>>((resolve) => {
releaseTokens = resolve;
});
getApiTokensMock.mockReturnValue(pendingTokens);
const responsePromise = app.inject({
method: 'POST',
url: '/api/accounts',
payload: {
siteId: site.id,
accessToken: 'session-token',
},
});
try {
const raceResult = await Promise.race([
responsePromise.then(() => 'response'),
new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 25)),
]);
expect(raceResult).toBe('response');
const response = await responsePromise;
expect(response.statusCode).toBe(200);
const body = response.json() as {
id: number;
queued?: boolean;
jobId?: string;
usernameDetected?: boolean;
apiTokenFound?: boolean;
};
expect(body).toMatchObject({
queued: true,
usernameDetected: true,
apiTokenFound: true,
});
expect(body.jobId).toBeTruthy();
const insertedAccounts = await db.select().from(schema.accounts).all();
expect(insertedAccounts).toHaveLength(1);
expect(getBackgroundTask?.(body.jobId!)).toMatchObject({
status: expect.stringMatching(/pending|running/),
});
releaseTokens?.([{ name: 'default', value: 'sk-demo' }]);
for (let attempt = 0; attempt < 20; attempt += 1) {
const task = getBackgroundTask?.(body.jobId!);
if (task?.status === 'succeeded') break;
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(syncTokensFromUpstreamMock).toHaveBeenCalledTimes(1);
expect(refreshBalanceMock).toHaveBeenCalledTimes(1);
expect(refreshModelsForAccountMock).toHaveBeenCalledTimes(1);
expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1);
expect(getBackgroundTask?.(body.jobId!)).toMatchObject({ status: 'succeeded' });
} finally {
releaseTokens?.([]);
await responsePromise.catch(() => undefined);
}
});
});
@@ -19,7 +19,7 @@ vi.mock('../../services/platforms/index.js', () => ({
type DbModule = typeof import('../../db/index.js');
describe('accounts credential mode', () => {
describe('accounts credential mode', { timeout: 15_000 }, () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
@@ -172,6 +172,62 @@ describe('accounts credential mode', () => {
});
});
it('marks codex oauth connection as direct-routed proxy-only connection without checkin/balance capabilities', async () => {
const site = await db.insert(schema.sites).values({
name: 'Codex Site',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'codex-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
checkinEnabled: false,
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'plus',
},
}),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
checkedAt: '2026-03-16T12:00:00.000Z',
}).run();
const listResponse = await app.inject({
method: 'GET',
url: '/api/accounts',
});
expect(listResponse.statusCode).toBe(200);
const list = listResponse.json() as Array<{
id: number;
credentialMode?: string;
capabilities?: {
canCheckin?: boolean;
canRefreshBalance?: boolean;
proxyOnly?: boolean;
};
}>;
const item = list.find((entry) => entry.id === account.id);
expect(item?.credentialMode).toBe('session');
expect(item?.capabilities).toMatchObject({
canCheckin: false,
canRefreshBalance: false,
proxyOnly: true,
});
});
it('stores managed refresh token for sub2api session account', async () => {
verifyTokenMock.mockResolvedValueOnce({
tokenType: 'session',
@@ -15,7 +15,7 @@ vi.mock('../../services/platforms/index.js', () => ({
type DbModule = typeof import('../../db/index.js');
describe('accounts rebind-session api', () => {
describe('accounts rebind-session api', { timeout: 15_000 }, () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
+124 -29
View File
@@ -9,6 +9,7 @@ import {
getCredentialModeFromExtraConfig,
getProxyUrlFromExtraConfig,
guessPlatformUserIdFromUsername,
hasOauthProvider,
getSub2ApiAuthFromExtraConfig,
mergeAccountExtraConfig,
normalizeCredentialMode as normalizeCredentialModeInput,
@@ -49,6 +50,17 @@ type AccountCapabilities = {
proxyOnly: boolean;
};
type AccountInitializationParams = {
accountId: number;
site: typeof schema.sites.$inferSelect;
adapter: NonNullable<ReturnType<typeof getAdapter>>;
tokenType: 'session' | 'apikey' | 'unknown';
accessToken: string;
apiToken: string;
platformUserId?: number;
skipModelFetch?: boolean;
};
type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null;
const limitAccountLogin = createRateLimitGuard({
@@ -80,7 +92,15 @@ function resolveStoredCredentialMode(account: typeof schema.accounts.$inferSelec
function buildCapabilitiesFromCredentialMode(
credentialMode: AccountCredentialMode,
hasSessionToken: boolean,
extraConfig?: string | null,
): AccountCapabilities {
if (hasOauthProvider(extraConfig)) {
return {
canCheckin: false,
canRefreshBalance: false,
proxyOnly: true,
};
}
const sessionCapable = credentialMode === 'session'
? hasSessionToken
: (credentialMode === 'apikey' ? false : hasSessionToken);
@@ -93,7 +113,7 @@ function buildCapabilitiesFromCredentialMode(
function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelect): AccountCapabilities {
const credentialMode = resolveStoredCredentialMode(account);
return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken));
return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken), account.extraConfig);
}
function normalizeBatchIds(input: unknown): number[] {
@@ -115,6 +135,75 @@ function normalizePinnedFlag(input: unknown): boolean | null {
return null;
}
async function initializeAccountInBackground({
accountId,
site,
adapter,
tokenType,
accessToken,
apiToken,
platformUserId,
skipModelFetch,
}: AccountInitializationParams) {
const summary = {
accountId,
syncedTokenCount: 0,
refreshedBalance: false,
refreshedModels: false,
rebuiltRoutes: false,
};
if (tokenType === 'session' && apiToken) {
try {
await ensureDefaultTokenForAccount(accountId, apiToken, { name: 'default', source: 'manual' });
} catch {}
}
if (tokenType === 'session' && accessToken) {
try {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId);
summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0;
if (summary.syncedTokenCount > 0) {
await syncTokensFromUpstream(accountId, syncedTokens);
}
} catch {}
}
if (tokenType === 'session') {
try {
await refreshBalance(accountId);
summary.refreshedBalance = true;
} catch {}
}
if (skipModelFetch !== true) {
try {
await refreshModelsForAccount(accountId);
summary.refreshedModels = true;
await rebuildTokenRoutesFromAvailability();
summary.rebuiltRoutes = true;
} catch {}
}
return summary;
}
function buildQueuedAccountInitializationMessage(
tokenType: 'session' | 'apikey' | 'unknown',
skipModelFetch?: boolean,
) {
if (tokenType === 'session' && skipModelFetch === true) {
return '账号已添加,后台正在同步令牌和余额信息。';
}
if (tokenType === 'session') {
return '账号已添加,后台正在同步令牌、余额和模型信息。';
}
if (skipModelFetch === true) {
return '已添加为 API Key 账号(可用于代理转发)。';
}
return '已添加为 API Key 账号,后台正在同步模型和路由信息。';
}
function normalizeSortOrder(input: unknown): number | null {
if (input === undefined || input === null || input === '') return null;
const parsed = Number.parseInt(String(input), 10);
@@ -411,6 +500,7 @@ export async function accountsRoutes(app: FastifyInstance) {
capabilities: buildCapabilitiesFromCredentialMode(
credentialMode,
hasSessionTokenValue(r.accounts.accessToken),
r.accounts.extraConfig,
),
todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000,
todayReward: Math.round(estimateRewardWithTodayIncomeFallback({
@@ -427,6 +517,7 @@ export async function accountsRoutes(app: FastifyInstance) {
sessionCapable: buildCapabilitiesFromCredentialMode(
credentialMode,
hasSessionTokenValue(r.accounts.accessToken),
r.accounts.extraConfig,
).canRefreshBalance,
hasDiscoveredModels: (modelCountByAccount[r.accounts.id] || 0) > 0,
}),
@@ -972,7 +1063,7 @@ export async function accountsRoutes(app: FastifyInstance) {
account: latest,
tokenType: 'session',
credentialMode: 'session',
capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true),
capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true, null),
apiTokenFound: !!nextApiToken,
};
},
@@ -1110,39 +1201,40 @@ export async function accountsRoutes(app: FastifyInstance) {
return reply.code(500).send({ success: false, message: '创建账号失败' });
}
if (tokenType === 'session' && apiToken) {
try {
await ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' });
} catch { }
}
if (tokenType === 'session' && accessToken) {
try {
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId);
if (syncedTokens.length > 0) {
await syncTokensFromUpstream(result.id, syncedTokens);
}
} catch { }
}
// Try to refresh balance
if (tokenType === 'session') {
try { await refreshBalance(result.id); } catch { }
}
// Try to refresh models only if not skipping.
if (body.skipModelFetch !== true) {
try {
await refreshModelsForAccount(result.id);
await rebuildTokenRoutesFromAvailability();
} catch { }
const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true;
let queuedTaskId: string | undefined;
let queuedMessage: string | undefined;
if (shouldQueueInitialization) {
const taskTitle = `初始化连接 #${result.id}`;
const { task } = startBackgroundTask(
{
type: 'account-init',
title: taskTitle,
dedupeKey: `account-init-${result.id}`,
notifyOnFailure: true,
successMessage: () => `${taskTitle}已完成`,
failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`,
},
async () => initializeAccountInBackground({
accountId: result.id,
site,
adapter,
tokenType,
accessToken,
apiToken,
platformUserId: resolvedPlatformUserId,
skipModelFetch: body.skipModelFetch,
}),
);
queuedTaskId = task.id;
queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch);
}
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode;
const capabilities = account
? buildCapabilitiesForAccount(account)
: buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session');
: buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session', null);
return {
...account,
tokenType,
@@ -1151,6 +1243,9 @@ export async function accountsRoutes(app: FastifyInstance) {
modelCount: verifiedModels.length,
apiTokenFound: !!apiToken,
usernameDetected: !!(!body.username && username),
queued: !!queuedTaskId,
jobId: queuedTaskId,
message: queuedMessage,
};
});
@@ -0,0 +1,510 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
type DbModule = typeof import('../../db/index.js');
describe('downstream api keys routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-downstream-routes-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./downstreamApiKeys.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.downstreamApiKeysRoutes);
});
beforeEach(async () => {
await db.delete(schema.proxyLogs).run();
await db.delete(schema.downstreamApiKeys).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('builds postgres trend buckets by casting text timestamps before date_trunc', async () => {
const routesModule = await import('./downstreamApiKeys.js') as Record<string, any>;
expect(typeof routesModule.buildBucketTsExpressionForDialect).toBe('function');
const expression = routesModule.buildBucketTsExpressionForDialect('postgres', schema.proxyLogs.createdAt, 3600);
const rendered = new PgDialect().sqlToQuery(expression).sql;
expect(rendered).toContain('date_trunc');
expect(rendered).toContain('cast');
expect(rendered).toContain('timestamp');
});
it('creates, updates, resets and deletes downstream api keys', async () => {
const site = await db.insert(schema.sites).values({
name: 'portal-site',
url: 'https://portal.example.com',
status: 'active',
platform: 'openai',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
displayName: 'portal-route',
enabled: true,
}).returning().get();
const createRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'portal-key',
key: 'sk-portal-key-001',
description: 'portal consumer',
groupName: '项目A',
tags: ['移动端', 'VIP'],
enabled: true,
maxCost: 12.5,
maxRequests: 500,
supportedModels: ['gpt-5.2', 'claude-sonnet-4-5'],
allowedRouteIds: [route.id],
siteWeightMultipliers: { [site.id]: 1.2 },
},
});
expect(createRes.statusCode).toBe(200);
const createdBody = createRes.json();
expect(createdBody.success).toBe(true);
expect(createdBody.item).toMatchObject({
name: 'portal-key',
keyMasked: expect.any(String),
groupName: '项目A',
tags: ['移动端', 'VIP'],
maxCost: 12.5,
maxRequests: 500,
supportedModels: ['gpt-5.2', 'claude-sonnet-4-5'],
allowedRouteIds: [route.id],
});
const keyId = createdBody.item.id as number;
const updateRes = await app.inject({
method: 'PUT',
url: `/api/downstream-keys/${keyId}`,
payload: {
name: 'portal-key-updated',
key: 'sk-portal-key-001',
groupName: '项目B',
tags: ['批量候选', 'VIP'],
enabled: false,
maxCost: 20,
maxRequests: 900,
},
});
expect(updateRes.statusCode).toBe(200);
expect(updateRes.json()).toMatchObject({
success: true,
item: {
id: keyId,
name: 'portal-key-updated',
groupName: '项目B',
tags: ['批量候选', 'VIP'],
enabled: false,
maxCost: 20,
maxRequests: 900,
},
});
await db.update(schema.downstreamApiKeys).set({
usedCost: 5.5,
usedRequests: 123,
}).where(eq(schema.downstreamApiKeys.id, keyId)).run();
const resetRes = await app.inject({
method: 'POST',
url: `/api/downstream-keys/${keyId}/reset-usage`,
});
expect(resetRes.statusCode).toBe(200);
expect(resetRes.json()).toMatchObject({ success: true });
const resetRow = await db.select().from(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, keyId))
.get();
expect(resetRow?.usedCost).toBe(0);
expect(resetRow?.usedRequests).toBe(0);
const deleteRes = await app.inject({
method: 'DELETE',
url: `/api/downstream-keys/${keyId}`,
});
expect(deleteRes.statusCode).toBe(200);
expect(deleteRes.json()).toMatchObject({ success: true });
const listRes = await app.inject({
method: 'GET',
url: '/api/downstream-keys',
});
expect(listRes.statusCode).toBe(200);
expect(listRes.json()).toMatchObject({ success: true, items: [] });
});
it('supports batch enable/disable/reset/delete operations', async () => {
const inserted = await db.insert(schema.downstreamApiKeys).values([
{
name: 'batch-a',
key: 'sk-batch-a-001',
enabled: true,
usedCost: 1.2,
usedRequests: 12,
},
{
name: 'batch-b',
key: 'sk-batch-b-001',
enabled: false,
usedCost: 2.4,
usedRequests: 24,
},
]).returning().all();
const disableRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: [inserted[0].id, inserted[1].id],
action: 'disable',
},
});
expect(disableRes.statusCode).toBe(200);
expect(disableRes.json()).toMatchObject({
success: true,
successIds: [inserted[0].id, inserted[1].id],
failedItems: [],
});
const disabledRows = await db.select().from(schema.downstreamApiKeys).all();
expect(disabledRows.every((row) => row.enabled === false)).toBe(true);
const resetRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: [inserted[0].id, inserted[1].id],
action: 'resetUsage',
},
});
expect(resetRes.statusCode).toBe(200);
const resetRows = await db.select().from(schema.downstreamApiKeys).all();
expect(resetRows.every((row) => Number(row.usedCost) === 0 && Number(row.usedRequests) === 0)).toBe(true);
const deleteRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: [inserted[0].id, inserted[1].id],
action: 'delete',
},
});
expect(deleteRes.statusCode).toBe(200);
expect(await db.select().from(schema.downstreamApiKeys).all()).toHaveLength(0);
});
it('supports batch metadata update for group and tags', async () => {
const inserted = await db.insert(schema.downstreamApiKeys).values([
{
name: 'meta-a',
key: 'sk-meta-a-001',
groupName: '旧分组',
tags: JSON.stringify(['旧标签']),
enabled: true,
},
{
name: 'meta-b',
key: 'sk-meta-b-001',
tags: JSON.stringify(['旧标签', '公共']),
enabled: true,
},
]).returning().all();
const res = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: inserted.map((item) => item.id),
action: 'updateMetadata',
groupOperation: 'set',
groupName: '新分组',
tagOperation: 'append',
tags: ['项目A', '公共'],
},
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({
success: true,
successIds: inserted.map((item) => item.id),
failedItems: [],
});
const rows = await db.select().from(schema.downstreamApiKeys).all();
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(row.groupName).toBe('新分组');
}
const views = rows.map((row) => ({
id: row.id,
groupName: row.groupName,
tags: JSON.parse(String(row.tags || '[]')),
}));
expect(views).toContainEqual(expect.objectContaining({ tags: ['旧标签', '项目A', '公共'] }));
expect(views).toContainEqual(expect.objectContaining({ tags: ['旧标签', '公共', '项目A'] }));
});
it('rejects duplicate key creation and invalid batch action', async () => {
const firstRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'dup-a',
key: 'sk-dup-key-001',
},
});
expect(firstRes.statusCode).toBe(200);
const duplicateRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'dup-b',
key: 'sk-dup-key-001',
},
});
expect(duplicateRes.statusCode).toBe(409);
expect(duplicateRes.json()).toMatchObject({
success: false,
message: 'API key 已存在',
});
const invalidBatchRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys/batch',
payload: {
ids: [999],
action: 'archive',
},
});
expect(invalidBatchRes.statusCode).toBe(400);
expect(invalidBatchRes.json()).toMatchObject({
success: false,
message: 'Invalid action',
});
});
it('returns summary, overview and trend aggregated from proxy logs', async () => {
const inserted = await db.insert(schema.downstreamApiKeys).values({
name: 'analytics-key',
key: 'sk-analytics-key-001',
enabled: true,
description: 'analytics',
usedCost: 0,
usedRequests: 0,
}).returning().get();
const now = Date.now();
const within24h = new Date(now - 2 * 60 * 60 * 1000).toISOString();
const within7d = new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString();
const outside7d = new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString();
await db.insert(schema.proxyLogs).values([
{
downstreamApiKeyId: inserted.id,
status: 'success',
totalTokens: 1200,
estimatedCost: 0.12,
createdAt: within24h,
},
{
downstreamApiKeyId: inserted.id,
status: 'failed',
totalTokens: 300,
estimatedCost: 0.03,
createdAt: within24h,
},
{
downstreamApiKeyId: inserted.id,
status: 'success',
totalTokens: 600,
estimatedCost: 0.06,
createdAt: within7d,
},
{
downstreamApiKeyId: inserted.id,
status: 'success',
totalTokens: 900,
estimatedCost: 0.09,
createdAt: outside7d,
},
]).run();
const summaryRes = await app.inject({
method: 'GET',
url: '/api/downstream-keys/summary?range=24h&status=enabled&search=analytics',
});
expect(summaryRes.statusCode).toBe(200);
expect(summaryRes.json()).toMatchObject({
success: true,
range: '24h',
status: 'enabled',
search: 'analytics',
group: '',
tags: [],
tagMatch: 'any',
items: [
{
id: inserted.id,
name: 'analytics-key',
rangeUsage: {
totalRequests: 2,
successRequests: 1,
failedRequests: 1,
successRate: 50,
totalTokens: 1500,
totalCost: 0.15,
},
},
],
});
const overviewRes = await app.inject({
method: 'GET',
url: `/api/downstream-keys/${inserted.id}/overview`,
});
expect(overviewRes.statusCode).toBe(200);
expect(overviewRes.json()).toMatchObject({
success: true,
item: { id: inserted.id, name: 'analytics-key' },
usage: {
last24h: {
totalRequests: 2,
successRequests: 1,
failedRequests: 1,
totalTokens: 1500,
totalCost: 0.15,
},
last7d: {
totalRequests: 3,
successRequests: 2,
failedRequests: 1,
totalTokens: 2100,
totalCost: 0.21,
},
all: {
totalRequests: 4,
successRequests: 3,
failedRequests: 1,
totalTokens: 3000,
totalCost: 0.3,
},
},
});
const trendRes = await app.inject({
method: 'GET',
url: `/api/downstream-keys/${inserted.id}/trend?range=all`,
});
expect(trendRes.statusCode).toBe(200);
const trendBody = trendRes.json();
expect(trendBody.success).toBe(true);
expect(trendBody.range).toBe('all');
expect(trendBody.item).toMatchObject({ id: inserted.id, name: 'analytics-key' });
expect(Array.isArray(trendBody.buckets)).toBe(true);
expect(trendBody.buckets.length).toBeGreaterThanOrEqual(3);
expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 1500)).toBe(true);
expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 600)).toBe(true);
expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 900)).toBe(true);
});
it('rejects unknown route ids and site ids in downstream policy payloads', async () => {
const createRes = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'invalid-policy',
key: 'sk-invalid-policy-001',
allowedRouteIds: [999],
},
});
expect(createRes.statusCode).toBe(400);
expect(createRes.json()).toMatchObject({
success: false,
message: 'allowedRouteIds 包含不存在的路由: 999',
});
const site = await db.insert(schema.sites).values({
name: 'site-a',
url: 'https://example.com',
status: 'active',
platform: 'openai',
}).returning().get();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.2',
enabled: true,
}).returning().get();
const created = await app.inject({
method: 'POST',
url: '/api/downstream-keys',
payload: {
name: 'valid-policy',
key: 'sk-valid-policy-001',
allowedRouteIds: [route.id],
siteWeightMultipliers: { [site.id]: 1.2 },
},
});
expect(created.statusCode).toBe(200);
const keyId = created.json().item.id as number;
const updateRes = await app.inject({
method: 'PUT',
url: `/api/downstream-keys/${keyId}`,
payload: {
siteWeightMultipliers: { 999: 1.5 },
},
});
expect(updateRes.statusCode).toBe(400);
expect(updateRes.json()).toMatchObject({
success: false,
message: 'siteWeightMultipliers 包含不存在的站点: 999',
});
const filteredSummaryRes = await app.inject({
method: 'GET',
url: '/api/downstream-keys/summary?range=all&group=__ungrouped__&tags=foo,bar&tagMatch=all',
});
expect(filteredSummaryRes.statusCode).toBe(200);
expect(filteredSummaryRes.json()).toMatchObject({
success: true,
range: 'all',
group: '__ungrouped__',
tags: ['foo', 'bar'],
tagMatch: 'all',
items: [],
});
});
});
+494 -6
View File
@@ -1,6 +1,6 @@
import { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { and, eq, inArray, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js';
import {
getDownstreamApiKeyById,
listDownstreamApiKeys,
@@ -8,6 +8,7 @@ import {
toDownstreamApiKeyPolicyView,
toPersistenceJson,
} from '../../services/downstreamApiKeyService.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
function parseRouteId(raw: string): number | null {
const id = Number.parseInt(raw, 10);
@@ -24,7 +25,380 @@ function looksLikeUniqueViolation(error: unknown): boolean {
return message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key');
}
function normalizeBatchIds(raw: unknown): number[] {
const values = Array.isArray(raw) ? raw : [];
const ids: number[] = [];
for (const item of values) {
const parsed = Number(item);
if (!Number.isFinite(parsed)) continue;
const id = Math.trunc(parsed);
if (id <= 0 || ids.includes(id)) continue;
ids.push(id);
if (ids.length >= 500) break;
}
return ids;
}
type DownstreamKeyRange = '24h' | '7d' | 'all';
type DownstreamKeyStatus = 'all' | 'enabled' | 'disabled';
function normalizeDownstreamKeyRange(raw: unknown): DownstreamKeyRange {
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
if (value === '24h') return '24h';
if (value === '7d') return '7d';
if (value === 'all') return 'all';
return '24h';
}
function normalizeDownstreamKeyStatus(raw: unknown): DownstreamKeyStatus {
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
if (value === 'enabled') return 'enabled';
if (value === 'disabled') return 'disabled';
return 'all';
}
function normalizeSearchQuery(raw: unknown): string {
const value = typeof raw === 'string' ? raw.trim() : '';
if (!value) return '';
return value.slice(0, 80);
}
function normalizeGroupQuery(raw: unknown): string {
const value = typeof raw === 'string' ? raw.trim() : '';
return value.slice(0, 64);
}
function normalizeTagQuery(raw: unknown): string[] {
const value = typeof raw === 'string' ? raw : Array.isArray(raw) ? raw.join(',') : '';
return value
.split(/[\r\n,,]+/g)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => item.slice(0, 32))
.filter((item, index, arr) => arr.findIndex((candidate) => candidate.toLowerCase() === item.toLowerCase()) === index)
.slice(0, 20);
}
function normalizeTagMatchMode(raw: unknown): 'any' | 'all' {
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
return value === 'all' ? 'all' : 'any';
}
function resolveRangeSinceUtc(range: DownstreamKeyRange): string | null {
const nowTs = Date.now();
if (range === '24h') return formatUtcSqlDateTime(new Date(nowTs - 24 * 60 * 60 * 1000));
if (range === '7d') return formatUtcSqlDateTime(new Date(nowTs - 7 * 24 * 60 * 60 * 1000));
return null;
}
function resolveBucketSeconds(range: DownstreamKeyRange): number {
return range === 'all' ? 86400 : 3600;
}
export function buildBucketTsExpressionForDialect(
dialect: 'sqlite' | 'mysql' | 'postgres',
createdAtColumn: SQLWrapper,
bucketSeconds: number,
) {
if (dialect === 'mysql') {
return sql<number>`floor(unix_timestamp(${createdAtColumn}) / ${bucketSeconds}) * ${bucketSeconds}`;
}
if (dialect === 'postgres') {
const createdAtTimestamp = sql`cast(${createdAtColumn} as timestamp)`;
if (bucketSeconds === 86400) {
return sql<number>`extract(epoch from date_trunc('day', ${createdAtTimestamp}))::bigint`;
}
return sql<number>`extract(epoch from date_trunc('hour', ${createdAtTimestamp}))::bigint`;
}
// sqlite
return sql<number>`cast(cast(strftime('%s', ${createdAtColumn}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`;
}
function resolveBucketTsExpression(bucketSeconds: number) {
return buildBucketTsExpressionForDialect(runtimeDbDialect, schema.proxyLogs.createdAt, bucketSeconds);
}
async function validatePolicyReferences(input: {
allowedRouteIds: number[];
siteWeightMultipliers: Record<number, number>;
}): Promise<string | null> {
const routeIds = input.allowedRouteIds || [];
if (routeIds.length > 0) {
const rows = await db.select({ id: schema.tokenRoutes.id })
.from(schema.tokenRoutes)
.where(inArray(schema.tokenRoutes.id, routeIds))
.all();
const existingIds = new Set(rows.map((row) => Number(row.id)));
const missingIds = routeIds.filter((id) => !existingIds.has(id));
if (missingIds.length > 0) {
return `allowedRouteIds 包含不存在的路由: ${missingIds.join(', ')}`;
}
}
const siteIds = Object.keys(input.siteWeightMultipliers || {})
.map((key) => Number(key))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value));
if (siteIds.length > 0) {
const rows = await db.select({ id: schema.sites.id })
.from(schema.sites)
.where(inArray(schema.sites.id, siteIds))
.all();
const existingIds = new Set(rows.map((row) => Number(row.id)));
const missingIds = siteIds.filter((id) => !existingIds.has(id));
if (missingIds.length > 0) {
return `siteWeightMultipliers 包含不存在的站点: ${missingIds.join(', ')}`;
}
}
return null;
}
export async function downstreamApiKeysRoutes(app: FastifyInstance) {
app.get<{ Querystring: { range?: string; status?: string; search?: string; group?: string; tags?: string | string[]; tagMatch?: string } }>('/api/downstream-keys/summary', async (request) => {
const range = normalizeDownstreamKeyRange(request.query?.range);
const status = normalizeDownstreamKeyStatus(request.query?.status);
const search = normalizeSearchQuery(request.query?.search);
const group = normalizeGroupQuery(request.query?.group);
const tags = normalizeTagQuery(request.query?.tags);
const tagMatch = normalizeTagMatchMode(request.query?.tagMatch);
const whereClauses: SQL[] = [];
if (status === 'enabled') {
whereClauses.push(eq(schema.downstreamApiKeys.enabled, true));
} else if (status === 'disabled') {
whereClauses.push(eq(schema.downstreamApiKeys.enabled, false));
}
if (search) {
const pattern = `%${search.toLowerCase()}%`;
whereClauses.push(sql`(lower(${schema.downstreamApiKeys.name}) like ${pattern} or lower(coalesce(${schema.downstreamApiKeys.description}, '')) like ${pattern})`);
}
let keysQuery = db.select().from(schema.downstreamApiKeys);
if (whereClauses.length > 0) {
keysQuery = keysQuery.where(and(...whereClauses));
}
const keys = (await keysQuery.all())
.map((row) => toDownstreamApiKeyPolicyView(row))
.filter((item) => {
if (group === '__ungrouped__') {
if (item.groupName) return false;
} else if (group && item.groupName !== group) {
return false;
}
if (!search && tags.length === 0) return true;
const haystack = [
item.name,
item.description || '',
item.keyMasked,
item.groupName || '',
...item.tags,
...item.supportedModels,
].join(' ').toLowerCase();
if (search && !haystack.includes(search.toLowerCase())) return false;
if (tags.length === 0) return true;
const itemTags = new Set(item.tags.map((tag) => tag.toLowerCase()));
return tagMatch === 'all'
? tags.every((tag) => itemTags.has(tag.toLowerCase()))
: tags.some((tag) => itemTags.has(tag.toLowerCase()));
})
.sort((a, b) => b.id - a.id);
if (keys.length === 0) {
return { success: true, range, status, search, group, tags, tagMatch, items: [] };
}
const columnReady = await hasProxyLogDownstreamApiKeyIdColumn();
const sinceUtc = resolveRangeSinceUtc(range);
const ids = keys.map((k) => k.id);
const usageRows = columnReady
? await db.select({
keyId: schema.proxyLogs.downstreamApiKeyId,
totalRequests: sql<number>`count(*)`,
successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
})
.from(schema.proxyLogs)
.where(and(
inArray(schema.proxyLogs.downstreamApiKeyId, ids),
...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []),
))
.groupBy(schema.proxyLogs.downstreamApiKeyId)
.all()
: [];
const usageByKey = new Map<number, {
totalRequests: number;
successRequests: number;
failedRequests: number;
totalTokens: number;
totalCost: number;
}>();
for (const row of usageRows) {
const keyId = Number((row as any).keyId ?? 0);
if (!Number.isFinite(keyId) || keyId <= 0) continue;
usageByKey.set(keyId, {
totalRequests: Number((row as any).totalRequests || 0),
successRequests: Number((row as any).successRequests || 0),
failedRequests: Number((row as any).failedRequests || 0),
totalTokens: Number((row as any).totalTokens || 0),
totalCost: Number((row as any).totalCost || 0),
});
}
return {
success: true,
range,
status,
search,
group,
tags,
tagMatch,
items: keys.map((key) => {
const usage = usageByKey.get(key.id) || {
totalRequests: 0,
successRequests: 0,
failedRequests: 0,
totalTokens: 0,
totalCost: 0,
};
const successRate = usage.totalRequests > 0
? Math.round((usage.successRequests / usage.totalRequests) * 1000) / 10
: null;
return {
...key,
rangeUsage: {
totalRequests: usage.totalRequests,
successRequests: usage.successRequests,
failedRequests: usage.failedRequests,
successRate,
totalTokens: usage.totalTokens,
totalCost: Math.round(usage.totalCost * 1_000_000) / 1_000_000,
},
};
}),
};
});
app.get<{ Params: { id: string } }>('/api/downstream-keys/:id/overview', async (request, reply) => {
const id = parseRouteId(request.params.id);
if (!id) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const item = await getDownstreamApiKeyById(id);
if (!item) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
const columnReady = await hasProxyLogDownstreamApiKeyIdColumn();
if (!columnReady) {
return { success: true, item, usage: { last24h: null, last7d: null, all: null } };
}
const readAggregate = async (range: DownstreamKeyRange) => {
const sinceUtc = resolveRangeSinceUtc(range);
const row = await db.select({
totalRequests: sql<number>`count(*)`,
successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
})
.from(schema.proxyLogs)
.where(and(
eq(schema.proxyLogs.downstreamApiKeyId, id),
...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []),
))
.get();
const totalRequests = Number((row as any)?.totalRequests || 0);
const successRequests = Number((row as any)?.successRequests || 0);
const totalCost = Number((row as any)?.totalCost || 0);
return {
totalRequests,
successRequests,
failedRequests: Number((row as any)?.failedRequests || 0),
successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null,
totalTokens: Number((row as any)?.totalTokens || 0),
totalCost: Math.round(totalCost * 1_000_000) / 1_000_000,
};
};
const [last24h, last7d, all] = await Promise.all([
readAggregate('24h'),
readAggregate('7d'),
readAggregate('all'),
]);
return { success: true, item, usage: { last24h, last7d, all } };
});
app.get<{ Params: { id: string }; Querystring: { range?: string } }>('/api/downstream-keys/:id/trend', async (request, reply) => {
const id = parseRouteId(request.params.id);
if (!id) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const range = normalizeDownstreamKeyRange(request.query?.range);
const item = await getDownstreamApiKeyById(id);
if (!item) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
const columnReady = await hasProxyLogDownstreamApiKeyIdColumn();
if (!columnReady) {
return { success: true, range, item: { id: item.id, name: item.name }, buckets: [] };
}
const bucketSeconds = resolveBucketSeconds(range);
const bucketTs = resolveBucketTsExpression(bucketSeconds);
const sinceUtc = resolveRangeSinceUtc(range);
const rows = await db.select({
bucketTs,
totalRequests: sql<number>`count(*)`,
successRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
failedRequests: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`,
totalTokens: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
})
.from(schema.proxyLogs)
.where(and(
eq(schema.proxyLogs.downstreamApiKeyId, id),
...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []),
))
.groupBy(bucketTs)
.orderBy(bucketTs)
.all();
return {
success: true,
range,
item: { id: item.id, name: item.name },
bucketSeconds,
buckets: rows.map((row: any) => {
const tsSeconds = Number(row.bucketTs || 0);
const totalRequests = Number(row.totalRequests || 0);
const successRequests = Number(row.successRequests || 0);
return {
startUtc: tsSeconds > 0 ? new Date(tsSeconds * 1000).toISOString() : null,
totalRequests,
successRequests,
failedRequests: Number(row.failedRequests || 0),
successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null,
totalTokens: Number(row.totalTokens || 0),
totalCost: Math.round(Number(row.totalCost || 0) * 1_000_000) / 1_000_000,
};
}),
};
});
app.get('/api/downstream-keys', async () => {
return {
success: true,
@@ -37,6 +411,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name?: unknown;
key?: unknown;
description?: unknown;
groupName?: unknown;
tags?: unknown;
enabled?: unknown;
expiresAt?: unknown;
maxCost?: unknown;
@@ -62,6 +438,13 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
if (!validateKeyShape(normalized.key)) {
return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' });
}
const policyRefError = await validatePolicyReferences({
allowedRouteIds: normalized.allowedRouteIds,
siteWeightMultipliers: normalized.siteWeightMultipliers,
});
if (policyRefError) {
return reply.code(400).send({ success: false, message: policyRefError });
}
const nowIso = new Date().toISOString();
@@ -70,6 +453,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name: normalized.name,
key: normalized.key,
description: normalized.description,
groupName: normalized.groupName,
tags: toPersistenceJson(normalized.tags),
enabled: normalized.enabled,
expiresAt: normalized.expiresAt,
maxCost: normalized.maxCost,
@@ -111,6 +496,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name?: unknown;
key?: unknown;
description?: unknown;
groupName?: unknown;
tags?: unknown;
enabled?: unknown;
expiresAt?: unknown;
maxCost?: unknown;
@@ -140,6 +527,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name: request.body?.name ?? existing.name,
key: request.body?.key ?? existing.key,
description: request.body?.description ?? existing.description,
groupName: request.body?.groupName ?? existing.groupName,
tags: request.body?.tags ?? existingView.tags,
enabled: request.body?.enabled ?? existing.enabled,
expiresAt: request.body?.expiresAt ?? existing.expiresAt,
maxCost: request.body?.maxCost ?? existing.maxCost,
@@ -161,6 +550,13 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
if (!validateKeyShape(normalized.key)) {
return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' });
}
const policyRefError = await validatePolicyReferences({
allowedRouteIds: normalized.allowedRouteIds,
siteWeightMultipliers: normalized.siteWeightMultipliers,
});
if (policyRefError) {
return reply.code(400).send({ success: false, message: policyRefError });
}
const nowIso = new Date().toISOString();
try {
@@ -168,6 +564,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
name: normalized.name,
key: normalized.key,
description: normalized.description,
groupName: normalized.groupName,
tags: toPersistenceJson(normalized.tags),
enabled: normalized.enabled,
expiresAt: normalized.expiresAt,
maxCost: normalized.maxCost,
@@ -178,7 +576,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
updatedAt: nowIso,
}).where(eq(schema.downstreamApiKeys.id, id)).run();
const updated = getDownstreamApiKeyById(id);
const updated = await getDownstreamApiKeyById(id);
return {
success: true,
item: updated,
@@ -197,7 +595,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const existing = getDownstreamApiKeyById(id);
const existing = await getDownstreamApiKeyById(id);
if (!existing) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
@@ -210,7 +608,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return {
success: true,
item: getDownstreamApiKeyById(id),
item: await getDownstreamApiKeyById(id),
};
});
@@ -220,7 +618,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return reply.code(400).send({ success: false, message: 'id 无效' });
}
const existing = getDownstreamApiKeyById(id);
const existing = await getDownstreamApiKeyById(id);
if (!existing) {
return reply.code(404).send({ success: false, message: 'API key 不存在' });
}
@@ -231,4 +629,94 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
return { success: true };
});
app.post<{ Body?: {
ids?: number[];
action?: string;
groupOperation?: string;
groupName?: unknown;
tagOperation?: string;
tags?: unknown;
} }>('/api/downstream-keys/batch', async (request, reply) => {
const ids = normalizeBatchIds(request.body?.ids);
const action = String(request.body?.action || '').trim();
if (ids.length === 0) {
return reply.code(400).send({ success: false, message: 'ids is required' });
}
if (!['enable', 'disable', 'delete', 'resetUsage', 'updateMetadata'].includes(action)) {
return reply.code(400).send({ success: false, message: 'Invalid action' });
}
const groupOperation = String(request.body?.groupOperation || 'keep').trim();
const tagOperation = String(request.body?.tagOperation || 'keep').trim();
const normalizedGroupName = normalizeDownstreamApiKeyPayload({ groupName: request.body?.groupName }).groupName;
const normalizedTags = normalizeDownstreamApiKeyPayload({ tags: request.body?.tags }).tags;
if (action === 'updateMetadata') {
if (!['keep', 'set', 'clear'].includes(groupOperation)) {
return reply.code(400).send({ success: false, message: 'Invalid groupOperation' });
}
if (!['keep', 'append'].includes(tagOperation)) {
return reply.code(400).send({ success: false, message: 'Invalid tagOperation' });
}
if (groupOperation === 'set' && !normalizedGroupName) {
return reply.code(400).send({ success: false, message: 'groupName is required when groupOperation=set' });
}
if (tagOperation === 'append' && normalizedTags.length === 0) {
return reply.code(400).send({ success: false, message: 'tags is required when tagOperation=append' });
}
}
const successIds: number[] = [];
const failedItems: Array<{ id: number; message: string }> = [];
for (const id of ids) {
try {
const existing = await getDownstreamApiKeyById(id);
if (!existing) {
failedItems.push({ id, message: 'API key 不存在' });
continue;
}
if (action === 'delete') {
await db.delete(schema.downstreamApiKeys)
.where(eq(schema.downstreamApiKeys.id, id))
.run();
} else if (action === 'resetUsage') {
await db.update(schema.downstreamApiKeys).set({
usedCost: 0,
usedRequests: 0,
updatedAt: new Date().toISOString(),
}).where(eq(schema.downstreamApiKeys.id, id)).run();
} else if (action === 'updateMetadata') {
const nextGroupName = groupOperation === 'keep'
? existing.groupName
: (groupOperation === 'clear' ? null : normalizedGroupName);
const nextTags = tagOperation === 'append'
? Array.from(new Map([...existing.tags, ...normalizedTags].map((tag) => [tag.toLowerCase(), tag])).values())
: existing.tags;
await db.update(schema.downstreamApiKeys).set({
groupName: nextGroupName,
tags: toPersistenceJson(nextTags),
updatedAt: new Date().toISOString(),
}).where(eq(schema.downstreamApiKeys.id, id)).run();
} else {
await db.update(schema.downstreamApiKeys).set({
enabled: action === 'enable',
updatedAt: new Date().toISOString(),
}).where(eq(schema.downstreamApiKeys.id, id)).run();
}
successIds.push(id);
} catch (error: any) {
failedItems.push({ id, message: error?.message || 'Batch operation failed' });
}
}
return {
success: true,
successIds,
failedItems,
};
});
}
@@ -0,0 +1,32 @@
import Fastify from 'fastify';
import { afterEach, describe, expect, it } from 'vitest';
import { oauthRoutes } from './oauth.js';
import { isPublicApiRoute } from '../../desktop.js';
describe('oauth route registration', () => {
const apps: Array<Awaited<ReturnType<typeof Fastify>>> = [];
afterEach(async () => {
await Promise.all(apps.splice(0).map((app) => app.close()));
});
it('registers oauth routes on a Fastify instance at runtime', async () => {
const app = Fastify();
apps.push(app);
await app.register(oauthRoutes);
const routes = app.printRoutes();
expect(routes).toContain('providers (GET, HEAD)');
expect(routes).toContain(':provider');
expect(routes).toContain('sessions/');
expect(routes).toContain(':state (GET, HEAD)');
expect(routes).toContain('onnections (GET, HEAD)');
expect(routes).toContain('allback/');
});
it('treats oauth callback route as a public desktop API route', () => {
expect(isPublicApiRoute('/api/oauth/callback/codex')).toBe(true);
expect(isPublicApiRoute('/api/oauth/callback/claude')).toBe(true);
expect(isPublicApiRoute('/api/oauth/providers')).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
+275
View File
@@ -0,0 +1,275 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
import {
deleteOauthConnection,
getOauthSessionStatus,
handleOauthCallback,
listOauthConnections,
listOauthProviders,
refreshOauthConnectionQuota,
startOauthProviderFlow,
startOauthRebindFlow,
submitOauthManualCallback,
} from '../../services/oauth/service.js';
const limitOauthProviderRead = createRateLimitGuard({
bucket: 'oauth-provider-read',
max: 60,
windowMs: 60_000,
});
const limitOauthStart = createRateLimitGuard({
bucket: 'oauth-start',
max: 20,
windowMs: 60_000,
});
const limitOauthSessionRead = createRateLimitGuard({
bucket: 'oauth-session-read',
max: 120,
windowMs: 60_000,
});
const limitOauthSessionMutate = createRateLimitGuard({
bucket: 'oauth-session-mutate',
max: 30,
windowMs: 60_000,
});
const limitOauthConnectionRead = createRateLimitGuard({
bucket: 'oauth-connection-read',
max: 60,
windowMs: 60_000,
});
const limitOauthConnectionMutate = createRateLimitGuard({
bucket: 'oauth-connection-mutate',
max: 20,
windowMs: 60_000,
});
const limitOauthCallback = createRateLimitGuard({
bucket: 'oauth-callback',
max: 30,
windowMs: 60_000,
});
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function renderCallbackPage(message: string): string {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>OAuth Callback</title>
</head>
<body>
<script>window.close();</script>
${escapeHtml(message)}
</body>
</html>`;
}
function parsePositiveInteger(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
if (typeof value !== 'string') return null;
const parsed = Number.parseInt(value.trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function parseOptionalProjectId(value: unknown): string | undefined | null {
if (value === undefined || value === null) return undefined;
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed || undefined;
}
function resolveRequestOrigin(request: FastifyRequest): string | undefined {
const forwardedProto = typeof request.headers['x-forwarded-proto'] === 'string'
? request.headers['x-forwarded-proto'].split(',')[0]?.trim()
: '';
const protocol = forwardedProto || request.protocol || 'http';
const forwardedHost = typeof request.headers['x-forwarded-host'] === 'string'
? request.headers['x-forwarded-host'].split(',')[0]?.trim()
: '';
const host = forwardedHost
|| (typeof request.headers.host === 'string' ? request.headers.host.trim() : '');
if (!host) return undefined;
return `${protocol}://${host}`;
}
export async function oauthRoutes(app: FastifyInstance) {
app.get('/api/oauth/providers', { preHandler: [limitOauthProviderRead] }, async () => ({
providers: listOauthProviders(),
}));
app.post<{ Params: { provider: string }; Body: { accountId?: number; projectId?: string } }>(
'/api/oauth/providers/:provider/start',
{ preHandler: [limitOauthStart] },
async (request, reply) => {
const rebindAccountId = request.body?.accountId === undefined
? undefined
: parsePositiveInteger(request.body.accountId);
if (request.body?.accountId !== undefined && rebindAccountId === null) {
return reply.code(400).send({ message: 'invalid account id' });
}
const projectId = parseOptionalProjectId(request.body?.projectId);
if (request.body?.projectId !== undefined && projectId === null) {
return reply.code(400).send({ message: 'invalid project id' });
}
try {
return await startOauthProviderFlow({
provider: request.params.provider,
rebindAccountId: rebindAccountId ?? undefined,
projectId: projectId ?? undefined,
requestOrigin: resolveRequestOrigin(request),
});
} catch (error: any) {
return reply.code(404).send({ message: error?.message || 'oauth provider not found' });
}
},
);
app.get<{ Params: { state: string } }>(
'/api/oauth/sessions/:state',
{ preHandler: [limitOauthSessionRead] },
async (request, reply) => {
const session = getOauthSessionStatus(request.params.state);
if (!session) {
return reply.code(404).send({ message: 'oauth session not found' });
}
return session;
},
);
app.post<{ Params: { state: string }; Body: { callbackUrl?: string } }>(
'/api/oauth/sessions/:state/manual-callback',
{ preHandler: [limitOauthSessionMutate] },
async (request, reply) => {
const callbackUrl = typeof request.body?.callbackUrl === 'string'
? request.body.callbackUrl.trim()
: '';
if (!callbackUrl) {
return reply.code(400).send({ message: 'invalid oauth callback url' });
}
try {
return await submitOauthManualCallback({
state: request.params.state,
callbackUrl,
});
} catch (error: any) {
const message = error?.message || 'oauth callback submission failed';
if (message === 'invalid oauth callback url' || message === 'oauth callback state mismatch') {
return reply.code(400).send({ message });
}
if (message === 'oauth session not found') {
return reply.code(404).send({ message });
}
return reply.code(500).send({ message });
}
},
);
app.get<{ Querystring: { limit?: string; offset?: string } }>(
'/api/oauth/connections',
{ preHandler: [limitOauthConnectionRead] },
async (request, reply) => {
const limit = request.query.limit === undefined ? undefined : parsePositiveInteger(request.query.limit);
const offset = request.query.offset === undefined
? undefined
: (() => {
if (typeof request.query.offset !== 'string') return null;
const parsed = Number.parseInt(request.query.offset.trim(), 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
})();
if (request.query.limit !== undefined && limit === null) {
return reply.code(400).send({ message: 'invalid limit' });
}
if (request.query.offset !== undefined && offset === null) {
return reply.code(400).send({ message: 'invalid offset' });
}
return listOauthConnections({
limit: limit ?? undefined,
offset: offset ?? undefined,
});
},
);
app.post<{ Params: { accountId: string } }>(
'/api/oauth/connections/:accountId/rebind',
{ preHandler: [limitOauthConnectionMutate] },
async (request, reply) => {
const accountId = parsePositiveInteger(request.params.accountId);
if (accountId === null) {
return reply.code(400).send({ message: 'invalid account id' });
}
try {
return await startOauthRebindFlow(accountId, resolveRequestOrigin(request));
} catch (error: any) {
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
}
},
);
app.delete<{ Params: { accountId: string } }>(
'/api/oauth/connections/:accountId',
{ preHandler: [limitOauthConnectionMutate] },
async (request, reply) => {
const accountId = parsePositiveInteger(request.params.accountId);
if (accountId === null) {
return reply.code(400).send({ message: 'invalid account id' });
}
try {
return await deleteOauthConnection(accountId);
} catch (error: any) {
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
}
},
);
app.post<{ Params: { accountId: string } }>(
'/api/oauth/connections/:accountId/quota/refresh',
{ preHandler: [limitOauthConnectionMutate] },
async (request, reply) => {
const accountId = parsePositiveInteger(request.params.accountId);
if (accountId === null) {
return reply.code(400).send({ message: 'invalid account id' });
}
try {
return await refreshOauthConnectionQuota(accountId);
} catch (error: any) {
return reply.code(404).send({ message: error?.message || 'oauth account not found' });
}
},
);
app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>(
'/api/oauth/callback/:provider',
{ preHandler: [limitOauthCallback] },
async (request, reply) => {
let message = 'OAuth callback received.';
try {
await handleOauthCallback({
provider: request.params.provider,
state: String(request.query.state || ''),
code: request.query.code,
error: request.query.error,
});
message = 'OAuth authorization succeeded. You can close this window.';
} catch {
message = 'OAuth authorization failed. Return to metapi and review the server logs.';
}
reply.type('text/html; charset=utf-8');
return renderCallbackPage(message);
},
);
}
@@ -137,4 +137,57 @@ describe('search routes', () => {
],
});
});
it('includes oauth direct-account model availability in model search results', async () => {
const site = await db.insert(schema.sites).values({
name: 'codex site',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'codex-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
extraConfig: mergeAccountExtraConfig(null, {
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'team',
},
}),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/search',
payload: {
query: 'gpt-5.2',
limit: 20,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
models: [
expect.objectContaining({
name: 'gpt-5.2-codex',
accountCount: 1,
tokenCount: 0,
siteCount: 1,
}),
],
});
});
});
+32 -1
View File
@@ -2,7 +2,8 @@ import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, like, desc, eq, or } from 'drizzle-orm';
import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import { getCredentialModeFromExtraConfig, supportsDirectAccountRoutingConnection } from '../../services/accountExtraConfig.js';
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
function hasSessionTokenValue(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
@@ -134,11 +135,31 @@ export async function searchRoutes(app: FastifyInstance) {
like(schema.tokenModelAvailability.modelName, q),
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
),
)
.limit(perCategory * 20)
.all();
const directAccountModelRows = await db.select({
modelName: schema.modelAvailability.modelName,
accountId: schema.accounts.id,
siteId: schema.sites.id,
accounts: schema.accounts,
})
.from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
like(schema.modelAvailability.modelName, q),
eq(schema.modelAvailability.available, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.limit(perCategory * 20)
.all();
const modelAgg = new Map<string, { tokenIds: Set<number>; accountIds: Set<number>; siteIds: Set<number> }>();
for (const row of modelRows) {
@@ -151,6 +172,16 @@ export async function searchRoutes(app: FastifyInstance) {
agg.accountIds.add(row.accountId);
agg.siteIds.add(row.siteId);
}
for (const row of directAccountModelRows) {
if (!supportsDirectAccountRoutingConnection(row.accounts)) continue;
const key = row.modelName;
if (!modelAgg.has(key)) {
modelAgg.set(key, { tokenIds: new Set(), accountIds: new Set(), siteIds: new Set() });
}
const agg = modelAgg.get(key)!;
agg.accountIds.add(row.accountId);
agg.siteIds.add(row.siteId);
}
const models = Array.from(modelAgg.entries())
.map(([name, agg]) => ({
@@ -29,6 +29,7 @@ describe('stats proxy logs routes', () => {
beforeEach(async () => {
await db.delete(schema.proxyLogs).run();
await db.delete(schema.downstreamApiKeys).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
@@ -52,6 +53,14 @@ describe('stats proxy logs routes', () => {
status: 'active',
}).returning().get();
const downstreamKey = await db.insert(schema.downstreamApiKeys).values({
name: '项目A-Key',
key: 'sk-project-a-001',
groupName: '项目A',
tags: JSON.stringify(['VIP', '灰度']),
enabled: true,
}).returning().get();
const timestamps = [
formatUtcSqlDateTime(new Date('2026-03-09T08:00:00.000Z')),
formatUtcSqlDateTime(new Date('2026-03-09T08:01:00.000Z')),
@@ -62,6 +71,7 @@ describe('stats proxy logs routes', () => {
await db.insert(schema.proxyLogs).values([
{
accountId: account.id,
downstreamApiKeyId: downstreamKey.id,
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'success',
@@ -74,6 +84,7 @@ describe('stats proxy logs routes', () => {
},
{
accountId: account.id,
downstreamApiKeyId: downstreamKey.id,
modelRequested: 'gpt-4o-mini',
modelActual: 'gpt-4o-mini',
status: 'failed',
@@ -136,6 +147,9 @@ describe('stats proxy logs routes', () => {
expect(body.items).toHaveLength(1);
expect(body.items[0]?.modelRequested).toBe('gpt-4o-mini');
expect(body.items[0]?.status).toBe('failed');
expect(body.items[0]?.downstreamKeyName).toBe('项目A-Key');
expect(body.items[0]?.downstreamKeyGroupName).toBe('项目A');
expect(body.items[0]?.downstreamKeyTags).toEqual(['VIP', '灰度']);
expect(body.items[0]).not.toHaveProperty('billingDetails');
expect(body.summary).toEqual({
totalCount: 3,
@@ -160,8 +174,17 @@ describe('stats proxy logs routes', () => {
status: 'active',
}).returning().get();
const downstreamKey = await db.insert(schema.downstreamApiKeys).values({
name: 'detail-key',
key: 'sk-detail-key-001',
groupName: '测试项目',
tags: JSON.stringify(['回归', '日志']),
enabled: true,
}).returning().get();
const inserted = await db.insert(schema.proxyLogs).values({
accountId: account.id,
downstreamApiKeyId: downstreamKey.id,
modelRequested: 'gpt-5',
modelActual: 'gpt-5',
status: 'success',
@@ -188,18 +211,93 @@ describe('stats proxy logs routes', () => {
id: number;
siteName: string | null;
username: string | null;
downstreamKeyName: string | null;
downstreamKeyGroupName: string | null;
downstreamKeyTags: string[];
billingDetails: Record<string, unknown> | null;
};
expect(body.id).toBe(logId);
expect(body.siteName).toBe('detail-site');
expect(body.username).toBe('detail-user');
expect(body.downstreamKeyName).toBe('detail-key');
expect(body.downstreamKeyGroupName).toBe('测试项目');
expect(body.downstreamKeyTags).toEqual(['回归', '日志']);
expect(body.billingDetails).toMatchObject({
breakdown: { totalCost: 0.12 },
usage: { promptTokens: 100, completionTokens: 20 },
});
});
it('supports searching proxy logs by downstream key metadata', async () => {
const site = await db.insert(schema.sites).values({
name: 'meta-site',
url: 'https://meta.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'meta-user',
accessToken: 'meta-token',
status: 'active',
}).returning().get();
const alphaKey = await db.insert(schema.downstreamApiKeys).values({
name: '渠道-A',
key: 'sk-channel-a',
groupName: '项目甲',
tags: JSON.stringify(['商务', 'VIP']),
enabled: true,
}).returning().get();
const betaKey = await db.insert(schema.downstreamApiKeys).values({
name: '渠道-B',
key: 'sk-channel-b',
groupName: '项目乙',
tags: JSON.stringify(['灰度']),
enabled: true,
}).returning().get();
await db.insert(schema.proxyLogs).values([
{
accountId: account.id,
downstreamApiKeyId: alphaKey.id,
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'success',
totalTokens: 12,
estimatedCost: 0.12,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T10:00:00.000Z')),
},
{
accountId: account.id,
downstreamApiKeyId: betaKey.id,
modelRequested: 'gpt-4.1-mini',
modelActual: 'gpt-4.1-mini',
status: 'success',
totalTokens: 22,
estimatedCost: 0.22,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T10:05:00.000Z')),
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/stats/proxy-logs?search=%E9%A1%B9%E7%9B%AE%E7%94%B2',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
total: number;
items: Array<Record<string, unknown>>;
};
expect(body.total).toBe(1);
expect(body.items[0]?.downstreamKeyName).toBe('渠道-A');
expect(body.items[0]?.downstreamKeyGroupName).toBe('项目甲');
});
it('filters proxy logs by site and time range', async () => {
const alphaSite = await db.insert(schema.sites).values({
name: 'alpha-site',
@@ -157,6 +157,52 @@ describe('/api/models/token-candidates', () => {
expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined();
});
it('does not report oauth direct connections as missing account tokens', async () => {
const site = await db.insert(schema.sites).values({
name: 'codex-site',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'codex-user@example.com',
accessToken: 'oauth-access-token',
apiToken: null,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
planType: 'team',
},
}),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/models/token-candidates',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
modelsWithoutToken: Record<string, Array<{ accountId: number }>>;
modelsMissingTokenGroups: Record<string, Array<{ accountId: number }>>;
};
expect(body.modelsWithoutToken['gpt-5.2-codex']).toBeUndefined();
expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined();
});
it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-b',
+78 -14
View File
@@ -17,7 +17,8 @@ import {
parseProxyLogBillingDetails,
withProxyLogSelectFields,
} from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js';
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
import {
formatLocalDateTime,
formatUtcSqlDateTime,
@@ -26,6 +27,7 @@ import {
parseStoredUtcDateTime,
toLocalDayKeyFromStoredUtc,
} from '../../services/localTimeService.js';
import { createRateLimitGuard } from '../../middleware/requestRateLimit.js';
function parseBooleanFlag(raw?: string): boolean {
if (!raw) return false;
@@ -33,14 +35,13 @@ function parseBooleanFlag(raw?: string): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
function isApiKeyConnection(account: { accessToken?: string | null; extraConfig?: string | null }): boolean {
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
if (explicit && explicit !== 'auto') return explicit === 'apikey';
return !(account.accessToken || '').trim();
}
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
const limitModelTokenCandidatesRead = createRateLimitGuard({
bucket: 'models-token-candidates-read',
max: 30,
windowMs: 60_000,
});
type ModelsMarketplaceCacheEntry = {
expiresAt: number;
@@ -121,12 +122,36 @@ function normalizeProxyLogTimeBoundary(raw?: string): string | null {
return formatUtcSqlDateTime(parsed);
}
function parseDownstreamKeyTags(raw: unknown): string[] {
if (typeof raw !== 'string' || !raw.trim()) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
const seen = new Set<string>();
const result: string[] = [];
for (const value of parsed) {
const text = String(value || '').trim();
if (!text) continue;
const dedupeKey = text.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
result.push(text);
}
return result;
} catch {
return [];
}
}
function buildProxyLogSearchCondition(search: string) {
if (!search) return null;
const likeTerm = `%${search}%`;
return sql<boolean>`(
lower(coalesce(${schema.proxyLogs.modelRequested}, '')) like ${likeTerm}
or lower(coalesce(${schema.proxyLogs.modelActual}, '')) like ${likeTerm}
or lower(coalesce(${schema.downstreamApiKeys.name}, '')) like ${likeTerm}
or lower(coalesce(${schema.downstreamApiKeys.groupName}, '')) like ${likeTerm}
or lower(coalesce(${schema.downstreamApiKeys.tags}, '')) like ${likeTerm}
)`;
}
@@ -316,6 +341,12 @@ function mapProxyLogRow(
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
accounts: { username?: string | null } | null;
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
downstream_api_keys: {
id?: number | null;
name?: string | null;
groupName?: string | null;
tags?: string | null;
} | null;
},
options?: { includeBillingDetails?: boolean },
) {
@@ -328,6 +359,10 @@ function mapProxyLogRow(
siteId: row.sites?.id || null,
siteName: row.sites?.name || null,
siteUrl: row.sites?.url || null,
downstreamKeyId: row.downstream_api_keys?.id || null,
downstreamKeyName: row.downstream_api_keys?.name || null,
downstreamKeyGroupName: row.downstream_api_keys?.groupName || null,
downstreamKeyTags: parseDownstreamKeyTags(row.downstream_api_keys?.tags),
};
}
@@ -530,9 +565,16 @@ export async function statsRoutes(app: FastifyInstance) {
proxy_logs: fields,
accounts: schema.accounts,
sites: schema.sites,
downstream_api_keys: {
id: schema.downstreamApiKeys.id,
name: schema.downstreamApiKeys.name,
groupName: schema.downstreamApiKeys.groupName,
tags: schema.downstreamApiKeys.tags,
},
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id));
if (listWhere) {
query = query.where(listWhere) as typeof query;
@@ -547,13 +589,15 @@ export async function statsRoutes(app: FastifyInstance) {
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
accounts: { username?: string | null } | null;
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
downstream_api_keys: { id?: number | null; name?: string | null; groupName?: string | null; tags?: string | null } | null;
}>;
let totalQuery = db.select({
total: sql<number>`count(*)`,
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id));
if (listWhere) {
totalQuery = totalQuery.where(listWhere) as typeof totalQuery;
}
@@ -567,7 +611,8 @@ export async function statsRoutes(app: FastifyInstance) {
totalTokensAll: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id));
if (summaryWhere) {
summaryQuery = summaryQuery.where(summaryWhere) as typeof summaryQuery;
}
@@ -599,15 +644,23 @@ export async function statsRoutes(app: FastifyInstance) {
proxy_logs: fields,
accounts: schema.accounts,
sites: schema.sites,
downstream_api_keys: {
id: schema.downstreamApiKeys.id,
name: schema.downstreamApiKeys.name,
groupName: schema.downstreamApiKeys.groupName,
tags: schema.downstreamApiKeys.tags,
},
}).from(schema.proxyLogs)
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id))
.where(eq(schema.proxyLogs.id, id))
.get()
), { includeBillingDetails: true }) as {
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
accounts: { username?: string | null } | null;
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
downstream_api_keys: { id?: number | null; name?: string | null; groupName?: string | null; tags?: string | null } | null;
} | undefined;
if (!row) {
@@ -672,6 +725,15 @@ export async function statsRoutes(app: FastifyInstance) {
.innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id))
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const accountAvailability = await db.select().from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
@@ -914,7 +976,7 @@ export async function statsRoutes(app: FastifyInstance) {
};
});
app.get('/api/models/token-candidates', async () => {
app.get('/api/models/token-candidates', { preHandler: [limitModelTokenCandidatesRead] }, async () => {
const resolveTokenGroupLabel = (tokenGroup: string | null, tokenName: string | null): string | null => {
const explicit = (tokenGroup || '').trim();
if (explicit) return explicit;
@@ -937,6 +999,7 @@ export async function statsRoutes(app: FastifyInstance) {
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
@@ -949,6 +1012,7 @@ export async function statsRoutes(app: FastifyInstance) {
siteId: schema.sites.id,
siteName: schema.sites.name,
accessToken: schema.accounts.accessToken,
apiToken: schema.accounts.apiToken,
extraConfig: schema.accounts.extraConfig,
})
.from(schema.modelAvailability)
@@ -1027,7 +1091,7 @@ export async function statsRoutes(app: FastifyInstance) {
}
for (const row of availableModelRows) {
if (isApiKeyConnection(row)) continue;
if (!requiresManagedAccountTokens(row)) continue;
const modelName = (row.modelName || '').trim();
if (!modelName) continue;
const coverageKey = `${row.accountId}::${modelName.toLowerCase()}`;
@@ -1044,7 +1108,7 @@ export async function statsRoutes(app: FastifyInstance) {
const accountIdsForGroupHints = new Set(
availableModelRows
.filter((row) => !isApiKeyConnection(row))
.filter((row) => requiresManagedAccountTokens(row))
.map((row) => row.accountId),
);
const requiredGroupsByAccountModel = new Map<string, Map<string, string>>();
@@ -1106,7 +1170,7 @@ export async function statsRoutes(app: FastifyInstance) {
}
for (const row of availableModelRows) {
if (isApiKeyConnection(row)) continue;
if (!requiresManagedAccountTokens(row)) continue;
const modelName = (row.modelName || '').trim();
if (!modelName) continue;
const accountModelKey = `${row.accountId}::${modelName.toLowerCase()}`;
@@ -149,4 +149,180 @@ describe('PUT /api/routes/:id route rebuild', () => {
expect(rebuiltAuto?.priority).toBe(0);
expect(rebuiltAuto?.weight).toBe(10);
});
it('creates explicit-group routes with sourceRouteIds and aggregates source channels', async () => {
const sourceA = await seedAccountWithToken('claude-opus-4-5');
const sourceB = await seedAccountWithToken('claude-sonnet-4-5');
const exactRouteA = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-5',
enabled: true,
}).returning().get();
const exactRouteB = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-sonnet-4-5',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values([
{
routeId: exactRouteA.id,
accountId: sourceA.account.id,
tokenId: sourceA.token.id,
sourceModel: 'claude-opus-4-5',
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
},
{
routeId: exactRouteB.id,
accountId: sourceB.account.id,
tokenId: sourceB.token.id,
sourceModel: 'claude-sonnet-4-5',
priority: 1,
weight: 8,
enabled: true,
manualOverride: false,
},
]).run();
const createResponse = await app.inject({
method: 'POST',
url: '/api/routes',
payload: {
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [exactRouteA.id, exactRouteB.id],
routingStrategy: 'weighted',
},
});
expect(createResponse.statusCode).toBe(200);
expect(createResponse.json()).toMatchObject({
displayName: 'claude-opus-4-6',
routeMode: 'explicit_group',
sourceRouteIds: [exactRouteA.id, exactRouteB.id],
});
const createdRouteId = (createResponse.json() as { id: number }).id;
const storedChannels = await db.select().from(schema.routeChannels)
.where(eq(schema.routeChannels.routeId, createdRouteId))
.all();
expect(storedChannels).toHaveLength(0);
const summaryResponse = await app.inject({
method: 'GET',
url: '/api/routes/summary',
});
expect(summaryResponse.statusCode).toBe(200);
expect(summaryResponse.json()).toContainEqual(expect.objectContaining({
id: createdRouteId,
routeMode: 'explicit_group',
sourceRouteIds: [exactRouteA.id, exactRouteB.id],
channelCount: 2,
enabledChannelCount: 2,
siteNames: expect.arrayContaining([sourceA.site.name, sourceB.site.name]),
}));
const channelsResponse = await app.inject({
method: 'GET',
url: `/api/routes/${createdRouteId}/channels`,
});
expect(channelsResponse.statusCode).toBe(200);
expect(channelsResponse.json()).toEqual(expect.arrayContaining([
expect.objectContaining({
routeId: exactRouteA.id,
accountId: sourceA.account.id,
sourceModel: 'claude-opus-4-5',
}),
expect.objectContaining({
routeId: exactRouteB.id,
accountId: sourceB.account.id,
sourceModel: 'claude-sonnet-4-5',
}),
]));
});
it('rejects invalid explicit-group payloads', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/routes',
payload: {
routeMode: 'explicit_group',
displayName: '',
sourceRouteIds: [],
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
});
});
it('prefers an exact route over a colliding explicit-group display name', async () => {
const exactCandidate = await seedAccountWithToken('claude-opus-4-6');
const groupedCandidate = await seedAccountWithToken('claude-opus-4-5');
const exactRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-6',
enabled: true,
}).returning().get();
const sourceRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: 'claude-opus-4-5',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values([
{
routeId: exactRoute.id,
accountId: exactCandidate.account.id,
tokenId: exactCandidate.token.id,
sourceModel: 'claude-opus-4-6',
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
},
{
routeId: sourceRoute.id,
accountId: groupedCandidate.account.id,
tokenId: groupedCandidate.token.id,
sourceModel: 'claude-opus-4-5',
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
},
]).run();
const groupResponse = await app.inject({
method: 'POST',
url: '/api/routes',
payload: {
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [sourceRoute.id],
},
});
expect(groupResponse.statusCode).toBe(200);
const decisionResponse = await app.inject({
method: 'GET',
url: '/api/routes/decision?model=claude-opus-4-6',
});
expect(decisionResponse.statusCode).toBe(200);
expect(decisionResponse.json()).toMatchObject({
success: true,
decision: {
matched: true,
routeId: exactRoute.id,
modelPattern: 'claude-opus-4-6',
actualModel: 'claude-opus-4-6',
},
});
});
});
+474 -219
View File
@@ -1,10 +1,14 @@
import { FastifyInstance } from 'fastify';
import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { and, eq, inArray } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import {
ACCOUNT_TOKEN_VALUE_STATUS_READY,
isUsableAccountToken,
} from '../../services/accountTokenService.js';
import { normalizeRouteRoutingStrategy } from '../../services/routeRoutingStrategy.js';
import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import {
clearRouteDecisionSnapshot,
clearRouteDecisionSnapshots,
@@ -12,19 +16,156 @@ import {
saveRouteDecisionSnapshots,
} from '../../services/routeDecisionSnapshotStore.js';
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized);
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?]/.test(normalized);
}
type RouteMode = 'pattern' | 'explicit_group';
type RouteRow = typeof schema.tokenRoutes.$inferSelect & {
routeMode: RouteMode;
sourceRouteIds: number[];
};
function normalizeRouteMode(routeMode: unknown): RouteMode {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
}
function isExplicitGroupRoute(route: Pick<RouteRow, 'routeMode'> | Pick<typeof schema.tokenRoutes.$inferSelect, 'routeMode'>): boolean {
return normalizeRouteMode(route.routeMode) === 'explicit_group';
}
function normalizeSourceRouteIdsInput(input: unknown): number[] {
const rawValues = Array.isArray(input) ? input : [];
const normalized: number[] = [];
for (const raw of rawValues) {
const value = Number(raw);
if (!Number.isFinite(value)) continue;
const routeId = Math.trunc(value);
if (routeId <= 0 || normalized.includes(routeId)) continue;
normalized.push(routeId);
if (normalized.length >= 500) break;
}
return normalized;
}
async function loadRouteSourceIdsMap(routeIds: number[]): Promise<Map<number, number[]>> {
const normalizedRouteIds = Array.from(new Set(routeIds.filter((routeId) => Number.isFinite(routeId) && routeId > 0)));
if (normalizedRouteIds.length === 0) return new Map();
const rows = await db.select().from(schema.routeGroupSources)
.where(inArray(schema.routeGroupSources.groupRouteId, normalizedRouteIds))
.all();
const sourceRouteIdsByRouteId = new Map<number, number[]>();
for (const row of rows) {
if (!sourceRouteIdsByRouteId.has(row.groupRouteId)) {
sourceRouteIdsByRouteId.set(row.groupRouteId, []);
}
sourceRouteIdsByRouteId.get(row.groupRouteId)!.push(row.sourceRouteId);
}
for (const [routeId, sourceRouteIds] of sourceRouteIdsByRouteId.entries()) {
sourceRouteIdsByRouteId.set(routeId, Array.from(new Set(sourceRouteIds)));
}
return sourceRouteIdsByRouteId;
}
function decorateRoutesWithSources(
routes: Array<typeof schema.tokenRoutes.$inferSelect>,
sourceRouteIdsByRouteId: Map<number, number[]>,
): RouteRow[] {
return routes.map((route) => ({
...route,
routeMode: normalizeRouteMode(route.routeMode),
sourceRouteIds: sourceRouteIdsByRouteId.get(route.id) ?? [],
}));
}
async function listRoutesWithSources(): Promise<RouteRow[]> {
const routes = await db.select().from(schema.tokenRoutes).all();
const sourceRouteIdsByRouteId = await loadRouteSourceIdsMap(routes.map((route) => route.id));
return decorateRoutesWithSources(routes, sourceRouteIdsByRouteId);
}
async function getRouteWithSources(routeId: number): Promise<RouteRow | null> {
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) return null;
const sourceRouteIdsByRouteId = await loadRouteSourceIdsMap([routeId]);
return decorateRoutesWithSources([route], sourceRouteIdsByRouteId)[0] ?? null;
}
async function validateExplicitGroupSourceRoutes(sourceRouteIds: number[], currentRouteId?: number): Promise<{ ok: true } | { ok: false; message: string }> {
if (sourceRouteIds.length === 0) {
return { ok: false, message: '显式群组至少需要选择一个来源模型' };
}
const routes = await db.select().from(schema.tokenRoutes)
.where(inArray(schema.tokenRoutes.id, sourceRouteIds))
.all();
if (routes.length !== sourceRouteIds.length) {
return { ok: false, message: '来源模型中存在不存在的路由' };
}
for (const route of routes) {
if (currentRouteId && route.id === currentRouteId) {
return { ok: false, message: '显式群组不能引用自身作为来源模型' };
}
if (normalizeRouteMode(route.routeMode) === 'explicit_group') {
return { ok: false, message: '显式群组只能选择精确模型路由作为来源模型' };
}
if (!isExactModelPattern(route.modelPattern)) {
return { ok: false, message: '显式群组只能选择精确模型路由作为来源模型' };
}
}
return { ok: true };
}
async function replaceRouteSourceRouteIds(routeId: number, sourceRouteIds: number[]): Promise<void> {
await db.delete(schema.routeGroupSources).where(eq(schema.routeGroupSources.groupRouteId, routeId)).run();
if (sourceRouteIds.length === 0) return;
await db.insert(schema.routeGroupSources).values(
sourceRouteIds.map((sourceRouteId) => ({
groupRouteId: routeId,
sourceRouteId,
})),
).run();
}
async function clearDependentExplicitGroupSnapshotsBySourceRouteIds(sourceRouteIds: number[]): Promise<void> {
const normalizedSourceRouteIds = Array.from(new Set(
sourceRouteIds.filter((routeId): routeId is number => Number.isFinite(routeId) && routeId > 0),
));
if (normalizedSourceRouteIds.length === 0) return;
const rows = await db.select({ groupRouteId: schema.routeGroupSources.groupRouteId })
.from(schema.routeGroupSources)
.where(inArray(schema.routeGroupSources.sourceRouteId, normalizedSourceRouteIds))
.all();
const dependentRouteIdSet = new Set<number>();
for (const row of rows) {
const routeId = Number(row.groupRouteId);
if (Number.isFinite(routeId) && routeId > 0) {
dependentRouteIdSet.add(routeId);
}
}
const dependentRouteIds = Array.from(dependentRouteIdSet);
if (dependentRouteIds.length === 0) return;
await clearRouteDecisionSnapshots(dependentRouteIds);
}
async function getDefaultTokenId(accountId: number): Promise<number | null> {
const token = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true), eq(schema.accountTokens.isDefault, true)))
.get();
return token?.id ?? null;
}
async function getDefaultTokenId(accountId: number): Promise<number | null> {
const token = await db.select().from(schema.accountTokens)
.where(and(
eq(schema.accountTokens.accountId, accountId),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.isDefault, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
))
.get();
return isUsableAccountToken(token ?? null) ? token!.id : null;
}
function canonicalModelAlias(modelName: string): string {
const normalized = modelName.trim().toLowerCase();
@@ -58,12 +199,12 @@ async function tokenSupportsModel(tokenId: number, modelName: string): Promise<b
});
}
async function checkTokenBelongsToAccount(tokenId: number, accountId: number): Promise<boolean> {
const row = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId)))
.get();
return !!row;
}
async function checkTokenBelongsToAccount(tokenId: number, accountId: number): Promise<boolean> {
const row = await db.select().from(schema.accountTokens)
.where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId)))
.get();
return isUsableAccountToken(row ?? null);
}
async function getPatternTokenCandidates(modelPattern: string): Promise<Array<{ tokenId: number; accountId: number; sourceModel: string }>> {
const rows = await db.select().from(schema.tokenModelAvailability)
@@ -72,18 +213,20 @@ async function getPatternTokenCandidates(modelPattern: string): Promise<Array<{
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(
and(
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const result: Array<{ tokenId: number; accountId: number; sourceModel: string }> = [];
for (const row of rows) {
const modelName = row.token_model_availability.modelName?.trim();
if (!modelName) continue;
eq(schema.tokenModelAvailability.available, true),
eq(schema.accountTokens.enabled, true),
eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY),
eq(schema.accounts.status, 'active'),
eq(schema.sites.status, 'active'),
),
)
.all();
const result: Array<{ tokenId: number; accountId: number; sourceModel: string }> = [];
for (const row of rows) {
if (!isUsableAccountToken(row.account_tokens)) continue;
const modelName = row.token_model_availability.modelName?.trim();
if (!modelName) continue;
if (!matchesModelPattern(modelName, modelPattern)) continue;
result.push({
tokenId: row.account_tokens.id,
@@ -379,25 +522,54 @@ function parseBatchRouteWideDecisionRouteIds(
};
}
async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, any[]>> {
if (routeIds.length === 0) return new Map();
const channelRows = await db.select().from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(inArray(schema.routeChannels.routeId, routeIds))
.all();
const channelsByRoute = new Map<number, any[]>();
for (const row of channelRows) {
const routeId = row.route_channels.routeId;
if (!channelsByRoute.has(routeId)) channelsByRoute.set(routeId, []);
channelsByRoute.get(routeId)!.push({
...row.route_channels,
account: row.accounts,
site: row.sites,
type RouteChannelSummary = {
channelCount: number;
enabledChannelCount: number;
siteNames: Set<string>;
};
async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise<Map<number, any[]>> {
if (routes.length === 0) return new Map();
const explicitSourceRouteIds = Array.from(new Set(routes
.filter((route) => isExplicitGroupRoute(route))
.flatMap((route) => route.sourceRouteIds)));
const enabledExplicitSourceRouteIds = explicitSourceRouteIds.length > 0
? (await db.select({
id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
routeMode: schema.tokenRoutes.routeMode,
enabled: schema.tokenRoutes.enabled,
}).from(schema.tokenRoutes)
.where(inArray(schema.tokenRoutes.id, explicitSourceRouteIds))
.all())
.filter((route) => route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern))
.map((route) => route.id)
: [];
const actualRouteIds = Array.from(new Set([
...routes.filter((route) => !isExplicitGroupRoute(route)).map((route) => route.id),
...enabledExplicitSourceRouteIds,
]));
if (actualRouteIds.length === 0) {
return new Map(routes.map((route) => [route.id, []]));
}
const channelRows = await db.select().from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id))
.where(inArray(schema.routeChannels.routeId, actualRouteIds))
.all();
const channelsByActualRouteId = new Map<number, any[]>();
for (const row of channelRows) {
const routeId = row.route_channels.routeId;
if (!channelsByActualRouteId.has(routeId)) channelsByActualRouteId.set(routeId, []);
channelsByActualRouteId.get(routeId)!.push({
...row.route_channels,
account: row.accounts,
site: row.sites,
token: row.account_tokens
? {
id: row.account_tokens.id,
@@ -406,63 +578,83 @@ async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, a
enabled: row.account_tokens.enabled,
isDefault: row.account_tokens.isDefault,
}
: null,
});
}
return channelsByRoute;
}
export async function tokensRoutes(app: FastifyInstance) {
// List routes with basic info only (lightweight for selectors)
app.get('/api/routes/lite', async () => {
return await db.select({
id: schema.tokenRoutes.id,
modelPattern: schema.tokenRoutes.modelPattern,
displayName: schema.tokenRoutes.displayName,
displayIcon: schema.tokenRoutes.displayIcon,
routingStrategy: schema.tokenRoutes.routingStrategy,
enabled: schema.tokenRoutes.enabled,
}).from(schema.tokenRoutes).all();
: null,
});
}
const channelsByRoute = new Map<number, any[]>();
for (const route of routes) {
if (isExplicitGroupRoute(route)) {
channelsByRoute.set(route.id, route.sourceRouteIds.flatMap((sourceRouteId) => channelsByActualRouteId.get(sourceRouteId) || []));
continue;
}
channelsByRoute.set(route.id, channelsByActualRouteId.get(route.id) || []);
}
return channelsByRoute;
}
async function fetchChannelsForRoutes(routeIds: number[]): Promise<Map<number, any[]>> {
if (routeIds.length === 0) return new Map();
return await fetchChannelsForRouteRows(await listRoutesWithSources()).then((channelsByRoute) => {
const filtered = new Map<number, any[]>();
for (const routeId of routeIds) {
filtered.set(routeId, channelsByRoute.get(routeId) || []);
}
return filtered;
});
}
async function buildRouteChannelSummaryMap(routes: RouteRow[]): Promise<Map<number, RouteChannelSummary>> {
const channelsByRoute = await fetchChannelsForRouteRows(routes);
const summaryByRoute = new Map<number, RouteChannelSummary>();
for (const route of routes) {
const channels = channelsByRoute.get(route.id) || [];
const siteNames = new Set<string>();
let enabledChannelCount = 0;
for (const channel of channels) {
if (channel.enabled) enabledChannelCount += 1;
if (channel.site?.name) siteNames.add(channel.site.name);
}
summaryByRoute.set(route.id, {
channelCount: channels.length,
enabledChannelCount,
siteNames,
});
}
return summaryByRoute;
}
// Route summary (no channel details) for first-screen rendering
app.get('/api/routes/summary', async () => {
const routes = await db.select().from(schema.tokenRoutes).all();
if (routes.length === 0) return [];
const routeIds = routes.map((route) => route.id);
// Aggregate channel counts and site names per route
const channelRows = await db.select({
routeId: schema.routeChannels.routeId,
enabled: schema.routeChannels.enabled,
siteName: schema.sites.name,
}).from(schema.routeChannels)
.innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id))
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(inArray(schema.routeChannels.routeId, routeIds))
.all();
const aggByRoute = new Map<number, { channelCount: number; enabledChannelCount: number; siteNames: Set<string> }>();
for (const row of channelRows) {
let agg = aggByRoute.get(row.routeId);
if (!agg) {
agg = { channelCount: 0, enabledChannelCount: 0, siteNames: new Set() };
aggByRoute.set(row.routeId, agg);
}
agg.channelCount += 1;
if (row.enabled) agg.enabledChannelCount += 1;
if (row.siteName) agg.siteNames.add(row.siteName);
}
return routes.map((route) => {
const agg = aggByRoute.get(route.id);
return {
id: route.id,
modelPattern: route.modelPattern,
displayName: route.displayName ?? null,
export async function tokensRoutes(app: FastifyInstance) {
// List routes with basic info only (lightweight for selectors)
app.get('/api/routes/lite', async () => {
return (await listRoutesWithSources()).map((route) => ({
id: route.id,
modelPattern: route.modelPattern,
displayName: route.displayName,
displayIcon: route.displayIcon,
routeMode: route.routeMode,
sourceRouteIds: route.sourceRouteIds,
routingStrategy: route.routingStrategy,
enabled: route.enabled,
}));
});
// Route summary (no channel details) for first-screen rendering
app.get('/api/routes/summary', async () => {
const routes = await listRoutesWithSources();
if (routes.length === 0) return [];
const aggByRoute = await buildRouteChannelSummaryMap(routes);
return routes.map((route) => {
const agg = aggByRoute.get(route.id);
return {
id: route.id,
modelPattern: route.modelPattern,
displayName: route.displayName ?? null,
displayIcon: route.displayIcon ?? null,
routeMode: route.routeMode,
sourceRouteIds: route.sourceRouteIds,
modelMapping: route.modelMapping ?? null,
routingStrategy: route.routingStrategy ?? 'weighted',
enabled: route.enabled,
@@ -475,26 +667,29 @@ export async function tokensRoutes(app: FastifyInstance) {
});
});
// Get channels for a single route (on-demand loading)
app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const channelsByRoute = await fetchChannelsForRoutes([routeId]);
return channelsByRoute.get(routeId) || [];
});
// Get channels for a single route (on-demand loading)
app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const route = await getRouteWithSources(routeId);
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const channelsByRoute = await fetchChannelsForRouteRows([route]);
return channelsByRoute.get(routeId) || [];
});
// Batch add channels to a route
app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = await getRouteWithSources(routeId);
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
if (isExplicitGroupRoute(route)) {
return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' });
}
if (!body?.channels || !Array.isArray(body.channels) || body.channels.length === 0) {
return reply.code(400).send({ success: false, message: 'channels 必须是非空数组' });
@@ -555,24 +750,24 @@ export async function tokensRoutes(app: FastifyInstance) {
}
}
if (created > 0) {
await clearRouteDecisionSnapshot(routeId);
invalidateTokenRouterCache();
}
if (created > 0) {
await clearRouteDecisionSnapshot(routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]);
invalidateTokenRouterCache();
}
return { success: true, created, skipped, errors };
});
// List all routes
app.get('/api/routes', async () => {
const routes = await db.select().from(schema.tokenRoutes).all();
if (routes.length === 0) return [];
const routeIds = routes.map((route) => route.id);
const channelsByRoute = await fetchChannelsForRoutes(routeIds);
return routes.map((route) => ({
...route,
// List all routes
app.get('/api/routes', async () => {
const routes = await listRoutesWithSources();
if (routes.length === 0) return [];
const channelsByRoute = await fetchChannelsForRouteRows(routes);
return routes.map((route) => ({
...route,
decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot),
decisionRefreshedAt: route.decisionRefreshedAt ?? null,
channels: channelsByRoute.get(route.id) || [],
@@ -678,87 +873,143 @@ export async function tokensRoutes(app: FastifyInstance) {
return { success: true, decisions };
});
// Create a route
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean } }>('/api/routes', async (request) => {
// Create a route
app.post<{ Body: { routeMode?: string; modelPattern?: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean; sourceRouteIds?: number[] } }>('/api/routes', async (request, reply) => {
const body = request.body;
const routeMode = normalizeRouteMode(body.routeMode);
const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : '';
const sourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds);
const modelPattern = routeMode === 'explicit_group'
? displayName
: (typeof body.modelPattern === 'string' ? body.modelPattern.trim() : '');
if (routeMode === 'explicit_group') {
if (!displayName) {
return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' });
}
const validation = await validateExplicitGroupSourceRoutes(sourceRouteIds);
if (!validation.ok) {
return reply.code(400).send({ success: false, message: validation.message });
}
} else if (!modelPattern) {
return reply.code(400).send({ success: false, message: '模型匹配不能为空' });
}
const insertedRoute = await db.insert(schema.tokenRoutes).values({
modelPattern: body.modelPattern,
displayName: body.displayName,
modelPattern,
displayName: displayName || body.displayName,
displayIcon: body.displayIcon,
routeMode,
modelMapping: body.modelMapping,
routingStrategy: normalizeRouteRoutingStrategy(body.routingStrategy),
enabled: body.enabled ?? true,
}).run();
const routeId = Number(insertedRoute.lastInsertRowid || 0);
if (routeId <= 0) {
return { success: false, message: '创建路由失败' };
}
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return { success: false, message: '创建路由失败' };
}
await populateRouteChannelsByModelPattern(route.id, body.modelPattern);
invalidateTokenRouterCache();
return route;
});
// Update a route
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => {
const id = parseInt(request.params.id, 10);
const body = request.body as Record<string, unknown>;
const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
if (!existingRoute) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const updates: Record<string, unknown> = {};
let nextModelPattern = existingRoute.modelPattern;
if (body.displayName !== undefined) updates.displayName = body.displayName;
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
if (body.modelPattern !== undefined) {
const routeId = Number(insertedRoute.lastInsertRowid || 0);
if (routeId <= 0) {
return { success: false, message: '创建路由失败' };
}
const route = await getRouteWithSources(routeId);
if (!route) {
return { success: false, message: '创建路由失败' };
}
if (routeMode === 'explicit_group') {
await replaceRouteSourceRouteIds(route.id, sourceRouteIds);
} else {
await populateRouteChannelsByModelPattern(route.id, modelPattern);
}
invalidateTokenRouterCache();
return await getRouteWithSources(routeId);
});
// Update a route
app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => {
const id = parseInt(request.params.id, 10);
const body = request.body as Record<string, unknown>;
const existingRoute = await getRouteWithSources(id);
if (!existingRoute) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
const routeMode = normalizeRouteMode(body.routeMode ?? existingRoute.routeMode);
if (routeMode !== existingRoute.routeMode) {
return reply.code(400).send({ success: false, message: '暂不支持在不同群组模式之间直接切换' });
}
const updates: Record<string, unknown> = {};
let nextModelPattern = existingRoute.modelPattern;
let nextDisplayName = existingRoute.displayName ?? '';
let nextSourceRouteIds = existingRoute.sourceRouteIds;
if (body.displayName !== undefined) {
nextDisplayName = String(body.displayName || '').trim();
updates.displayName = nextDisplayName || null;
}
if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon;
if (routeMode === 'explicit_group') {
nextModelPattern = nextDisplayName;
updates.modelPattern = nextModelPattern;
if (body.sourceRouteIds !== undefined) {
nextSourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds);
}
if (!nextDisplayName) {
return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' });
}
const validation = await validateExplicitGroupSourceRoutes(nextSourceRouteIds, id);
if (!validation.ok) {
return reply.code(400).send({ success: false, message: validation.message });
}
} else if (body.modelPattern !== undefined) {
nextModelPattern = String(body.modelPattern);
updates.modelPattern = nextModelPattern;
}
if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping;
if (body.routingStrategy !== undefined) updates.routingStrategy = normalizeRouteRoutingStrategy(body.routingStrategy);
if (body.enabled !== undefined) updates.enabled = body.enabled;
if (body.routeMode !== undefined) updates.routeMode = routeMode;
updates.updatedAt = new Date().toISOString();
await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern;
if (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined) {
await replaceRouteSourceRouteIds(id, nextSourceRouteIds);
}
const modelPatternChanged = nextModelPattern !== existingRoute.modelPattern;
const routeBehaviorChanged = modelPatternChanged
|| (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined)
|| body.modelMapping !== undefined
|| body.routingStrategy !== undefined
|| body.enabled !== undefined;
if (modelPatternChanged) {
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
}
if (routeBehaviorChanged) {
await clearRouteDecisionSnapshot(id);
}
invalidateTokenRouterCache();
return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
});
if (routeMode === 'pattern' && modelPatternChanged) {
await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern);
}
if (routeBehaviorChanged) {
await clearRouteDecisionSnapshot(id);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]);
}
invalidateTokenRouterCache();
return await getRouteWithSources(id);
});
// Delete a route
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
const id = parseInt(request.params.id, 10);
await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
invalidateTokenRouterCache();
return { success: true };
});
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
const id = parseInt(request.params.id, 10);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]);
await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
invalidateTokenRouterCache();
return { success: true };
});
// Add a channel to a route
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => {
const routeId = parseInt(request.params.id, 10);
const body = request.body;
const route = await getRouteWithSources(routeId);
if (!route) {
return reply.code(404).send({ success: false, message: '路由不存在' });
}
if (isExplicitGroupRoute(route)) {
return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' });
}
const sourceModel = typeof body.sourceModel === 'string'
? body.sourceModel.trim()
@@ -800,11 +1051,12 @@ export async function tokensRoutes(app: FastifyInstance) {
const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
if (!created) {
return reply.code(500).send({ success: false, message: '创建通道失败' });
}
await clearRouteDecisionSnapshot(routeId);
invalidateTokenRouterCache();
return created;
});
}
await clearRouteDecisionSnapshot(routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]);
invalidateTokenRouterCache();
return created;
});
// Batch update channel priorities
app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => {
@@ -830,13 +1082,14 @@ export async function tokensRoutes(app: FastifyInstance) {
}).where(eq(schema.routeChannels.id, update.id)).run();
}
const updatedChannels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, channelIds))
.all();
await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId));
invalidateTokenRouterCache();
return { success: true, channels: updatedChannels };
});
const updatedChannels = await db.select().from(schema.routeChannels)
.where(inArray(schema.routeChannels.id, channelIds))
.all();
await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId));
await clearDependentExplicitGroupSnapshotsBySourceRouteIds(existingChannels.map((channel) => channel.routeId));
invalidateTokenRouterCache();
return { success: true, channels: updatedChannels };
});
// Update a channel
app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => {
@@ -878,23 +1131,25 @@ export async function tokensRoutes(app: FastifyInstance) {
if (body[key] !== undefined) updates[key] = body[key];
}
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
await clearRouteDecisionSnapshot(channel.routeId);
invalidateTokenRouterCache();
return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
});
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
await clearRouteDecisionSnapshot(channel.routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]);
invalidateTokenRouterCache();
return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
});
// Delete a channel
app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => {
const channelId = parseInt(request.params.channelId, 10);
const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
if (channel) {
await clearRouteDecisionSnapshot(channel.routeId);
}
invalidateTokenRouterCache();
return { success: true };
});
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
if (channel) {
await clearRouteDecisionSnapshot(channel.routeId);
await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]);
}
invalidateTokenRouterCache();
return { success: true };
});
// Rebuild routes/channels from model availability.
app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => {