[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:
Cita
2026-04-01 20:58:37 +08:00
committed by GitHub
parent ce77bde95a
commit 76c85d0a13
49 changed files with 7226 additions and 986 deletions
+90 -1
View File
@@ -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' }),
]);
+31
View File
@@ -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']);
+27 -2
View File
@@ -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);
}
}
}