Add tests for centered modal adoption, search modal results, and accounts batch actions

- Implement tests for the CenteredModal component to ensure it follows the shared modal shell pattern.
- Add comprehensive tests for the SearchModal results, including API interactions and UI behavior.
- Create tests for accounts batch actions, verifying the functionality of refreshing balances and selecting accounts via row clicks.
- Introduce tests for the accounts centered modal to confirm the use of CenteredModal for add/edit/rebind flows.
- Add tests for the accounts edit panel to ensure proper rendering and functionality when opening the edit modal.
- Implement tests for the accounts tokens header to verify the reuse of the accounts page header in the tokens segment.
- Create tests for the system proxy settings, ensuring the correct saving of the proxy URL.
- Add tests for the tokens actions layout to confirm the proper layout and styling of token row actions.
- Implement tests for the tokens batch actions, including deletion of selected tokens and row selection behavior.
- Introduce tests for the tokens focus navigation to ensure the correct highlighting of focused token rows.
This commit is contained in:
cita
2026-03-07 18:04:05 +08:00
parent c31bbcbb51
commit c437d93715
65 changed files with 3984 additions and 486 deletions
+1
View File
@@ -45,6 +45,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
return {
authToken: env.AUTH_TOKEN || 'change-me-admin-token',
proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token',
systemProxyUrl: env.SYSTEM_PROXY_URL || '',
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
+18
View File
@@ -175,6 +175,23 @@ function ensureSiteProxySchema() {
}
}
function ensureSiteUseSystemProxySchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('sites')) {
return;
}
if (!tableColumnExists('sites', 'use_system_proxy')) {
sqlite.exec(`ALTER TABLE sites ADD COLUMN use_system_proxy integer DEFAULT 0;`);
}
sqlite.exec(`
UPDATE sites
SET use_system_proxy = 0
WHERE use_system_proxy IS NULL;
`);
}
function ensureSiteExternalCheckinUrlSchema() {
const sqlite = requireSqliteConnection();
if (!tableExists('sites')) {
@@ -604,6 +621,7 @@ function initSqliteDb() {
ensureTokenManagementSchema();
ensureSiteStatusSchema();
ensureSiteProxySchema();
ensureSiteUseSystemProxySchema();
ensureSiteExternalCheckinUrlSchema();
ensureSiteGlobalWeightSchema();
ensureRouteGroupingSchema();
+1
View File
@@ -8,6 +8,7 @@ export const sites = sqliteTable('sites', {
externalCheckinUrl: text('external_checkin_url'),
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini'
proxyUrl: text('proxy_url'),
useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false),
status: text('status').notNull().default('active'), // 'active' | 'disabled'
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
sortOrder: integer('sort_order').default(0),
+3
View File
@@ -111,6 +111,9 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
const proxyToken = parseSettingFromMap<string>(settingsMap, 'proxy_token');
if (typeof proxyToken === 'string' && proxyToken) config.proxyToken = proxyToken;
const systemProxyUrl = parseSettingFromMap<string>(settingsMap, 'system_proxy_url');
if (typeof systemProxyUrl === 'string') config.systemProxyUrl = systemProxyUrl;
const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron');
if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron;
@@ -0,0 +1,137 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const { deleteApiTokenMock } = vi.hoisted(() => ({
deleteApiTokenMock: vi.fn(),
}));
vi.mock('../../services/platforms/index.js', () => ({
getAdapter: vi.fn(() => ({
deleteApiToken: deleteApiTokenMock,
})),
}));
type DbModule = typeof import('../../db/index.js');
describe('account token batch routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-tokens-batch-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accountTokens.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountTokensRoutes);
});
beforeEach(async () => {
deleteApiTokenMock.mockReset();
deleteApiTokenMock.mockResolvedValue(true);
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
await db.insert(schema.sites).values({
id: 1,
name: 'site-1',
url: 'https://site-1.example.com',
platform: 'new-api',
status: 'active',
}).run();
await db.insert(schema.accounts).values({
id: 1,
siteId: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
}).run();
await db.insert(schema.accountTokens).values([
{
id: 1,
accountId: 1,
name: 'token-1',
token: 'sk-token-1',
enabled: false,
},
{
id: 2,
accountId: 1,
name: 'token-2',
token: 'sk-token-2',
enabled: false,
},
]).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('enables selected account tokens and reports failures', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens/batch',
payload: {
ids: [1, 2, 999],
action: 'enable',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
successIds?: number[];
failedItems?: Array<{ id: number; message: string }>;
};
expect(body.successIds).toEqual([1, 2]);
expect(body.failedItems).toHaveLength(1);
expect(body.failedItems?.[0]?.id).toBe(999);
const rows = await db.select().from(schema.accountTokens).all();
expect(rows.every((row) => row.enabled === true)).toBe(true);
});
it('deletes selected account tokens through the upstream adapter', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens/batch',
payload: {
ids: [1],
action: 'delete',
},
});
expect(response.statusCode).toBe(200);
expect(deleteApiTokenMock).toHaveBeenCalledTimes(1);
const remaining = await db.select().from(schema.accountTokens).all();
expect(remaining.map((item) => item.id)).toEqual([2]);
});
it('rejects invalid account token batch action', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/account-tokens/batch',
payload: {
ids: [1],
action: 'nope',
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { message?: string }).message).toContain('action');
});
});
+118 -39
View File
@@ -136,6 +136,13 @@ function parseExpiredTime(value: unknown): number | undefined {
return seconds > 0 ? seconds : undefined;
}
function normalizeBatchIds(input: unknown): number[] {
if (!Array.isArray(input)) return [];
return input
.map((item) => Number.parseInt(String(item), 10))
.filter((id) => Number.isFinite(id) && id > 0);
}
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number, timeoutMessage: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
@@ -494,6 +501,111 @@ export async function accountTokensRoutes(app: FastifyInstance) {
};
});
const deleteAccountTokenById = async (tokenId: number): Promise<{ success: boolean; message?: string }> => {
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))
.where(eq(schema.accountTokens.id, tokenId))
.get();
if (!row) {
return { success: false, message: '令牌不存在' };
}
if (isApiKeyConnection(row.accounts)) {
return { success: false, message: 'API Key 连接不支持管理账号令牌' };
}
const existing = row.account_tokens;
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
const shouldDeleteUpstream = !isSiteDisabled(site.status) && !!account.accessToken?.trim() && !!adapter;
if (shouldDeleteUpstream) {
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
const upstreamDeleted = await adapter!.deleteApiToken(
site.url,
account.accessToken,
existing.token,
platformUserId,
);
if (!upstreamDeleted) {
return { success: false, message: '站点删除令牌失败,本地未删除' };
}
}
await db.delete(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).run();
if (existing.isDefault) {
repairDefaultToken(existing.accountId);
}
return { success: true };
};
app.post<{ Body?: { ids?: number[]; action?: string } }>('/api/account-tokens/batch', async (request, reply) => {
const ids = normalizeBatchIds(request.body?.ids);
const action = String(request.body?.action || '').trim();
if (ids.length === 0) {
return reply.code(400).send({ message: 'ids is required' });
}
if (!['enable', 'disable', 'delete'].includes(action)) {
return reply.code(400).send({ message: 'Invalid action' });
}
const successIds: number[] = [];
const failedItems: Array<{ id: number; message: string }> = [];
for (const id of ids) {
try {
const existing = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, id)).get();
if (!existing) {
failedItems.push({ id, message: 'Token not found' });
continue;
}
const owner = await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.accountId)).get();
if (!owner) {
failedItems.push({ id, message: 'Account not found' });
continue;
}
if (isApiKeyConnection(owner)) {
failedItems.push({ id, message: 'API Key 连接不支持管理账号令牌' });
continue;
}
if (action === 'delete') {
const result = await deleteAccountTokenById(id);
if (!result.success) {
failedItems.push({ id, message: result.message || 'Batch operation failed' });
continue;
}
} else {
await db.update(schema.accountTokens)
.set({
enabled: action === 'enable',
updatedAt: new Date().toISOString(),
})
.where(eq(schema.accountTokens.id, id))
.run();
if (existing.isDefault && action === 'disable') {
repairDefaultToken(existing.accountId);
}
}
successIds.push(id);
} catch (error: any) {
failedItems.push({ id, message: error?.message || 'Batch operation failed' });
}
}
return {
success: true,
successIds,
failedItems,
};
});
app.put<{ Params: { id: string }; Body: { name?: string; token?: string; enabled?: boolean; isDefault?: boolean; source?: string } }>('/api/account-tokens/:id', async (request, reply) => {
const tokenId = Number.parseInt(request.params.id, 10);
if (Number.isNaN(tokenId)) {
@@ -652,46 +764,13 @@ export async function accountTokensRoutes(app: FastifyInstance) {
if (Number.isNaN(tokenId)) {
return reply.code(400).send({ success: false, message: '令牌 ID 无效' });
}
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))
.where(eq(schema.accountTokens.id, tokenId))
.get();
if (!row) {
return reply.code(404).send({ success: false, message: '令牌不存在' });
const result = await deleteAccountTokenById(tokenId);
if (!result.success) {
const statusCode = result.message === '令牌不存在'
? 404
: (result.message === 'API Key 连接不支持管理账号令牌' ? 400 : 502);
return reply.code(statusCode).send({ success: false, message: result.message });
}
if (isApiKeyConnection(row.accounts)) {
return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' });
}
const existing = row.account_tokens;
const account = row.accounts;
const site = row.sites;
const adapter = getAdapter(site.platform);
const shouldDeleteUpstream = !isSiteDisabled(site.status) && !!account.accessToken?.trim() && !!adapter;
if (shouldDeleteUpstream) {
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
const upstreamDeleted = await adapter!.deleteApiToken(
site.url,
account.accessToken,
existing.token,
platformUserId,
);
if (!upstreamDeleted) {
return reply.code(502).send({ success: false, message: '站点删除令牌失败,本地未删除' });
}
}
await db.delete(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).run();
if (existing.isDefault) {
repairDefaultToken(existing.accountId);
}
return { success: true };
});
@@ -0,0 +1,111 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const { refreshBalanceMock } = vi.hoisted(() => ({
refreshBalanceMock: vi.fn(),
}));
vi.mock('../../services/balanceService.js', () => ({
refreshBalance: refreshBalanceMock,
}));
type DbModule = typeof import('../../db/index.js');
describe('accounts batch routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-batch-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./accounts.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.accountsRoutes);
});
beforeEach(async () => {
refreshBalanceMock.mockReset();
refreshBalanceMock.mockImplementation(async (id: number) => {
if (id === 999) return null;
return { id, balance: 12.5 };
});
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
await db.insert(schema.sites).values({
id: 1,
name: 'site-1',
url: 'https://site-1.example.com',
platform: 'new-api',
}).run();
await db.insert(schema.accounts).values([
{
id: 1,
siteId: 1,
username: 'alpha',
accessToken: 'session-alpha',
status: 'active',
},
{
id: 2,
siteId: 1,
username: 'beta',
accessToken: 'session-beta',
status: 'active',
},
]).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('refreshes balance for selected accounts and reports failures', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/accounts/batch',
payload: {
ids: [1, 2, 999],
action: 'refreshBalance',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
successIds?: number[];
failedItems?: Array<{ id: number; message: string }>;
};
expect(body.successIds).toEqual([1, 2]);
expect(body.failedItems).toHaveLength(1);
expect(body.failedItems?.[0]?.id).toBe(999);
expect(refreshBalanceMock).toHaveBeenCalledTimes(3);
});
it('rejects invalid accounts batch action', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/accounts/batch',
payload: {
ids: [1],
action: 'nope',
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { message?: string }).message).toContain('action');
});
});
@@ -129,6 +129,49 @@ describe('accounts credential mode', () => {
expect(parsedExtra.credentialMode).toBe('apikey');
});
it('marks apikey connection healthy in account list after model discovery succeeds', async () => {
const site = await db.insert(schema.sites).values({
name: 'Healthy API Key Site',
url: 'https://healthy-apikey.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'Wong',
accessToken: '',
apiToken: 'sk-healthy-apikey',
checkinEnabled: false,
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.4',
available: true,
latencyMs: 1200,
checkedAt: '2026-03-07T07:35:00.000Z',
}).run();
const listResponse = await app.inject({
method: 'GET',
url: '/api/accounts',
});
expect(listResponse.statusCode).toBe(200);
const list = listResponse.json() as Array<{
id: number;
runtimeHealth?: { state?: string; reason?: string };
capabilities?: { proxyOnly?: boolean };
}>;
expect(list).toHaveLength(1);
expect(list[0]?.capabilities?.proxyOnly).toBe(true);
expect(list[0]?.runtimeHealth).toMatchObject({
state: 'healthy',
reason: '模型探测成功',
});
});
it('stores managed refresh token for sub2api session account', async () => {
verifyTokenMock.mockResolvedValueOnce({
tokenType: 'session',
+86 -3
View File
@@ -25,7 +25,7 @@ import {
type RuntimeHealthState,
} from '../../services/accountHealthService.js';
import { appendSessionTokenRebindHint } from '../../services/alertRules.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
type AccountWithSiteRow = {
accounts: typeof schema.accounts.$inferSelect;
@@ -82,6 +82,13 @@ function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelec
return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken));
}
function normalizeBatchIds(input: unknown): number[] {
if (!Array.isArray(input)) return [];
return input
.map((item) => Number.parseInt(String(item), 10))
.filter((id) => Number.isFinite(id) && id > 0);
}
function normalizePinnedFlag(input: unknown): boolean | null {
if (input === undefined || input === null) return null;
if (typeof input === 'boolean') return input;
@@ -345,6 +352,19 @@ export async function accountsRoutes(app: FastifyInstance) {
spendByAccount[row.accountId] = Number(row.totalSpend || 0);
}
const modelCountRows = await db.select({
accountId: schema.modelAvailability.accountId,
modelCount: sql<number>`count(*)`,
}).from(schema.modelAvailability)
.where(eq(schema.modelAvailability.available, true))
.groupBy(schema.modelAvailability.accountId)
.all();
const modelCountByAccount: Record<number, number> = {};
for (const row of modelCountRows) {
if (row.accountId == null) continue;
modelCountByAccount[row.accountId] = Number(row.modelCount || 0);
}
// Aggregate today's checkin rewards per account
const todayCheckins = await db.select({
accountId: schema.checkinLogs.accountId,
@@ -394,6 +414,7 @@ export async function accountsRoutes(app: FastifyInstance) {
credentialMode,
hasSessionTokenValue(r.accounts.accessToken),
).canRefreshBalance,
hasDiscoveredModels: (modelCountByAccount[r.accounts.id] || 0) > 0,
}),
};
});
@@ -584,7 +605,7 @@ export async function accountsRoutes(app: FastifyInstance) {
try {
const testRes = await fetch(
`${site.url}/api/user/self`,
withExplicitProxyRequestInit(site.proxyUrl, {
withSiteRecordProxyRequestInit(site, {
headers,
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
}),
@@ -763,7 +784,7 @@ export async function accountsRoutes(app: FastifyInstance) {
try {
const testRes = await fetch(
`${site.url}/api/user/self`,
withExplicitProxyRequestInit(site.proxyUrl, {
withSiteRecordProxyRequestInit(site, {
headers,
signal: AbortSignal.timeout(ACCOUNT_VERIFY_DIAG_TIMEOUT_MS),
}),
@@ -1193,6 +1214,68 @@ export async function accountsRoutes(app: FastifyInstance) {
return { success: true };
});
app.post<{ Body?: { ids?: number[]; action?: string } }>('/api/accounts/batch', async (request, reply) => {
const ids = normalizeBatchIds(request.body?.ids);
const action = String(request.body?.action || '').trim();
if (ids.length === 0) {
return reply.code(400).send({ message: 'ids is required' });
}
if (!['enable', 'disable', 'delete', 'refreshBalance'].includes(action)) {
return reply.code(400).send({ message: 'Invalid action' });
}
const successIds: number[] = [];
const failedItems: Array<{ id: number; message: string }> = [];
let shouldRebuildRoutes = false;
for (const id of ids) {
try {
if (action === 'refreshBalance') {
const result = await refreshBalance(id);
if (!result) {
failedItems.push({ id, message: 'Account not found or balance refresh unsupported' });
continue;
}
successIds.push(id);
continue;
}
const existing = await db.select().from(schema.accounts).where(eq(schema.accounts.id, id)).get();
if (!existing) {
failedItems.push({ id, message: 'Account not found' });
continue;
}
if (action === 'delete') {
await db.delete(schema.accounts).where(eq(schema.accounts.id, id)).run();
shouldRebuildRoutes = true;
} else {
const nextStatus = action === 'enable' ? 'active' : 'disabled';
await db.update(schema.accounts)
.set({ status: nextStatus, updatedAt: new Date().toISOString() })
.where(eq(schema.accounts.id, id))
.run();
}
successIds.push(id);
} catch (error: any) {
failedItems.push({ id, message: error?.message || 'Batch operation failed' });
}
}
if (shouldRebuildRoutes) {
try {
await rebuildTokenRoutesFromAvailability();
} catch { }
}
return {
success: true,
successIds,
failedItems,
};
});
app.post<{ Body?: { accountId?: number; wait?: boolean } }>('/api/accounts/health/refresh', async (request, reply) => {
const rawAccountId = request.body?.accountId as unknown;
const hasAccountId = rawAccountId !== undefined && rawAccountId !== null && String(rawAccountId).trim() !== '';
+140
View File
@@ -0,0 +1,140 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import { eq } from 'drizzle-orm';
import { mergeAccountExtraConfig } from '../../services/accountExtraConfig.js';
type DbModule = typeof import('../../db/index.js');
describe('search routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-search-route-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./search.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.searchRoutes);
});
beforeEach(async () => {
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.proxyLogs).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('returns apikey connections and account tokens for global search', async () => {
const site = await db.insert(schema.sites).values({
name: 'searchable key site',
url: 'https://searchable-key.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: '',
accessToken: '',
apiToken: 'sk-searchable-key',
status: 'active',
extraConfig: mergeAccountExtraConfig(null, { credentialMode: 'apikey' }),
}).returning().get();
await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'searchable token',
token: 'sk-token-searchable',
tokenGroup: 'searchable-group',
enabled: true,
isDefault: true,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/search',
payload: {
query: 'searchable',
limit: 20,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
accounts: [
expect.objectContaining({
id: account.id,
segment: 'apikey',
site: expect.objectContaining({
name: 'searchable key site',
}),
}),
],
accountTokens: [
expect.objectContaining({
name: 'searchable token',
accountId: account.id,
site: expect.objectContaining({
name: 'searchable key site',
}),
}),
],
});
});
it('finds apikey accounts by the API Key display label', async () => {
const site = await db.insert(schema.sites).values({
name: 'plain site',
url: 'https://plain.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: '',
accessToken: '',
apiToken: 'sk-only-key',
status: 'active',
extraConfig: mergeAccountExtraConfig(null, { credentialMode: 'apikey' }),
}).returning().get();
const response = await app.inject({
method: 'POST',
url: '/api/search',
payload: {
query: 'API Key',
limit: 20,
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
accounts: [
expect.objectContaining({
id: account.id,
segment: 'apikey',
}),
],
});
});
});
+76 -6
View File
@@ -1,7 +1,36 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, like, desc, eq } from 'drizzle-orm';
import { and, like, desc, eq, or } from 'drizzle-orm';
import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
function hasSessionTokenValue(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
function resolveAccountSearchSegment(account: typeof schema.accounts.$inferSelect): 'session' | 'apikey' {
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
if (explicit === 'apikey') return 'apikey';
if (explicit === 'session') return 'session';
return hasSessionTokenValue(account.accessToken) ? 'session' : 'apikey';
}
function normalizeSearchQuery(input: string): string {
return input.trim().toLowerCase().replace(/\s+/g, ' ');
}
function matchesApiKeyDisplayLabel(query: string): boolean {
const normalized = normalizeSearchQuery(query);
return [
'apikey',
'api key',
'api-key',
'api key 连接',
'apikey 连接',
'api key connection',
'apikey connection',
].some((keyword) => normalized.includes(keyword));
}
export async function searchRoutes(app: FastifyInstance) {
const proxyLogBaseFields = getProxyLogBaseSelectFields();
@@ -9,11 +38,11 @@ export async function searchRoutes(app: FastifyInstance) {
app.post<{ Body: { query: string; limit?: number } }>('/api/search', async (request) => {
const { query, limit = 20 } = request.body;
if (!query || query.trim().length === 0) {
return { accounts: [], sites: [], checkinLogs: [], proxyLogs: [], models: [] };
return { accounts: [], accountTokens: [], sites: [], checkinLogs: [], proxyLogs: [], models: [] };
}
const q = `%${query.trim()}%`;
const perCategory = Math.min(Math.ceil(limit / 5), 10);
const perCategory = Math.min(Math.ceil(limit / 6), 10);
// Search sites
const sites = (await db.select().from(schema.sites)
@@ -30,9 +59,50 @@ export async function searchRoutes(app: FastifyInstance) {
// Search accounts (join with sites for site name)
const accountResults = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(like(schema.accounts.username, q))
.where(or(
like(schema.accounts.username, q),
like(schema.sites.name, q),
))
.limit(perCategory).all();
const accounts = accountResults.map(r => ({ ...r.accounts, site: r.sites }));
const apiKeyLabelMatches = matchesApiKeyDisplayLabel(query);
const apiKeyAccountResults = apiKeyLabelMatches
? await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.where(or(
eq(schema.accounts.accessToken, ''),
like(schema.accounts.extraConfig, '%"credentialMode":"apikey"%'),
))
.limit(perCategory)
.all()
: [];
const accounts = [...new Map([...accountResults, ...apiKeyAccountResults].map((r) => [r.accounts.id, ({
...r.accounts,
segment: resolveAccountSearchSegment(r.accounts),
site: r.sites,
})])).values()].slice(0, perCategory);
// Search account tokens by token name/group/account/site
const tokenResults = 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))
.where(or(
like(schema.accountTokens.name, q),
like(schema.accountTokens.tokenGroup, q),
like(schema.accounts.username, q),
like(schema.sites.name, q),
))
.orderBy(desc(schema.accountTokens.updatedAt))
.limit(perCategory)
.all();
const accountTokens = tokenResults.map(r => ({
...r.account_tokens,
account: {
id: r.accounts.id,
username: r.accounts.username,
segment: resolveAccountSearchSegment(r.accounts),
},
site: r.sites,
}));
// Search checkin logs (by message)
const checkinLogs = (await db.select().from(schema.checkinLogs)
@@ -96,6 +166,6 @@ export async function searchRoutes(app: FastifyInstance) {
})
.slice(0, perCategory);
return { accounts, sites: uniqueSites, checkinLogs, proxyLogs, models };
return { accounts, accountTokens, sites: uniqueSites, checkinLogs, proxyLogs, models };
});
}
@@ -40,6 +40,7 @@ describe('settings and auth events', () => {
config.authToken = 'old-admin-token-123';
config.proxyToken = 'sk-old-proxy-token-123';
config.systemProxyUrl = '';
config.checkinCron = '0 8 * * *';
config.balanceRefreshCron = '0 * * * *';
config.routingFallbackUnitCost = 1;
@@ -191,6 +192,64 @@ describe('settings and auth events', () => {
expect(runtime.routingFallbackUnitCost).toBe(0.25);
});
it('persists and returns system proxy url from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
systemProxyUrl: 'http://127.0.0.1:7890',
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { systemProxyUrl?: string };
expect(updated.systemProxyUrl).toBe('http://127.0.0.1:7890');
expect(config.systemProxyUrl).toBe('http://127.0.0.1:7890');
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'system_proxy_url')).get();
expect(saved).toBeTruthy();
expect(saved?.value).toBe(JSON.stringify('http://127.0.0.1:7890'));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as { systemProxyUrl?: string };
expect(runtime.systemProxyUrl).toBe('http://127.0.0.1:7890');
});
it('invalidates cached site proxy resolution when system proxy url changes', async () => {
await db.insert(schema.sites).values({
name: 'proxy-site',
url: 'https://proxy-site.example.com',
platform: 'new-api',
useSystemProxy: true,
}).run();
const { resolveSiteProxyUrlByRequestUrl } = await import('../../services/siteProxy.js');
const firstUpdate = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
systemProxyUrl: 'http://127.0.0.1:7890',
},
});
expect(firstUpdate.statusCode).toBe(200);
expect(await resolveSiteProxyUrlByRequestUrl('https://proxy-site.example.com/v1/chat/completions')).toBe('http://127.0.0.1:7890');
const secondUpdate = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
systemProxyUrl: 'http://127.0.0.1:7891',
},
});
expect(secondUpdate.statusCode).toBe(200);
expect(await resolveSiteProxyUrlByRequestUrl('https://proxy-site.example.com/v1/chat/completions')).toBe('http://127.0.0.1:7891');
});
it('rejects allowlist update that does not include current request IP', async () => {
const response = await app.inject({
method: 'PUT',
+24 -1
View File
@@ -16,11 +16,13 @@ import {
} from '../../services/databaseMigrationService.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js';
type RoutingWeights = typeof config.routingWeights;
interface RuntimeSettingsBody {
proxyToken?: string;
systemProxyUrl?: string;
checkinCron?: string;
balanceRefreshCron?: string;
webhookUrl?: string;
@@ -156,6 +158,11 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.proxyToken = nextToken;
return;
}
case 'system_proxy_url': {
if (typeof value !== 'string') return;
config.systemProxyUrl = normalizeSiteProxyUrl(value) || '';
return;
}
case 'webhook_url': {
if (typeof value !== 'string') return;
config.webhookUrl = value.trim();
@@ -295,6 +302,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
notifyCooldownSec: config.notifyCooldownSec,
adminIpAllowlist: config.adminIpAllowlist,
currentAdminIp,
systemProxyUrl: config.systemProxyUrl,
proxyTokenMasked: maskSecret(config.proxyToken),
};
}
@@ -471,6 +479,22 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('proxy_token', proxyToken);
}
if (body.systemProxyUrl !== undefined) {
const rawSystemProxyUrl = String(body.systemProxyUrl || '').trim();
const normalizedSystemProxyUrl = rawSystemProxyUrl
? normalizeSiteProxyUrl(rawSystemProxyUrl)
: '';
if (rawSystemProxyUrl && !normalizedSystemProxyUrl) {
return reply.code(400).send({ success: false, message: '系统代理地址无效,请填写合法的 http(s)/socks 代理 URL' });
}
if (normalizedSystemProxyUrl !== config.systemProxyUrl) {
changedLabels.push('系统代理');
}
config.systemProxyUrl = normalizedSystemProxyUrl || '';
upsertSetting('system_proxy_url', config.systemProxyUrl);
invalidateSiteProxyCache();
}
if (body.webhookUrl !== undefined) {
if (String(body.webhookUrl || '').trim() !== config.webhookUrl) {
changedLabels.push('Webhook 地址');
@@ -887,4 +911,3 @@ export async function settingsRoutes(app: FastifyInstance) {
};
});
}
+92
View File
@@ -0,0 +1,92 @@
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';
type DbModule = typeof import('../../db/index.js');
describe('sites batch routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-batch-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./sites.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.sitesRoutes);
});
beforeEach(async () => {
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('enables system proxy for selected sites and reports failures', async () => {
await db.insert(schema.sites).values([
{
id: 1,
name: 'site-1',
url: 'https://site-1.example.com',
platform: 'new-api',
useSystemProxy: false,
},
{
id: 2,
name: 'site-2',
url: 'https://site-2.example.com',
platform: 'new-api',
useSystemProxy: false,
},
]).run();
const response = await app.inject({
method: 'POST',
url: '/api/sites/batch',
payload: {
ids: [1, 2, 999],
action: 'enableSystemProxy',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
successIds?: number[];
failedItems?: Array<{ id: number; message: string }>;
};
expect(body.successIds).toEqual([1, 2]);
expect(body.failedItems).toHaveLength(1);
expect(body.failedItems?.[0]?.id).toBe(999);
const rows = await db.select().from(schema.sites).all();
expect(rows.every((row) => row.useSystemProxy === true)).toBe(true);
});
it('rejects invalid sites batch action', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites/batch',
payload: {
ids: [1],
action: 'nope',
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { message?: string }).message).toContain('action');
});
});
+34 -8
View File
@@ -6,7 +6,7 @@ import { mkdtempSync } from 'node:fs';
type DbModule = typeof import('../../db/index.js');
describe('sites proxy url settings', () => {
describe('sites system proxy settings', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
@@ -36,7 +36,7 @@ describe('sites proxy url settings', () => {
delete process.env.DATA_DIR;
});
it('stores proxy url and external checkin url when creating a site', async () => {
it('stores useSystemProxy and external checkin url when creating a site', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
@@ -44,7 +44,7 @@ describe('sites proxy url settings', () => {
name: 'proxy-site',
url: 'https://proxy-site.example.com',
platform: 'new-api',
proxyUrl: 'http://127.0.0.1:7890',
useSystemProxy: true,
externalCheckinUrl: 'https://checkin.example.com/welfare',
globalWeight: 1.5,
},
@@ -52,16 +52,16 @@ describe('sites proxy url settings', () => {
expect(response.statusCode).toBe(200);
const payload = response.json() as {
proxyUrl?: string | null;
useSystemProxy?: boolean;
externalCheckinUrl?: string | null;
globalWeight?: number;
};
expect(payload.proxyUrl).toBe('http://127.0.0.1:7890');
expect(payload.useSystemProxy).toBe(true);
expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare');
expect(payload.globalWeight).toBe(1.5);
});
it('rejects invalid proxy url', async () => {
it('rejects invalid useSystemProxy flag', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
@@ -69,12 +69,12 @@ describe('sites proxy url settings', () => {
name: 'proxy-site',
url: 'https://proxy-site.example.com',
platform: 'new-api',
proxyUrl: 'not-a-url',
useSystemProxy: 'not-a-boolean',
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid proxyUrl');
expect((response.json() as { error?: string }).error).toContain('Invalid useSystemProxy');
});
it('rejects invalid site global weight', async () => {
@@ -108,4 +108,30 @@ describe('sites proxy url settings', () => {
expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid externalCheckinUrl');
});
it('updates useSystemProxy for an existing site', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'toggle-site',
url: 'https://toggle-site.example.com',
platform: 'new-api',
useSystemProxy: false,
},
});
expect(created.statusCode).toBe(200);
const site = created.json() as { id: number };
const response = await app.inject({
method: 'PUT',
url: `/api/sites/${site.id}`,
payload: {
useSystemProxy: true,
},
});
expect(response.statusCode).toBe(200);
expect((response.json() as { useSystemProxy?: boolean }).useSystemProxy).toBe(true);
});
});
+125 -50
View File
@@ -2,7 +2,7 @@ import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, eq, sql } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js';
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
@@ -25,6 +25,10 @@ function normalizePinnedFlag(input: unknown): boolean | null {
return null;
}
function normalizeUseSystemProxyFlag(input: unknown): boolean | null {
return normalizePinnedFlag(input);
}
function normalizeSortOrder(input: unknown): number | null {
if (input === undefined || input === null || input === '') return null;
const parsed = Number.parseInt(String(input), 10);
@@ -70,6 +74,59 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
}
export async function sitesRoutes(app: FastifyInstance) {
async function applySiteStatusSideEffects(
siteId: number,
existingSiteName: string,
normalizedStatus: 'active' | 'disabled',
) {
const now = new Date().toISOString();
if (normalizedStatus === 'disabled') {
await db.update(schema.accounts)
.set({ status: 'disabled', updatedAt: now })
.where(eq(schema.accounts.siteId, siteId))
.run();
try {
const createdAt = formatUtcSqlDateTime(new Date());
await db.insert(schema.events).values({
type: 'status',
title: '站点已禁用',
message: `${existingSiteName} 已禁用,关联账号已全部置为禁用`,
level: 'warning',
relatedId: siteId,
relatedType: 'site',
createdAt,
}).run();
} catch {}
return;
}
await db.update(schema.accounts)
.set({ status: 'active', updatedAt: now })
.where(and(eq(schema.accounts.siteId, siteId), eq(schema.accounts.status, 'disabled')))
.run();
try {
const createdAt = formatUtcSqlDateTime(new Date());
await db.insert(schema.events).values({
type: 'status',
title: '站点已启用',
message: `${existingSiteName} 已启用,关联禁用账号已恢复为活跃`,
level: 'info',
relatedId: siteId,
relatedType: 'site',
createdAt,
}).run();
} catch {}
}
function normalizeBatchIds(input: unknown): number[] {
if (!Array.isArray(input)) return [];
return input
.map((item) => Number.parseInt(String(item), 10))
.filter((id) => Number.isFinite(id) && id > 0);
}
// List all sites
app.get('/api/sites', async () => {
const siteRows = await db.select().from(schema.sites).all();
@@ -96,21 +153,21 @@ export async function sitesRoutes(app: FastifyInstance) {
name: string;
url: string;
platform?: string;
proxyUrl?: string | null;
useSystemProxy?: boolean;
externalCheckinUrl?: string | null;
status?: string;
isPinned?: boolean;
sortOrder?: number;
globalWeight?: number;
} }>('/api/sites', async (request, reply) => {
const { name, url, platform, proxyUrl, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
const { name, url, platform, useSystemProxy, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
}
const parsedProxyUrl = parseSiteProxyUrlInput(proxyUrl);
if (!parsedProxyUrl.valid) {
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
const normalizedUseSystemProxy = normalizeUseSystemProxyFlag(useSystemProxy);
if (useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
}
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl);
if (!normalizedExternalCheckinUrl.valid) {
@@ -144,7 +201,7 @@ export async function sitesRoutes(app: FastifyInstance) {
name,
url: url.replace(/\/+$/, ''),
platform: detectedPlatform,
proxyUrl: parsedProxyUrl.proxyUrl,
useSystemProxy: normalizedUseSystemProxy ?? false,
externalCheckinUrl: normalizedExternalCheckinUrl.url,
status: normalizedStatus ?? 'active',
isPinned: normalizedPinned ?? false,
@@ -168,7 +225,7 @@ export async function sitesRoutes(app: FastifyInstance) {
name?: string;
url?: string;
platform?: string;
proxyUrl?: string | null;
useSystemProxy?: boolean;
externalCheckinUrl?: string | null;
status?: string;
isPinned?: boolean;
@@ -191,9 +248,9 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
}
const parsedProxyUrl = parseSiteProxyUrlInput(body.proxyUrl);
if (!parsedProxyUrl.valid) {
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
const normalizedUseSystemProxy = normalizeUseSystemProxyFlag(body.useSystemProxy);
if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
}
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl);
if (!normalizedExternalCheckinUrl.valid) {
@@ -215,7 +272,7 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.name !== undefined) updates.name = body.name;
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
if (body.platform !== undefined) updates.platform = body.platform;
if (parsedProxyUrl.present) updates.proxyUrl = parsedProxyUrl.proxyUrl;
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
if (body.status !== undefined) updates.status = normalizedStatus;
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
@@ -226,44 +283,7 @@ export async function sitesRoutes(app: FastifyInstance) {
invalidateSiteProxyCache();
if (body.status !== undefined && normalizedStatus) {
const now = new Date().toISOString();
if (normalizedStatus === 'disabled') {
await db.update(schema.accounts)
.set({ status: 'disabled', updatedAt: now })
.where(eq(schema.accounts.siteId, id))
.run();
try {
const createdAt = formatUtcSqlDateTime(new Date());
await db.insert(schema.events).values({
type: 'status',
title: '站点已禁用',
message: `${existingSite.name} 已禁用,关联账号已全部置为禁用`,
level: 'warning',
relatedId: id,
relatedType: 'site',
createdAt,
}).run();
} catch {}
} else {
await db.update(schema.accounts)
.set({ status: 'active', updatedAt: now })
.where(and(eq(schema.accounts.siteId, id), eq(schema.accounts.status, 'disabled')))
.run();
try {
const createdAt = formatUtcSqlDateTime(new Date());
await db.insert(schema.events).values({
type: 'status',
title: '站点已启用',
message: `${existingSite.name} 已启用,关联禁用账号已恢复为活跃`,
level: 'info',
relatedId: id,
relatedType: 'site',
createdAt,
}).run();
} catch {}
}
await applySiteStatusSideEffects(id, existingSite.name, normalizedStatus);
}
return await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
@@ -277,6 +297,61 @@ export async function sitesRoutes(app: FastifyInstance) {
return { success: true };
});
app.post<{ Body?: { ids?: number[]; action?: string } }>('/api/sites/batch', async (request, reply) => {
const ids = normalizeBatchIds(request.body?.ids);
const action = String(request.body?.action || '').trim();
if (ids.length === 0) {
return reply.code(400).send({ message: 'ids is required' });
}
if (!['enable', 'disable', 'delete', 'enableSystemProxy', 'disableSystemProxy'].includes(action)) {
return reply.code(400).send({ message: 'Invalid action' });
}
const successIds: number[] = [];
const failedItems: Array<{ id: number; message: string }> = [];
for (const id of ids) {
const existingSite = await db.select().from(schema.sites).where(eq(schema.sites.id, id)).get();
if (!existingSite) {
failedItems.push({ id, message: 'Site not found' });
continue;
}
try {
if (action === 'delete') {
await db.delete(schema.sites).where(eq(schema.sites.id, id)).run();
} else if (action === 'enableSystemProxy') {
await db.update(schema.sites)
.set({ useSystemProxy: true, updatedAt: new Date().toISOString() })
.where(eq(schema.sites.id, id))
.run();
} else if (action === 'disableSystemProxy') {
await db.update(schema.sites)
.set({ useSystemProxy: false, updatedAt: new Date().toISOString() })
.where(eq(schema.sites.id, id))
.run();
} else {
const nextStatus = action === 'enable' ? 'active' : 'disabled';
await db.update(schema.sites)
.set({ status: nextStatus, updatedAt: new Date().toISOString() })
.where(eq(schema.sites.id, id))
.run();
await applySiteStatusSideEffects(id, existingSite.name, nextStatus);
}
successIds.push(id);
} catch (error: any) {
failedItems.push({ id, message: error?.message || 'Batch operation failed' });
}
}
invalidateSiteProxyCache();
return {
success: true,
successIds,
failedItems,
};
});
// Detect platform for a URL
app.post<{ Body: { url: string } }>('/api/sites/detect', async (request) => {
const result = await detectSite(request.body.url);
+4 -4
View File
@@ -8,7 +8,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import {
type DownstreamFormat,
createStreamTransformContext,
@@ -183,7 +183,7 @@ async function handleChatProxyRequest(
try {
const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url,
proxyUrl: selected.site.proxyUrl,
proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates,
buildRequest: (endpoint) => {
const endpointRequest = buildUpstreamEndpointRequest({
@@ -225,7 +225,7 @@ async function handleChatProxyRequest(
downstreamHeaders: request.headers as Record<string, unknown>,
});
const normalizedTargetUrl = `${selected.site.url}${normalizedClaudeRequest.path}`;
const normalizedResponse = await fetch(normalizedTargetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
const normalizedResponse = await fetch(normalizedTargetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: normalizedClaudeRequest.headers,
body: JSON.stringify(normalizedClaudeRequest.body),
@@ -264,7 +264,7 @@ async function handleChatProxyRequest(
return null;
}
const minimalResponse = await fetch(ctx.targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
const minimalResponse = await fetch(ctx.targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: minimalHeaders,
body: JSON.stringify(ctx.request.body),
+2 -2
View File
@@ -9,7 +9,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
@@ -57,7 +57,7 @@ export async function completionsProxyRoute(app: FastifyInstance) {
const startTime = Date.now();
try {
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+2 -2
View File
@@ -9,7 +9,7 @@ import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
@@ -54,7 +54,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
const startTime = Date.now();
try {
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+2 -2
View File
@@ -8,7 +8,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
import { estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
@@ -50,7 +50,7 @@ export async function imagesProxyRoute(app: FastifyInstance) {
const startTime = Date.now();
try {
const upstream = await fetch(targetUrl, withExplicitProxyRequestInit(selected.site.proxyUrl, {
const upstream = await fetch(targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -0,0 +1,140 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn();
const recordSuccessMock = vi.fn();
const recordFailureMock = vi.fn();
const refreshModelsAndRebuildRoutesMock = vi.fn();
const reportProxyAllFailedMock = vi.fn();
const reportTokenExpiredMock = vi.fn();
const estimateProxyCostMock = vi.fn(async (_arg?: any) => 0);
const buildProxyBillingDetailsMock = vi.fn(async (_arg?: any) => null);
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: any): Promise<any> => null);
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
...usage,
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const dbInsertMock = vi.fn((_arg?: any) => ({
values: () => ({
run: () => undefined,
}),
}));
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: {
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
},
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
}));
vi.mock('../../services/alertService.js', () => ({
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
}));
vi.mock('../../services/alertRules.js', () => ({
isTokenExpiredError: () => false,
}));
vi.mock('../../services/modelPricingService.js', () => ({
estimateProxyCost: (arg: any) => estimateProxyCostMock(arg),
buildProxyBillingDetails: (arg: any) => buildProxyBillingDetailsMock(arg),
fetchModelPricingCatalog: (arg: any) => fetchModelPricingCatalogMock(arg),
}));
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
}));
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
schema: {
proxyLogs: {},
},
}));
describe('responses proxy compact upstream routing', () => {
let app: FastifyInstance;
beforeAll(async () => {
const { responsesProxyRoute } = await import('./responses.js');
app = Fastify();
await app.register(responsesProxyRoute);
});
beforeEach(() => {
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
recordSuccessMock.mockReset();
recordFailureMock.mockReset();
refreshModelsAndRebuildRoutesMock.mockReset();
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
estimateProxyCostMock.mockClear();
buildProxyBillingDetailsMock.mockClear();
fetchModelPricingCatalogMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
selectNextChannelMock.mockReturnValue(null);
fetchModelPricingCatalogMock.mockResolvedValue(null);
});
afterAll(async () => {
await app.close();
});
it('forwards compact requests to the upstream /v1/responses/compact path first', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'resp_123',
object: 'response',
output_text: 'hello from compact',
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/responses/compact');
});
});
@@ -0,0 +1,32 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('responses proxy compact route', () => {
let app: FastifyInstance;
beforeAll(async () => {
const { responsesProxyRoute } = await import('./responses.js');
app = Fastify();
await app.register(responsesProxyRoute);
});
afterAll(async () => {
await app.close();
});
it('accepts /v1/responses/compact requests instead of returning 404', async () => {
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({
error: {
message: 'model is required',
type: 'invalid_request_error',
},
});
});
});
+22 -8
View File
@@ -8,7 +8,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import {
createStreamTransformContext,
normalizeUpstreamFinalResponse,
@@ -1137,15 +1137,19 @@ function serializeConvertedResponsesEvents(input: {
}
export async function responsesProxyRoute(app: FastifyInstance) {
app.post('/v1/responses', async (request: FastifyRequest, reply: FastifyReply) => {
const handleResponsesRequest = async (
request: FastifyRequest,
reply: FastifyReply,
downstreamPath: string,
) => {
const body = request.body as any;
const requestedModel = typeof body?.model === 'string' ? body.model.trim() : '';
const downstreamPath = '/v1/responses';
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
}
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const isCompactRequest = downstreamPath === '/v1/responses/compact';
const isStream = body.stream === true;
const excludeChannelIds: number[] = [];
@@ -1193,7 +1197,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
try {
const endpointResult = await executeEndpointFlow({
siteUrl: selected.site.url,
proxyUrl: selected.site.proxyUrl,
proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates,
buildRequest: (endpoint) => {
const endpointRequest = buildUpstreamEndpointRequest({
@@ -1208,9 +1212,14 @@ export async function responsesProxyRoute(app: FastifyInstance) {
responsesOriginalBody: body,
downstreamHeaders: request.headers as Record<string, unknown>,
});
const upstreamPath = (
isCompactRequest && endpoint === 'responses'
? `${endpointRequest.path}/compact`
: endpointRequest.path
);
return {
endpoint,
path: endpointRequest.path,
path: upstreamPath,
headers: endpointRequest.headers,
body: endpointRequest.body as Record<string, unknown>,
};
@@ -1231,7 +1240,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
for (const compatibilityBody of compatibilityBodies) {
const compatibilityResponse = await fetch(
ctx.targetUrl,
withExplicitProxyRequestInit(selected.site.proxyUrl, {
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: compatibilityHeadersCandidate,
body: JSON.stringify(compatibilityBody),
@@ -1266,7 +1275,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
});
const minimalResponse = await fetch(
ctx.targetUrl,
withExplicitProxyRequestInit(selected.site.proxyUrl, {
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: minimalHeaders,
body: JSON.stringify(ctx.request.body),
@@ -1554,7 +1563,12 @@ export async function responsesProxyRoute(app: FastifyInstance) {
});
}
}
});
};
app.post('/v1/responses', async (request: FastifyRequest, reply: FastifyReply) =>
handleResponsesRequest(request, reply, '/v1/responses'));
app.post('/v1/responses/compact', async (request: FastifyRequest, reply: FastifyReply) =>
handleResponsesRequest(request, reply, '/v1/responses/compact'));
}
async function logProxy(
@@ -35,6 +35,28 @@ describe('accountHealthService', () => {
expect(health.reason).not.toContain('访问令牌已过期');
});
it('ignores stored auth failures for proxy-only accounts', () => {
const health = buildRuntimeHealthForAccount({
accountStatus: 'active',
siteStatus: 'active',
sessionCapable: false,
extraConfig: JSON.stringify({
runtimeHealth: {
state: 'unhealthy',
reason: '访问令牌失效:无权进行此操作,access token 无效',
source: 'auth',
checkedAt: '2026-03-07T09:00:01.432Z',
},
}),
});
expect(health).toMatchObject({
state: 'unknown',
source: 'none',
});
expect(health.reason).not.toContain('访问令牌');
});
it('returns stored runtime health from extra config when available', () => {
const health = buildRuntimeHealthForAccount({
accountStatus: 'active',
+27 -1
View File
@@ -82,11 +82,21 @@ export function extractRuntimeHealth(extraConfig?: string | null): RuntimeHealth
return normalizeRuntimeHealthRecord(parsed.runtimeHealth);
}
function isProxyOnlyAuthFailure(
health: RuntimeHealthInfo | null,
sessionCapable?: boolean,
): boolean {
if (!health || sessionCapable !== false) return false;
if (health.state !== 'unhealthy') return false;
return (health.source || '').toLowerCase() === 'auth';
}
export function buildRuntimeHealthForAccount(input: {
accountStatus?: string | null;
siteStatus?: string | null;
extraConfig?: string | null;
sessionCapable?: boolean;
hasDiscoveredModels?: boolean;
}): RuntimeHealthInfo {
const accountStatus = (input.accountStatus || 'active').toLowerCase();
const siteStatus = (input.siteStatus || 'active').toLowerCase();
@@ -110,7 +120,23 @@ export function buildRuntimeHealthForAccount(input: {
}
const stored = extractRuntimeHealth(input.extraConfig);
if (stored) return stored;
const ignoreStoredProxyOnlyAuthFailure = isProxyOnlyAuthFailure(stored, input.sessionCapable);
if (
stored
&& !ignoreStoredProxyOnlyAuthFailure
&& !(input.sessionCapable === false && input.hasDiscoveredModels && stored.state === 'unknown')
) {
return stored;
}
if (input.sessionCapable === false && input.hasDiscoveredModels) {
return {
state: 'healthy',
reason: '模型探测成功',
source: 'model-discovery',
checkedAt: stored?.checkedAt || null,
};
}
return {
state: 'unknown',
+1
View File
@@ -162,6 +162,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
externalCheckinUrl: null,
platform,
proxyUrl: null,
useSystemProxy: false,
status: 'active',
isPinned: false,
sortOrder: sites.length,
@@ -279,6 +279,47 @@ describe('balanceService auto relogin', () => {
expect(reportTokenExpiredMock).not.toHaveBeenCalled();
});
it('skips balance refresh for api-key-only accounts without expiring them', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 10,
username: 'wong-key',
accessToken: '',
apiToken: 'sk-only-token',
balance: 5,
balanceUsed: 1,
quota: 6,
status: 'active',
extraConfig: JSON.stringify({
credentialMode: 'apikey',
}),
},
sites: {
id: 10,
name: 'wong',
url: 'https://wzw.pp.ua',
platform: 'new-api',
},
},
]);
const { refreshBalance } = await import('./balanceService.js');
const result = await refreshBalance(10);
expect(result).toEqual({
balance: 5,
used: 1,
quota: 6,
skipped: true,
reason: 'proxy_only',
});
expect(adapterMock.getBalance).not.toHaveBeenCalled();
expect(adapterMock.login).not.toHaveBeenCalled();
expect(reportTokenExpiredMock).not.toHaveBeenCalled();
expect(setAccountRuntimeHealthMock).not.toHaveBeenCalled();
});
it('keeps degraded health when checkin is unsupported but balance refresh succeeds', async () => {
selectAllMock.mockReturnValue([
{
+20 -3
View File
@@ -5,6 +5,7 @@ import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.
import { reportTokenExpired } from './alertService.js';
import {
getAutoReloginConfig,
getCredentialModeFromExtraConfig,
getSub2ApiAuthFromExtraConfig,
mergeAccountExtraConfig,
resolvePlatformUserId,
@@ -13,12 +14,18 @@ import { decryptAccountPassword } from './accountCredentialService.js';
import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js';
import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js';
import type { BalanceInfo } from './platforms/base.js';
import { withExplicitProxyRequestInit } from './siteProxy.js';
import { resolveProxyUrlForSite, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
function isSiteDisabled(status?: string | null): boolean {
return (status || 'active') === 'disabled';
}
function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boolean {
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
if (explicit && explicit !== 'auto') return explicit === 'apikey';
return !(account.accessToken || '').trim();
}
function shouldAttemptAutoRelogin(message?: string | null): boolean {
if (!message) return false;
if (isTokenExpiredError({ message })) return true;
@@ -196,7 +203,7 @@ async function refreshSub2ApiManagedSession(params: {
const { fetch } = await import('undici');
let payload: unknown = null;
try {
const response = await fetch(endpoint, withExplicitProxyRequestInit(params.site.proxyUrl, {
const response = await fetch(endpoint, withSiteRecordProxyRequestInit(params.site, {
method: 'POST',
headers,
body: JSON.stringify({ refresh_token: refreshToken }),
@@ -363,6 +370,16 @@ export async function refreshBalance(accountId: number) {
const adapter = getAdapter(site.platform);
if (!adapter) return null;
if (isApiKeyConnection(account)) {
return {
balance: account.balance ?? 0,
used: account.balanceUsed ?? 0,
quota: account.quota ?? 0,
skipped: true,
reason: 'proxy_only',
};
}
const platformUserId = resolvePlatformUserId(account.extraConfig, account.username);
let activeAccessToken = account.accessToken;
let activeExtraConfig = account.extraConfig;
@@ -457,7 +474,7 @@ export async function refreshBalance(accountId: number) {
accessToken: activeAccessToken,
platform: site.platform,
platformUserId,
proxyUrl: site.proxyUrl,
proxyUrl: resolveProxyUrlForSite(site),
});
if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) {
balanceInfo.todayIncome = fallbackIncome;
+17 -7
View File
@@ -4,6 +4,7 @@ import { getAdapter } from './platforms/index.js';
import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js';
import { getCredentialModeFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js';
import { invalidateTokenRouterCache } from './tokenRouter.js';
import { setAccountRuntimeHealth } from './accountHealthService.js';
const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000;
const MODEL_DISCOVERY_TIMEOUT_MS = 12_000;
@@ -202,18 +203,27 @@ export async function refreshModelsForAccount(accountId: number) {
mergeDiscoveredModels(models, latencyMs);
}
if (accountModels.size > 0) {
const checkedAt = new Date().toISOString();
await db.insert(schema.modelAvailability).values(
Array.from(accountModels).map((modelName) => ({
if (accountModels.size > 0) {
const checkedAt = new Date().toISOString();
await db.insert(schema.modelAvailability).values(
Array.from(accountModels).map((modelName) => ({
accountId: account.id,
modelName,
available: true,
latencyMs: modelLatency.get(modelName) ?? null,
checkedAt,
})),
).run();
}
})),
).run();
if (isApiKeyConnection(account)) {
await setAccountRuntimeHealth(account.id, {
state: 'healthy',
reason: '模型探测成功',
source: 'model-discovery',
checkedAt,
});
}
}
return {
accountId,
@@ -4,7 +4,7 @@ import {
buildNewApiCookieCandidates,
fetchJsonWithShieldCookieRetry,
} from './platforms/newApiShield.js';
import { withExplicitProxyRequestInit } from './siteProxy.js';
import { withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
const SELF_LOG_FETCH_TIMEOUT_MS = 8_000;
const SELF_LOG_PAGE_SIZE = 20;
@@ -28,6 +28,7 @@ interface ProxyUsageFallbackInput {
url: string;
platform: string;
apiKey?: string | null;
useSystemProxy?: boolean | null;
proxyUrl?: string | null;
};
account: {
@@ -358,7 +359,7 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
page_size: String(SELF_LOG_PAGE_SIZE),
model: input.modelName,
});
const response = await fetch(`${baseUrl}/api/v1/usage?${query.toString()}`, withExplicitProxyRequestInit(input.site.proxyUrl, {
const response = await fetch(`${baseUrl}/api/v1/usage?${query.toString()}`, withSiteRecordProxyRequestInit(input.site, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
@@ -412,7 +413,7 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
}
}
const response = await fetch(url, withExplicitProxyRequestInit(input.site.proxyUrl, {
const response = await fetch(url, withSiteRecordProxyRequestInit(input.site, {
method: 'GET',
headers,
signal: controller.signal,
+23 -22
View File
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { sql } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
@@ -22,6 +23,7 @@ describe('siteProxy', () => {
beforeEach(async () => {
const { invalidateSiteProxyCache } = await import('./siteProxy.js');
await db.delete(schema.accounts).run();
await db.delete(schema.settings).run();
await db.delete(schema.sites).run();
invalidateSiteProxyCache();
});
@@ -30,36 +32,35 @@ describe('siteProxy', () => {
delete process.env.DATA_DIR;
});
it('resolves longest matched site proxy url', async () => {
await db.insert(schema.sites).values([
{
name: 'base-site',
url: 'https://relay.example.com',
platform: 'new-api',
proxyUrl: 'http://127.0.0.1:7891',
},
{
name: 'openai-site',
url: 'https://relay.example.com/openai',
platform: 'new-api',
proxyUrl: 'http://127.0.0.1:7890',
},
]).run();
it('resolves system proxy only for sites that opt in', async () => {
await db.insert(schema.settings).values({
key: 'system_proxy_url',
value: JSON.stringify('http://127.0.0.1:7890'),
}).run();
await db.run(sql`
INSERT INTO sites (name, url, platform, use_system_proxy)
VALUES
('base-site', 'https://relay.example.com', 'new-api', 0),
('openai-site', 'https://relay.example.com/openai', 'new-api', 1)
`);
const { resolveSiteProxyUrlByRequestUrl } = await import('./siteProxy.js');
expect(await resolveSiteProxyUrlByRequestUrl('https://relay.example.com/openai/v1/models'))
.toBe('http://127.0.0.1:7890');
expect(await resolveSiteProxyUrlByRequestUrl('https://relay.example.com/v1/models'))
.toBe('http://127.0.0.1:7891');
.toBeNull();
});
it('injects dispatcher when proxy exists', async () => {
await db.insert(schema.sites).values({
name: 'proxy-site',
url: 'https://proxy-site.example.com',
platform: 'new-api',
proxyUrl: 'http://127.0.0.1:7890',
it('injects dispatcher when a site opts into the configured system proxy', async () => {
await db.insert(schema.settings).values({
key: 'system_proxy_url',
value: JSON.stringify('http://127.0.0.1:7890'),
}).run();
await db.run(sql`
INSERT INTO sites (name, url, platform, use_system_proxy)
VALUES ('proxy-site', 'https://proxy-site.example.com', 'new-api', 1)
`);
const { withSiteProxyRequestInit } = await import('./siteProxy.js');
const requestInit = await withSiteProxyRequestInit('https://proxy-site.example.com/v1/chat/completions', {
+53 -13
View File
@@ -1,4 +1,6 @@
import { db, schema } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { config } from '../config.js';
import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
import { ProxyAgent } from 'undici';
@@ -15,7 +17,7 @@ const SUPPORTED_PROXY_PROTOCOLS = new Set([
type SiteProxyRow = {
siteUrl: string;
proxyUrl: string | null;
useSystemProxy: boolean;
};
type ParsedSiteProxyInput = {
@@ -24,12 +26,18 @@ type ParsedSiteProxyInput = {
proxyUrl: string | null;
};
type SiteProxyConfigLike = {
useSystemProxy?: boolean | null;
};
let siteProxyCache: {
loadedAt: number;
rows: SiteProxyRow[];
systemProxyUrl: string | null;
} = {
loadedAt: 0,
rows: [],
systemProxyUrl: null,
};
const dispatcherCache = new Map<string, Dispatcher>();
@@ -54,23 +62,41 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
}
try {
const rows = await db
.select({
siteUrl: schema.sites.url,
proxyUrl: schema.sites.proxyUrl,
})
.from(schema.sites)
.all();
const [rows, systemProxySetting] = await Promise.all([
db
.select({
siteUrl: schema.sites.url,
useSystemProxy: schema.sites.useSystemProxy,
})
.from(schema.sites)
.all(),
db.select({ value: schema.settings.value })
.from(schema.settings)
.where(eq(schema.settings.key, 'system_proxy_url'))
.get(),
]);
const parsedSystemProxyUrl = normalizeSiteProxyUrl(
typeof systemProxySetting?.value === 'string'
? (() => {
try {
return JSON.parse(systemProxySetting.value);
} catch {
return systemProxySetting.value;
}
})()
: systemProxySetting?.value,
);
siteProxyCache = {
loadedAt: nowMs,
rows: rows.map((row) => ({
siteUrl: normalizeSiteUrl(row.siteUrl),
proxyUrl: normalizeSiteProxyUrl(row.proxyUrl),
useSystemProxy: !!row.useSystemProxy,
})),
systemProxyUrl: parsedSystemProxyUrl,
};
} catch {
siteProxyCache = { loadedAt: nowMs, rows: [] };
siteProxyCache = { loadedAt: nowMs, rows: [], systemProxyUrl: null };
}
return siteProxyCache.rows;
@@ -138,7 +164,7 @@ export function parseSiteProxyUrlInput(input: unknown): ParsedSiteProxyInput {
}
export function invalidateSiteProxyCache(): void {
siteProxyCache = { loadedAt: 0, rows: [] };
siteProxyCache = { loadedAt: 0, rows: [], systemProxyUrl: null };
}
export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promise<string | null> {
@@ -146,11 +172,13 @@ export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promi
if (!normalizedRequestUrl) return null;
const rows = await getCachedSiteProxyRows();
const systemProxyUrl = siteProxyCache.systemProxyUrl;
if (!systemProxyUrl) return null;
let bestMatch: string | null = null;
let bestMatchLength = -1;
for (const row of rows) {
if (!row.proxyUrl) continue;
if (!row.useSystemProxy) continue;
if (!row.siteUrl) continue;
const isPrefixMatch = (
@@ -161,7 +189,7 @@ export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promi
if (!isPrefixMatch) continue;
if (row.siteUrl.length > bestMatchLength) {
bestMatch = row.proxyUrl;
bestMatch = systemProxyUrl;
bestMatchLength = row.siteUrl.length;
}
}
@@ -200,3 +228,15 @@ export function withExplicitProxyRequestInit(
dispatcher,
};
}
export function resolveProxyUrlForSite(site: SiteProxyConfigLike | null | undefined): string | null {
if (!site?.useSystemProxy) return null;
return normalizeSiteProxyUrl(config.systemProxyUrl);
}
export function withSiteRecordProxyRequestInit(
site: SiteProxyConfigLike | null | undefined,
options?: UndiciRequestInit,
): UndiciRequestInit {
return withExplicitProxyRequestInit(resolveProxyUrlForSite(site), options);
}