[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
+55
View File
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
buildSiteSaveAction,
emptySiteApiEndpoint,
emptySiteCustomHeader,
emptySiteForm,
serializeSiteApiEndpoints,
serializeSiteCustomHeaders,
siteFormFromSite,
} from './sitesEditor.js';
@@ -17,6 +19,10 @@ describe('buildSiteSaveAction', () => {
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
proxyUrl: 'socks5://127.0.0.1:1080',
apiEndpoints: [
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
],
customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false,
globalWeight: 1.2,
@@ -31,6 +37,10 @@ describe('buildSiteSaveAction', () => {
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
proxyUrl: 'socks5://127.0.0.1:1080',
apiEndpoints: [
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
],
customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false,
globalWeight: 1.2,
@@ -48,6 +58,7 @@ describe('buildSiteSaveAction', () => {
platform: 'one-api',
proxyUrl: '',
useSystemProxy: true,
apiEndpoints: [],
customHeaders: '',
globalWeight: 0.8,
},
@@ -63,6 +74,7 @@ describe('buildSiteSaveAction', () => {
platform: 'one-api',
proxyUrl: '',
useSystemProxy: true,
apiEndpoints: [],
customHeaders: '',
globalWeight: 0.8,
},
@@ -80,6 +92,7 @@ describe('buildSiteSaveAction', () => {
platform: '',
proxyUrl: '',
useSystemProxy: false,
apiEndpoints: [],
customHeaders: '',
globalWeight: 1,
},
@@ -94,6 +107,14 @@ describe('buildSiteSaveAction', () => {
externalCheckinUrl: null,
platform: 'new-api',
proxyUrl: 'http://127.0.0.1:8080',
apiEndpoints: [
{
url: 'https://api.example.com',
enabled: false,
cooldownUntil: '2026-04-01T00:05:00.000Z',
lastFailureReason: 'HTTP 502',
},
],
customHeaders: '{"x-site-token":"alpha"}',
globalWeight: 1,
apiKey: 'sk-legacy-site-key',
@@ -101,11 +122,20 @@ describe('buildSiteSaveAction', () => {
expect(emptySiteForm()).not.toHaveProperty('apiKey');
expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]);
expect(emptySiteForm().apiEndpoints).toEqual([emptySiteApiEndpoint()]);
expect(emptySiteForm().proxyUrl).toBe('');
expect(siteFormFromSite(legacySite)).not.toHaveProperty('apiKey');
expect(siteFormFromSite({
proxyUrl: 'http://127.0.0.1:8080',
}).proxyUrl).toBe('http://127.0.0.1:8080');
expect(siteFormFromSite(legacySite).apiEndpoints).toEqual([
{
url: 'https://api.example.com',
enabled: false,
cooldownUntil: '2026-04-01T00:05:00.000Z',
lastFailureReason: 'HTTP 502',
},
]);
});
it('parses custom headers json into key value rows', () => {
@@ -139,4 +169,29 @@ describe('buildSiteSaveAction', () => {
error: '请求头 "authorization" 重复了',
});
});
it('serializes api endpoint rows into ordered payloads', () => {
expect(serializeSiteApiEndpoints([
{ url: 'https://api-a.example.com/', enabled: true },
{ url: 'https://api-b.example.com', enabled: false },
emptySiteApiEndpoint(),
])).toEqual({
valid: true,
apiEndpoints: [
{ url: 'https://api-a.example.com', enabled: true, sortOrder: 0 },
{ url: 'https://api-b.example.com', enabled: false, sortOrder: 1 },
],
});
});
it('rejects duplicate api endpoints after normalization', () => {
expect(serializeSiteApiEndpoints([
{ url: 'https://api.example.com/', enabled: true },
{ url: 'https://api.example.com', enabled: true },
])).toEqual({
valid: false,
apiEndpoints: [],
error: 'AI 请求地址 "https://api.example.com" 重复了',
});
});
});
+112 -1
View File
@@ -3,6 +3,14 @@ export type SiteCustomHeaderField = {
value: string;
};
export type SiteApiEndpointField = {
draftId?: string;
url: string;
enabled: boolean;
cooldownUntil?: string | null;
lastFailureReason?: string | null;
};
export type SiteForm = {
name: string;
url: string;
@@ -10,6 +18,7 @@ export type SiteForm = {
platform: string;
proxyUrl: string;
useSystemProxy: boolean;
apiEndpoints: SiteApiEndpointField[];
customHeaders: SiteCustomHeaderField[];
globalWeight: string;
};
@@ -26,6 +35,11 @@ export type SiteSavePayload = {
initializationPresetId?: string | null;
proxyUrl: string;
useSystemProxy: boolean;
apiEndpoints: Array<{
url: string;
enabled: boolean;
sortOrder: number;
}>;
customHeaders: string;
globalWeight: number;
};
@@ -38,6 +52,15 @@ export function emptySiteCustomHeader(): SiteCustomHeaderField {
return { key: '', value: '' };
}
export function emptySiteApiEndpoint(): SiteApiEndpointField {
return {
url: '',
enabled: true,
cooldownUntil: null,
lastFailureReason: null,
};
}
function ensureSiteCustomHeaderRows(rows: SiteCustomHeaderField[]): SiteCustomHeaderField[] {
return rows.length > 0 ? rows : [emptySiteCustomHeader()];
}
@@ -50,11 +73,16 @@ export function emptySiteForm(): SiteForm {
platform: '',
proxyUrl: '',
useSystemProxy: false,
apiEndpoints: [emptySiteApiEndpoint()],
customHeaders: [emptySiteCustomHeader()],
globalWeight: '1',
};
}
function ensureSiteApiEndpointRows(rows: SiteApiEndpointField[]): SiteApiEndpointField[] {
return rows.length > 0 ? rows : [emptySiteApiEndpoint()];
}
function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
if (typeof raw !== 'string') {
return ensureSiteCustomHeaderRows([]);
@@ -80,10 +108,35 @@ function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
}
}
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'proxyUrl' | 'useSystemProxy'>> & {
function parseApiEndpointsForEditor(raw: unknown): SiteApiEndpointField[] {
if (!Array.isArray(raw)) {
return ensureSiteApiEndpointRows([]);
}
const rows: SiteApiEndpointField[] = [];
for (const item of raw) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
const row = item as Record<string, unknown>;
rows.push({
url: typeof row.url === 'string' ? row.url : '',
enabled: row.enabled !== false,
cooldownUntil: typeof row.cooldownUntil === 'string' ? row.cooldownUntil : null,
lastFailureReason: typeof row.lastFailureReason === 'string' ? row.lastFailureReason : null,
});
}
return ensureSiteApiEndpointRows(rows);
}
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'apiEndpoints' | 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'proxyUrl' | 'useSystemProxy'>> & {
externalCheckinUrl?: string | null;
proxyUrl?: string | null;
useSystemProxy?: boolean | null;
apiEndpoints?: Array<{
url?: string | null;
enabled?: boolean | null;
cooldownUntil?: string | null;
lastFailureReason?: string | null;
}> | null;
customHeaders?: string | null;
globalWeight?: number | string | null;
}): SiteForm {
@@ -96,11 +149,27 @@ export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' |
platform: site.platform ?? '',
proxyUrl: site.proxyUrl ?? '',
useSystemProxy: !!site.useSystemProxy,
apiEndpoints: parseApiEndpointsForEditor(site.apiEndpoints),
customHeaders: parseCustomHeadersForEditor(site.customHeaders),
globalWeight,
};
}
// Keep this in sync with normalizeSiteApiEndpointBaseUrl in
// src/server/services/siteApiEndpointService.ts.
function normalizeSiteApiEndpointUrl(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(/\/+$/, '');
}
}
export function serializeSiteCustomHeaders(fields: SiteCustomHeaderField[]): {
valid: boolean;
customHeaders: string;
@@ -131,6 +200,48 @@ export function serializeSiteCustomHeaders(fields: SiteCustomHeaderField[]): {
};
}
export function serializeSiteApiEndpoints(fields: SiteApiEndpointField[]): {
valid: boolean;
apiEndpoints: Array<{
url: string;
enabled: boolean;
sortOrder: number;
}>;
error?: string;
} {
const apiEndpoints: Array<{
url: string;
enabled: boolean;
sortOrder: number;
}> = [];
const seen = new Set<string>();
for (const field of fields) {
const rawUrl = field.url.trim();
if (!rawUrl) continue;
const normalizedUrl = normalizeSiteApiEndpointUrl(rawUrl);
if (!normalizedUrl) continue;
if (seen.has(normalizedUrl)) {
return {
valid: false,
apiEndpoints: [],
error: `AI 请求地址 "${normalizedUrl}" 重复了`,
};
}
seen.add(normalizedUrl);
apiEndpoints.push({
url: normalizedUrl || rawUrl,
enabled: field.enabled !== false,
sortOrder: apiEndpoints.length,
});
}
return {
valid: true,
apiEndpoints,
};
}
export function buildSiteSaveAction(editor: SiteEditorState, form: SiteSavePayload): SiteSaveAction {
if (editor.mode === 'edit') {
if (!Number.isFinite(editor.editingSiteId)) {