test:Add database migration service with normalization and masking functions
- Implemented `normalizeMigrationInput` to standardize database migration input for sqlite, mysql, and postgres. - Added `maskConnectionString` to obscure sensitive credentials in connection strings. - Created comprehensive tests for migration input normalization and connection string masking. - Established database schema creation logic for multiple dialects (sqlite, mysql, postgres). - Developed functions for managing database transactions, including begin, commit, and rollback. - Implemented data insertion logic from backup snapshots into the target database.
This commit is contained in:
@@ -35,18 +35,18 @@ describe('account tokens sync routes with site status', () => {
|
||||
return seedId;
|
||||
};
|
||||
|
||||
const seedAccount = (input: { siteStatus?: 'active' | 'disabled'; accountStatus?: string; accessToken?: string | null }) => {
|
||||
const seedAccount = async (input: { siteStatus?: 'active' | 'disabled'; accountStatus?: string; accessToken?: string | null }) => {
|
||||
const id = nextSeed();
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: `site-${id}`,
|
||||
url: `https://site-${id}.example.com`,
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
if (input.siteStatus === 'disabled') {
|
||||
db.run(sql`update sites set status = 'disabled' where id = ${site.id}`);
|
||||
await db.run(sql`update sites set status = 'disabled' where id = ${site.id}`);
|
||||
}
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: `user-${id}`,
|
||||
accessToken: input.accessToken ?? `access-token-${id}`,
|
||||
@@ -70,7 +70,7 @@ describe('account tokens sync routes with site status', () => {
|
||||
await app.register(routesModule.accountTokensRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
getApiTokensMock.mockReset();
|
||||
getApiTokenMock.mockReset();
|
||||
createApiTokenMock.mockReset();
|
||||
@@ -78,14 +78,14 @@ describe('account tokens sync routes with site status', () => {
|
||||
deleteApiTokenMock.mockReset();
|
||||
seedId = 0;
|
||||
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.checkinLogs).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
await db.delete(schema.accountTokens).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.checkinLogs).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -94,7 +94,7 @@ describe('account tokens sync routes with site status', () => {
|
||||
});
|
||||
|
||||
it('returns skipped for single-account sync when site is disabled', async () => {
|
||||
const { account } = seedAccount({ siteStatus: 'disabled' });
|
||||
const { account } = await seedAccount({ siteStatus: 'disabled' });
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -113,7 +113,7 @@ describe('account tokens sync routes with site status', () => {
|
||||
});
|
||||
|
||||
it('returns skipped when upstream has no api tokens', async () => {
|
||||
const { account } = seedAccount({ siteStatus: 'active' });
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
getApiTokensMock.mockResolvedValue([]);
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('account tokens sync routes with site status', () => {
|
||||
reason: 'no_upstream_tokens',
|
||||
});
|
||||
|
||||
const tokenRows = db.select()
|
||||
const tokenRows = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, account.id))
|
||||
.all();
|
||||
@@ -138,8 +138,8 @@ describe('account tokens sync routes with site status', () => {
|
||||
});
|
||||
|
||||
it('sync-all skips disabled-site accounts and syncs active-site accounts', async () => {
|
||||
const disabled = seedAccount({ siteStatus: 'disabled' });
|
||||
const active = seedAccount({ siteStatus: 'active' });
|
||||
const disabled = await seedAccount({ siteStatus: 'disabled' });
|
||||
const active = await seedAccount({ siteStatus: 'active' });
|
||||
|
||||
getApiTokensMock.mockResolvedValue([
|
||||
{ name: 'default', key: 'sk-synced-token', enabled: true },
|
||||
@@ -186,7 +186,7 @@ describe('account tokens sync routes with site status', () => {
|
||||
synced: true,
|
||||
});
|
||||
|
||||
const syncedDefaultToken = db.select()
|
||||
const syncedDefaultToken = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(and(eq(schema.accountTokens.accountId, active.account.id), eq(schema.accountTokens.isDefault, true)))
|
||||
.get();
|
||||
@@ -194,7 +194,7 @@ describe('account tokens sync routes with site status', () => {
|
||||
});
|
||||
|
||||
it('creates token via upstream api and syncs into local store when manual token is omitted', async () => {
|
||||
const { account, site } = seedAccount({ siteStatus: 'active' });
|
||||
const { account, site } = await seedAccount({ siteStatus: 'active' });
|
||||
createApiTokenMock.mockResolvedValue(true);
|
||||
getApiTokensMock.mockResolvedValue([
|
||||
{ name: 'created-from-upstream', key: 'sk-created-upstream-token', enabled: true },
|
||||
@@ -220,7 +220,7 @@ describe('account tokens sync routes with site status', () => {
|
||||
expect(createApiTokenMock.mock.calls[0][0]).toBe(site.url);
|
||||
expect(createApiTokenMock.mock.calls[0][1]).toBe(account.accessToken);
|
||||
|
||||
const tokenRows = db.select()
|
||||
const tokenRows = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, account.id))
|
||||
.all();
|
||||
@@ -232,7 +232,7 @@ describe('account tokens sync routes with site status', () => {
|
||||
});
|
||||
|
||||
it('passes token creation options to upstream adapter', async () => {
|
||||
const { account } = seedAccount({ siteStatus: 'active' });
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
createApiTokenMock.mockResolvedValue(true);
|
||||
getApiTokensMock.mockResolvedValue([
|
||||
{ name: 'custom-token', key: 'sk-created-upstream-token', enabled: true },
|
||||
@@ -265,7 +265,7 @@ describe('account tokens sync routes with site status', () => {
|
||||
});
|
||||
|
||||
it('returns 400 when limited token misses remainQuota', async () => {
|
||||
const { account } = seedAccount({ siteStatus: 'active' });
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
@@ -286,7 +286,7 @@ describe('account tokens sync routes with site status', () => {
|
||||
});
|
||||
|
||||
it('returns 502 when upstream token creation fails', async () => {
|
||||
const { account } = seedAccount({ siteStatus: 'active' });
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
createApiTokenMock.mockResolvedValue(false);
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -306,7 +306,7 @@ describe('account tokens sync routes with site status', () => {
|
||||
});
|
||||
|
||||
it('fetches account token groups from upstream', async () => {
|
||||
const { account } = seedAccount({ siteStatus: 'active' });
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
getUserGroupsMock.mockResolvedValue(['default', 'vip']);
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -323,8 +323,8 @@ describe('account tokens sync routes with site status', () => {
|
||||
});
|
||||
|
||||
it('deletes upstream token before removing local token', async () => {
|
||||
const { account, site } = seedAccount({ siteStatus: 'active' });
|
||||
const token = db.insert(schema.accountTokens).values({
|
||||
const { account, site } = await seedAccount({ siteStatus: 'active' });
|
||||
const token = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'upstream-token',
|
||||
token: 'sk-upstream-token',
|
||||
@@ -345,13 +345,13 @@ describe('account tokens sync routes with site status', () => {
|
||||
expect(deleteApiTokenMock.mock.calls[0][1]).toBe(account.accessToken);
|
||||
expect(deleteApiTokenMock.mock.calls[0][2]).toBe('sk-upstream-token');
|
||||
|
||||
const removed = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
|
||||
const removed = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
|
||||
expect(removed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps local token when upstream deletion fails', async () => {
|
||||
const { account } = seedAccount({ siteStatus: 'active' });
|
||||
const token = db.insert(schema.accountTokens).values({
|
||||
const { account } = await seedAccount({ siteStatus: 'active' });
|
||||
const token = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'upstream-token',
|
||||
token: 'sk-upstream-token',
|
||||
@@ -372,7 +372,7 @@ describe('account tokens sync routes with site status', () => {
|
||||
message: '站点删除令牌失败,本地未删除',
|
||||
});
|
||||
|
||||
const existing = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
|
||||
const existing = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get();
|
||||
expect(existing).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,7 +233,7 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
|
||||
};
|
||||
}
|
||||
|
||||
const synced = syncTokensFromUpstream(accountId, tokens);
|
||||
const synced = await syncTokensFromUpstream(accountId, tokens);
|
||||
return {
|
||||
...base,
|
||||
status: 'synced',
|
||||
@@ -255,7 +255,7 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise<SyncExe
|
||||
}
|
||||
}
|
||||
|
||||
function appendTokenSyncEvent(result: SyncExecutionResult) {
|
||||
async function appendTokenSyncEvent(result: SyncExecutionResult) {
|
||||
const title = result.status === 'synced'
|
||||
? '令牌同步成功'
|
||||
: (result.status === 'skipped' ? '令牌同步跳过' : '令牌同步失败');
|
||||
@@ -267,7 +267,7 @@ function appendTokenSyncEvent(result: SyncExecutionResult) {
|
||||
: (result.message || result.reason || 'sync skipped');
|
||||
|
||||
try {
|
||||
db.insert(schema.events).values({
|
||||
await db.insert(schema.events).values({
|
||||
type: 'token',
|
||||
title,
|
||||
message: `${result.accountName} @ ${result.siteName}: ${detail}`,
|
||||
@@ -279,7 +279,7 @@ function appendTokenSyncEvent(result: SyncExecutionResult) {
|
||||
}
|
||||
|
||||
async function executeSyncAllAccountTokens() {
|
||||
const rows = db.select().from(schema.accounts)
|
||||
const rows = await db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.accounts.status, 'active'))
|
||||
.all();
|
||||
@@ -331,7 +331,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
modelLimits?: string;
|
||||
} }>('/api/account-tokens', async (request, reply) => {
|
||||
const body = request.body;
|
||||
const row = db.select()
|
||||
const row = await db.select()
|
||||
.from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.accounts.id, body.accountId))
|
||||
@@ -343,11 +343,11 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
const tokenValue = (body.token || '').trim();
|
||||
if (tokenValue) {
|
||||
const now = new Date().toISOString();
|
||||
const existing = db.select().from(schema.accountTokens)
|
||||
const existing = await db.select().from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, body.accountId))
|
||||
.all();
|
||||
|
||||
const created = db.insert(schema.accountTokens).values({
|
||||
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,
|
||||
@@ -357,12 +357,20 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
isDefault: body.isDefault ?? false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).returning().get();
|
||||
}).run();
|
||||
const createdId = Number(inserted.lastInsertRowid || 0);
|
||||
if (createdId <= 0) {
|
||||
return reply.code(500).send({ success: false, message: '创建令牌失败' });
|
||||
}
|
||||
const created = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, createdId)).get();
|
||||
if (!created) {
|
||||
return reply.code(500).send({ success: false, message: '创建令牌失败' });
|
||||
}
|
||||
|
||||
if (body.isDefault || (existing.length === 0 && (body.enabled ?? true))) {
|
||||
setDefaultToken(created.id);
|
||||
await setDefaultToken(created.id);
|
||||
} else if (existing.every((token) => !token.isDefault) && (body.enabled ?? true)) {
|
||||
setDefaultToken(created.id);
|
||||
await setDefaultToken(created.id);
|
||||
}
|
||||
|
||||
return { success: true, token: created };
|
||||
@@ -445,12 +453,12 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(502).send({ success: false, message: syncResult.message || '站点未返回可用令牌' });
|
||||
}
|
||||
|
||||
const preferred = db.select().from(schema.accountTokens)
|
||||
const preferred = await db.select().from(schema.accountTokens)
|
||||
.where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.isDefault, true)))
|
||||
.get();
|
||||
const token = preferred || db.select().from(schema.accountTokens)
|
||||
const token = preferred || (await db.select().from(schema.accountTokens)
|
||||
.where(eq(schema.accountTokens.accountId, account.id))
|
||||
.all()
|
||||
.all())
|
||||
.slice(-1)[0] || null;
|
||||
|
||||
return {
|
||||
@@ -467,7 +475,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
|
||||
}
|
||||
|
||||
const existing = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
|
||||
const existing = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ success: false, message: '令牌不存在' });
|
||||
}
|
||||
@@ -491,9 +499,9 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
if (body.source !== undefined) updates.source = body.source;
|
||||
if (body.isDefault !== undefined) updates.isDefault = body.isDefault;
|
||||
|
||||
db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run();
|
||||
await db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run();
|
||||
|
||||
const latest = db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
|
||||
const latest = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get();
|
||||
if (!latest) {
|
||||
return reply.code(500).send({ success: false, message: '更新失败' });
|
||||
}
|
||||
@@ -529,7 +537,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
|
||||
}
|
||||
|
||||
const row = db.select()
|
||||
const row = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
@@ -555,7 +563,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
|
||||
}
|
||||
|
||||
const row = db.select()
|
||||
const row = await db.select()
|
||||
.from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
@@ -593,7 +601,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
|
||||
}
|
||||
|
||||
const row = db.select()
|
||||
const row = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
@@ -622,7 +630,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
db.delete(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).run();
|
||||
await db.delete(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).run();
|
||||
|
||||
if (existing.isDefault) {
|
||||
repairDefaultToken(existing.accountId);
|
||||
@@ -637,7 +645,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
|
||||
}
|
||||
|
||||
const row = db.select().from(schema.accounts)
|
||||
const row = await db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.get();
|
||||
@@ -707,7 +715,7 @@ export async function accountTokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: '账号 ID 无效' });
|
||||
}
|
||||
|
||||
const row = db.select()
|
||||
const row = await db.select()
|
||||
.from(schema.accountTokens)
|
||||
.innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
|
||||
@@ -36,20 +36,20 @@ describe('accounts add requires token verification success', () => {
|
||||
await app.register(routesModule.accountsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
verifyTokenMock.mockReset();
|
||||
getApiTokensMock.mockReset();
|
||||
getApiTokensMock.mockResolvedValue([]);
|
||||
|
||||
db.delete(schema.proxyLogs).run();
|
||||
db.delete(schema.checkinLogs).run();
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
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 () => {
|
||||
@@ -60,7 +60,7 @@ describe('accounts add requires token verification success', () => {
|
||||
it('rejects binding when token verification is not successful', async () => {
|
||||
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Verify Site',
|
||||
url: 'https://verify.example.com',
|
||||
platform: 'new-api',
|
||||
@@ -79,13 +79,13 @@ describe('accounts add requires token verification success', () => {
|
||||
expect(response.json()).toMatchObject({
|
||||
success: false,
|
||||
});
|
||||
expect(db.select().from(schema.accounts).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.accounts).all()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns rebind hint when token verify reports invalid access token', async () => {
|
||||
verifyTokenMock.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效'));
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Verify Site',
|
||||
url: 'https://verify.example.com',
|
||||
platform: 'new-api',
|
||||
@@ -105,7 +105,7 @@ describe('accounts add requires token verification success', () => {
|
||||
success: false,
|
||||
message: '无权进行此操作,access token 无效,请在中转站重新生成系统访问令牌后重新绑定账号',
|
||||
});
|
||||
expect(db.select().from(schema.accounts).all()).toHaveLength(0);
|
||||
expect(await db.select().from(schema.accounts).all()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('allows binding when token verification succeeds as api key', async () => {
|
||||
@@ -114,7 +114,7 @@ describe('accounts add requires token verification success', () => {
|
||||
models: ['gpt-4o-mini'],
|
||||
});
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'API Key Site',
|
||||
url: 'https://apikey.example.com',
|
||||
platform: 'new-api',
|
||||
@@ -134,7 +134,7 @@ describe('accounts add requires token verification success', () => {
|
||||
expect(body.tokenType).toBe('apikey');
|
||||
expect(body.apiTokenFound).toBe(true);
|
||||
|
||||
const accounts = db.select().from(schema.accounts).all();
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect((accounts[0]?.apiToken || '').startsWith('sk-')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -39,21 +39,21 @@ describe('accounts credential mode', () => {
|
||||
await app.register(routesModule.accountsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
verifyTokenMock.mockReset();
|
||||
getModelsMock.mockReset();
|
||||
getApiTokensMock.mockReset();
|
||||
getApiTokensMock.mockResolvedValue([]);
|
||||
|
||||
db.delete(schema.proxyLogs).run();
|
||||
db.delete(schema.checkinLogs).run();
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
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 () => {
|
||||
@@ -65,7 +65,7 @@ describe('accounts credential mode', () => {
|
||||
verifyTokenMock.mockRejectedValueOnce(new Error('verifyToken should not be called'));
|
||||
getModelsMock.mockResolvedValueOnce(['gpt-5-mini', 'gpt-4o-mini']);
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Fast Verify Site',
|
||||
url: 'https://fast-verify.example.com',
|
||||
platform: 'new-api',
|
||||
@@ -95,7 +95,7 @@ describe('accounts credential mode', () => {
|
||||
verifyTokenMock.mockRejectedValueOnce(new Error('verifyToken should not be called'));
|
||||
getModelsMock.mockResolvedValueOnce(['gpt-4o-mini']);
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Proxy Only Site',
|
||||
url: 'https://proxy-only.example.com',
|
||||
platform: 'new-api',
|
||||
@@ -116,7 +116,7 @@ describe('accounts credential mode', () => {
|
||||
expect(body.tokenType).toBe('apikey');
|
||||
expect(body.capabilities?.proxyOnly).toBe(true);
|
||||
|
||||
const accounts = db.select().from(schema.accounts).all();
|
||||
const accounts = await db.select().from(schema.accounts).all();
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0]?.accessToken || '').toBe('');
|
||||
expect((accounts[0]?.apiToken || '').startsWith('sk-')).toBe(true);
|
||||
@@ -132,7 +132,7 @@ describe('accounts credential mode', () => {
|
||||
userInfo: { username: 'sub2-user' },
|
||||
});
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Sub2 Site',
|
||||
url: 'https://sub2.example.com',
|
||||
platform: 'sub2api',
|
||||
@@ -150,7 +150,7 @@ describe('accounts credential mode', () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const created = db.select().from(schema.accounts).all()[0];
|
||||
const created = (await db.select().from(schema.accounts).all())[0];
|
||||
const parsedExtra = JSON.parse(created?.extraConfig || '{}') as {
|
||||
credentialMode?: string;
|
||||
sub2apiAuth?: {
|
||||
@@ -164,12 +164,12 @@ describe('accounts credential mode', () => {
|
||||
});
|
||||
|
||||
it('updates and clears managed refresh token via account update API', async () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Sub2 Site',
|
||||
url: 'https://sub2.example.com',
|
||||
platform: 'sub2api',
|
||||
}).returning().get();
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'sub2-user',
|
||||
accessToken: 'access-token',
|
||||
@@ -192,7 +192,7 @@ describe('accounts credential mode', () => {
|
||||
});
|
||||
expect(updateResponse.statusCode).toBe(200);
|
||||
|
||||
const updated = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const updated = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const parsedUpdated = JSON.parse(updated?.extraConfig || '{}') as {
|
||||
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
|
||||
};
|
||||
@@ -208,7 +208,7 @@ describe('accounts credential mode', () => {
|
||||
});
|
||||
expect(clearResponse.statusCode).toBe(200);
|
||||
|
||||
const cleared = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const cleared = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const parsedCleared = JSON.parse(cleared?.extraConfig || '{}') as {
|
||||
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
|
||||
};
|
||||
|
||||
@@ -32,17 +32,17 @@ describe('accounts health refresh runtime state', () => {
|
||||
await app.register(routesModule.accountsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
refreshBalanceMock.mockReset();
|
||||
db.delete(schema.proxyLogs).run();
|
||||
db.delete(schema.checkinLogs).run();
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
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 () => {
|
||||
@@ -51,13 +51,13 @@ describe('accounts health refresh runtime state', () => {
|
||||
});
|
||||
|
||||
it('keeps degraded runtime state for unsupported checkin after health refresh', async () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Wind Hub',
|
||||
url: 'https://windhub.cc',
|
||||
platform: 'done-hub',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'ld6jl3djexjf',
|
||||
accessToken: 'token',
|
||||
|
||||
@@ -34,18 +34,18 @@ describe('accounts login shield detection', () => {
|
||||
await app.register(routesModule.accountsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
loginMock.mockReset();
|
||||
|
||||
db.delete(schema.proxyLogs).run();
|
||||
db.delete(schema.checkinLogs).run();
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
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 () => {
|
||||
@@ -59,7 +59,7 @@ describe('accounts login shield detection', () => {
|
||||
message: "Unexpected token '<', \"<html><scr\"... is not valid JSON",
|
||||
});
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'AnyRouter',
|
||||
url: 'https://anyrouter.example.com',
|
||||
platform: 'new-api',
|
||||
|
||||
@@ -35,18 +35,18 @@ describe('accounts rebind-session api', () => {
|
||||
await app.register(routesModule.accountsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
verifyTokenMock.mockReset();
|
||||
|
||||
db.delete(schema.proxyLogs).run();
|
||||
db.delete(schema.checkinLogs).run();
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
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 () => {
|
||||
@@ -57,13 +57,13 @@ describe('accounts rebind-session api', () => {
|
||||
it('rejects rebinding when token is not verified as session', async () => {
|
||||
verifyTokenMock.mockResolvedValueOnce({ tokenType: 'unknown' });
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Rebind Site',
|
||||
url: 'https://rebind.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'linuxdo_1001',
|
||||
accessToken: 'old-access-token',
|
||||
@@ -83,7 +83,7 @@ describe('accounts rebind-session api', () => {
|
||||
success: false,
|
||||
});
|
||||
|
||||
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
expect(latest?.accessToken).toBe('old-access-token');
|
||||
expect(latest?.status).toBe('expired');
|
||||
});
|
||||
@@ -91,13 +91,13 @@ describe('accounts rebind-session api', () => {
|
||||
it('returns rebind hint when verify reports invalid access token', async () => {
|
||||
verifyTokenMock.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效'));
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Rebind Site',
|
||||
url: 'https://rebind.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'linuxdo_1001',
|
||||
accessToken: 'old-access-token',
|
||||
@@ -126,13 +126,13 @@ describe('accounts rebind-session api', () => {
|
||||
apiToken: 'sk-rebound-token',
|
||||
});
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Rebind Site',
|
||||
url: 'https://rebind.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'linuxdo_1001',
|
||||
accessToken: 'old-access-token',
|
||||
@@ -153,7 +153,7 @@ describe('accounts rebind-session api', () => {
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.apiTokenFound).toBe(true);
|
||||
|
||||
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
expect(latest?.accessToken).toBe('new-session-token');
|
||||
expect(latest?.apiToken).toBe('sk-rebound-token');
|
||||
expect(latest?.username).toBe('linuxdo_1002');
|
||||
@@ -166,13 +166,13 @@ describe('accounts rebind-session api', () => {
|
||||
userInfo: { username: 'sub2_user' },
|
||||
});
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Sub2 Rebind Site',
|
||||
url: 'https://sub2.example.com',
|
||||
platform: 'sub2api',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'sub2_user',
|
||||
accessToken: 'old-access-token',
|
||||
@@ -197,7 +197,7 @@ describe('accounts rebind-session api', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const parsedExtra = JSON.parse(String(latest?.extraConfig || '{}')) as {
|
||||
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
|
||||
};
|
||||
@@ -211,13 +211,13 @@ describe('accounts rebind-session api', () => {
|
||||
userInfo: { username: 'sub2_user' },
|
||||
});
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Sub2 Rebind Site',
|
||||
url: 'https://sub2.example.com',
|
||||
platform: 'sub2api',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'sub2_user',
|
||||
accessToken: 'old-access-token',
|
||||
@@ -242,7 +242,7 @@ describe('accounts rebind-session api', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const parsedExtra = JSON.parse(String(latest?.extraConfig || '{}')) as {
|
||||
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
|
||||
};
|
||||
@@ -256,13 +256,13 @@ describe('accounts rebind-session api', () => {
|
||||
userInfo: { username: 'linuxdo_1003' },
|
||||
});
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Rebind Site',
|
||||
url: 'https://rebind.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'linuxdo_1001',
|
||||
accessToken: 'old-access-token',
|
||||
@@ -282,7 +282,7 @@ describe('accounts rebind-session api', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const parsedExtra = JSON.parse(String(latest?.extraConfig || '{}')) as {
|
||||
sub2apiAuth?: { refreshToken?: string; tokenExpiresAt?: number };
|
||||
platformUserId?: number;
|
||||
|
||||
@@ -32,16 +32,16 @@ describe('accounts api today reward fallback', () => {
|
||||
await app.register(routesModule.accountsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.delete(schema.proxyLogs).run();
|
||||
db.delete(schema.checkinLogs).run();
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
beforeEach(async () => {
|
||||
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 () => {
|
||||
@@ -51,12 +51,12 @@ describe('accounts api today reward fallback', () => {
|
||||
|
||||
it('uses today income value when checkin reward is missing', async () => {
|
||||
const today = formatLocalDate(new Date());
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'reward-site',
|
||||
url: 'https://reward-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'reward-user',
|
||||
accessToken: 'token',
|
||||
@@ -71,7 +71,7 @@ describe('accounts api today reward fallback', () => {
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
db.insert(schema.checkinLogs).values({
|
||||
await db.insert(schema.checkinLogs).values({
|
||||
accountId: account.id,
|
||||
status: 'success',
|
||||
message: 'checked in',
|
||||
@@ -92,12 +92,12 @@ describe('accounts api today reward fallback', () => {
|
||||
|
||||
it('prefers parsed checkin reward when available', async () => {
|
||||
const today = formatLocalDate(new Date());
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'reward-site',
|
||||
url: 'https://reward-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'reward-user',
|
||||
accessToken: 'token',
|
||||
@@ -112,7 +112,7 @@ describe('accounts api today reward fallback', () => {
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
db.insert(schema.checkinLogs).values({
|
||||
await db.insert(schema.checkinLogs).values({
|
||||
accountId: account.id,
|
||||
status: 'success',
|
||||
message: 'checkin success',
|
||||
@@ -132,12 +132,12 @@ describe('accounts api today reward fallback', () => {
|
||||
});
|
||||
|
||||
it('counts today spend only inside local-day range', async () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'spend-site',
|
||||
url: 'https://spend-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'spend-user',
|
||||
accessToken: 'token',
|
||||
@@ -151,7 +151,7 @@ describe('accounts api today reward fallback', () => {
|
||||
const inRange = formatUtcSqlDateTime(new Date(startDate.getTime() + 60_000));
|
||||
const afterEnd = formatUtcSqlDateTime(new Date(endDate.getTime() + 60_000));
|
||||
|
||||
db.insert(schema.proxyLogs).values([
|
||||
await db.insert(schema.proxyLogs).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
status: 'success',
|
||||
|
||||
@@ -114,8 +114,8 @@ function normalizeManagedTokenExpiresAt(input: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getNextAccountSortOrder(): number {
|
||||
const rows = db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all();
|
||||
async function getNextAccountSortOrder(): Promise<number> {
|
||||
const rows = await db.select({ sortOrder: schema.accounts.sortOrder }).from(schema.accounts).all();
|
||||
const max = rows.reduce((currentMax, row) => Math.max(currentMax, row.sortOrder || 0), -1);
|
||||
return max + 1;
|
||||
}
|
||||
@@ -191,7 +191,7 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
|
||||
|
||||
try {
|
||||
await refreshBalance(accountId);
|
||||
const refreshedAccount = db.select().from(schema.accounts)
|
||||
const refreshedAccount = await db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
.get();
|
||||
const runtimeHealth = buildRuntimeHealthForAccount({
|
||||
@@ -227,7 +227,7 @@ async function refreshRuntimeHealthForRow(row: AccountWithSiteRow): Promise<Acco
|
||||
}
|
||||
|
||||
async function executeRefreshAccountRuntimeHealth(accountId?: number) {
|
||||
const rows = db.select().from(schema.accounts)
|
||||
const rows = await db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.all();
|
||||
|
||||
@@ -249,13 +249,13 @@ async function executeRefreshAccountRuntimeHealth(accountId?: number) {
|
||||
export async function accountsRoutes(app: FastifyInstance) {
|
||||
// List all accounts (with site info)
|
||||
app.get('/api/accounts', async () => {
|
||||
const rows = db.select().from(schema.accounts)
|
||||
const rows = await db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)).all();
|
||||
|
||||
const { localDay, startUtc, endUtc } = getLocalDayRangeUtc();
|
||||
|
||||
// Aggregate today's spend per account from proxy logs
|
||||
const todaySpendRows = db.select({
|
||||
const todaySpendRows = await db.select({
|
||||
accountId: schema.proxyLogs.accountId,
|
||||
totalSpend: sql<number>`coalesce(sum(${schema.proxyLogs.estimatedCost}), 0)`,
|
||||
}).from(schema.proxyLogs)
|
||||
@@ -269,7 +269,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
// Aggregate today's checkin rewards per account
|
||||
const todayCheckins = db.select({
|
||||
const todayCheckins = await db.select({
|
||||
accountId: schema.checkinLogs.accountId,
|
||||
reward: schema.checkinLogs.reward,
|
||||
message: schema.checkinLogs.message,
|
||||
@@ -323,7 +323,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
const { siteId, username, password } = request.body;
|
||||
|
||||
// Get site info
|
||||
const site = db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
|
||||
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
|
||||
if (!site) return { success: false, message: 'site not found' };
|
||||
|
||||
// Get platform adapter
|
||||
@@ -354,7 +354,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
} catch { }
|
||||
|
||||
const preferredApiToken = apiTokens.find((token) => token.enabled !== false && token.key)?.key || apiToken || null;
|
||||
const existing = db.select().from(schema.accounts)
|
||||
const existing = await db.select().from(schema.accounts)
|
||||
.where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.username, username)))
|
||||
.get();
|
||||
|
||||
@@ -374,7 +374,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
// Create or update account
|
||||
let accountId = existing?.id;
|
||||
if (existing) {
|
||||
db.update(schema.accounts).set({
|
||||
await db.update(schema.accounts).set({
|
||||
accessToken: loginResult.accessToken,
|
||||
apiToken: preferredApiToken || undefined,
|
||||
checkinEnabled: true,
|
||||
@@ -383,7 +383,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(schema.accounts.id, existing.id)).run();
|
||||
} else {
|
||||
const created = db.insert(schema.accounts).values({
|
||||
const inserted = await db.insert(schema.accounts).values({
|
||||
siteId,
|
||||
username,
|
||||
accessToken: loginResult.accessToken,
|
||||
@@ -391,23 +391,24 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
checkinEnabled: true,
|
||||
extraConfig,
|
||||
isPinned: false,
|
||||
sortOrder: getNextAccountSortOrder(),
|
||||
}).returning().get();
|
||||
accountId = created.id;
|
||||
sortOrder: await getNextAccountSortOrder(),
|
||||
}).run();
|
||||
const insertedId = Number(inserted.lastInsertRowid || 0);
|
||||
accountId = insertedId > 0 ? insertedId : undefined;
|
||||
}
|
||||
|
||||
const result = db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
|
||||
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId!)).get();
|
||||
if (!result) {
|
||||
return { success: false, message: 'account create failed' };
|
||||
}
|
||||
|
||||
if (apiTokens.length > 0) {
|
||||
try {
|
||||
syncTokensFromUpstream(result.id, apiTokens);
|
||||
await syncTokensFromUpstream(result.id, apiTokens);
|
||||
} catch { }
|
||||
} else if (preferredApiToken) {
|
||||
try {
|
||||
ensureDefaultTokenForAccount(result.id, preferredApiToken, { name: 'default', source: 'sync' });
|
||||
await ensureDefaultTokenForAccount(result.id, preferredApiToken, { name: 'default', source: 'sync' });
|
||||
} catch { }
|
||||
}
|
||||
|
||||
@@ -415,10 +416,10 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
try { await refreshBalance(result.id); } catch { }
|
||||
try {
|
||||
await refreshModelsForAccount(result.id);
|
||||
rebuildTokenRoutesFromAvailability();
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
} catch { }
|
||||
|
||||
const account = db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
|
||||
const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
|
||||
return {
|
||||
success: true,
|
||||
account,
|
||||
@@ -433,7 +434,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
const { siteId, platformUserId } = request.body;
|
||||
const accessToken = (request.body.accessToken || '').trim();
|
||||
const credentialMode = resolveRequestedCredentialMode(request.body.credentialMode);
|
||||
const site = db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
|
||||
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
|
||||
if (!site) return { success: false, message: 'site not found' };
|
||||
|
||||
if (!accessToken) {
|
||||
@@ -607,7 +608,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: '请提供新的 Session Token' });
|
||||
}
|
||||
|
||||
const row = db.select()
|
||||
const row = await db.select()
|
||||
.from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.accounts.id, accountId))
|
||||
@@ -686,11 +687,11 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
updates.extraConfig = mergeAccountExtraConfig(account.extraConfig, extraConfigPatch);
|
||||
|
||||
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run();
|
||||
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, accountId)).run();
|
||||
|
||||
if (nextApiToken) {
|
||||
try {
|
||||
ensureDefaultTokenForAccount(accountId, nextApiToken, { name: 'default', source: 'sync' });
|
||||
await ensureDefaultTokenForAccount(accountId, nextApiToken, { name: 'default', source: 'sync' });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -699,10 +700,10 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
} catch {}
|
||||
try {
|
||||
await refreshModelsForAccount(accountId);
|
||||
rebuildTokenRoutesFromAvailability();
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
} catch {}
|
||||
|
||||
const latest = db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get();
|
||||
const latest = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get();
|
||||
return {
|
||||
success: true,
|
||||
account: latest,
|
||||
@@ -717,7 +718,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
// Add an account (manual credential input)
|
||||
app.post<{ Body: { siteId: number; username?: string; accessToken: string; apiToken?: string; platformUserId?: number; checkinEnabled?: boolean; credentialMode?: AccountCredentialMode; refreshToken?: string; tokenExpiresAt?: number | string } }>('/api/accounts', async (request, reply) => {
|
||||
const body = request.body;
|
||||
const site = db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get();
|
||||
const site = await db.select().from(schema.sites).where(eq(schema.sites.id, body.siteId)).get();
|
||||
if (!site) {
|
||||
return reply.code(400).send({ success: false, message: 'site not found' });
|
||||
}
|
||||
@@ -821,7 +822,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
const extraConfig = mergeAccountExtraConfig(undefined, extraConfigPatch);
|
||||
|
||||
const result = db.insert(schema.accounts).values({
|
||||
const inserted = await db.insert(schema.accounts).values({
|
||||
siteId: body.siteId,
|
||||
username: username || undefined,
|
||||
accessToken,
|
||||
@@ -829,12 +830,20 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
checkinEnabled: tokenType === 'session' ? (body.checkinEnabled ?? true) : false,
|
||||
extraConfig,
|
||||
isPinned: false,
|
||||
sortOrder: getNextAccountSortOrder(),
|
||||
}).returning().get();
|
||||
sortOrder: await getNextAccountSortOrder(),
|
||||
}).run();
|
||||
const insertedId = Number(inserted.lastInsertRowid || 0);
|
||||
if (insertedId <= 0) {
|
||||
return reply.code(500).send({ success: false, message: '创建账号失败' });
|
||||
}
|
||||
const result = await db.select().from(schema.accounts).where(eq(schema.accounts.id, insertedId)).get();
|
||||
if (!result) {
|
||||
return reply.code(500).send({ success: false, message: '创建账号失败' });
|
||||
}
|
||||
|
||||
if (apiToken) {
|
||||
try {
|
||||
ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' });
|
||||
await ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' });
|
||||
} catch { }
|
||||
}
|
||||
|
||||
@@ -842,7 +851,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
try {
|
||||
const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId);
|
||||
if (syncedTokens.length > 0) {
|
||||
syncTokensFromUpstream(result.id, syncedTokens);
|
||||
await syncTokensFromUpstream(result.id, syncedTokens);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
@@ -853,10 +862,10 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
try {
|
||||
await refreshModelsForAccount(result.id);
|
||||
rebuildTokenRoutesFromAvailability();
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
} catch { }
|
||||
|
||||
const account = db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get();
|
||||
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)
|
||||
@@ -876,7 +885,7 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
app.put<{ Params: { id: string }; Body: any }>('/api/accounts/:id', async (request, reply) => {
|
||||
const id = parseInt(request.params.id);
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const row = db.select()
|
||||
const row = await db.select()
|
||||
.from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.accounts.id, id))
|
||||
@@ -933,28 +942,28 @@ export async function accountsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
|
||||
await db.update(schema.accounts).set(updates).where(eq(schema.accounts.id, id)).run();
|
||||
|
||||
if (typeof updates.apiToken === 'string' && updates.apiToken.trim()) {
|
||||
try {
|
||||
ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' });
|
||||
await ensureDefaultTokenForAccount(id, updates.apiToken, { name: 'default', source: 'manual' });
|
||||
} catch { }
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshModelsForAccount(id);
|
||||
rebuildTokenRoutesFromAvailability();
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
} catch { }
|
||||
|
||||
return db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get();
|
||||
return await db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get();
|
||||
});
|
||||
|
||||
// Delete an account
|
||||
app.delete<{ Params: { id: string } }>('/api/accounts/:id', async (request) => {
|
||||
const id = parseInt(request.params.id);
|
||||
db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
|
||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
|
||||
try {
|
||||
rebuildTokenRoutesFromAvailability();
|
||||
await rebuildTokenRoutesFromAvailability();
|
||||
} catch { }
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -41,20 +41,20 @@ describe('accounts verify-token shield detection', () => {
|
||||
await app.register(routesModule.accountsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
verifyTokenMock.mockReset();
|
||||
undiciFetchMock.mockReset();
|
||||
adapterPlatformName = 'new-api';
|
||||
|
||||
db.delete(schema.proxyLogs).run();
|
||||
db.delete(schema.checkinLogs).run();
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
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 () => {
|
||||
@@ -65,7 +65,7 @@ describe('accounts verify-token shield detection', () => {
|
||||
it('returns rebind hint when verify-token reports invalid access token', async () => {
|
||||
verifyTokenMock.mockRejectedValueOnce(new Error('invalid access token'));
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'AnyRouter',
|
||||
url: 'https://anyrouter.example.com',
|
||||
platform: 'new-api',
|
||||
@@ -96,7 +96,7 @@ describe('accounts verify-token shield detection', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'AnyRouter',
|
||||
url: 'https://anyrouter.example.com',
|
||||
platform: 'new-api',
|
||||
@@ -129,7 +129,7 @@ describe('accounts verify-token shield detection', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'AnyRouter Alias',
|
||||
url: 'https://anyrouter-alias.example.com',
|
||||
platform: 'newapi',
|
||||
@@ -165,7 +165,7 @@ describe('accounts verify-token shield detection', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Legacy Shielded',
|
||||
url: 'https://legacy-shield.example.com',
|
||||
platform: 'one-api',
|
||||
|
||||
@@ -21,18 +21,18 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
// Save to settings table
|
||||
const existing = db.select().from(schema.settings).where(eq(schema.settings.key, 'auth_token')).get();
|
||||
const existing = await db.select().from(schema.settings).where(eq(schema.settings.key, 'auth_token')).get();
|
||||
if (existing) {
|
||||
db.update(schema.settings).set({ value: JSON.stringify(newToken) }).where(eq(schema.settings.key, 'auth_token')).run();
|
||||
await db.update(schema.settings).set({ value: JSON.stringify(newToken) }).where(eq(schema.settings.key, 'auth_token')).run();
|
||||
} else {
|
||||
db.insert(schema.settings).values({ key: 'auth_token', value: JSON.stringify(newToken) }).run();
|
||||
await db.insert(schema.settings).values({ key: 'auth_token', value: JSON.stringify(newToken) }).run();
|
||||
}
|
||||
|
||||
// Update runtime config
|
||||
config.authToken = newToken;
|
||||
|
||||
try {
|
||||
db.insert(schema.events).values({
|
||||
await db.insert(schema.events).values({
|
||||
type: 'token',
|
||||
title: '管理员登录令牌已更新',
|
||||
message: '管理员登录 Token 已被修改,请使用新 Token 登录。',
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function checkinRoutes(app: FastifyInstance) {
|
||||
query = query.where(eq(schema.checkinLogs.accountId, parseInt(request.query.accountId, 10))) as any;
|
||||
}
|
||||
|
||||
const rows = query.all();
|
||||
const rows = await query.all();
|
||||
return rows.map((row: any) => {
|
||||
const source = row?.checkin_logs || row;
|
||||
const failureReason = classifyFailureReason({
|
||||
@@ -144,7 +144,7 @@ export async function checkinRoutes(app: FastifyInstance) {
|
||||
app.put<{ Body: { cron: string } }>('/api/checkin/schedule', async (request) => {
|
||||
try {
|
||||
updateCheckinCron(request.body.cron);
|
||||
db.insert(schema.settings).values({ key: 'checkin_cron', value: JSON.stringify(request.body.cron) })
|
||||
await db.insert(schema.settings).values({ key: 'checkin_cron', value: JSON.stringify(request.body.cron) })
|
||||
.onConflictDoUpdate({ target: schema.settings.key, set: { value: JSON.stringify(request.body.cron) } }).run();
|
||||
return { success: true, cron: request.body.cron };
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||
app.get('/api/downstream-keys', async () => {
|
||||
return {
|
||||
success: true,
|
||||
items: listDownstreamApiKeys(),
|
||||
items: await listDownstreamApiKeys(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const inserted = db.insert(schema.downstreamApiKeys).values({
|
||||
const insertedResult = await db.insert(schema.downstreamApiKeys).values({
|
||||
name: normalized.name,
|
||||
key: normalized.key,
|
||||
description: normalized.description,
|
||||
@@ -81,7 +81,17 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||
siteWeightMultipliers: toPersistenceJson(normalized.siteWeightMultipliers),
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
}).returning().get();
|
||||
}).run();
|
||||
const insertedId = Number(insertedResult.lastInsertRowid || 0);
|
||||
if (insertedId <= 0) {
|
||||
return reply.code(500).send({ success: false, message: '创建失败' });
|
||||
}
|
||||
const inserted = await db.select().from(schema.downstreamApiKeys)
|
||||
.where(eq(schema.downstreamApiKeys.id, insertedId))
|
||||
.get();
|
||||
if (!inserted) {
|
||||
return reply.code(500).send({ success: false, message: '创建失败' });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -115,7 +125,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: 'id 无效' });
|
||||
}
|
||||
|
||||
const existing = db.select().from(schema.downstreamApiKeys)
|
||||
const existing = await db.select().from(schema.downstreamApiKeys)
|
||||
.where(eq(schema.downstreamApiKeys.id, id))
|
||||
.get();
|
||||
|
||||
@@ -154,7 +164,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
try {
|
||||
db.update(schema.downstreamApiKeys).set({
|
||||
await db.update(schema.downstreamApiKeys).set({
|
||||
name: normalized.name,
|
||||
key: normalized.key,
|
||||
description: normalized.description,
|
||||
@@ -192,7 +202,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ success: false, message: 'API key 不存在' });
|
||||
}
|
||||
|
||||
db.update(schema.downstreamApiKeys).set({
|
||||
await db.update(schema.downstreamApiKeys).set({
|
||||
usedCost: 0,
|
||||
usedRequests: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
@@ -215,7 +225,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) {
|
||||
return reply.code(404).send({ success: false, message: 'API key 不存在' });
|
||||
}
|
||||
|
||||
db.delete(schema.downstreamApiKeys)
|
||||
await db.delete(schema.downstreamApiKeys)
|
||||
.where(eq(schema.downstreamApiKeys.id, id))
|
||||
.run();
|
||||
|
||||
|
||||
@@ -10,14 +10,14 @@ export async function eventsRoutes(app: FastifyInstance) {
|
||||
const type = request.query.type;
|
||||
const readQuery = request.query.read;
|
||||
|
||||
const filters = [];
|
||||
const filters: any[] = [];
|
||||
if (type) filters.push(eq(schema.events.type, type));
|
||||
if (readQuery === 'true') filters.push(eq(schema.events.read, true));
|
||||
if (readQuery === 'false') filters.push(eq(schema.events.read, false));
|
||||
|
||||
const base = db.select().from(schema.events);
|
||||
if (filters.length > 0) {
|
||||
return base
|
||||
return await base
|
||||
.where(and(...filters))
|
||||
.orderBy(desc(schema.events.createdAt))
|
||||
.limit(limit)
|
||||
@@ -25,7 +25,7 @@ export async function eventsRoutes(app: FastifyInstance) {
|
||||
.all();
|
||||
}
|
||||
|
||||
return base
|
||||
return await base
|
||||
.orderBy(desc(schema.events.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
@@ -34,7 +34,7 @@ export async function eventsRoutes(app: FastifyInstance) {
|
||||
|
||||
// Unread count
|
||||
app.get('/api/events/count', async () => {
|
||||
const result = db.select({ count: sql<number>`count(*)` }).from(schema.events)
|
||||
const result = await db.select({ count: sql<number>`count(*)` }).from(schema.events)
|
||||
.where(eq(schema.events.read, false)).get();
|
||||
return { count: result?.count || 0 };
|
||||
});
|
||||
@@ -42,19 +42,19 @@ export async function eventsRoutes(app: FastifyInstance) {
|
||||
// Mark one as read
|
||||
app.post<{ Params: { id: string } }>('/api/events/:id/read', async (request) => {
|
||||
const id = parseInt(request.params.id);
|
||||
db.update(schema.events).set({ read: true }).where(eq(schema.events.id, id)).run();
|
||||
await db.update(schema.events).set({ read: true }).where(eq(schema.events.id, id)).run();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Mark all as read
|
||||
app.post('/api/events/read-all', async () => {
|
||||
db.update(schema.events).set({ read: true }).where(eq(schema.events.read, false)).run();
|
||||
await db.update(schema.events).set({ read: true }).where(eq(schema.events.read, false)).run();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Clear all events
|
||||
app.delete('/api/events', async () => {
|
||||
db.delete(schema.events).run();
|
||||
await db.delete(schema.events).run();
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ const MONITOR_AUTH_COOKIE = 'meta_monitor_auth';
|
||||
const LDOH_BASE_URL = 'https://ldoh.105117.xyz';
|
||||
const LDOH_COOKIE_SETTING_KEY = 'monitor_ldoh_cookie';
|
||||
|
||||
function upsertSetting(key: string, value: unknown) {
|
||||
db.insert(schema.settings)
|
||||
async function upsertSetting(key: string, value: unknown) {
|
||||
await db.insert(schema.settings)
|
||||
.values({ key, value: JSON.stringify(value) })
|
||||
.onConflictDoUpdate({
|
||||
target: schema.settings.key,
|
||||
@@ -17,8 +17,8 @@ function upsertSetting(key: string, value: unknown) {
|
||||
.run();
|
||||
}
|
||||
|
||||
function getSettingString(key: string): string {
|
||||
const row = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
|
||||
async function getSettingString(key: string): Promise<string> {
|
||||
const row = await db.select().from(schema.settings).where(eq(schema.settings.key, key)).get();
|
||||
if (!row?.value) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(row.value);
|
||||
@@ -111,7 +111,7 @@ function resolveLdohProxyPath(request: FastifyRequest): string {
|
||||
|
||||
export async function monitorRoutes(app: FastifyInstance) {
|
||||
app.get('/api/monitor/config', async () => {
|
||||
const ldohCookie = getSettingString(LDOH_COOKIE_SETTING_KEY);
|
||||
const ldohCookie = await getSettingString(LDOH_COOKIE_SETTING_KEY);
|
||||
return {
|
||||
ldohCookieConfigured: !!ldohCookie,
|
||||
ldohCookieMasked: ldohCookie ? maskCookieValue(ldohCookie) : '',
|
||||
@@ -121,7 +121,7 @@ export async function monitorRoutes(app: FastifyInstance) {
|
||||
app.put<{ Body: { ldohCookie?: string | null } }>('/api/monitor/config', async (request, reply) => {
|
||||
const raw = String(request.body?.ldohCookie || '').trim();
|
||||
if (!raw) {
|
||||
upsertSetting(LDOH_COOKIE_SETTING_KEY, '');
|
||||
await upsertSetting(LDOH_COOKIE_SETTING_KEY, '');
|
||||
return { success: true, message: 'LDOH Cookie 已清空', ldohCookieConfigured: false };
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ export async function monitorRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: 'Cookie 格式无效,请填写 ld_auth_session 或其值' });
|
||||
}
|
||||
|
||||
upsertSetting(LDOH_COOKIE_SETTING_KEY, normalized);
|
||||
await upsertSetting(LDOH_COOKIE_SETTING_KEY, normalized);
|
||||
return {
|
||||
success: true,
|
||||
message: 'LDOH Cookie 已保存',
|
||||
@@ -151,7 +151,7 @@ export async function monitorRoutes(app: FastifyInstance) {
|
||||
const handleLdohProxy = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!ensureMonitorAuth(request, reply)) return;
|
||||
|
||||
const storedCookie = getSettingString(LDOH_COOKIE_SETTING_KEY);
|
||||
const storedCookie = await getSettingString(LDOH_COOKIE_SETTING_KEY);
|
||||
if (!storedCookie) {
|
||||
return reply.code(400).send('LDOH cookie not configured');
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@ export async function searchRoutes(app: FastifyInstance) {
|
||||
const perCategory = Math.min(Math.ceil(limit / 5), 10);
|
||||
|
||||
// Search sites
|
||||
const sites = db.select().from(schema.sites)
|
||||
const sites = (await db.select().from(schema.sites)
|
||||
.where(like(schema.sites.name, q))
|
||||
.limit(perCategory).all()
|
||||
.limit(perCategory).all())
|
||||
.concat(
|
||||
db.select().from(schema.sites)
|
||||
await db.select().from(schema.sites)
|
||||
.where(like(schema.sites.url, q))
|
||||
.limit(perCategory).all()
|
||||
);
|
||||
@@ -25,28 +25,28 @@ export async function searchRoutes(app: FastifyInstance) {
|
||||
const uniqueSites = [...new Map(sites.map(s => [s.id, s])).values()].slice(0, perCategory);
|
||||
|
||||
// Search accounts (join with sites for site name)
|
||||
const accountResults = db.select().from(schema.accounts)
|
||||
const accountResults = await db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(like(schema.accounts.username, q))
|
||||
.limit(perCategory).all();
|
||||
const accounts = accountResults.map(r => ({ ...r.accounts, site: r.sites }));
|
||||
|
||||
// Search checkin logs (by message)
|
||||
const checkinLogs = db.select().from(schema.checkinLogs)
|
||||
const checkinLogs = (await db.select().from(schema.checkinLogs)
|
||||
.innerJoin(schema.accounts, eq(schema.checkinLogs.accountId, schema.accounts.id))
|
||||
.where(like(schema.checkinLogs.message, q))
|
||||
.orderBy(desc(schema.checkinLogs.createdAt))
|
||||
.limit(perCategory).all()
|
||||
.limit(perCategory).all())
|
||||
.map(r => ({ ...r.checkin_logs, account: r.accounts }));
|
||||
|
||||
// Search proxy logs (by model name)
|
||||
const proxyLogs = db.select().from(schema.proxyLogs)
|
||||
const proxyLogs = await db.select().from(schema.proxyLogs)
|
||||
.where(like(schema.proxyLogs.modelRequested, q))
|
||||
.orderBy(desc(schema.proxyLogs.createdAt))
|
||||
.limit(perCategory).all();
|
||||
|
||||
// Search models (only keep routable items)
|
||||
const modelRows = db.select({
|
||||
const modelRows = await db.select({
|
||||
modelName: schema.tokenModelAvailability.modelName,
|
||||
tokenId: schema.accountTokens.id,
|
||||
accountId: schema.accounts.id,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
type DbModule = typeof import('../../db/index.js');
|
||||
|
||||
describe('settings database migration api', () => {
|
||||
let app: FastifyInstance;
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-db-migration-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../../db/migrate.js');
|
||||
const dbModule = await import('../../db/index.js');
|
||||
const settingsRoutesModule = await import('./settings.js');
|
||||
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
|
||||
app = Fastify();
|
||||
await app.register(settingsRoutesModule.settingsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.routeChannels).run();
|
||||
await db.delete(schema.tokenRoutes).run();
|
||||
await db.delete(schema.accountTokens).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
await db.delete(schema.settings).run();
|
||||
await db.delete(schema.events).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('tests sqlite target connection from settings api', async () => {
|
||||
const targetPath = join(dataDir, 'target-connect.db');
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/database/test-connection',
|
||||
payload: {
|
||||
dialect: 'sqlite',
|
||||
connectionString: targetPath,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as { success?: boolean; dialect?: string };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.dialect).toBe('sqlite');
|
||||
});
|
||||
|
||||
it('migrates current sqlite data to another sqlite file via settings api', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'Target Site',
|
||||
url: 'https://example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
proxyUrl: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).returning().get();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'u1',
|
||||
accessToken: 'token-1',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
checkinEnabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'default',
|
||||
token: 'sk-test',
|
||||
source: 'manual',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).run();
|
||||
|
||||
await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
modelMapping: null,
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).run();
|
||||
|
||||
await db.insert(schema.settings).values({
|
||||
key: 'routing_fallback_unit_cost',
|
||||
value: JSON.stringify(0.25),
|
||||
}).run();
|
||||
|
||||
const targetPath = join(dataDir, 'target-migrate.db');
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/settings/database/migrate',
|
||||
payload: {
|
||||
dialect: 'sqlite',
|
||||
connectionString: targetPath,
|
||||
overwrite: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as { success?: boolean; rows?: Record<string, number> };
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.rows?.sites).toBe(1);
|
||||
expect(body.rows?.accounts).toBe(1);
|
||||
expect(body.rows?.accountTokens).toBe(1);
|
||||
expect(body.rows?.settings).toBe(1);
|
||||
|
||||
const targetDb = new Database(targetPath);
|
||||
try {
|
||||
const targetSites = targetDb.prepare('SELECT COUNT(*) AS cnt FROM sites').get() as { cnt: number };
|
||||
const targetAccounts = targetDb.prepare('SELECT COUNT(*) AS cnt FROM accounts').get() as { cnt: number };
|
||||
const targetSettings = targetDb.prepare('SELECT COUNT(*) AS cnt FROM settings').get() as { cnt: number };
|
||||
|
||||
expect(targetSites.cnt).toBe(1);
|
||||
expect(targetAccounts.cnt).toBe(1);
|
||||
expect(targetSettings.cnt).toBe(1);
|
||||
} finally {
|
||||
targetDb.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,9 +34,9 @@ describe('settings and auth events', () => {
|
||||
await app.register(authRoutesModule.authRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.delete(schema.events).run();
|
||||
db.delete(schema.settings).run();
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.events).run();
|
||||
await db.delete(schema.settings).run();
|
||||
|
||||
config.authToken = 'old-admin-token-123';
|
||||
config.proxyToken = 'sk-old-proxy-token-123';
|
||||
@@ -62,7 +62,7 @@ describe('settings and auth events', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const events = db.select().from(schema.events).all();
|
||||
const events = await db.select().from(schema.events).all();
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
type: 'status',
|
||||
@@ -178,7 +178,7 @@ describe('settings and auth events', () => {
|
||||
expect(updated.routingFallbackUnitCost).toBe(0.25);
|
||||
expect(config.routingFallbackUnitCost).toBe(0.25);
|
||||
|
||||
const saved = db.select().from(schema.settings).where(eq(schema.settings.key, 'routing_fallback_unit_cost')).get();
|
||||
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'routing_fallback_unit_cost')).get();
|
||||
expect(saved).toBeTruthy();
|
||||
expect(saved?.value).toBe(JSON.stringify(0.25));
|
||||
|
||||
@@ -206,7 +206,7 @@ describe('settings and auth events', () => {
|
||||
expect(body.message).toContain('白名单');
|
||||
expect(body.message).toContain('198.51.100.10');
|
||||
|
||||
const saved = db.select().from(schema.settings).where(eq(schema.settings.key, 'admin_ip_allowlist')).get();
|
||||
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'admin_ip_allowlist')).get();
|
||||
expect(saved).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('settings and auth events', () => {
|
||||
const body = response.json() as { adminIpAllowlist?: string[] };
|
||||
expect(body.adminIpAllowlist).toEqual(['198.51.100.10', '198.51.100.11']);
|
||||
|
||||
const saved = db.select().from(schema.settings).where(eq(schema.settings.key, 'admin_ip_allowlist')).get();
|
||||
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'admin_ip_allowlist')).get();
|
||||
expect(saved?.value).toBe(JSON.stringify(['198.51.100.10', '198.51.100.11']));
|
||||
});
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('settings and auth events', () => {
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
const events = db.select().from(schema.events).all();
|
||||
const events = await db.select().from(schema.events).all();
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
type: 'token',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { updateBalanceRefreshCron, updateCheckinCron } from '../../services/chec
|
||||
import { sendNotification } from '../../services/notifyService.js';
|
||||
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
|
||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||
import { migrateCurrentDatabase, testDatabaseConnection } from '../../services/databaseMigrationService.js';
|
||||
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
||||
|
||||
type RoutingWeights = typeof config.routingWeights;
|
||||
@@ -38,6 +39,12 @@ interface RuntimeSettingsBody {
|
||||
routingWeights?: Partial<RoutingWeights>;
|
||||
}
|
||||
|
||||
interface DatabaseMigrationBody {
|
||||
dialect?: unknown;
|
||||
connectionString?: unknown;
|
||||
overwrite?: unknown;
|
||||
}
|
||||
|
||||
const PROXY_TOKEN_PREFIX = 'sk-';
|
||||
|
||||
function isValidProxyToken(value: string): boolean {
|
||||
@@ -50,8 +57,8 @@ function maskSecret(value: string): string {
|
||||
return `${value.slice(0, 4)}****${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
function upsertSetting(key: string, value: unknown) {
|
||||
db.insert(schema.settings)
|
||||
async function upsertSetting(key: string, value: unknown) {
|
||||
await db.insert(schema.settings)
|
||||
.values({ key, value: JSON.stringify(value) })
|
||||
.onConflictDoUpdate({
|
||||
target: schema.settings.key,
|
||||
@@ -60,14 +67,14 @@ function upsertSetting(key: string, value: unknown) {
|
||||
.run();
|
||||
}
|
||||
|
||||
function appendSettingsEvent(input: {
|
||||
async function appendSettingsEvent(input: {
|
||||
type: 'checkin' | 'balance' | 'proxy' | 'status' | 'token';
|
||||
title: string;
|
||||
message: string;
|
||||
level?: 'info' | 'warning' | 'error';
|
||||
}) {
|
||||
try {
|
||||
db.insert(schema.events).values({
|
||||
await db.insert(schema.events).values({
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
@@ -275,7 +282,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||
}
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
app.get('/api/settings/runtime', async (request) => {
|
||||
await app.get('/api/settings/runtime', async (request) => {
|
||||
const currentAdminIp = extractClientIp(request.ip, request.headers['x-forwarded-for']);
|
||||
return getRuntimeSettingsResponse(currentAdminIp);
|
||||
});
|
||||
@@ -595,13 +602,50 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { type?: string } }>('/api/settings/backup/export', async (request, reply) => {
|
||||
app.post<{ Body: DatabaseMigrationBody }>('/api/settings/database/test-connection', async (request, reply) => {
|
||||
try {
|
||||
const result = await testDatabaseConnection(request.body || {});
|
||||
return {
|
||||
success: true,
|
||||
message: '目标数据库连接成功',
|
||||
...result,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: err?.message || '数据库连接失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: DatabaseMigrationBody }>('/api/settings/database/migrate', async (request, reply) => {
|
||||
try {
|
||||
const result = await migrateCurrentDatabase(request.body || {});
|
||||
appendSettingsEvent({
|
||||
type: 'status',
|
||||
title: '数据库迁移已完成',
|
||||
message: `目标 ${result.dialect},已迁移站点 ${result.rows.sites}、账号 ${result.rows.accounts}、令牌 ${result.rows.accountTokens}、路由 ${result.rows.tokenRoutes}、通道 ${result.rows.routeChannels}、设置 ${result.rows.settings}`,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: '数据库迁移完成',
|
||||
...result,
|
||||
};
|
||||
} catch (err: any) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
message: err?.message || '数据库迁移失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await app.get<{ Querystring: { type?: string } }>('/api/settings/backup/export', async (request, reply) => {
|
||||
const rawType = String(request.query.type || 'all').trim().toLowerCase();
|
||||
const type: BackupExportType = rawType === 'accounts' || rawType === 'preferences' ? rawType : 'all';
|
||||
if (rawType && !['all', 'accounts', 'preferences'].includes(rawType)) {
|
||||
return reply.code(400).send({ success: false, message: '导出类型无效,仅支持 all/accounts/preferences' });
|
||||
}
|
||||
return exportBackup(type);
|
||||
return await exportBackup(type);
|
||||
});
|
||||
|
||||
app.post<{ Body: { data?: Record<string, unknown> } }>('/api/settings/backup/import', async (request, reply) => {
|
||||
@@ -611,7 +655,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = importBackup(payload);
|
||||
const result = await importBackup(payload);
|
||||
for (const item of result.appliedSettings) {
|
||||
applyImportedSettingToRuntime(item.key, item.value);
|
||||
}
|
||||
@@ -653,9 +697,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post('/api/settings/maintenance/clear-cache', async (_, reply) => {
|
||||
const deletedModelAvailability = db.delete(schema.modelAvailability).run().changes;
|
||||
const deletedRouteChannels = db.delete(schema.routeChannels).run().changes;
|
||||
const deletedTokenRoutes = db.delete(schema.tokenRoutes).run().changes;
|
||||
const deletedModelAvailability = (await db.delete(schema.modelAvailability).run()).changes;
|
||||
const deletedRouteChannels = (await db.delete(schema.routeChannels).run()).changes;
|
||||
const deletedTokenRoutes = (await db.delete(schema.tokenRoutes).run()).changes;
|
||||
|
||||
const { task, reused } = startBackgroundTask(
|
||||
{
|
||||
@@ -686,9 +730,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
app.post('/api/settings/maintenance/clear-usage', async () => {
|
||||
const deletedProxyLogs = db.delete(schema.proxyLogs).run().changes;
|
||||
const deletedProxyLogs = (await db.delete(schema.proxyLogs).run()).changes;
|
||||
|
||||
db.update(schema.routeChannels).set({
|
||||
await db.update(schema.routeChannels).set({
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
totalLatencyMs: 0,
|
||||
@@ -698,7 +742,7 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
cooldownUntil: null,
|
||||
}).run();
|
||||
|
||||
db.update(schema.accounts).set({
|
||||
await db.update(schema.accounts).set({
|
||||
balanceUsed: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).run();
|
||||
@@ -717,3 +761,4 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ describe('sites proxy url settings', () => {
|
||||
await app.register(routesModule.sitesRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -27,9 +27,9 @@ describe('sites status cascade', () => {
|
||||
await app.register(routesModule.sitesRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -38,13 +38,13 @@ describe('sites status cascade', () => {
|
||||
});
|
||||
|
||||
it('disables and re-enables related accounts with site status', async () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'status-site',
|
||||
url: 'https://status-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'status-user',
|
||||
accessToken: 'access-token',
|
||||
@@ -58,7 +58,7 @@ describe('sites status cascade', () => {
|
||||
});
|
||||
expect(disableResp.statusCode).toBe(200);
|
||||
|
||||
const disabledAccount = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const disabledAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
expect(disabledAccount?.status).toBe('disabled');
|
||||
|
||||
const enableResp = await app.inject({
|
||||
@@ -68,7 +68,7 @@ describe('sites status cascade', () => {
|
||||
});
|
||||
expect(enableResp.statusCode).toBe(200);
|
||||
|
||||
const enabledAccount = db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const enabledAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
expect(enabledAccount?.status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,8 +71,8 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
|
||||
export async function sitesRoutes(app: FastifyInstance) {
|
||||
// List all sites
|
||||
app.get('/api/sites', async () => {
|
||||
const siteRows = db.select().from(schema.sites).all();
|
||||
const accountBalanceRows = db.select({
|
||||
const siteRows = await db.select().from(schema.sites).all();
|
||||
const accountBalanceRows = await db.select({
|
||||
siteId: schema.accounts.siteId,
|
||||
totalBalance: sql<number>`coalesce(sum(${schema.accounts.balance}), 0)`,
|
||||
}).from(schema.accounts)
|
||||
@@ -129,7 +129,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ error: 'Invalid globalWeight value. Expected a positive number.' });
|
||||
}
|
||||
|
||||
const existingSites = db.select().from(schema.sites).all();
|
||||
const existingSites = await db.select().from(schema.sites).all();
|
||||
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
|
||||
|
||||
let detectedPlatform = platform;
|
||||
@@ -140,7 +140,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (!detectedPlatform) {
|
||||
return { error: 'Could not detect platform. Please specify manually.' };
|
||||
}
|
||||
const result = db.insert(schema.sites).values({
|
||||
const inserted = await db.insert(schema.sites).values({
|
||||
name,
|
||||
url: url.replace(/\/+$/, ''),
|
||||
platform: detectedPlatform,
|
||||
@@ -151,7 +151,15 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
isPinned: normalizedPinned ?? false,
|
||||
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
|
||||
globalWeight: normalizedGlobalWeight ?? 1,
|
||||
}).returning().get();
|
||||
}).run();
|
||||
const siteId = Number(inserted.lastInsertRowid || 0);
|
||||
if (siteId <= 0) {
|
||||
return reply.code(500).send({ error: 'Create site failed' });
|
||||
}
|
||||
const result = await db.select().from(schema.sites).where(eq(schema.sites.id, siteId)).get();
|
||||
if (!result) {
|
||||
return reply.code(500).send({ error: 'Create site failed' });
|
||||
}
|
||||
invalidateSiteProxyCache();
|
||||
return result;
|
||||
});
|
||||
@@ -174,7 +182,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ error: 'Invalid site id' });
|
||||
}
|
||||
|
||||
const existingSite = db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
|
||||
const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
|
||||
if (!existingSite) {
|
||||
return reply.code(404).send({ error: 'Site not found' });
|
||||
}
|
||||
@@ -217,19 +225,19 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
|
||||
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
|
||||
await db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run();
|
||||
invalidateSiteProxyCache();
|
||||
|
||||
if (body.status !== undefined && normalizedStatus) {
|
||||
const now = new Date().toISOString();
|
||||
if (normalizedStatus === 'disabled') {
|
||||
db.update(schema.accounts)
|
||||
await db.update(schema.accounts)
|
||||
.set({ status: 'disabled', updatedAt: now })
|
||||
.where(eq(schema.accounts.siteId, id))
|
||||
.run();
|
||||
|
||||
try {
|
||||
db.insert(schema.events).values({
|
||||
await db.insert(schema.events).values({
|
||||
type: 'status',
|
||||
title: '站点已禁用',
|
||||
message: `${existingSite.name} 已禁用,关联账号已全部置为禁用`,
|
||||
@@ -239,13 +247,13 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
}).run();
|
||||
} catch {}
|
||||
} else {
|
||||
db.update(schema.accounts)
|
||||
await db.update(schema.accounts)
|
||||
.set({ status: 'active', updatedAt: now })
|
||||
.where(and(eq(schema.accounts.siteId, id), eq(schema.accounts.status, 'disabled')))
|
||||
.run();
|
||||
|
||||
try {
|
||||
db.insert(schema.events).values({
|
||||
await db.insert(schema.events).values({
|
||||
type: 'status',
|
||||
title: '站点已启用',
|
||||
message: `${existingSite.name} 已启用,关联禁用账号已恢复为活跃`,
|
||||
@@ -257,13 +265,13 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
return db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
|
||||
return await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
|
||||
});
|
||||
|
||||
// Delete a site
|
||||
app.delete<{ Params: { id: string } }>('/api/sites/:id', async (request) => {
|
||||
const id = parseInt(request.params.id);
|
||||
db.delete(schema.sites).where(eq(schema.sites.id, id)).run();
|
||||
await db.delete(schema.sites).where(eq(schema.sites.id, id)).run();
|
||||
invalidateSiteProxyCache();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -28,16 +28,16 @@ describe('/api/models/marketplace', () => {
|
||||
await app.register(routesModule.statsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.delete(schema.proxyLogs).run();
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.checkinLogs).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.proxyLogs).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.checkinLogs).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -46,14 +46,14 @@ describe('/api/models/marketplace', () => {
|
||||
});
|
||||
|
||||
it('returns account-level discovered models even when account has no managed tokens', async () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-no-token',
|
||||
url: 'https://site-no-token.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'alice',
|
||||
accessToken: 'session-token',
|
||||
@@ -61,14 +61,14 @@ describe('/api/models/marketplace', () => {
|
||||
balance: 12.5,
|
||||
}).returning().get();
|
||||
|
||||
db.insert(schema.modelAvailability).values({
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId: account.id,
|
||||
modelName: 'claude-sonnet-4-5-20250929',
|
||||
available: true,
|
||||
latencyMs: 233,
|
||||
}).run();
|
||||
|
||||
const visibleRows = db.select().from(schema.modelAvailability)
|
||||
const visibleRows = await db.select().from(schema.modelAvailability)
|
||||
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(
|
||||
|
||||
@@ -28,16 +28,16 @@ describe('stats dashboard filters disabled sites', () => {
|
||||
await app.register(routesModule.statsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.delete(schema.proxyLogs).run();
|
||||
db.delete(schema.checkinLogs).run();
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
beforeEach(async () => {
|
||||
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 () => {
|
||||
@@ -46,21 +46,21 @@ describe('stats dashboard filters disabled sites', () => {
|
||||
});
|
||||
|
||||
it('excludes disabled-site balances from dashboard totals', async () => {
|
||||
const activeSite = db.insert(schema.sites).values({
|
||||
const activeSite = await db.insert(schema.sites).values({
|
||||
name: 'active-site',
|
||||
url: 'https://active-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const disabledSite = db.insert(schema.sites).values({
|
||||
const disabledSite = await db.insert(schema.sites).values({
|
||||
name: 'disabled-site',
|
||||
url: 'https://disabled-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
db.run(sql`update sites set status = 'disabled' where id = ${disabledSite.id}`);
|
||||
await db.run(sql`update sites set status = 'disabled' where id = ${disabledSite.id}`);
|
||||
|
||||
db.insert(schema.accounts).values({
|
||||
await db.insert(schema.accounts).values({
|
||||
siteId: activeSite.id,
|
||||
username: 'active-user',
|
||||
accessToken: 'active-token',
|
||||
@@ -68,7 +68,7 @@ describe('stats dashboard filters disabled sites', () => {
|
||||
status: 'active',
|
||||
}).run();
|
||||
|
||||
db.insert(schema.accounts).values({
|
||||
await db.insert(schema.accounts).values({
|
||||
siteId: disabledSite.id,
|
||||
username: 'disabled-user',
|
||||
accessToken: 'disabled-token',
|
||||
@@ -95,13 +95,13 @@ describe('stats dashboard filters disabled sites', () => {
|
||||
|
||||
it('treats skipped checkins as successful in dashboard stats', async () => {
|
||||
const today = formatLocalDate(new Date());
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'checkin-site',
|
||||
url: 'https://checkin-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'checkin-user',
|
||||
accessToken: 'token',
|
||||
@@ -109,7 +109,7 @@ describe('stats dashboard filters disabled sites', () => {
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
db.insert(schema.checkinLogs).values([
|
||||
await db.insert(schema.checkinLogs).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
status: 'success',
|
||||
|
||||
@@ -32,16 +32,16 @@ describe('stats dashboard today reward fallback', () => {
|
||||
await app.register(routesModule.statsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.delete(schema.proxyLogs).run();
|
||||
db.delete(schema.checkinLogs).run();
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
beforeEach(async () => {
|
||||
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 () => {
|
||||
@@ -51,12 +51,12 @@ describe('stats dashboard today reward fallback', () => {
|
||||
|
||||
it('uses today income value for dashboard todayReward when reward text is empty', async () => {
|
||||
const today = formatLocalDate(new Date());
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'stats-site',
|
||||
url: 'https://stats-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'stats-user',
|
||||
accessToken: 'token',
|
||||
@@ -71,7 +71,7 @@ describe('stats dashboard today reward fallback', () => {
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
db.insert(schema.checkinLogs).values({
|
||||
await db.insert(schema.checkinLogs).values({
|
||||
accountId: account.id,
|
||||
status: 'success',
|
||||
message: 'checked in',
|
||||
@@ -90,12 +90,12 @@ describe('stats dashboard today reward fallback', () => {
|
||||
});
|
||||
|
||||
it('counts dashboard today spend only inside local-day range', async () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'stats-spend-site',
|
||||
url: 'https://stats-spend.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'stats-spend-user',
|
||||
accessToken: 'token',
|
||||
@@ -109,7 +109,7 @@ describe('stats dashboard today reward fallback', () => {
|
||||
const inRange = formatUtcSqlDateTime(new Date(startDate.getTime() + 60_000));
|
||||
const afterEnd = formatUtcSqlDateTime(new Date(endDate.getTime() + 60_000));
|
||||
|
||||
db.insert(schema.proxyLogs).values([
|
||||
await db.insert(schema.proxyLogs).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
status: 'success',
|
||||
|
||||
@@ -35,18 +35,18 @@ describe('/api/models/token-candidates', () => {
|
||||
await app.register(routesModule.statsRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
fetchModelPricingCatalogMock.mockReset();
|
||||
fetchModelPricingCatalogMock.mockResolvedValue(null);
|
||||
db.delete(schema.proxyLogs).run();
|
||||
db.delete(schema.checkinLogs).run();
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.tokenModelAvailability).run();
|
||||
db.delete(schema.modelAvailability).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
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 () => {
|
||||
@@ -55,21 +55,21 @@ describe('/api/models/token-candidates', () => {
|
||||
});
|
||||
|
||||
it('returns modelsWithoutToken for models available in account but not covered by enabled tokens', async () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-a',
|
||||
url: 'https://site-a.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'alice',
|
||||
accessToken: 'acc-token',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const token = db.insert(schema.accountTokens).values({
|
||||
const token = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'default',
|
||||
token: 'tk-default',
|
||||
@@ -77,7 +77,7 @@ describe('/api/models/token-candidates', () => {
|
||||
isDefault: true,
|
||||
}).returning().get();
|
||||
|
||||
db.insert(schema.modelAvailability).values([
|
||||
await db.insert(schema.modelAvailability).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
modelName: 'claude-haiku-4-5-20251001',
|
||||
@@ -90,7 +90,7 @@ describe('/api/models/token-candidates', () => {
|
||||
},
|
||||
]).run();
|
||||
|
||||
db.insert(schema.tokenModelAvailability).values({
|
||||
await db.insert(schema.tokenModelAvailability).values({
|
||||
tokenId: token.id,
|
||||
modelName: 'claude-haiku-4-5-20251001',
|
||||
available: true,
|
||||
@@ -120,21 +120,21 @@ describe('/api/models/token-candidates', () => {
|
||||
});
|
||||
|
||||
it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-b',
|
||||
url: 'https://site-b.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'bob',
|
||||
accessToken: 'acc-token-b',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const defaultToken = db.insert(schema.accountTokens).values({
|
||||
const defaultToken = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'default-token',
|
||||
token: 'sk-default',
|
||||
@@ -143,13 +143,13 @@ describe('/api/models/token-candidates', () => {
|
||||
isDefault: true,
|
||||
}).returning().get();
|
||||
|
||||
db.insert(schema.modelAvailability).values({
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId: account.id,
|
||||
modelName: 'claude-opus-4-6',
|
||||
available: true,
|
||||
}).run();
|
||||
|
||||
db.insert(schema.tokenModelAvailability).values({
|
||||
await db.insert(schema.tokenModelAvailability).values({
|
||||
tokenId: defaultToken.id,
|
||||
modelName: 'claude-opus-4-6',
|
||||
available: true,
|
||||
@@ -202,21 +202,21 @@ describe('/api/models/token-candidates', () => {
|
||||
});
|
||||
|
||||
it('infers default group from token name when token_group is empty', async () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-c',
|
||||
url: 'https://site-c.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'charlie',
|
||||
accessToken: 'acc-token-c',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const token = db.insert(schema.accountTokens).values({
|
||||
const token = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'default',
|
||||
token: 'sk-default-c',
|
||||
@@ -225,13 +225,13 @@ describe('/api/models/token-candidates', () => {
|
||||
isDefault: true,
|
||||
}).returning().get();
|
||||
|
||||
db.insert(schema.modelAvailability).values({
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId: account.id,
|
||||
modelName: 'claude-sonnet-4-5-20250929',
|
||||
available: true,
|
||||
}).run();
|
||||
|
||||
db.insert(schema.tokenModelAvailability).values({
|
||||
await db.insert(schema.tokenModelAvailability).values({
|
||||
tokenId: token.id,
|
||||
modelName: 'claude-sonnet-4-5-20250929',
|
||||
available: true,
|
||||
@@ -283,21 +283,21 @@ describe('/api/models/token-candidates', () => {
|
||||
});
|
||||
|
||||
it('marks coverage as uncertain when token group cannot be inferred', async () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'site-d',
|
||||
url: 'https://site-d.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'david',
|
||||
accessToken: 'acc-token-d',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const token = db.insert(schema.accountTokens).values({
|
||||
const token = await db.insert(schema.accountTokens).values({
|
||||
accountId: account.id,
|
||||
name: 'token-1',
|
||||
token: 'sk-token-1',
|
||||
@@ -306,13 +306,13 @@ describe('/api/models/token-candidates', () => {
|
||||
isDefault: true,
|
||||
}).returning().get();
|
||||
|
||||
db.insert(schema.modelAvailability).values({
|
||||
await db.insert(schema.modelAvailability).values({
|
||||
accountId: account.id,
|
||||
modelName: 'claude-opus-4-5-20251101',
|
||||
available: true,
|
||||
}).run();
|
||||
|
||||
db.insert(schema.tokenModelAvailability).values({
|
||||
await db.insert(schema.tokenModelAvailability).values({
|
||||
tokenId: token.id,
|
||||
modelName: 'claude-opus-4-5-20251101',
|
||||
available: true,
|
||||
|
||||
@@ -71,7 +71,7 @@ function proxyCostSqlExpression() {
|
||||
export async function statsRoutes(app: FastifyInstance) {
|
||||
// Dashboard summary
|
||||
app.get('/api/stats/dashboard', async () => {
|
||||
const accountRows = db.select().from(schema.accounts)
|
||||
const accountRows = await db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.sites.status, 'active'))
|
||||
.all();
|
||||
@@ -80,7 +80,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
const activeCount = accounts.filter((a) => a.status === 'active').length;
|
||||
|
||||
const { localDay: today, startUtc: todayStartUtc, endUtc: todayEndUtc } = getLocalDayRangeUtc();
|
||||
const todayCheckinRows = db.select().from(schema.checkinLogs)
|
||||
const todayCheckinRows = await db.select().from(schema.checkinLogs)
|
||||
.innerJoin(schema.accounts, eq(schema.checkinLogs.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(and(
|
||||
@@ -109,13 +109,13 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
const nowTs = Date.now();
|
||||
const last24hDate = formatUtcSqlDateTime(new Date(nowTs - 86400000));
|
||||
const last7dDate = getLocalRangeStartUtc(7);
|
||||
const recentProxyLogs = db.select().from(schema.proxyLogs)
|
||||
const recentProxyLogs = (await db.select().from(schema.proxyLogs)
|
||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(and(gte(schema.proxyLogs.createdAt, last7dDate), eq(schema.sites.status, 'active')))
|
||||
.all()
|
||||
.all())
|
||||
.map((row) => row.proxy_logs);
|
||||
const totalUsedRow = db.select({
|
||||
const totalUsedRow = await db.select({
|
||||
totalUsed: sql<number>`coalesce(sum(${proxyCostSqlExpression()}), 0)`,
|
||||
})
|
||||
.from(schema.proxyLogs)
|
||||
@@ -123,7 +123,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(eq(schema.sites.status, 'active'))
|
||||
.get();
|
||||
const proxy24hRow = db.select({
|
||||
const proxy24hRow = await db.select({
|
||||
total: sql<number>`count(*)`,
|
||||
success: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
|
||||
failed: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'failed' then 1 else 0 end), 0)`,
|
||||
@@ -134,7 +134,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(and(gte(schema.proxyLogs.createdAt, last24hDate), eq(schema.sites.status, 'active')))
|
||||
.get();
|
||||
const todaySpendRow = db.select({
|
||||
const todaySpendRow = await db.select({
|
||||
todaySpend: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
|
||||
})
|
||||
.from(schema.proxyLogs)
|
||||
@@ -179,7 +179,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
app.get<{ Querystring: { limit?: string; offset?: string } }>('/api/stats/proxy-logs', async (request) => {
|
||||
const limit = parseInt(request.query.limit || '50', 10);
|
||||
const offset = parseInt(request.query.offset || '0', 10);
|
||||
const rows = db.select().from(schema.proxyLogs)
|
||||
const rows = await db.select().from(schema.proxyLogs)
|
||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.orderBy(desc(schema.proxyLogs.createdAt))
|
||||
@@ -244,12 +244,12 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
const availability = db.select().from(schema.tokenModelAvailability)
|
||||
const availability = await db.select().from(schema.tokenModelAvailability)
|
||||
.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))
|
||||
.all();
|
||||
const accountAvailability = db.select().from(schema.modelAvailability)
|
||||
const accountAvailability = await db.select().from(schema.modelAvailability)
|
||||
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(
|
||||
@@ -262,7 +262,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
.all();
|
||||
|
||||
const last7d = getLocalRangeStartUtc(7);
|
||||
const recentLogs = db.select().from(schema.proxyLogs)
|
||||
const recentLogs = await db.select().from(schema.proxyLogs)
|
||||
.where(gte(schema.proxyLogs.createdAt, last7d))
|
||||
.all();
|
||||
|
||||
@@ -299,7 +299,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
|
||||
const modelMetadataMap = new Map<string, ModelMetadataAggregate>();
|
||||
if (includePricing) {
|
||||
const activeAccountRows = db.select().from(schema.accounts)
|
||||
const activeAccountRows = await db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(and(eq(schema.accounts.status, 'active'), eq(schema.sites.status, 'active')))
|
||||
.all();
|
||||
@@ -506,7 +506,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
return name;
|
||||
};
|
||||
|
||||
const rows = db.select().from(schema.tokenModelAvailability)
|
||||
const rows = await db.select().from(schema.tokenModelAvailability)
|
||||
.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))
|
||||
@@ -519,7 +519,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
),
|
||||
)
|
||||
.all();
|
||||
const availableModelRows = db.select({
|
||||
const availableModelRows = await db.select({
|
||||
modelName: schema.modelAvailability.modelName,
|
||||
accountId: schema.accounts.id,
|
||||
username: schema.accounts.username,
|
||||
@@ -621,7 +621,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
const hasPotentialGroupHints = hasAnyTokenGroupSignals || unknownGroupCoverageByAccountModel.size > 0;
|
||||
|
||||
if (hasPotentialGroupHints && accountIdsForGroupHints.size > 0) {
|
||||
const accountRows = db.select().from(schema.accounts)
|
||||
const accountRows = await db.select().from(schema.accounts)
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(
|
||||
and(
|
||||
@@ -749,7 +749,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
|
||||
// Site distribution – per-site aggregate data
|
||||
app.get('/api/stats/site-distribution', async () => {
|
||||
const accountRows = db.select({
|
||||
const accountRows = await db.select({
|
||||
siteId: schema.sites.id,
|
||||
siteName: schema.sites.name,
|
||||
platform: schema.sites.platform,
|
||||
@@ -762,7 +762,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
.groupBy(schema.sites.id, schema.sites.name, schema.sites.platform)
|
||||
.all();
|
||||
|
||||
const spendRows = db.select({
|
||||
const spendRows = await db.select({
|
||||
siteId: schema.sites.id,
|
||||
totalSpend: sql<number>`coalesce(sum(${proxyCostSqlExpression()}), 0)`,
|
||||
})
|
||||
@@ -796,7 +796,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
const days = Math.max(1, parseInt(request.query.days || '7', 10));
|
||||
const sinceDate = getLocalRangeStartUtc(days);
|
||||
|
||||
const rows = db.select().from(schema.proxyLogs)
|
||||
const rows = await db.select().from(schema.proxyLogs)
|
||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(and(gte(schema.proxyLogs.createdAt, sinceDate), eq(schema.sites.status, 'active')))
|
||||
@@ -847,12 +847,12 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
// Get account IDs belonging to the site (if filtered)
|
||||
let accountIds: Set<number> | null = null;
|
||||
if (siteId != null && !Number.isNaN(siteId)) {
|
||||
const siteAccounts = db.select().from(schema.accounts)
|
||||
const siteAccounts = await db.select().from(schema.accounts)
|
||||
.where(eq(schema.accounts.siteId, siteId)).all();
|
||||
accountIds = new Set(siteAccounts.map((a) => a.id));
|
||||
}
|
||||
|
||||
const rows = db.select().from(schema.proxyLogs)
|
||||
const rows = await db.select().from(schema.proxyLogs)
|
||||
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
.where(and(gte(schema.proxyLogs.createdAt, sinceDate), eq(schema.sites.status, 'active')))
|
||||
|
||||
@@ -19,24 +19,24 @@ describe('PUT /api/channels/batch', () => {
|
||||
return seedId;
|
||||
};
|
||||
|
||||
const seedChannel = (options: { priority: number; weight: number; manualOverride?: boolean }) => {
|
||||
const seedChannel = async (options: { priority: number; weight: number; manualOverride?: boolean }) => {
|
||||
const id = nextId();
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: `site-${id}`,
|
||||
url: `https://example.com/${id}`,
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
accessToken: `access-token-${id}`,
|
||||
apiToken: `api-token-${id}`,
|
||||
}).returning().get();
|
||||
const route = db.insert(schema.tokenRoutes).values({
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: `gpt-4o-${id}`,
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
return db.insert(schema.routeChannels).values({
|
||||
return await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
priority: options.priority,
|
||||
@@ -59,12 +59,12 @@ describe('PUT /api/channels/batch', () => {
|
||||
await app.register(routesModule.tokensRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.accountTokens).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.routeChannels).run();
|
||||
await db.delete(schema.accountTokens).run();
|
||||
await db.delete(schema.tokenRoutes).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -112,8 +112,8 @@ describe('PUT /api/channels/batch', () => {
|
||||
});
|
||||
|
||||
it('updates priorities in batch, sets manualOverride, and keeps weight unchanged', async () => {
|
||||
const channelA = seedChannel({ priority: 9, weight: 17, manualOverride: false });
|
||||
const channelB = seedChannel({ priority: 8, weight: 23, manualOverride: false });
|
||||
const channelA = await seedChannel({ priority: 9, weight: 17, manualOverride: false });
|
||||
const channelB = await seedChannel({ priority: 8, weight: 23, manualOverride: false });
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
@@ -145,8 +145,8 @@ describe('PUT /api/channels/batch', () => {
|
||||
expect(returnedA?.manualOverride).toBe(true);
|
||||
expect(returnedB?.manualOverride).toBe(true);
|
||||
|
||||
const dbA = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelA.id)).get();
|
||||
const dbB = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelB.id)).get();
|
||||
const dbA = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelA.id)).get();
|
||||
const dbB = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelB.id)).get();
|
||||
expect(dbA?.priority).toBe(3);
|
||||
expect(dbB?.priority).toBe(0);
|
||||
expect(dbA?.weight).toBe(17);
|
||||
|
||||
@@ -20,15 +20,15 @@ describe('POST /api/routes/decision/batch', () => {
|
||||
return seedId;
|
||||
};
|
||||
|
||||
const seedRoutableChannel = () => {
|
||||
const seedRoutableChannel = async () => {
|
||||
const id = nextId();
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: `site-${id}`,
|
||||
url: `https://site-${id}.example.com`,
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: `user-${id}`,
|
||||
accessToken: `access-token-${id}`,
|
||||
@@ -36,12 +36,12 @@ describe('POST /api/routes/decision/batch', () => {
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const route = db.insert(schema.tokenRoutes).values({
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'gpt-4o-mini',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
db.insert(schema.routeChannels).values({
|
||||
await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
@@ -67,12 +67,12 @@ describe('POST /api/routes/decision/batch', () => {
|
||||
await app.register(routesModule.tokensRoutes);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
seedId = 0;
|
||||
db.delete(schema.routeChannels).run();
|
||||
db.delete(schema.tokenRoutes).run();
|
||||
db.delete(schema.accounts).run();
|
||||
db.delete(schema.sites).run();
|
||||
await db.delete(schema.routeChannels).run();
|
||||
await db.delete(schema.tokenRoutes).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.sites).run();
|
||||
invalidateTokenRouterCache();
|
||||
});
|
||||
|
||||
@@ -107,13 +107,13 @@ describe('POST /api/routes/decision/batch', () => {
|
||||
});
|
||||
|
||||
it('returns decisions scoped by route id to avoid wildcard channel mismatch', async () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'wildcard-site',
|
||||
url: 'https://wildcard-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'wildcard-user',
|
||||
accessToken: 'wildcard-access',
|
||||
@@ -121,12 +121,12 @@ describe('POST /api/routes/decision/batch', () => {
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const exactRoute = db.insert(schema.tokenRoutes).values({
|
||||
const exactRoute = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 'claude-opus-4-6',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
db.insert(schema.routeChannels).values({
|
||||
await db.insert(schema.routeChannels).values({
|
||||
routeId: exactRoute.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
@@ -136,12 +136,12 @@ describe('POST /api/routes/decision/batch', () => {
|
||||
enabled: true,
|
||||
}).run();
|
||||
|
||||
const wildcardRoute = db.insert(schema.tokenRoutes).values({
|
||||
const wildcardRoute = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const wildcardChannel = db.insert(schema.routeChannels).values({
|
||||
const wildcardChannel = await db.insert(schema.routeChannels).values({
|
||||
routeId: wildcardRoute.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
@@ -173,13 +173,13 @@ describe('POST /api/routes/decision/batch', () => {
|
||||
});
|
||||
|
||||
it('returns route-wide wildcard probabilities normalized to 100 across all channels', async () => {
|
||||
const site = db.insert(schema.sites).values({
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'route-wide-site',
|
||||
url: 'https://route-wide-site.example.com',
|
||||
platform: 'new-api',
|
||||
}).returning().get();
|
||||
|
||||
const account = db.insert(schema.accounts).values({
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'route-wide-user',
|
||||
accessToken: 'route-wide-access',
|
||||
@@ -187,12 +187,12 @@ describe('POST /api/routes/decision/batch', () => {
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const route = db.insert(schema.tokenRoutes).values({
|
||||
const route = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const channelA = db.insert(schema.routeChannels).values({
|
||||
const channelA = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
@@ -202,7 +202,7 @@ describe('POST /api/routes/decision/batch', () => {
|
||||
enabled: true,
|
||||
}).returning().get();
|
||||
|
||||
const channelB = db.insert(schema.routeChannels).values({
|
||||
const channelB = await db.insert(schema.routeChannels).values({
|
||||
routeId: route.id,
|
||||
accountId: account.id,
|
||||
tokenId: null,
|
||||
|
||||
@@ -12,8 +12,8 @@ function isExactModelPattern(modelPattern: string): boolean {
|
||||
return !/[\*\?\[]/.test(normalized);
|
||||
}
|
||||
|
||||
function getDefaultTokenId(accountId: number): number | null {
|
||||
const token = db.select().from(schema.accountTokens)
|
||||
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;
|
||||
@@ -35,8 +35,8 @@ function isModelAliasEquivalent(left: string, right: string): boolean {
|
||||
return !!a && !!b && a === b;
|
||||
}
|
||||
|
||||
function tokenSupportsModel(tokenId: number, modelName: string): boolean {
|
||||
const rows = db.select().from(schema.tokenModelAvailability)
|
||||
async function tokenSupportsModel(tokenId: number, modelName: string): Promise<boolean> {
|
||||
const rows = await db.select().from(schema.tokenModelAvailability)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.tokenModelAvailability.tokenId, tokenId),
|
||||
@@ -51,15 +51,15 @@ function tokenSupportsModel(tokenId: number, modelName: string): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
function checkTokenBelongsToAccount(tokenId: number, accountId: number): boolean {
|
||||
const row = db.select().from(schema.accountTokens)
|
||||
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;
|
||||
}
|
||||
|
||||
function getPatternTokenCandidates(modelPattern: string): Array<{ tokenId: number; accountId: number; sourceModel: string }> {
|
||||
const rows = db.select().from(schema.tokenModelAvailability)
|
||||
async function getPatternTokenCandidates(modelPattern: string): Promise<Array<{ tokenId: number; accountId: number; sourceModel: string }>> {
|
||||
const rows = await db.select().from(schema.tokenModelAvailability)
|
||||
.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))
|
||||
@@ -88,7 +88,7 @@ function getPatternTokenCandidates(modelPattern: string): Array<{ tokenId: numbe
|
||||
return result;
|
||||
}
|
||||
|
||||
function getMatchedExactRouteChannelCandidates(modelPattern: string): Array<{
|
||||
async function getMatchedExactRouteChannelCandidates(modelPattern: string): Promise<Array<{
|
||||
tokenId: number | null;
|
||||
accountId: number;
|
||||
sourceModel: string;
|
||||
@@ -96,17 +96,17 @@ function getMatchedExactRouteChannelCandidates(modelPattern: string): Array<{
|
||||
weight: number;
|
||||
enabled: boolean;
|
||||
manualOverride: boolean;
|
||||
}> {
|
||||
const matchedRoutes = db.select().from(schema.tokenRoutes)
|
||||
}>> {
|
||||
const matchedRoutes = (await db.select().from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.enabled, true))
|
||||
.all()
|
||||
.all())
|
||||
.filter((route) => isExactModelPattern(route.modelPattern) && matchesModelPattern(route.modelPattern, modelPattern));
|
||||
|
||||
if (matchedRoutes.length === 0) return [];
|
||||
const routeMap = new Map<number, typeof matchedRoutes[number]>();
|
||||
for (const route of matchedRoutes) routeMap.set(route.id, route);
|
||||
|
||||
const channels = db.select().from(schema.routeChannels)
|
||||
const channels = await db.select().from(schema.routeChannels)
|
||||
.where(inArray(schema.routeChannels.routeId, matchedRoutes.map((route) => route.id)))
|
||||
.all();
|
||||
|
||||
@@ -121,9 +121,9 @@ function getMatchedExactRouteChannelCandidates(modelPattern: string): Array<{
|
||||
})).filter((candidate) => candidate.sourceModel.length > 0);
|
||||
}
|
||||
|
||||
function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): number {
|
||||
const routeCandidates = getMatchedExactRouteChannelCandidates(modelPattern);
|
||||
const availabilityCandidates = getPatternTokenCandidates(modelPattern).map((candidate) => ({
|
||||
async function populateRouteChannelsByModelPattern(routeId: number, modelPattern: string): Promise<number> {
|
||||
const routeCandidates = await getMatchedExactRouteChannelCandidates(modelPattern);
|
||||
const availabilityCandidates = (await getPatternTokenCandidates(modelPattern)).map((candidate) => ({
|
||||
tokenId: candidate.tokenId,
|
||||
accountId: candidate.accountId,
|
||||
sourceModel: candidate.sourceModel,
|
||||
@@ -135,7 +135,7 @@ function populateRouteChannelsByModelPattern(routeId: number, modelPattern: stri
|
||||
const candidates = [...routeCandidates, ...availabilityCandidates];
|
||||
if (candidates.length === 0) return 0;
|
||||
|
||||
const existingChannels = db.select().from(schema.routeChannels)
|
||||
const existingChannels = await db.select().from(schema.routeChannels)
|
||||
.where(eq(schema.routeChannels.routeId, routeId))
|
||||
.all();
|
||||
const existingPairs = new Set<string>(
|
||||
@@ -152,7 +152,7 @@ function populateRouteChannelsByModelPattern(routeId: number, modelPattern: stri
|
||||
const tokenId = typeof candidate.tokenId === 'number' && Number.isFinite(candidate.tokenId) ? candidate.tokenId : 0;
|
||||
const pairKey = `${candidate.accountId}::${tokenId}::${candidate.sourceModel.trim().toLowerCase()}`;
|
||||
if (existingPairs.has(pairKey)) continue;
|
||||
db.insert(schema.routeChannels).values({
|
||||
await db.insert(schema.routeChannels).values({
|
||||
routeId,
|
||||
accountId: candidate.accountId,
|
||||
tokenId: candidate.tokenId,
|
||||
@@ -328,11 +328,11 @@ function parseBatchRouteWideDecisionRouteIds(
|
||||
export async function tokensRoutes(app: FastifyInstance) {
|
||||
// List all routes
|
||||
app.get('/api/routes', async () => {
|
||||
const routes = db.select().from(schema.tokenRoutes).all();
|
||||
const routes = await db.select().from(schema.tokenRoutes).all();
|
||||
if (routes.length === 0) return [];
|
||||
|
||||
const routeIds = routes.map((route) => route.id);
|
||||
const channelRows = db.select().from(schema.routeChannels)
|
||||
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))
|
||||
@@ -372,7 +372,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: 'model 不能为空' });
|
||||
}
|
||||
|
||||
const decision = tokenRouter.explainSelection(model);
|
||||
const decision = await tokenRouter.explainSelection(model);
|
||||
return { success: true, decision };
|
||||
});
|
||||
|
||||
@@ -382,10 +382,10 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: parsed.message });
|
||||
}
|
||||
|
||||
const decisions: Record<string, ReturnType<typeof tokenRouter.explainSelection>> = {};
|
||||
for (const model of parsed.models) {
|
||||
decisions[model] = tokenRouter.explainSelection(model);
|
||||
}
|
||||
const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelection>>> = {};
|
||||
for (const model of parsed.models) {
|
||||
decisions[model] = await tokenRouter.explainSelection(model);
|
||||
}
|
||||
|
||||
return { success: true, decisions };
|
||||
});
|
||||
@@ -396,12 +396,12 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: parsed.message });
|
||||
}
|
||||
|
||||
const decisions: Record<string, Record<string, ReturnType<typeof tokenRouter.explainSelection>>> = {};
|
||||
for (const item of parsed.items) {
|
||||
const routeKey = String(item.routeId);
|
||||
if (!decisions[routeKey]) decisions[routeKey] = {};
|
||||
decisions[routeKey][item.model] = tokenRouter.explainSelectionForRoute(item.routeId, item.model);
|
||||
}
|
||||
const decisions: Record<string, Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionForRoute>>>> = {};
|
||||
for (const item of parsed.items) {
|
||||
const routeKey = String(item.routeId);
|
||||
if (!decisions[routeKey]) decisions[routeKey] = {};
|
||||
decisions[routeKey][item.model] = await tokenRouter.explainSelectionForRoute(item.routeId, item.model);
|
||||
}
|
||||
|
||||
return { success: true, decisions };
|
||||
});
|
||||
@@ -412,10 +412,10 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: parsed.message });
|
||||
}
|
||||
|
||||
const decisions: Record<string, ReturnType<typeof tokenRouter.explainSelection>> = {};
|
||||
for (const routeId of parsed.routeIds) {
|
||||
decisions[String(routeId)] = tokenRouter.explainSelectionRouteWide(routeId);
|
||||
}
|
||||
const decisions: Record<string, Awaited<ReturnType<typeof tokenRouter.explainSelectionRouteWide>>> = {};
|
||||
for (const routeId of parsed.routeIds) {
|
||||
decisions[String(routeId)] = await tokenRouter.explainSelectionRouteWide(routeId);
|
||||
}
|
||||
|
||||
return { success: true, decisions };
|
||||
});
|
||||
@@ -423,15 +423,23 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
// Create a route
|
||||
app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; enabled?: boolean } }>('/api/routes', async (request) => {
|
||||
const body = request.body;
|
||||
const route = db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: body.modelPattern,
|
||||
displayName: body.displayName,
|
||||
displayIcon: body.displayIcon,
|
||||
modelMapping: body.modelMapping,
|
||||
enabled: body.enabled ?? true,
|
||||
}).returning().get();
|
||||
const insertedRoute = await db.insert(schema.tokenRoutes).values({
|
||||
modelPattern: body.modelPattern,
|
||||
displayName: body.displayName,
|
||||
displayIcon: body.displayIcon,
|
||||
modelMapping: body.modelMapping,
|
||||
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: '创建路由失败' };
|
||||
}
|
||||
|
||||
populateRouteChannelsByModelPattern(route.id, body.modelPattern);
|
||||
await populateRouteChannelsByModelPattern(route.id, body.modelPattern);
|
||||
invalidateTokenRouterCache();
|
||||
return route;
|
||||
});
|
||||
@@ -449,15 +457,15 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
if (body.enabled !== undefined) updates.enabled = body.enabled;
|
||||
updates.updatedAt = new Date().toISOString();
|
||||
|
||||
db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
|
||||
await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run();
|
||||
invalidateTokenRouterCache();
|
||||
return db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
|
||||
return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get();
|
||||
});
|
||||
|
||||
// Delete a route
|
||||
app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => {
|
||||
const id = parseInt(request.params.id, 10);
|
||||
db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
|
||||
await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run();
|
||||
invalidateTokenRouterCache();
|
||||
return { success: true };
|
||||
});
|
||||
@@ -467,7 +475,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
const routeId = parseInt(request.params.id, 10);
|
||||
const body = request.body;
|
||||
|
||||
const route = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get();
|
||||
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: '路由不存在' });
|
||||
}
|
||||
@@ -475,19 +483,19 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
const sourceModel = typeof body.sourceModel === 'string'
|
||||
? body.sourceModel.trim()
|
||||
: (isExactModelPattern(route.modelPattern) ? route.modelPattern.trim() : '');
|
||||
const effectiveTokenId = body.tokenId ?? getDefaultTokenId(body.accountId);
|
||||
const effectiveTokenId = body.tokenId ?? await getDefaultTokenId(body.accountId);
|
||||
|
||||
if (body.tokenId && !checkTokenBelongsToAccount(body.tokenId, body.accountId)) {
|
||||
if (body.tokenId && !await checkTokenBelongsToAccount(body.tokenId, body.accountId)) {
|
||||
return reply.code(400).send({ success: false, message: '令牌不存在或不属于当前账号' });
|
||||
}
|
||||
|
||||
if (isExactModelPattern(route.modelPattern) && effectiveTokenId && !tokenSupportsModel(effectiveTokenId, route.modelPattern)) {
|
||||
if (isExactModelPattern(route.modelPattern) && effectiveTokenId && !await tokenSupportsModel(effectiveTokenId, route.modelPattern)) {
|
||||
return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' });
|
||||
}
|
||||
|
||||
const duplicate = db.select().from(schema.routeChannels)
|
||||
const duplicate = (await db.select().from(schema.routeChannels)
|
||||
.where(eq(schema.routeChannels.routeId, routeId))
|
||||
.all()
|
||||
.all())
|
||||
.some((channel) =>
|
||||
channel.accountId === body.accountId
|
||||
&& (channel.tokenId ?? null) === (body.tokenId ?? null)
|
||||
@@ -497,14 +505,22 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ success: false, message: '该来源模型的通道已存在' });
|
||||
}
|
||||
|
||||
const created = db.insert(schema.routeChannels).values({
|
||||
routeId,
|
||||
accountId: body.accountId,
|
||||
tokenId: body.tokenId,
|
||||
sourceModel: sourceModel || null,
|
||||
priority: body.priority ?? 0,
|
||||
weight: body.weight ?? 10,
|
||||
}).returning().get();
|
||||
const insertedChannel = await db.insert(schema.routeChannels).values({
|
||||
routeId,
|
||||
accountId: body.accountId,
|
||||
tokenId: body.tokenId,
|
||||
sourceModel: sourceModel || null,
|
||||
priority: body.priority ?? 0,
|
||||
weight: body.weight ?? 10,
|
||||
}).run();
|
||||
const channelId = Number(insertedChannel.lastInsertRowid || 0);
|
||||
if (channelId <= 0) {
|
||||
return reply.code(500).send({ success: false, message: '创建通道失败' });
|
||||
}
|
||||
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: '创建通道失败' });
|
||||
}
|
||||
invalidateTokenRouterCache();
|
||||
return created;
|
||||
});
|
||||
@@ -517,7 +533,7 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const channelIds = Array.from(new Set(parsed.updates.map((update) => update.id)));
|
||||
const existingChannels = db.select().from(schema.routeChannels)
|
||||
const existingChannels = await db.select().from(schema.routeChannels)
|
||||
.where(inArray(schema.routeChannels.id, channelIds))
|
||||
.all();
|
||||
if (existingChannels.length !== channelIds.length) {
|
||||
@@ -527,13 +543,13 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
for (const update of parsed.updates) {
|
||||
db.update(schema.routeChannels).set({
|
||||
await db.update(schema.routeChannels).set({
|
||||
priority: update.priority,
|
||||
manualOverride: true,
|
||||
}).where(eq(schema.routeChannels.id, update.id)).run();
|
||||
}
|
||||
|
||||
const updatedChannels = db.select().from(schema.routeChannels)
|
||||
const updatedChannels = await db.select().from(schema.routeChannels)
|
||||
.where(inArray(schema.routeChannels.id, channelIds))
|
||||
.all();
|
||||
invalidateTokenRouterCache();
|
||||
@@ -545,28 +561,28 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
const channelId = parseInt(request.params.channelId, 10);
|
||||
const body = request.body as Record<string, unknown>;
|
||||
|
||||
const channel = db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
||||
const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
||||
if (!channel) {
|
||||
return reply.code(404).send({ success: false, message: '通道不存在' });
|
||||
}
|
||||
|
||||
const route = db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, channel.routeId)).get();
|
||||
const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, channel.routeId)).get();
|
||||
if (!route) {
|
||||
return reply.code(404).send({ success: false, message: '路由不存在' });
|
||||
}
|
||||
|
||||
if (body.tokenId !== undefined && body.tokenId !== null) {
|
||||
const tokenId = Number(body.tokenId);
|
||||
if (!Number.isFinite(tokenId) || !checkTokenBelongsToAccount(tokenId, channel.accountId)) {
|
||||
if (!Number.isFinite(tokenId) || !await checkTokenBelongsToAccount(tokenId, channel.accountId)) {
|
||||
return reply.code(400).send({ success: false, message: '令牌不存在或不属于通道账号' });
|
||||
}
|
||||
}
|
||||
|
||||
const nextTokenId = body.tokenId === undefined
|
||||
? (channel.tokenId ?? getDefaultTokenId(channel.accountId))
|
||||
: (body.tokenId === null ? getDefaultTokenId(channel.accountId) : Number(body.tokenId));
|
||||
const nextTokenId = body.tokenId === undefined
|
||||
? (channel.tokenId ?? await getDefaultTokenId(channel.accountId))
|
||||
: (body.tokenId === null ? await getDefaultTokenId(channel.accountId) : Number(body.tokenId));
|
||||
|
||||
if (isExactModelPattern(route.modelPattern) && nextTokenId && !tokenSupportsModel(nextTokenId, route.modelPattern)) {
|
||||
if (isExactModelPattern(route.modelPattern) && nextTokenId && !await tokenSupportsModel(nextTokenId, route.modelPattern)) {
|
||||
return reply.code(400).send({ success: false, message: '该令牌不支持当前模型' });
|
||||
}
|
||||
|
||||
@@ -580,15 +596,15 @@ export async function tokensRoutes(app: FastifyInstance) {
|
||||
if (body[key] !== undefined) updates[key] = body[key];
|
||||
}
|
||||
|
||||
db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
invalidateTokenRouterCache();
|
||||
return db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get();
|
||||
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);
|
||||
db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run();
|
||||
invalidateTokenRouterCache();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
@@ -139,7 +139,7 @@ async function handleChatProxyRequest(
|
||||
|
||||
const { requestedModel, isStream, upstreamBody, claudeOriginalBody } = parsedRequest.value!;
|
||||
const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions';
|
||||
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
|
||||
const excludeChannelIds: number[] = [];
|
||||
@@ -147,12 +147,12 @@ async function handleChatProxyRequest(
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
let selected = retryCount === 0
|
||||
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
@@ -703,7 +703,7 @@ async function handleChatProxyRequest(
|
||||
}
|
||||
}
|
||||
|
||||
function logProxy(
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
@@ -724,7 +724,7 @@ function logProxy(
|
||||
upstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
db.insert(schema.proxyLogs).values({
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||
}
|
||||
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
|
||||
const isStream = body.stream === true;
|
||||
@@ -31,12 +31,12 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
let selected = retryCount === 0
|
||||
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
@@ -228,7 +228,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
function logProxy(
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
@@ -246,7 +246,7 @@ function logProxy(
|
||||
downstreamPath: '/v1/completions',
|
||||
errorMessage,
|
||||
});
|
||||
db.insert(schema.proxyLogs).values({
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
|
||||
@@ -7,15 +7,15 @@ export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamR
|
||||
return getProxyAuthContext(request)?.policy || EMPTY_DOWNSTREAM_ROUTING_POLICY;
|
||||
}
|
||||
|
||||
export function ensureModelAllowedForDownstreamKey(
|
||||
export async function ensureModelAllowedForDownstreamKey(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
requestedModel: string,
|
||||
): boolean {
|
||||
): Promise<boolean> {
|
||||
const authContext = getProxyAuthContext(request);
|
||||
if (!authContext) return true;
|
||||
|
||||
if (isModelAllowedByPolicyOrAllowedRoutes(requestedModel, authContext.policy)) {
|
||||
if (await isModelAllowedByPolicyOrAllowedRoutes(requestedModel, authContext.policy)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -31,5 +31,5 @@ export function ensureModelAllowedForDownstreamKey(
|
||||
export function recordDownstreamCostUsage(request: FastifyRequest, estimatedCost: number): void {
|
||||
const authContext = getProxyAuthContext(request);
|
||||
if (!authContext || authContext.keyId === null) return;
|
||||
recordManagedKeyCostUsage(authContext.keyId, estimatedCost);
|
||||
void recordManagedKeyCostUsage(authContext.keyId, estimatedCost);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||
}
|
||||
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
|
||||
const excludeChannelIds: number[] = [];
|
||||
@@ -30,12 +30,12 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
let selected = retryCount === 0
|
||||
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
@@ -143,7 +143,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
function logProxy(
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
@@ -161,7 +161,7 @@ function logProxy(
|
||||
downstreamPath: '/v1/embeddings',
|
||||
errorMessage,
|
||||
});
|
||||
db.insert(schema.proxyLogs).values({
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
|
||||
@@ -17,19 +17,19 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
app.post('/v1/images/generations', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const body = request.body as any;
|
||||
const requestedModel = body?.model || 'gpt-image-1';
|
||||
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
const excludeChannelIds: number[] = [];
|
||||
let retryCount = 0;
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
let selected = retryCount === 0
|
||||
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
@@ -116,7 +116,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
function logProxy(
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
@@ -131,7 +131,7 @@ function logProxy(
|
||||
downstreamPath: '/v1/images/generations',
|
||||
errorMessage,
|
||||
});
|
||||
db.insert(schema.proxyLogs).values({
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
|
||||
@@ -9,8 +9,8 @@ export async function modelsProxyRoute(app: FastifyInstance) {
|
||||
app.get('/v1/models', async (request) => {
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
|
||||
const readModels = () => {
|
||||
const rows = db.select({ modelName: schema.modelAvailability.modelName })
|
||||
const readModels = async () => {
|
||||
const rows = await db.select({ modelName: schema.modelAvailability.modelName })
|
||||
.from(schema.modelAvailability)
|
||||
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
|
||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||
@@ -22,23 +22,29 @@ export async function modelsProxyRoute(app: FastifyInstance) {
|
||||
),
|
||||
)
|
||||
.all();
|
||||
const routeAliases = db.select({ displayName: schema.tokenRoutes.displayName })
|
||||
const routeAliases = (await db.select({ displayName: schema.tokenRoutes.displayName })
|
||||
.from(schema.tokenRoutes)
|
||||
.where(eq(schema.tokenRoutes.enabled, true))
|
||||
.all()
|
||||
.all())
|
||||
.map((row) => (row.displayName || '').trim())
|
||||
.filter((name) => name.length > 0);
|
||||
const deduped = Array.from(new Set([
|
||||
...rows.map((r) => r.modelName),
|
||||
...routeAliases,
|
||||
])).sort();
|
||||
return deduped.filter((modelName) => isModelAllowedByPolicyOrAllowedRoutes(modelName, downstreamPolicy));
|
||||
const allowed: string[] = [];
|
||||
for (const modelName of deduped) {
|
||||
if (await isModelAllowedByPolicyOrAllowedRoutes(modelName, downstreamPolicy)) {
|
||||
allowed.push(modelName);
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
};
|
||||
|
||||
let models = readModels();
|
||||
let models = await readModels();
|
||||
if (models.length === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
models = readModels();
|
||||
models = await readModels();
|
||||
}
|
||||
|
||||
const wantsClaudeFormat = typeof request.headers['anthropic-version'] === 'string'
|
||||
|
||||
@@ -1147,7 +1147,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
if (!requestedModel) {
|
||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||
}
|
||||
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||
|
||||
const isStream = body.stream === true;
|
||||
@@ -1156,12 +1156,12 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
|
||||
while (retryCount <= MAX_RETRIES) {
|
||||
let selected = retryCount === 0
|
||||
? tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
? await tokenRouter.selectChannel(requestedModel, downstreamPolicy)
|
||||
: await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy);
|
||||
|
||||
if (!selected && retryCount === 0) {
|
||||
await refreshModelsAndRebuildRoutes();
|
||||
selected = tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
@@ -1561,7 +1561,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
function logProxy(
|
||||
async function logProxy(
|
||||
selected: any,
|
||||
modelRequested: string,
|
||||
status: string,
|
||||
@@ -1582,7 +1582,7 @@ function logProxy(
|
||||
upstreamPath,
|
||||
errorMessage,
|
||||
});
|
||||
db.insert(schema.proxyLogs).values({
|
||||
await db.insert(schema.proxyLogs).values({
|
||||
routeId: selected.channel.routeId,
|
||||
channelId: selected.channel.id,
|
||||
accountId: selected.account.id,
|
||||
|
||||
Reference in New Issue
Block a user