[codex] Split site API endpoint pool from panel host (#373)
* feat: add site api endpoint schema scaffolding * feat: wire site api endpoints into backup migration * feat: add site api endpoint selection service * feat: add site api endpoint contracts * feat: route discovery through site api endpoints * feat: route proxy traffic through site api endpoints * feat: add site api endpoint pool editor * fix: keep proxy video tasks on site endpoint pool * fix: restore core test compatibility for site endpoint pool * fix: address endpoint pool review findings
This commit is contained in:
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type BackupServiceModule = typeof import('./backupService.js');
|
||||
@@ -155,6 +155,19 @@ describe('backupService', () => {
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
await db.insert(schema.siteApiEndpoints).values({
|
||||
siteId: site.id,
|
||||
url: 'https://api-roundtrip.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
cooldownUntil: null,
|
||||
lastSelectedAt: now,
|
||||
lastFailedAt: null,
|
||||
lastFailureReason: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run();
|
||||
|
||||
await db.insert(schema.modelAvailability).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
@@ -199,6 +212,15 @@ describe('backupService', () => {
|
||||
expect(exported.accounts.siteDisabledModels).toEqual([
|
||||
{ siteId: site.id, modelName: 'gpt-hidden' },
|
||||
]);
|
||||
expect(exported.accounts.siteApiEndpoints).toEqual([
|
||||
expect.objectContaining({
|
||||
siteId: site.id,
|
||||
url: 'https://api-roundtrip.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
cooldownUntil: null,
|
||||
}),
|
||||
]);
|
||||
expect(exported.accounts.manualModels).toEqual([
|
||||
{ accountId: account.id, modelName: 'gpt-manual' },
|
||||
]);
|
||||
@@ -579,6 +601,33 @@ describe('backupService', () => {
|
||||
createdAt: exportedAt,
|
||||
}).run();
|
||||
|
||||
await db.insert(schema.siteApiEndpoints).values([
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://api-a.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
cooldownUntil: null,
|
||||
lastSelectedAt: exportedAt,
|
||||
lastFailedAt: null,
|
||||
lastFailureReason: null,
|
||||
createdAt: exportedAt,
|
||||
updatedAt: exportedAt,
|
||||
},
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://api-b.example.com',
|
||||
enabled: false,
|
||||
sortOrder: 1,
|
||||
cooldownUntil: exportedAt,
|
||||
lastSelectedAt: null,
|
||||
lastFailedAt: exportedAt,
|
||||
lastFailureReason: 'HTTP 429',
|
||||
createdAt: exportedAt,
|
||||
updatedAt: exportedAt,
|
||||
},
|
||||
]).run();
|
||||
|
||||
await db.insert(schema.modelAvailability).values([
|
||||
{
|
||||
accountId: account.id,
|
||||
@@ -678,6 +727,22 @@ describe('backupService', () => {
|
||||
updatedAt: localRuntimeAt,
|
||||
}).where(eq(schema.sites.id, site.id)).run();
|
||||
|
||||
await db.delete(schema.siteApiEndpoints)
|
||||
.where(eq(schema.siteApiEndpoints.siteId, site.id))
|
||||
.run();
|
||||
await db.insert(schema.siteApiEndpoints).values({
|
||||
siteId: site.id,
|
||||
url: 'https://api-local-only.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 9,
|
||||
cooldownUntil: null,
|
||||
lastSelectedAt: localRuntimeAt,
|
||||
lastFailedAt: null,
|
||||
lastFailureReason: null,
|
||||
createdAt: localRuntimeAt,
|
||||
updatedAt: localRuntimeAt,
|
||||
}).run();
|
||||
|
||||
await db.update(schema.tokenRoutes).set({
|
||||
displayName: 'local-route-name',
|
||||
updatedAt: localRuntimeAt,
|
||||
@@ -821,6 +886,11 @@ describe('backupService', () => {
|
||||
const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, account.id)).get();
|
||||
const restoredRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, route.id)).get();
|
||||
const restoredChannel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, insertedChannel!.id)).get();
|
||||
const restoredSiteApiEndpoints = await db.select()
|
||||
.from(schema.siteApiEndpoints)
|
||||
.where(eq(schema.siteApiEndpoints.siteId, site.id))
|
||||
.orderBy(asc(schema.siteApiEndpoints.sortOrder), asc(schema.siteApiEndpoints.id))
|
||||
.all();
|
||||
const restoredDisabledModels = await db.select().from(schema.siteDisabledModels).all();
|
||||
const restoredAvailability = await db.select().from(schema.modelAvailability).all();
|
||||
const restoredTokenAvailability = await db.select().from(schema.tokenModelAvailability).all();
|
||||
@@ -848,6 +918,25 @@ describe('backupService', () => {
|
||||
expect(restoredChannel?.cooldownLevel).toBe(2);
|
||||
expect(restoredChannel?.cooldownUntil).toBe(localRuntimeAt);
|
||||
|
||||
expect(restoredSiteApiEndpoints).toEqual([
|
||||
expect.objectContaining({
|
||||
siteId: site.id,
|
||||
url: 'https://api-a.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
cooldownUntil: null,
|
||||
lastFailureReason: null,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
siteId: site.id,
|
||||
url: 'https://api-b.example.com',
|
||||
enabled: false,
|
||||
sortOrder: 1,
|
||||
cooldownUntil: exportedAt,
|
||||
lastFailureReason: 'HTTP 429',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(restoredDisabledModels).toEqual([
|
||||
expect.objectContaining({ siteId: site.id, modelName: 'gpt-backup-disabled' }),
|
||||
]);
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface BackupWebdavState {
|
||||
}
|
||||
|
||||
type SiteRow = typeof schema.sites.$inferSelect;
|
||||
type SiteApiEndpointRow = typeof schema.siteApiEndpoints.$inferSelect;
|
||||
type AccountRow = typeof schema.accounts.$inferSelect;
|
||||
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
|
||||
type TokenRouteRow = typeof schema.tokenRoutes.$inferSelect;
|
||||
@@ -100,6 +101,7 @@ type BackupDownstreamApiKeyRow = Pick<DownstreamApiKeyRow,
|
||||
|
||||
interface AccountsBackupSection {
|
||||
sites: SiteRow[];
|
||||
siteApiEndpoints?: SiteApiEndpointRow[];
|
||||
accounts: BackupAccountRow[];
|
||||
accountTokens: AccountTokenRow[];
|
||||
tokenRoutes: TokenRouteRow[];
|
||||
@@ -1282,6 +1284,7 @@ function isSettingValueAcceptable(key: string, value: unknown): boolean {
|
||||
async function exportAccountsSection(): Promise<AccountsBackupSection> {
|
||||
const [
|
||||
sites,
|
||||
siteApiEndpoints,
|
||||
accounts,
|
||||
accountTokens,
|
||||
tokenRoutes,
|
||||
@@ -1292,6 +1295,13 @@ async function exportAccountsSection(): Promise<AccountsBackupSection> {
|
||||
downstreamApiKeys,
|
||||
] = await Promise.all([
|
||||
db.select().from(schema.sites).orderBy(asc(schema.sites.id)).all(),
|
||||
db.select().from(schema.siteApiEndpoints)
|
||||
.orderBy(
|
||||
asc(schema.siteApiEndpoints.siteId),
|
||||
asc(schema.siteApiEndpoints.sortOrder),
|
||||
asc(schema.siteApiEndpoints.id),
|
||||
)
|
||||
.all(),
|
||||
db.select().from(schema.accounts).orderBy(asc(schema.accounts.id)).all(),
|
||||
db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all(),
|
||||
db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all(),
|
||||
@@ -1309,6 +1319,7 @@ async function exportAccountsSection(): Promise<AccountsBackupSection> {
|
||||
|
||||
return {
|
||||
sites,
|
||||
siteApiEndpoints,
|
||||
accounts: accounts.map(({ balanceUsed: _balanceUsed, lastCheckinAt: _lastCheckinAt, lastBalanceRefresh: _lastBalanceRefresh, ...row }) => row),
|
||||
accountTokens,
|
||||
tokenRoutes,
|
||||
@@ -1389,6 +1400,9 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null {
|
||||
if (!isRecord(input)) return null;
|
||||
|
||||
const sites = Array.isArray(input.sites) ? input.sites as SiteRow[] : null;
|
||||
const siteApiEndpoints = Array.isArray(input.siteApiEndpoints)
|
||||
? input.siteApiEndpoints as SiteApiEndpointRow[]
|
||||
: undefined;
|
||||
const accounts = Array.isArray(input.accounts) ? input.accounts as BackupAccountRow[] : null;
|
||||
const accountTokens = Array.isArray(input.accountTokens) ? input.accountTokens as AccountTokenRow[] : null;
|
||||
const tokenRoutes = Array.isArray(input.tokenRoutes) ? input.tokenRoutes as TokenRouteRow[] : null;
|
||||
@@ -1410,6 +1424,7 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null {
|
||||
|
||||
return {
|
||||
sites,
|
||||
siteApiEndpoints,
|
||||
accounts,
|
||||
accountTokens,
|
||||
tokenRoutes,
|
||||
@@ -1534,6 +1549,22 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
|
||||
}).run();
|
||||
}
|
||||
|
||||
for (const row of section.siteApiEndpoints || []) {
|
||||
await tx.insert(schema.siteApiEndpoints).values({
|
||||
id: row.id,
|
||||
siteId: row.siteId,
|
||||
url: row.url,
|
||||
enabled: row.enabled ?? true,
|
||||
sortOrder: row.sortOrder ?? 0,
|
||||
cooldownUntil: row.cooldownUntil ?? null,
|
||||
lastSelectedAt: row.lastSelectedAt ?? null,
|
||||
lastFailedAt: row.lastFailedAt ?? null,
|
||||
lastFailureReason: row.lastFailureReason ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}).run();
|
||||
}
|
||||
|
||||
for (const row of section.accounts) {
|
||||
const oauthColumns = resolveImportedOauthColumns(row);
|
||||
const accountKey = importedIndexes.accountKeyById.get(row.id);
|
||||
|
||||
@@ -14,6 +14,7 @@ function createDbSchemaMock() {
|
||||
return {
|
||||
settings: { __table: 'settings' },
|
||||
sites: { __table: 'sites' },
|
||||
siteApiEndpoints: { __table: 'siteApiEndpoints' },
|
||||
siteAnnouncements: { __table: 'siteAnnouncements' },
|
||||
siteDisabledModels: { __table: 'siteDisabledModels' },
|
||||
accounts: { __table: 'accounts' },
|
||||
@@ -245,6 +246,81 @@ describe('databaseMigrationService', () => {
|
||||
expect(siteStatement?.values[customHeadersIndex]).toBe('{"x-site-scope":"internal"}');
|
||||
});
|
||||
|
||||
it('includes site api endpoints when building migration statements', () => {
|
||||
const statements = __databaseMigrationServiceTestUtils.buildStatements({
|
||||
version: 'test',
|
||||
timestamp: Date.now(),
|
||||
accounts: {
|
||||
sites: [{
|
||||
id: 1,
|
||||
name: 'demo',
|
||||
url: 'https://example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}],
|
||||
siteApiEndpoints: [{
|
||||
id: 9,
|
||||
siteId: 1,
|
||||
url: 'https://api.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 2,
|
||||
cooldownUntil: '2026-03-31T12:05:00.000Z',
|
||||
lastSelectedAt: '2026-03-31T12:00:00.000Z',
|
||||
lastFailedAt: '2026-03-31T11:59:00.000Z',
|
||||
lastFailureReason: 'HTTP 502',
|
||||
createdAt: '2026-03-30T00:00:00.000Z',
|
||||
updatedAt: '2026-03-31T12:05:00.000Z',
|
||||
}],
|
||||
siteAnnouncements: [],
|
||||
siteDisabledModels: [],
|
||||
accounts: [],
|
||||
accountTokens: [],
|
||||
checkinLogs: [],
|
||||
modelAvailability: [],
|
||||
tokenModelAvailability: [],
|
||||
tokenRoutes: [],
|
||||
routeChannels: [],
|
||||
routeGroupSources: [],
|
||||
proxyLogs: [],
|
||||
proxyVideoTasks: [],
|
||||
proxyFiles: [],
|
||||
downstreamApiKeys: [],
|
||||
events: [],
|
||||
},
|
||||
preferences: {
|
||||
settings: [],
|
||||
},
|
||||
} as any);
|
||||
|
||||
const endpointStatement = statements.find((statement) => statement.table === 'site_api_endpoints');
|
||||
expect(endpointStatement?.columns).toEqual([
|
||||
'id',
|
||||
'site_id',
|
||||
'url',
|
||||
'enabled',
|
||||
'sort_order',
|
||||
'cooldown_until',
|
||||
'last_selected_at',
|
||||
'last_failed_at',
|
||||
'last_failure_reason',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
expect(endpointStatement?.values).toEqual([
|
||||
9,
|
||||
1,
|
||||
'https://api.example.com',
|
||||
true,
|
||||
2,
|
||||
'2026-03-31T12:05:00.000Z',
|
||||
'2026-03-31T12:00:00.000Z',
|
||||
'2026-03-31T11:59:00.000Z',
|
||||
'HTTP 502',
|
||||
'2026-03-30T00:00:00.000Z',
|
||||
'2026-03-31T12:05:00.000Z',
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes parsed JSON-column values when building migration statements', () => {
|
||||
const statements = __databaseMigrationServiceTestUtils.buildStatements({
|
||||
version: 'test',
|
||||
|
||||
@@ -29,6 +29,7 @@ type BackupSnapshot = {
|
||||
timestamp: number;
|
||||
accounts: {
|
||||
sites: Array<Record<string, unknown>>;
|
||||
siteApiEndpoints: Array<Record<string, unknown>>;
|
||||
siteAnnouncements: Array<Record<string, unknown>>;
|
||||
siteDisabledModels: Array<Record<string, unknown>>;
|
||||
accounts: Array<Record<string, unknown>>;
|
||||
@@ -58,6 +59,7 @@ export interface DatabaseMigrationSummary {
|
||||
timestamp: number;
|
||||
rows: {
|
||||
sites: number;
|
||||
siteApiEndpoints: number;
|
||||
siteAnnouncements: number;
|
||||
siteDisabledModels: number;
|
||||
accounts: number;
|
||||
@@ -251,6 +253,7 @@ async function toBackupSnapshot(): Promise<BackupSnapshot> {
|
||||
timestamp: Date.now(),
|
||||
accounts: {
|
||||
sites: await db.select().from(schema.sites).all() as Array<Record<string, unknown>>,
|
||||
siteApiEndpoints: await db.select().from(schema.siteApiEndpoints).all() as Array<Record<string, unknown>>,
|
||||
siteAnnouncements: await db.select().from(schema.siteAnnouncements).all() as Array<Record<string, unknown>>,
|
||||
siteDisabledModels: await db.select().from(schema.siteDisabledModels).all() as Array<Record<string, unknown>>,
|
||||
accounts: await db.select().from(schema.accounts).all() as Array<Record<string, unknown>>,
|
||||
@@ -299,6 +302,7 @@ async function clearTargetData(client: SqlClient): Promise<void> {
|
||||
'accounts',
|
||||
'site_announcements',
|
||||
'site_disabled_models',
|
||||
'site_api_endpoints',
|
||||
'token_routes',
|
||||
'sites',
|
||||
'downstream_api_keys',
|
||||
@@ -340,6 +344,38 @@ function buildStatements(
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of snapshot.accounts.siteApiEndpoints || []) {
|
||||
statements.push({
|
||||
table: 'site_api_endpoints',
|
||||
columns: [
|
||||
'id',
|
||||
'site_id',
|
||||
'url',
|
||||
'enabled',
|
||||
'sort_order',
|
||||
'cooldown_until',
|
||||
'last_selected_at',
|
||||
'last_failed_at',
|
||||
'last_failure_reason',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
values: [
|
||||
asNumber(row.id, 0),
|
||||
asNumber(row.siteId, 0),
|
||||
asNullableString(row.url),
|
||||
asBoolean(row.enabled, true),
|
||||
asNumber(row.sortOrder, 0),
|
||||
asNullableString(row.cooldownUntil),
|
||||
asNullableString(row.lastSelectedAt),
|
||||
asNullableString(row.lastFailedAt),
|
||||
asNullableString(row.lastFailureReason),
|
||||
asNullableString(row.createdAt),
|
||||
asNullableString(row.updatedAt),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
for (const row of snapshot.accounts.siteDisabledModels) {
|
||||
statements.push({
|
||||
table: 'site_disabled_models',
|
||||
@@ -708,6 +744,7 @@ async function syncPostgresSequences(client: SqlClient): Promise<void> {
|
||||
if (client.dialect !== 'postgres') return;
|
||||
const tables = [
|
||||
'sites',
|
||||
'site_api_endpoints',
|
||||
'site_announcements',
|
||||
'site_disabled_models',
|
||||
'accounts',
|
||||
@@ -776,6 +813,7 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro
|
||||
timestamp: snapshot.timestamp,
|
||||
rows: {
|
||||
sites: snapshot.accounts.sites.length,
|
||||
siteApiEndpoints: snapshot.accounts.siteApiEndpoints.length,
|
||||
siteAnnouncements: snapshot.accounts.siteAnnouncements.length,
|
||||
siteDisabledModels: snapshot.accounts.siteDisabledModels.length,
|
||||
accounts: snapshot.accounts.accounts.length,
|
||||
|
||||
@@ -75,6 +75,7 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
await db.delete(schema.modelAvailability).run();
|
||||
await db.delete(schema.accountTokens).run();
|
||||
await db.delete(schema.accounts).run();
|
||||
await db.delete(schema.siteApiEndpoints).run();
|
||||
await db.delete(schema.settings).run();
|
||||
await db.delete(schema.sites).run();
|
||||
const { config } = await import('../config.js');
|
||||
@@ -134,6 +135,49 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
expect(tokenRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses the configured ai endpoint for direct model discovery credentials', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockImplementation(async (baseUrl: string, token: string) => (
|
||||
baseUrl === 'https://api.nih.cc' && token === 'session-token'
|
||||
? ['gpt-4.1']
|
||||
: []
|
||||
));
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'nihao-panel',
|
||||
url: 'https://nih.cc',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.siteApiEndpoints).values({
|
||||
siteId: site.id,
|
||||
url: 'https://api.nih.cc',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
}).run();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'nihao-user',
|
||||
accessToken: 'session-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({ credentialMode: 'session' }),
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
status: 'success',
|
||||
modelCount: 1,
|
||||
modelsPreview: ['gpt-4.1'],
|
||||
});
|
||||
expect(getModelsMock).toHaveBeenCalledWith('https://api.nih.cc', 'session-token', undefined);
|
||||
});
|
||||
|
||||
it('deduplicates discovered model names before writing availability rows', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockResolvedValue(['? ', '?', 'GPT-4.1', 'gpt-4.1']);
|
||||
@@ -694,6 +738,145 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the configured ai endpoint for codex cloud model discovery', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockRejectedValue(new Error('codex plan discovery should not call adapter.getModels'));
|
||||
undiciFetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: [
|
||||
{ id: 'gpt-5.3-codex' },
|
||||
],
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'codex-panel-site',
|
||||
url: 'https://chatgpt.com/panel-codex',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.siteApiEndpoints).values({
|
||||
siteId: site.id,
|
||||
url: 'https://chatgpt.com/backend-api/codex',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
}).run();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'codex-user@example.com',
|
||||
accessToken: 'oauth-access-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'chatgpt-account-456',
|
||||
email: 'codex-user@example.com',
|
||||
planType: 'plus',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
status: 'success',
|
||||
modelCount: 1,
|
||||
modelsPreview: ['gpt-5.3-codex'],
|
||||
});
|
||||
expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://chatgpt.com/backend-api/codex/models?client_version=1.0.0');
|
||||
});
|
||||
|
||||
it('rotates codex cloud discovery across configured ai endpoints after a retryable failure', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockRejectedValue(new Error('codex plan discovery should not call adapter.getModels'));
|
||||
undiciFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 502,
|
||||
json: async () => ({ error: 'bad gateway' }),
|
||||
text: async () => 'bad gateway',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: [
|
||||
{ id: 'gpt-5.3-codex' },
|
||||
],
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'codex-pool-site',
|
||||
url: 'https://chatgpt.com/panel-codex',
|
||||
platform: 'codex',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.siteApiEndpoints).values([
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://chatgpt.com/backend-api/codex-a',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://chatgpt.com/backend-api/codex-b',
|
||||
enabled: true,
|
||||
sortOrder: 1,
|
||||
},
|
||||
]).run();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'codex-pool-user@example.com',
|
||||
accessToken: 'oauth-access-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'codex',
|
||||
accountId: 'chatgpt-account-789',
|
||||
email: 'codex-pool-user@example.com',
|
||||
planType: 'plus',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
status: 'success',
|
||||
modelCount: 1,
|
||||
modelsPreview: ['gpt-5.3-codex'],
|
||||
});
|
||||
expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://chatgpt.com/backend-api/codex-a/models?client_version=1.0.0');
|
||||
expect(String(undiciFetchMock.mock.calls[1]?.[0] || '')).toBe('https://chatgpt.com/backend-api/codex-b/models?client_version=1.0.0');
|
||||
|
||||
const endpoints = await db.select().from(schema.siteApiEndpoints)
|
||||
.where(eq(schema.siteApiEndpoints.siteId, site.id))
|
||||
.all();
|
||||
const firstEndpoint = endpoints.find((item) => item.url === 'https://chatgpt.com/backend-api/codex-a');
|
||||
const secondEndpoint = endpoints.find((item) => item.url === 'https://chatgpt.com/backend-api/codex-b');
|
||||
expect(firstEndpoint?.cooldownUntil).toBeTruthy();
|
||||
expect(firstEndpoint?.lastFailureReason).toContain('HTTP 502');
|
||||
expect(secondEndpoint?.lastSelectedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('discovers Claude OAuth models from the upstream /v1/models response', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockRejectedValue(new Error('claude oauth discovery should not call adapter.getModels'));
|
||||
@@ -1276,6 +1459,86 @@ describe('refreshModelsForAccount credential discovery', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rotates antigravity discovery across configured ai endpoints before using built-in fallback hosts', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels'));
|
||||
undiciFetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({ error: 'endpoint-a unavailable' }),
|
||||
text: async () => 'endpoint-a unavailable',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: {
|
||||
'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' },
|
||||
},
|
||||
}),
|
||||
text: async () => JSON.stringify({ ok: true }),
|
||||
});
|
||||
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'antigravity-endpoint-pool-site',
|
||||
url: 'https://cloudcode-panel.example.com',
|
||||
platform: 'antigravity',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.siteApiEndpoints).values([
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://api-antigravity-a.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://api-antigravity-b.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 1,
|
||||
},
|
||||
]).run();
|
||||
|
||||
const account = await db.insert(schema.accounts).values({
|
||||
siteId: site.id,
|
||||
username: 'antigravity-endpoint-pool@example.com',
|
||||
accessToken: 'antigravity-access-token',
|
||||
apiToken: null,
|
||||
status: 'active',
|
||||
extraConfig: JSON.stringify({
|
||||
credentialMode: 'session',
|
||||
oauth: {
|
||||
provider: 'antigravity',
|
||||
email: 'antigravity-endpoint-pool@example.com',
|
||||
projectId: 'project-demo',
|
||||
},
|
||||
}),
|
||||
}).returning().get();
|
||||
|
||||
const result = await refreshModelsForAccount(account.id);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accountId: account.id,
|
||||
refreshed: true,
|
||||
status: 'success',
|
||||
modelCount: 1,
|
||||
modelsPreview: ['gemini-3-pro-preview'],
|
||||
});
|
||||
expect(undiciFetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://api-antigravity-a.example.com/v1internal:fetchAvailableModels');
|
||||
expect(String(undiciFetchMock.mock.calls[1]?.[0] || '')).toBe('https://api-antigravity-b.example.com/v1internal:fetchAvailableModels');
|
||||
|
||||
const endpoints = await db.select().from(schema.siteApiEndpoints).all();
|
||||
const firstEndpoint = endpoints.find((item) => item.url === 'https://api-antigravity-a.example.com');
|
||||
const secondEndpoint = endpoints.find((item) => item.url === 'https://api-antigravity-b.example.com');
|
||||
expect(firstEndpoint?.cooldownUntil).toBeTruthy();
|
||||
expect(firstEndpoint?.lastFailureReason).toContain('HTTP 503');
|
||||
expect(secondEndpoint?.lastSelectedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('preserves manual models after successful model refresh', async () => {
|
||||
getApiTokenMock.mockResolvedValue(null);
|
||||
getModelsMock.mockResolvedValue(['gpt-4.1', 'claude-opus-4-6']);
|
||||
|
||||
@@ -26,6 +26,7 @@ import { withAccountProxyOverride } from './siteProxy.js';
|
||||
import { isCodexPlatform } from './oauth/codexAccount.js';
|
||||
import { buildStoredOauthStateFromAccount, getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
||||
import { refreshOauthAccessTokenSingleflight } from './oauth/refreshSingleflight.js';
|
||||
import { requireSiteApiBaseUrl } from './siteApiEndpointService.js';
|
||||
import {
|
||||
discoverAntigravityModelsFromCloud,
|
||||
discoverClaudeModelsFromCloud,
|
||||
@@ -775,6 +776,30 @@ export async function refreshModelsForAccount(
|
||||
}
|
||||
}
|
||||
|
||||
let aiBaseUrl: string;
|
||||
try {
|
||||
aiBaseUrl = await requireSiteApiBaseUrl(site);
|
||||
} catch (err) {
|
||||
const rawMessage = (err as { message?: string })?.message || '模型获取失败';
|
||||
const errorCode = classifyModelDiscoveryError(rawMessage);
|
||||
const errorMessage = rawMessage;
|
||||
await setAccountRuntimeHealth(account.id, {
|
||||
state: 'unhealthy',
|
||||
reason: errorMessage,
|
||||
source: 'model-discovery',
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
await restorePreviousAvailability();
|
||||
return buildFailedRefreshResult({
|
||||
accountId,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
tokenScanned: 0,
|
||||
discoveredByCredential: false,
|
||||
discoveredApiToken: !!discoveredApiToken,
|
||||
});
|
||||
}
|
||||
|
||||
const accountModels = new Map<string, string>(); // lowercase key → original name (first-wins)
|
||||
const modelLatency = new Map<string, number | null>();
|
||||
let scannedTokenCount = 0;
|
||||
@@ -813,7 +838,7 @@ export async function refreshModelsForAccount(
|
||||
models = normalizeModels(
|
||||
await withTimeout(
|
||||
() => withAccountProxyOverride(accountProxyUrl,
|
||||
() => adapter.getModels(site.url, credential, platformUserId)),
|
||||
() => adapter.getModels(aiBaseUrl, credential, platformUserId)),
|
||||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||
),
|
||||
@@ -841,7 +866,7 @@ export async function refreshModelsForAccount(
|
||||
models = normalizeModels(
|
||||
await withTimeout(
|
||||
() => withAccountProxyOverride(accountProxyUrl,
|
||||
() => adapter.getModels(site.url, token.token, platformUserId)),
|
||||
() => adapter.getModels(aiBaseUrl, token.token, platformUserId)),
|
||||
MODEL_DISCOVERY_TIMEOUT_MS,
|
||||
`model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fetch } from 'undici';
|
||||
import { schema } from '../db/index.js';
|
||||
import { withSiteRecordProxyRequestInit } from './siteProxy.js';
|
||||
import { runWithSiteApiEndpointPool } from './siteApiEndpointService.js';
|
||||
import { getOauthInfoFromAccount } from './oauth/oauthAccount.js';
|
||||
import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js';
|
||||
import {
|
||||
@@ -136,15 +137,18 @@ export async function discoverCodexModelsFromCloud(input: {
|
||||
headers['Chatgpt-Account-Id'] = oauth.accountId;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
buildCodexModelsEndpoint(input.site.url),
|
||||
withSiteRecordProxyRequestInit(input.site, { method: 'GET', headers }),
|
||||
);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${response.status}: ${text || 'codex model discovery failed'}`);
|
||||
}
|
||||
return normalizeDiscoveredModels(extractCodexModelIds(await response.json()));
|
||||
const payload = await runWithSiteApiEndpointPool(input.site, async (target) => {
|
||||
const response = await fetch(
|
||||
buildCodexModelsEndpoint(target.baseUrl),
|
||||
withSiteRecordProxyRequestInit(input.site, { method: 'GET', headers }),
|
||||
);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${response.status}: ${text || 'codex model discovery failed'}`);
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
return normalizeDiscoveredModels(extractCodexModelIds(payload));
|
||||
}
|
||||
|
||||
export async function discoverClaudeModelsFromCloud(input: {
|
||||
@@ -155,22 +159,25 @@ export async function discoverClaudeModelsFromCloud(input: {
|
||||
if (!accessToken) {
|
||||
throw new Error('claude oauth access token missing');
|
||||
}
|
||||
const response = await fetch(
|
||||
`${input.site.url.replace(/\/+$/, '')}/v1/models`,
|
||||
withSiteRecordProxyRequestInit(input.site, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${response.status}: ${text || 'claude oauth model discovery failed'}`);
|
||||
}
|
||||
return normalizeDiscoveredModels(extractClaudeModelIds(await response.json()));
|
||||
const payload = await runWithSiteApiEndpointPool(input.site, async (target) => {
|
||||
const response = await fetch(
|
||||
`${target.baseUrl.replace(/\/+$/, '')}/v1/models`,
|
||||
withSiteRecordProxyRequestInit(input.site, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`HTTP ${response.status}: ${text || 'claude oauth model discovery failed'}`);
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
return normalizeDiscoveredModels(extractClaudeModelIds(payload));
|
||||
}
|
||||
|
||||
export async function validateGeminiCliOauthConnection(input: {
|
||||
@@ -220,38 +227,45 @@ export async function discoverAntigravityModelsFromCloud(input: {
|
||||
const oauth = getOauthInfoFromAccount(input.account);
|
||||
const projectId = (oauth?.projectId || '').trim();
|
||||
const requestBody = projectId ? { project: projectId } : {};
|
||||
let lastError = '';
|
||||
return runWithSiteApiEndpointPool(input.site, async (target) => {
|
||||
let lastError = '';
|
||||
const selectedBaseUrl = normalizeBaseUrl(target.baseUrl || ANTIGRAVITY_UPSTREAM_BASE_URL) || ANTIGRAVITY_UPSTREAM_BASE_URL;
|
||||
const discoveryBaseUrls = target.endpointId
|
||||
? [selectedBaseUrl]
|
||||
: buildAntigravityDiscoveryBaseUrls(selectedBaseUrl);
|
||||
|
||||
for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/v1internal:fetchAvailableModels`,
|
||||
withSiteRecordProxyRequestInit(input.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
}),
|
||||
);
|
||||
if (!response.ok) {
|
||||
lastError = await response.text().catch(() => '') || `HTTP ${response.status}`;
|
||||
continue;
|
||||
}
|
||||
for (const discoveryBaseUrl of discoveryBaseUrls) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${discoveryBaseUrl}/v1internal:fetchAvailableModels`,
|
||||
withSiteRecordProxyRequestInit(input.site, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
}),
|
||||
);
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
lastError = `HTTP ${response.status}: ${text || '未获取到可用模型'}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const models = normalizeDiscoveredModels(extractAntigravityModelIds(payload));
|
||||
if (models.length > 0) {
|
||||
return models;
|
||||
const payload = await response.json();
|
||||
const models = normalizeDiscoveredModels(extractAntigravityModelIds(payload));
|
||||
if (models.length > 0) {
|
||||
return models;
|
||||
}
|
||||
lastError = '未获取到可用模型';
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? `${discoveryBaseUrl}: ${error.message}` : String(error);
|
||||
}
|
||||
lastError = '未获取到可用模型';
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? `${baseUrl}: ${error.message}` : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(lastError || '未获取到可用模型');
|
||||
throw new Error(lastError || '未获取到可用模型');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,6 +89,18 @@ export async function getProxyVideoTaskByPublicId(publicId: string): Promise<Pro
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveProxyVideoTaskSite(accountId: number | null): Promise<typeof schema.sites.$inferSelect | null> {
|
||||
if (!Number.isFinite(accountId) || !accountId || accountId <= 0) {
|
||||
return null;
|
||||
}
|
||||
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();
|
||||
return row?.sites ?? null;
|
||||
}
|
||||
|
||||
export async function deleteProxyVideoTaskByPublicId(publicId: string): Promise<void> {
|
||||
await db.delete(schema.proxyVideoTasks)
|
||||
.where(eq(schema.proxyVideoTasks.publicId, publicId))
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
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 { asc, eq } from 'drizzle-orm';
|
||||
|
||||
type DbModule = typeof import('../db/index.js');
|
||||
type SiteApiEndpointServiceModule = typeof import('./siteApiEndpointService.js');
|
||||
|
||||
describe('siteApiEndpointService', () => {
|
||||
let db: DbModule['db'];
|
||||
let schema: DbModule['schema'];
|
||||
let selectSiteApiEndpointTarget: SiteApiEndpointServiceModule['selectSiteApiEndpointTarget'];
|
||||
let recordSiteApiEndpointFailure: SiteApiEndpointServiceModule['recordSiteApiEndpointFailure'];
|
||||
let recordSiteApiEndpointSuccess: SiteApiEndpointServiceModule['recordSiteApiEndpointSuccess'];
|
||||
let dataDir = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-api-endpoint-service-'));
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
await import('../db/migrate.js');
|
||||
const dbModule = await import('../db/index.js');
|
||||
const serviceModule = await import('./siteApiEndpointService.js');
|
||||
|
||||
db = dbModule.db;
|
||||
schema = dbModule.schema;
|
||||
selectSiteApiEndpointTarget = serviceModule.selectSiteApiEndpointTarget;
|
||||
recordSiteApiEndpointFailure = serviceModule.recordSiteApiEndpointFailure;
|
||||
recordSiteApiEndpointSuccess = serviceModule.recordSiteApiEndpointSuccess;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.delete(schema.siteApiEndpoints).run();
|
||||
await db.delete(schema.sites).run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.DATA_DIR;
|
||||
});
|
||||
|
||||
it('returns a synthetic site-url fallback when the site has no configured api endpoints', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'panel-only-site',
|
||||
url: 'https://panel.example.com/',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const selected = await selectSiteApiEndpointTarget(site, '2026-03-31T12:00:00.000Z');
|
||||
|
||||
expect(selected).toMatchObject({
|
||||
kind: 'site-fallback',
|
||||
siteId: site.id,
|
||||
endpointId: null,
|
||||
baseUrl: 'https://panel.example.com',
|
||||
configuredEndpointCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('selects the least recently selected enabled endpoint when sort order is tied', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'pool-site',
|
||||
url: 'https://panel.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.siteApiEndpoints).values([
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://api-b.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 1,
|
||||
lastSelectedAt: '2026-03-31T11:59:00.000Z',
|
||||
},
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://api-a.example.com/',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
lastSelectedAt: '2026-03-31T11:00:00.000Z',
|
||||
},
|
||||
]).run();
|
||||
|
||||
const selected = await selectSiteApiEndpointTarget(site, '2026-03-31T12:00:00.000Z');
|
||||
|
||||
expect(selected).toMatchObject({
|
||||
kind: 'endpoint',
|
||||
siteId: site.id,
|
||||
baseUrl: 'https://api-a.example.com',
|
||||
configuredEndpointCount: 2,
|
||||
endpoint: expect.objectContaining({
|
||||
url: 'https://api-a.example.com/',
|
||||
sortOrder: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers lower sortOrder before lastSelectedAt when selecting an enabled endpoint', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'ordered-site',
|
||||
url: 'https://panel.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.siteApiEndpoints).values([
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://api-secondary.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 1,
|
||||
lastSelectedAt: '2026-03-31T11:00:00.000Z',
|
||||
},
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://api-primary.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
lastSelectedAt: '2026-03-31T11:59:00.000Z',
|
||||
},
|
||||
]).run();
|
||||
|
||||
const selected = await selectSiteApiEndpointTarget(site, '2026-03-31T12:00:00.000Z');
|
||||
|
||||
expect(selected).toMatchObject({
|
||||
kind: 'endpoint',
|
||||
siteId: site.id,
|
||||
baseUrl: 'https://api-primary.example.com',
|
||||
configuredEndpointCount: 2,
|
||||
endpoint: expect.objectContaining({
|
||||
url: 'https://api-primary.example.com',
|
||||
sortOrder: 0,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('skips disabled endpoints and endpoints that are still cooling down', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'filtered-site',
|
||||
url: 'https://panel.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.siteApiEndpoints).values([
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://api-disabled.example.com',
|
||||
enabled: false,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://api-cooling.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 1,
|
||||
cooldownUntil: '2026-03-31T12:05:00.000Z',
|
||||
},
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://api-ready.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 2,
|
||||
cooldownUntil: '2026-03-31T11:55:00.000Z',
|
||||
},
|
||||
]).run();
|
||||
|
||||
const selected = await selectSiteApiEndpointTarget(site, '2026-03-31T12:00:00.000Z');
|
||||
|
||||
expect(selected).toMatchObject({
|
||||
kind: 'endpoint',
|
||||
baseUrl: 'https://api-ready.example.com',
|
||||
configuredEndpointCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the site has configured api endpoints but none are currently eligible', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'exhausted-site',
|
||||
url: 'https://panel.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
await db.insert(schema.siteApiEndpoints).values([
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://api-disabled.example.com',
|
||||
enabled: false,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
siteId: site.id,
|
||||
url: 'https://api-cooling.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 1,
|
||||
cooldownUntil: '2026-03-31T12:05:00.000Z',
|
||||
},
|
||||
]).run();
|
||||
|
||||
const selected = await selectSiteApiEndpointTarget(site, '2026-03-31T12:00:00.000Z');
|
||||
|
||||
expect(selected).toBeNull();
|
||||
});
|
||||
|
||||
it('records retryable failures with a 5-minute cooldown', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'retryable-site',
|
||||
url: 'https://panel.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const endpoint = await db.insert(schema.siteApiEndpoints).values({
|
||||
siteId: site.id,
|
||||
url: 'https://api-retryable.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
}).returning().get();
|
||||
|
||||
const result = await recordSiteApiEndpointFailure(endpoint.id, {
|
||||
status: 502,
|
||||
message: 'Bad gateway',
|
||||
}, '2026-03-31T12:00:00.000Z');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
retryable: true,
|
||||
rotateToNextEndpoint: true,
|
||||
cooldownUntil: '2026-03-31T12:05:00.000Z',
|
||||
failureReason: 'HTTP 502: Bad gateway',
|
||||
});
|
||||
|
||||
const stored = await db.select().from(schema.siteApiEndpoints)
|
||||
.where(eq(schema.siteApiEndpoints.id, endpoint.id))
|
||||
.get();
|
||||
expect(stored).toMatchObject({
|
||||
cooldownUntil: '2026-03-31T12:05:00.000Z',
|
||||
lastFailedAt: '2026-03-31T12:00:00.000Z',
|
||||
lastFailureReason: 'HTTP 502: Bad gateway',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses retryable HTTP status codes from failure messages when no explicit status is provided', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'message-status-site',
|
||||
url: 'https://panel.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const endpoint = await db.insert(schema.siteApiEndpoints).values({
|
||||
siteId: site.id,
|
||||
url: 'https://api-message-status.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
}).returning().get();
|
||||
|
||||
const result = await recordSiteApiEndpointFailure(endpoint.id, {
|
||||
message: 'HTTP 502: upstream temporarily unavailable',
|
||||
}, '2026-03-31T12:00:00.000Z');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
retryable: true,
|
||||
rotateToNextEndpoint: true,
|
||||
cooldownUntil: '2026-03-31T12:05:00.000Z',
|
||||
failureReason: 'HTTP 502: upstream temporarily unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('records auth and validation failures without triggering cooldown rotation', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'non-retryable-site',
|
||||
url: 'https://panel.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const endpoint = await db.insert(schema.siteApiEndpoints).values({
|
||||
siteId: site.id,
|
||||
url: 'https://api-auth.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
cooldownUntil: '2026-03-31T11:00:00.000Z',
|
||||
}).returning().get();
|
||||
|
||||
const result = await recordSiteApiEndpointFailure(endpoint.id, {
|
||||
status: 401,
|
||||
message: 'Invalid token',
|
||||
}, '2026-03-31T12:00:00.000Z');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
retryable: false,
|
||||
rotateToNextEndpoint: false,
|
||||
cooldownUntil: null,
|
||||
failureReason: 'HTTP 401: Invalid token',
|
||||
});
|
||||
|
||||
const stored = await db.select().from(schema.siteApiEndpoints)
|
||||
.where(eq(schema.siteApiEndpoints.id, endpoint.id))
|
||||
.get();
|
||||
expect(stored).toMatchObject({
|
||||
cooldownUntil: null,
|
||||
lastFailedAt: '2026-03-31T12:00:00.000Z',
|
||||
lastFailureReason: 'HTTP 401: Invalid token',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears cooldown metadata and updates lastSelectedAt after a recorded success', async () => {
|
||||
const site = await db.insert(schema.sites).values({
|
||||
name: 'success-site',
|
||||
url: 'https://panel.example.com',
|
||||
platform: 'new-api',
|
||||
status: 'active',
|
||||
}).returning().get();
|
||||
|
||||
const endpoint = await db.insert(schema.siteApiEndpoints).values({
|
||||
siteId: site.id,
|
||||
url: 'https://api-success.example.com',
|
||||
enabled: true,
|
||||
sortOrder: 0,
|
||||
cooldownUntil: '2026-03-31T12:05:00.000Z',
|
||||
lastFailedAt: '2026-03-31T12:00:00.000Z',
|
||||
lastFailureReason: 'HTTP 502: Bad gateway',
|
||||
}).returning().get();
|
||||
|
||||
await recordSiteApiEndpointSuccess(endpoint.id, '2026-03-31T12:01:00.000Z');
|
||||
|
||||
const stored = await db.select().from(schema.siteApiEndpoints)
|
||||
.where(eq(schema.siteApiEndpoints.id, endpoint.id))
|
||||
.orderBy(asc(schema.siteApiEndpoints.id))
|
||||
.get();
|
||||
expect(stored).toMatchObject({
|
||||
cooldownUntil: null,
|
||||
lastSelectedAt: '2026-03-31T12:01:00.000Z',
|
||||
lastFailureReason: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { db, schema } from '../db/index.js';
|
||||
import { RETRYABLE_TIMEOUT_PATTERNS } from './proxyRetryPolicy.js';
|
||||
|
||||
const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503, 504]);
|
||||
const NON_RETRYABLE_STATUS_CODES = new Set([400, 401, 403, 404, 422]);
|
||||
const NETWORK_FAILURE_PATTERNS = [
|
||||
/network error/i,
|
||||
/fetch failed/i,
|
||||
/socket hang up/i,
|
||||
/econnreset/i,
|
||||
/econnrefused/i,
|
||||
/enotfound/i,
|
||||
/ehostunreach/i,
|
||||
/ecanceled/i,
|
||||
...RETRYABLE_TIMEOUT_PATTERNS,
|
||||
];
|
||||
|
||||
export const SITE_API_ENDPOINT_COOLDOWN_MS = 5 * 60 * 1000;
|
||||
|
||||
type SiteRow = typeof schema.sites.$inferSelect;
|
||||
type SiteApiEndpointRow = typeof schema.siteApiEndpoints.$inferSelect;
|
||||
|
||||
export interface SiteApiEndpointTarget {
|
||||
kind: 'site-fallback' | 'endpoint';
|
||||
siteId: number;
|
||||
endpointId: number | null;
|
||||
baseUrl: string;
|
||||
configuredEndpointCount: number;
|
||||
endpoint: SiteApiEndpointRow | null;
|
||||
}
|
||||
|
||||
export interface SiteApiEndpointFailureInput {
|
||||
status?: number | null;
|
||||
message?: string | null;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface SiteApiEndpointFailureDisposition {
|
||||
retryable: boolean;
|
||||
rotateToNextEndpoint: boolean;
|
||||
failureReason: string;
|
||||
}
|
||||
|
||||
export interface RecordedSiteApiEndpointFailure extends SiteApiEndpointFailureDisposition {
|
||||
cooldownUntil: string | null;
|
||||
}
|
||||
|
||||
export class SiteApiEndpointRequestError extends Error {
|
||||
readonly status: number | null;
|
||||
readonly rawErrText: string | null;
|
||||
|
||||
constructor(message: string, options?: { status?: number | null; rawErrText?: string | null; cause?: unknown }) {
|
||||
super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
|
||||
this.name = 'SiteApiEndpointRequestError';
|
||||
this.status = typeof options?.status === 'number' ? options.status : null;
|
||||
this.rawErrText = typeof options?.rawErrText === 'string' && options.rawErrText.trim()
|
||||
? options.rawErrText
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSiteApiEndpointBaseUrl(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
} catch {
|
||||
return trimmed.replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
function toIsoTimestamp(now?: string | Date): string {
|
||||
if (typeof now === 'string' && now.trim()) return now;
|
||||
if (now instanceof Date) return now.toISOString();
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function compareNullableTimeAsc(left?: string | null, right?: string | null): number {
|
||||
if (!left && !right) return 0;
|
||||
if (!left) return -1;
|
||||
if (!right) return 1;
|
||||
return left.localeCompare(right);
|
||||
}
|
||||
|
||||
function isEndpointCoolingDown(endpoint: SiteApiEndpointRow, nowIso: string): boolean {
|
||||
return !!endpoint.cooldownUntil && endpoint.cooldownUntil > nowIso;
|
||||
}
|
||||
|
||||
function extractFailureMessage(input: SiteApiEndpointFailureInput): string {
|
||||
const direct = typeof input.message === 'string' ? input.message.trim() : '';
|
||||
if (direct) return direct;
|
||||
const errorMessage = input.error instanceof Error ? input.error.message.trim() : '';
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
function formatFailureReason(status: number | null, message: string): string {
|
||||
if (status && message) {
|
||||
if (message.match(new RegExp(`^HTTP\\s+${status}\\b`, 'i'))) {
|
||||
return message;
|
||||
}
|
||||
return `HTTP ${status}: ${message}`;
|
||||
}
|
||||
if (status) return `HTTP ${status}`;
|
||||
return message || 'endpoint failure';
|
||||
}
|
||||
|
||||
function parseStatusFromFailureMessage(message: string): number | null {
|
||||
const matched = message.match(/\bHTTP\s+(\d{3})\b/i);
|
||||
if (!matched) return null;
|
||||
const status = Number.parseInt(matched[1] || '', 10);
|
||||
return Number.isFinite(status) ? status : null;
|
||||
}
|
||||
|
||||
export function classifySiteApiEndpointFailure(
|
||||
input: SiteApiEndpointFailureInput,
|
||||
): SiteApiEndpointFailureDisposition {
|
||||
const message = extractFailureMessage(input);
|
||||
const status = typeof input.status === 'number'
|
||||
? input.status
|
||||
: parseStatusFromFailureMessage(message);
|
||||
const failureReason = formatFailureReason(status, message);
|
||||
|
||||
if (status !== null) {
|
||||
if (RETRYABLE_STATUS_CODES.has(status)) {
|
||||
return { retryable: true, rotateToNextEndpoint: true, failureReason };
|
||||
}
|
||||
if (NON_RETRYABLE_STATUS_CODES.has(status)) {
|
||||
return { retryable: false, rotateToNextEndpoint: false, failureReason };
|
||||
}
|
||||
}
|
||||
|
||||
if (NETWORK_FAILURE_PATTERNS.some((pattern) => pattern.test(message))) {
|
||||
return { retryable: true, rotateToNextEndpoint: true, failureReason };
|
||||
}
|
||||
|
||||
return { retryable: false, rotateToNextEndpoint: false, failureReason };
|
||||
}
|
||||
|
||||
export async function selectSiteApiEndpointTarget(
|
||||
site: SiteRow,
|
||||
now?: string | Date,
|
||||
): Promise<SiteApiEndpointTarget | null> {
|
||||
const nowIso = toIsoTimestamp(now);
|
||||
const endpoints = await db.select().from(schema.siteApiEndpoints)
|
||||
.where(eq(schema.siteApiEndpoints.siteId, site.id))
|
||||
.orderBy(asc(schema.siteApiEndpoints.sortOrder), asc(schema.siteApiEndpoints.id))
|
||||
.all();
|
||||
|
||||
if (endpoints.length === 0) {
|
||||
return {
|
||||
kind: 'site-fallback',
|
||||
siteId: site.id,
|
||||
endpointId: null,
|
||||
baseUrl: normalizeSiteApiEndpointBaseUrl(site.url),
|
||||
configuredEndpointCount: 0,
|
||||
endpoint: null,
|
||||
};
|
||||
}
|
||||
|
||||
const eligible = endpoints
|
||||
.filter((endpoint) => (endpoint.enabled ?? true) && !isEndpointCoolingDown(endpoint, nowIso))
|
||||
.sort((left, right) => {
|
||||
const sortOrder = (left.sortOrder ?? 0) - (right.sortOrder ?? 0);
|
||||
if (sortOrder !== 0) return sortOrder;
|
||||
const selectionOrder = compareNullableTimeAsc(left.lastSelectedAt, right.lastSelectedAt);
|
||||
if (selectionOrder !== 0) return selectionOrder;
|
||||
return (left.id ?? 0) - (right.id ?? 0);
|
||||
});
|
||||
|
||||
const selected = eligible[0];
|
||||
if (!selected) return null;
|
||||
|
||||
return {
|
||||
kind: 'endpoint',
|
||||
siteId: site.id,
|
||||
endpointId: selected.id,
|
||||
baseUrl: normalizeSiteApiEndpointBaseUrl(selected.url),
|
||||
configuredEndpointCount: endpoints.length,
|
||||
endpoint: selected,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveSiteApiBaseUrl(
|
||||
site: SiteRow,
|
||||
now?: string | Date,
|
||||
): Promise<string | null> {
|
||||
const target = await selectSiteApiEndpointTarget(site, now);
|
||||
return target?.baseUrl || null;
|
||||
}
|
||||
|
||||
export async function requireSiteApiBaseUrl(
|
||||
site: SiteRow,
|
||||
now?: string | Date,
|
||||
): Promise<string> {
|
||||
const baseUrl = await resolveSiteApiBaseUrl(site, now);
|
||||
if (baseUrl) return baseUrl;
|
||||
throw new Error('当前站点的 AI 请求地址均不可用');
|
||||
}
|
||||
|
||||
export async function recordSiteApiEndpointFailure(
|
||||
endpointId: number,
|
||||
input: SiteApiEndpointFailureInput,
|
||||
now?: string | Date,
|
||||
): Promise<RecordedSiteApiEndpointFailure> {
|
||||
const nowIso = toIsoTimestamp(now);
|
||||
const disposition = classifySiteApiEndpointFailure(input);
|
||||
const cooldownUntil = disposition.retryable
|
||||
? new Date(Date.parse(nowIso) + SITE_API_ENDPOINT_COOLDOWN_MS).toISOString()
|
||||
: null;
|
||||
|
||||
await db.update(schema.siteApiEndpoints).set({
|
||||
cooldownUntil,
|
||||
lastFailedAt: nowIso,
|
||||
lastFailureReason: disposition.failureReason,
|
||||
updatedAt: nowIso,
|
||||
}).where(eq(schema.siteApiEndpoints.id, endpointId)).run();
|
||||
|
||||
return {
|
||||
...disposition,
|
||||
cooldownUntil,
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordSiteApiEndpointSuccess(
|
||||
endpointId: number,
|
||||
now?: string | Date,
|
||||
): Promise<void> {
|
||||
const nowIso = toIsoTimestamp(now);
|
||||
await db.update(schema.siteApiEndpoints).set({
|
||||
cooldownUntil: null,
|
||||
lastSelectedAt: nowIso,
|
||||
lastFailureReason: null,
|
||||
updatedAt: nowIso,
|
||||
}).where(eq(schema.siteApiEndpoints.id, endpointId)).run();
|
||||
}
|
||||
|
||||
export async function runWithSiteApiEndpointPool<T>(
|
||||
site: SiteRow,
|
||||
operation: (target: SiteApiEndpointTarget) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const attemptedEndpointIds = new Set<number>();
|
||||
let lastError: unknown;
|
||||
|
||||
while (true) {
|
||||
const target = await selectSiteApiEndpointTarget(site);
|
||||
if (!target) {
|
||||
if (lastError) throw lastError;
|
||||
throw new Error('当前站点的 AI 请求地址均不可用');
|
||||
}
|
||||
if (target.endpointId && attemptedEndpointIds.has(target.endpointId)) {
|
||||
if (lastError) throw lastError;
|
||||
throw new Error('当前站点的 AI 请求地址均不可用');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await operation(target);
|
||||
if (target.endpointId) {
|
||||
try {
|
||||
await recordSiteApiEndpointSuccess(target.endpointId);
|
||||
} catch (error) {
|
||||
console.warn('[siteApiEndpointService] failed to record endpoint success', error);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!target.endpointId) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recordedFailure = await recordSiteApiEndpointFailure(target.endpointId, {
|
||||
status: error instanceof SiteApiEndpointRequestError ? error.status : undefined,
|
||||
message: error instanceof Error ? error.message : String(error ?? ''),
|
||||
error,
|
||||
});
|
||||
if (!recordedFailure.rotateToNextEndpoint) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
attemptedEndpointIds.add(target.endpointId);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user