1
0
forked from boos/BoosAPI

[codex] normalize primary site URL handling (#424)

* normalize primary site url handling

* fix site create redirect test selector

* fix sub2api gemini model discovery and site detect race

* fix sub2api antigravity fallback and test selectors

* fix site modal wording test

* guard blur detect against manual platform changes
This commit is contained in:
Cita
2026-04-05 20:09:34 +08:00
committed by GitHub
parent df75a80bf9
commit d5560fb036
15 changed files with 857 additions and 61 deletions
+73 -5
View File
@@ -514,7 +514,7 @@ describe('sites proxy settings', () => {
});
});
it('canonicalizes create payload url and platform before persistence and conflict checks', async () => {
it('canonicalizes create payload url, strips known non-api api suffixes, and normalizes platform before persistence and conflict checks', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/sites',
@@ -528,7 +528,7 @@ describe('sites proxy settings', () => {
expect(created.statusCode).toBe(200);
expect(created.json()).toMatchObject({
name: 'Aliyun Generic OpenAI',
url: 'https://coding.dashscope.aliyuncs.com/v1',
url: 'https://coding.dashscope.aliyuncs.com',
platform: 'openai',
});
@@ -537,7 +537,7 @@ describe('sites proxy settings', () => {
url: '/api/sites',
payload: {
name: 'Aliyun Generic OpenAI Duplicate',
url: 'https://coding.dashscope.aliyuncs.com/v1',
url: 'https://coding.dashscope.aliyuncs.com',
platform: 'openai',
},
});
@@ -546,7 +546,7 @@ describe('sites proxy settings', () => {
expect((duplicate.json() as { error?: string }).error).toContain('already exists');
});
it('canonicalizes update payload url and platform before saving', async () => {
it('canonicalizes update payload url, strips known non-api api suffixes, and normalizes platform before saving', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/sites',
@@ -569,7 +569,75 @@ describe('sites proxy settings', () => {
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
url: 'https://coding.dashscope.aliyuncs.com/v1',
url: 'https://coding.dashscope.aliyuncs.com',
platform: 'openai',
});
});
it('preserves /api-prefixed main site paths instead of auto-stripping them', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'API Path Site',
url: 'https://panel.example.com/api/v1/models',
platform: 'openai',
},
});
expect(created.statusCode).toBe(200);
expect(created.json()).toMatchObject({
url: 'https://panel.example.com/api/v1/models',
platform: 'openai',
});
});
it('preserves known semantic paths like codex backend-api roots', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'Codex Site',
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
},
});
expect(created.statusCode).toBe(200);
expect(created.json()).toMatchObject({
url: 'https://chatgpt.com/backend-api/codex',
platform: 'codex',
});
});
it('returns canonical root urls for detect requests that hit known non-api api suffixes', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites/detect',
payload: {
url: 'https://api.openai.com/v1/messages',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
url: 'https://api.openai.com',
platform: 'openai',
});
});
it('does not strip /api-prefixed paths from detect responses', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites/detect',
payload: {
url: 'https://api.openai.com/api/v1/models',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
url: 'https://api.openai.com/api/v1/models',
platform: 'openai',
});
});
+7 -6
View File
@@ -3,7 +3,7 @@ import { db, schema } from '../../db/index.js';
import { getInsertedRowId } from '../../db/insertHelpers.js';
import { and, asc, eq, inArray } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js';
import { invalidateSiteProxyCache, normalizeSiteUrl, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
@@ -17,6 +17,7 @@ import {
} from '../../contracts/siteRoutePayloads.js';
import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js';
import { normalizeSiteApiEndpointBaseUrl } from '../../services/siteApiEndpointService.js';
import { analyzePrimarySiteUrl } from '../../../shared/sitePrimaryUrl.js';
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
if (input === undefined || input === null) return null;
@@ -93,9 +94,7 @@ type ErrorLike = {
};
function normalizeCanonicalSiteUrl(value: string): string {
const trimmed = value.trim();
const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}`;
return normalizeSiteUrl(withScheme);
return analyzePrimarySiteUrl(value).persistedUrl;
}
function normalizeSitePlatform(value: string | undefined): string | null {
@@ -520,7 +519,9 @@ export async function sitesRoutes(app: FastifyInstance) {
const existingSites = await db.select().from(schema.sites).all();
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
const canonicalUrl = normalizeCanonicalSiteUrl(url);
const analyzedPrimarySiteUrl = analyzePrimarySiteUrl(url);
const canonicalUrl = analyzedPrimarySiteUrl.persistedUrl;
const detectionUrl = analyzedPrimarySiteUrl.canonicalUrl || canonicalUrl;
const canonicalPlatform = normalizeSitePlatform(platform);
let detectedPlatform = canonicalPlatform;
let responseInitializationPresetId: string | null = explicitInitializationPreset?.id || null;
@@ -528,7 +529,7 @@ export async function sitesRoutes(app: FastifyInstance) {
if (explicitInitializationPreset) {
detectedPlatform = explicitInitializationPreset.platform;
} else {
const detected = await detectSite(canonicalUrl);
const detected = await detectSite(detectionUrl);
detectedPlatform = detected?.platform ?? null;
responseInitializationPresetId = detected?.initializationPresetId || null;
}
@@ -259,6 +259,64 @@ describe('Sub2ApiAdapter', () => {
expect(models).toEqual(['gpt-4o', 'claude-3-opus']);
});
it('fetches gemini models via /v1beta/models when ai base url already targets gemini endpoint', async () => {
await startServer((req, res) => {
if (req.url === '/v1beta/models') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
models: [
{ name: 'models/gemini-2.5-flash' },
{ name: 'models/gemini-2.5-pro' },
],
}));
return;
}
res.writeHead(404).end();
});
const models = await adapter.getModels(`${baseUrl}/v1beta`, 'gemini-key');
expect(models).toEqual(['gemini-2.5-flash', 'gemini-2.5-pro']);
});
it('falls back to /v1beta/models when openai-compatible model endpoints are unavailable', async () => {
await startServer((req, res) => {
if (req.url === '/v1/models' || req.url === '/api/v1/models') {
res.writeHead(404).end();
return;
}
if (req.url === '/v1beta/models') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
models: [
{ name: 'models/gemini-2.5-flash-lite' },
{ name: 'models/gemini-3-pro-preview' },
],
}));
return;
}
res.writeHead(404).end();
});
const models = await adapter.getModels(baseUrl, 'gemini-key');
expect(models).toEqual(['gemini-2.5-flash-lite', 'gemini-3-pro-preview']);
});
it('uses the api/v1 model endpoint directly when the ai base already includes /api/v1', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/models') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
data: [{ id: 'gpt-4o-mini' }, { id: 'claude-3-5-sonnet' }],
}));
return;
}
res.writeHead(404).end();
});
const models = await adapter.getModels(`${baseUrl}/api/v1`, 'jwt-token');
expect(models).toEqual(['gpt-4o-mini', 'claude-3-5-sonnet']);
});
it('fetches models via api key discovered from /api/v1/keys when JWT cannot call /v1/models directly', async () => {
await startServer((req, res) => {
const auth = req.headers.authorization || '';
@@ -299,6 +357,94 @@ describe('Sub2ApiAdapter', () => {
expect(models).toEqual(['gpt-4o-mini', 'claude-3-5-sonnet']);
});
it('discovers an api key for gemini /v1beta/models when session JWT cannot call the endpoint directly', async () => {
await startServer((req, res) => {
const auth = req.headers.authorization || '';
if (req.url === '/v1beta/models' && auth === 'Bearer jwt-token') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: {
code: 401,
message: 'API key is required',
status: 'UNAUTHENTICATED',
},
}));
return;
}
if (req.url === '/api/v1/keys?page=1&page_size=100' && auth === 'Bearer jwt-token') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: {
items: [
{ id: 1, key: 'sk-sub2-gemini', name: 'gemini', status: 'active' },
],
},
}));
return;
}
if (req.url === '/v1beta/models' && auth === 'Bearer sk-sub2-gemini') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
models: [
{ name: 'models/gemini-2.5-flash' },
{ name: 'models/gemini-3.1-pro-preview' },
],
}));
return;
}
res.writeHead(404).end();
});
const models = await adapter.getModels(`${baseUrl}/v1beta`, 'jwt-token');
expect(models).toEqual(['gemini-2.5-flash', 'gemini-3.1-pro-preview']);
});
it('strips a bare antigravity suffix before listing api keys for jwt fallback', async () => {
await startServer((req, res) => {
const auth = req.headers.authorization || '';
if (req.url === '/antigravity/v1beta/models' && auth === 'Bearer jwt-token') {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
error: {
code: 401,
message: 'API key is required',
status: 'UNAUTHENTICATED',
},
}));
return;
}
if (req.url === '/api/v1/keys?page=1&page_size=100' && auth === 'Bearer jwt-token') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: {
items: [
{ id: 1, key: 'sk-sub2-antigravity', name: 'gemini', status: 'active' },
],
},
}));
return;
}
if (req.url === '/antigravity/v1beta/models' && auth === 'Bearer sk-sub2-antigravity') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
models: [
{ name: 'models/gemini-2.5-flash' },
{ name: 'models/gemini-2.5-pro' },
],
}));
return;
}
res.writeHead(404).end();
});
const models = await adapter.getModels(`${baseUrl}/antigravity`, 'jwt-token');
expect(models).toEqual(['gemini-2.5-flash', 'gemini-2.5-pro']);
});
it('handles non-zero code as error in /api/v1/auth/me', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
+57 -5
View File
@@ -436,12 +436,64 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
})();
const models = rawModels
.map((item: any) => (typeof item === 'string' ? item : item?.id))
.map((item: any) => (typeof item === 'string' ? item : item?.id ?? item?.name))
.map((value: unknown) => String(value || '').trim())
.map((value: string) => value.replace(/^models\//i, ''))
.filter(Boolean);
return Array.from(new Set(models));
}
private resolveModelEndpoints(baseUrl: string): string[] {
const normalizedBase = normalizeBaseUrl(baseUrl);
if (!normalizedBase) return [];
if (/\/models$/i.test(normalizedBase)) return [normalizedBase];
if (/\/(?:antigravity\/)?v\d+(?:\.\d+)?(?:beta)?$/i.test(normalizedBase)) {
return [`${normalizedBase}/models`];
}
if (/\/antigravity$/i.test(normalizedBase)) {
return [
`${normalizedBase}/v1/models`,
`${normalizedBase}/v1beta/models`,
];
}
return [
`${normalizedBase}/v1/models`,
`${normalizedBase}/api/v1/models`,
`${normalizedBase}/v1beta/models`,
`${normalizedBase}/antigravity/v1beta/models`,
];
}
private resolveManagementBaseUrl(baseUrl: string): string {
let normalizedBase = normalizeBaseUrl(baseUrl);
if (!normalizedBase) return normalizedBase;
const suffixes = [
'/models',
'/antigravity',
'/antigravity/v1beta',
'/antigravity/v1',
'/api/v1',
'/v1beta',
'/v1',
];
let changed = true;
while (changed) {
changed = false;
for (const suffix of suffixes) {
if (!normalizedBase.toLowerCase().endsWith(suffix)) continue;
const trimmed = normalizeBaseUrl(normalizedBase.slice(0, -suffix.length));
if (!trimmed || trimmed === normalizedBase) continue;
normalizedBase = trimmed;
changed = true;
break;
}
}
return normalizedBase;
}
private async listApiKeys(baseUrl: string, accessToken: string): Promise<Array<{ id: number; key: string; name: string; enabled: boolean; tokenGroup: string | null }>> {
const endpoints = [
'/api/v1/keys?page=1&page_size=100',
@@ -467,10 +519,9 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
const authToken = this.normalizeTokenKeyForCompare(token);
if (!authToken) return [];
const endpoints = ['/v1/models', '/api/v1/models'];
for (const endpoint of endpoints) {
for (const url of this.resolveModelEndpoints(baseUrl)) {
try {
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
const res = await this.fetchJson<any>(url, {
headers: { Authorization: `Bearer ${authToken}` },
});
const models = this.extractModelIds(res);
@@ -679,11 +730,12 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
// --- Models: Standard OpenAI-compatible endpoint ---
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
const normalizedBase = normalizeBaseUrl(baseUrl);
const managementBase = this.resolveManagementBaseUrl(normalizedBase);
const directModels = await this.fetchModelsByToken(normalizedBase, apiToken);
if (directModels.length > 0) return directModels;
// Session JWT cannot access /v1/models directly; discover a user key first.
const discoveredApiToken = await this.getApiToken(normalizedBase, apiToken);
const discoveredApiToken = await this.getApiToken(managementBase, apiToken);
if (!discoveredApiToken) return [];
if (this.normalizeTokenKeyForCompare(discoveredApiToken) === this.normalizeTokenKeyForCompare(apiToken)) {
return [];
+8 -6
View File
@@ -1,18 +1,20 @@
import { detectPlatform } from './platforms/index.js';
import { detectSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
import { stripTrailingSlashes } from './urlNormalization.js';
import { analyzePrimarySiteUrl } from '../../shared/sitePrimaryUrl.js';
export async function detectSite(url: string) {
const normalizedUrl = stripTrailingSlashes(url);
const preset = detectSiteInitializationPreset(normalizedUrl);
const analyzed = analyzePrimarySiteUrl(url);
const detectionUrl = analyzed.canonicalUrl;
const persistedUrl = analyzed.persistedUrl || detectionUrl;
const preset = detectSiteInitializationPreset(detectionUrl);
if (preset) {
return {
url: normalizedUrl,
url: persistedUrl,
platform: preset.platform,
initializationPresetId: preset.id,
};
}
const adapter = await detectPlatform(normalizedUrl);
const adapter = await detectPlatform(detectionUrl);
if (!adapter) return null;
return { url: normalizedUrl, platform: adapter.platformName };
return { url: persistedUrl, platform: adapter.platformName };
}
+14
View File
@@ -1,3 +1,5 @@
import { analyzePrimarySiteUrl } from './sitePrimaryUrl.js';
function normalizeUrlCandidate(url) {
return typeof url === 'string' ? url.trim() : '';
}
@@ -298,5 +300,17 @@ export function detectSiteInitializationPreset(url, platform) {
if (normalizedPlatform && preset.platform !== normalizedPlatform) continue;
if (preset.matches(url)) return clonePreset(preset);
}
if (!normalizedPlatform) return null;
const analyzed = analyzePrimarySiteUrl(url);
for (const preset of SITE_INITIALIZATION_PRESETS) {
if (preset.platform !== normalizedPlatform) continue;
if (!preset.defaultUrl) continue;
const presetAnalyzed = analyzePrimarySiteUrl(preset.defaultUrl);
if (!presetAnalyzed.persistedUrl) continue;
if (presetAnalyzed.persistedUrl === analyzed.persistedUrl) return clonePreset(preset);
}
return null;
}
@@ -190,4 +190,15 @@ describe('siteInitializationPresets', () => {
platform: 'openai',
});
});
it('reselects openai presets from canonicalized root urls when platform is already known', () => {
expect(detectSiteInitializationPreset('https://api.deepseek.com', 'openai')).toMatchObject({
id: 'deepseek-openai',
platform: 'openai',
});
expect(detectSiteInitializationPreset('https://coding.dashscope.aliyuncs.com', 'openai')).toMatchObject({
id: 'codingplan-openai',
platform: 'openai',
});
});
});
+15
View File
@@ -0,0 +1,15 @@
export type PrimarySiteUrlAction =
| 'unchanged'
| 'auto_strip_known_api_suffix'
| 'preserve_api_path'
| 'preserve_semantic_path'
| 'preserve_unknown_path';
export type PrimarySiteUrlAnalysis = {
canonicalUrl: string;
persistedUrl: string;
matchedPath: string;
action: PrimarySiteUrlAction;
};
export declare function analyzePrimarySiteUrl(url: string | null | undefined): PrimarySiteUrlAnalysis;
+107
View File
@@ -0,0 +1,107 @@
function normalizePathname(pathname) {
let normalized = typeof pathname === 'string' ? pathname.trim() : '';
if (!normalized || normalized === '/') return '/';
if (!normalized.startsWith('/')) normalized = `/${normalized}`;
while (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
function parseUrlCandidate(url) {
const trimmed = typeof url === 'string' ? url.trim() : '';
if (!trimmed) return null;
const candidates = trimmed.includes('://')
? [trimmed]
: [`https://${trimmed}`];
for (const candidate of candidates) {
try {
return new URL(candidate);
} catch {}
}
return null;
}
const AUTO_STRIP_PRIMARY_SITE_PATHS = new Set([
'/v1',
'/v1beta',
'/v1/models',
'/v1/chat/completions',
'/v1/responses',
'/v1/messages',
'/v1beta/models',
]);
const SEMANTIC_PRIMARY_SITE_PATHS = new Set([
'/backend-api/codex',
'/anthropic',
'/apps/anthropic',
'/api/anthropic',
'/api/coding/paas/v4',
'/v1beta/openai',
]);
export function analyzePrimarySiteUrl(url) {
const parsed = parseUrlCandidate(url);
if (!parsed) {
const trimmed = typeof url === 'string' ? url.trim().replace(/\/+$/, '') : '';
return {
canonicalUrl: trimmed,
persistedUrl: trimmed,
matchedPath: '',
action: 'unchanged',
};
}
parsed.search = '';
parsed.hash = '';
const matchedPath = normalizePathname(parsed.pathname);
const canonicalUrl = matchedPath === '/'
? parsed.origin
: `${parsed.origin}${matchedPath}`;
if (matchedPath === '/') {
return {
canonicalUrl,
persistedUrl: canonicalUrl,
matchedPath,
action: 'unchanged',
};
}
if (SEMANTIC_PRIMARY_SITE_PATHS.has(matchedPath)) {
return {
canonicalUrl,
persistedUrl: canonicalUrl,
matchedPath,
action: 'preserve_semantic_path',
};
}
if (AUTO_STRIP_PRIMARY_SITE_PATHS.has(matchedPath)) {
return {
canonicalUrl,
persistedUrl: parsed.origin,
matchedPath,
action: 'auto_strip_known_api_suffix',
};
}
if (matchedPath.startsWith('/api')) {
return {
canonicalUrl,
persistedUrl: canonicalUrl,
matchedPath,
action: 'preserve_api_path',
};
}
return {
canonicalUrl,
persistedUrl: canonicalUrl,
matchedPath,
action: 'preserve_unknown_path',
};
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { analyzePrimarySiteUrl } from './sitePrimaryUrl.js';
describe('sitePrimaryUrl', () => {
it('returns unchanged for empty, nullish, and invalid inputs', () => {
expect(analyzePrimarySiteUrl('')).toMatchObject({
canonicalUrl: '',
persistedUrl: '',
action: 'unchanged',
matchedPath: '',
});
expect(analyzePrimarySiteUrl(null as unknown as string)).toMatchObject({
canonicalUrl: '',
persistedUrl: '',
action: 'unchanged',
matchedPath: '',
});
expect(analyzePrimarySiteUrl(undefined as unknown as string)).toMatchObject({
canonicalUrl: '',
persistedUrl: '',
action: 'unchanged',
matchedPath: '',
});
expect(analyzePrimarySiteUrl(' not a valid url/// ')).toMatchObject({
canonicalUrl: 'not a valid url',
persistedUrl: 'not a valid url',
action: 'unchanged',
matchedPath: '',
});
});
it('returns unchanged for root-only urls', () => {
expect(analyzePrimarySiteUrl('https://example.com/')).toMatchObject({
canonicalUrl: 'https://example.com',
persistedUrl: 'https://example.com',
action: 'unchanged',
matchedPath: '/',
});
});
it('auto-strips known non-api request suffixes to the host root', () => {
expect(analyzePrimarySiteUrl('https://api.openai.com/v1/messages?trace=1#frag')).toMatchObject({
canonicalUrl: 'https://api.openai.com/v1/messages',
persistedUrl: 'https://api.openai.com',
action: 'auto_strip_known_api_suffix',
matchedPath: '/v1/messages',
});
});
it('preserves api-prefixed paths and marks them as warnings', () => {
expect(analyzePrimarySiteUrl('api.example.com/api/v1/models')).toMatchObject({
canonicalUrl: 'https://api.example.com/api/v1/models',
persistedUrl: 'https://api.example.com/api/v1/models',
action: 'preserve_api_path',
matchedPath: '/api/v1/models',
});
});
it('preserves known semantic paths without warnings', () => {
expect(analyzePrimarySiteUrl('https://chatgpt.com/backend-api/codex/')).toMatchObject({
canonicalUrl: 'https://chatgpt.com/backend-api/codex',
persistedUrl: 'https://chatgpt.com/backend-api/codex',
action: 'preserve_semantic_path',
matchedPath: '/backend-api/codex',
});
});
it('preserves unknown non-api paths and marks them as warnings', () => {
expect(analyzePrimarySiteUrl('https://gateway.example.com/custom-base')).toMatchObject({
canonicalUrl: 'https://gateway.example.com/custom-base',
persistedUrl: 'https://gateway.example.com/custom-base',
action: 'preserve_unknown_path',
matchedPath: '/custom-base',
});
});
});
+3
View File
@@ -14,6 +14,7 @@ type ModernSelectProps = {
value: string;
onChange: (value: string) => void;
options: ModernSelectOption[];
'data-testid'?: string;
placeholder?: string;
disabled?: boolean;
emptyLabel?: string;
@@ -28,6 +29,7 @@ export default function ModernSelect({
value,
onChange,
options,
'data-testid': dataTestId,
placeholder = 'Select',
disabled = false,
emptyLabel = 'No options',
@@ -112,6 +114,7 @@ export default function ModernSelect({
return (
<div
ref={rootRef}
data-testid={dataTestId}
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
>
<button
+90 -8
View File
@@ -35,6 +35,7 @@ import {
getSiteInitializationPreset,
listSiteInitializationPresets,
} from '../../shared/siteInitializationPresets.js';
import { analyzePrimarySiteUrl } from '../../shared/sitePrimaryUrl.js';
type SiteSubscriptionSummary = {
activeCount: number;
@@ -294,6 +295,22 @@ export default function Sites() {
() => getSiteInitializationPreset(selectedInitializationPresetId),
[selectedInitializationPresetId],
);
const primarySiteUrlAnalysis = useMemo(() => analyzePrimarySiteUrl(form.url), [form.url]);
const latestPrimarySiteUrlRef = useRef(form.url);
const latestPlatformRef = useRef(form.platform);
const latestInitializationPresetIdRef = useRef(selectedInitializationPresetId);
useEffect(() => {
latestPrimarySiteUrlRef.current = form.url;
}, [form.url]);
useEffect(() => {
latestPlatformRef.current = form.platform;
}, [form.platform]);
useEffect(() => {
latestInitializationPresetIdRef.current = selectedInitializationPresetId;
}, [selectedInitializationPresetId]);
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
@@ -529,7 +546,7 @@ export default function Sites() {
const payload = {
name: form.name.trim(),
url: form.url.trim(),
url: primarySiteUrlAnalysis.persistedUrl || form.url.trim(),
externalCheckinUrl: form.externalCheckinUrl.trim(),
platform: form.platform.trim(),
initializationPresetId: selectedInitializationPresetId,
@@ -550,6 +567,13 @@ export default function Sites() {
if (action.kind === 'add') {
const created = await api.addSite(action.payload);
toast.success(`站点 "${payload.name}" 已添加`);
if (
primarySiteUrlAnalysis.action === 'auto_strip_known_api_suffix'
&& typeof created?.url === 'string'
&& created.url.trim()
) {
toast.info(`已自动规范化主站点 URL 为 ${created.url.trim()}`);
}
const createdSiteId = Number(created?.id) || 0;
if (createdSiteId > 0) {
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
@@ -567,8 +591,15 @@ export default function Sites() {
});
}
} else {
await api.updateSite(action.id, action.payload);
const updated = await api.updateSite(action.id, action.payload);
toast.success(`站点 "${payload.name}" 已更新`);
if (
primarySiteUrlAnalysis.action === 'auto_strip_known_api_suffix'
&& typeof updated?.url === 'string'
&& updated.url.trim()
) {
toast.info(`已自动规范化主站点 URL 为 ${updated.url.trim()}`);
}
}
closeEditor();
await load();
@@ -684,22 +715,48 @@ export default function Sites() {
};
const handleDetect = async () => {
if (!form.url.trim()) {
const requestedUrl = form.url.trim();
const requestedPlatform = form.platform.trim();
const requestedInitializationPresetId = selectedInitializationPresetId;
if (!requestedUrl) {
toast.error('请先输入 URL');
return;
}
const requestedPrimarySiteUrl = analyzePrimarySiteUrl(requestedUrl);
setDetecting(true);
try {
const result = await api.detectSite(form.url.trim());
const result = await api.detectSite(requestedUrl);
if (
latestPrimarySiteUrlRef.current.trim() !== requestedUrl
|| latestPlatformRef.current.trim() !== requestedPlatform
|| latestInitializationPresetIdRef.current !== requestedInitializationPresetId
) {
return;
}
if (result?.platform) {
const detectedPreset = getSiteInitializationPreset(result?.initializationPresetId);
setForm((prev) => ({ ...prev, platform: result.platform }));
setForm((prev) => ({
...prev,
platform: result.platform,
url: requestedPrimarySiteUrl.action === 'auto_strip_known_api_suffix'
&& typeof result?.url === 'string'
&& result.url.trim()
? result.url.trim()
: prev.url,
}));
setSelectedInitializationPresetId((current) => {
if (detectedPreset) return detectedPreset.id;
const activePreset = getSiteInitializationPreset(current);
if (activePreset && activePreset.platform !== result.platform) return null;
return current;
});
if (
requestedPrimarySiteUrl.action === 'auto_strip_known_api_suffix'
&& typeof result?.url === 'string'
&& result.url.trim()
) {
toast.info(`已自动规范化主站点 URL 为 ${result.url.trim()}`);
}
toast.success(
detectedPreset
? `检测到平台: ${result.platform}(${detectedPreset.label})`
@@ -1035,7 +1092,7 @@ export default function Sites() {
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
<input
data-testid="site-primary-url-input"
placeholder="站点 URL(面板/登录/签到地址,如 https://console.example.com)"
placeholder="准确主站点 URL(面板/登录/签到地址,如 https://nih.cc)"
value={form.url}
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
onBlur={() => {
@@ -1064,6 +1121,7 @@ export default function Sites() {
}}
>
<ModernSelect
data-testid="site-platform-select"
value={platformSelectValue}
onChange={(value) => {
if (value.startsWith('preset:')) {
@@ -1109,8 +1167,32 @@ export default function Sites() {
</div>
)}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
主站点 URL 用于登录、签到、面板接口和系统访问令牌管理;如果 API 请求地址和主站点不同,请在下面的 API 请求地址池里填写。
请填写准确的主站点 URL。这里填写主站点/面板/登录地址,用于登录、签到、面板接口和系统访问令牌管理;不要把 OpenAI/Gemini 请求路径直接填到主站点 URL;如果 API 请求地址和主站点不同,请在下面的 API 请求地址池里填写。
</div>
{primarySiteUrlAnalysis.action === 'auto_strip_known_api_suffix' && primarySiteUrlAnalysis.persistedUrl ? (
<div className="alert alert-info animate-scale-in">
<div className="alert-title">检测到常见 API 路径后缀</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7 }}>
保存或自动检测时会将主站点 URL 规范化为 {primarySiteUrlAnalysis.persistedUrl}。
</div>
</div>
) : null}
{primarySiteUrlAnalysis.action === 'preserve_api_path' && primarySiteUrlAnalysis.persistedUrl ? (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">请确认主站点 URL</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7 }}>
当前 URL 含 /api 路径,将原样保留。请确认这就是准确的主站点 URL;如果这是 API 请求地址,请填到下方的 API 请求地址池。
</div>
</div>
) : null}
{primarySiteUrlAnalysis.action === 'preserve_unknown_path' && primarySiteUrlAnalysis.persistedUrl ? (
<div className="alert alert-warning animate-scale-in">
<div className="alert-title">请确认主站点 URL</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.7 }}>
当前 URL 含额外路径,将原样保留。请确认这就是准确的主站点 URL;如果这是 API 请求地址,请填到下方的 API 请求地址池。
</div>
</div>
) : null}
<div
style={{
display: 'flex',
@@ -1153,7 +1235,7 @@ export default function Sites() {
>
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
<input
placeholder="API 请求地址(如 https://api.example.com)"
placeholder="API 请求地址(如 https://api.nih.cc)"
value={endpoint.url}
onChange={(e) => updateApiEndpointRow(index, { url: e.target.value })}
style={{ ...formInputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
+4 -4
View File
@@ -16,12 +16,12 @@ describe('Sites centered modal adoption', () => {
expect(source).toContain('API 请求地址池');
expect(source).toContain('+ 添加 API 地址');
expect(source).toContain('站点 URL(面板/登录/签到地址,如 https://console.example.com)');
expect(source).toContain('API 请求地址(如 https://api.example.com)');
expect(source).toContain('准确主站点 URL(面板/登录/签到地址,如 https://nih.cc)');
expect(source).toContain('API 请求地址(如 https://api.nih.cc)');
expect(source).toContain('label="API 请求地址"');
expect(source).toContain('API 地址: {buildSiteApiEndpointSummary(site)}');
expect(source).not.toContain('nih.cc');
expect(source).not.toContain('api.nih.cc');
expect(source).not.toContain('站点 URL(面板/登录/签到地址,如 https://console.example.com)');
expect(source).not.toContain('API 请求地址(如 https://api.example.com)');
expect(source).not.toContain('AI 请求地址池');
expect(source).not.toContain('+ 添加 AI 地址');
expect(source).not.toContain('label="AI 请求地址"');
+16 -27
View File
@@ -40,6 +40,15 @@ function findPrimarySiteUrlInput(root: ReactTestRenderer) {
));
}
function findClickableButtonByText(root: ReactTestRenderer, label: string) {
return root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& node.props['aria-label'] !== '关闭弹框'
&& collectText(node).includes(label)
));
}
function LocationProbe() {
const location = useLocation();
return <div>{`${location.pathname}${location.search}`}</div>;
@@ -106,34 +115,14 @@ async function createSiteAndClickModalChoice(
});
await flushMicrotasks();
// Find the created-site modal and click the appropriate button
const modalContent = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('modal-content')
&& collectText(node).includes('站点创建成功')
));
const modalButtons = modalContent.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& node.props['aria-label'] !== '关闭弹框'
));
const targetButton = choice === 'session'
? findClickableButtonByText(root, createdSite.platform === 'codex' ? '添加 OAuth 连接' : '添加账号(用户名密码登录)')
: choice === 'apikey'
? findClickableButtonByText(root, '添加 API Key')
: findClickableButtonByText(root, '稍后配置');
// Find the button based on choice
let targetButton;
if (choice === 'session') {
const expectedSessionLabel = createdSite.platform === 'codex'
? '添加 OAuth 连接'
: '添加账号(用户名密码登录)';
targetButton = modalButtons.find((btn) => collectText(btn).includes(expectedSessionLabel));
} else if (choice === 'apikey') {
targetButton = modalButtons.find((btn) => collectText(btn).includes('添加 API Key'));
} else {
targetButton = modalButtons.find((btn) => collectText(btn).includes('稍后配置'));
}
expect(targetButton).toBeTruthy();
await act(async () => {
targetButton!.props.onClick();
targetButton.props.onClick();
});
await flushMicrotasks();
@@ -574,7 +563,7 @@ describe('Sites create redirect', () => {
{
id: 36,
name: 'DeepSeek Official',
url: 'https://api.deepseek.com/v1',
url: 'https://api.deepseek.com',
platform: 'openai',
status: 'active',
},
+229
View File
@@ -0,0 +1,229 @@
import type { ReactNode } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import ModernSelect from '../components/ModernSelect.js';
import { ToastProvider } from '../components/Toast.js';
import Sites from './Sites.js';
const { apiMock, toastMock } = vi.hoisted(() => ({
apiMock: {
getSites: vi.fn(),
detectSite: vi.fn(),
getSiteDisabledModels: vi.fn().mockResolvedValue({ models: [] }),
getSiteAvailableModels: vi.fn().mockResolvedValue({ models: [] }),
},
toastMock: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
toast: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/Toast.js', () => ({
ToastProvider: ({ children }: { children: ReactNode }) => children,
useToast: () => toastMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function collectText(node: any): string {
const children = node?.children || [];
return children.map((child: any) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function findPrimarySiteUrlInput(root: ReactTestRenderer) {
return root.root.find((node) => (
node.type === 'input'
&& node.props['data-testid'] === 'site-primary-url-input'
));
}
function findPlatformSelect(root: ReactTestRenderer) {
return root.root.findByProps({ 'data-testid': 'site-platform-select' });
}
describe('Sites detect race handling', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getSites.mockResolvedValue([]);
});
afterEach(() => {
vi.clearAllMocks();
});
it('ignores stale detect results after the operator edits the primary site url', async () => {
const pendingDetect = deferred<{
platform: string;
url: string;
initializationPresetId: string | null;
}>();
apiMock.detectSite.mockReturnValueOnce(pendingDetect.promise);
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/sites']}>
<ToastProvider>
<Sites />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const openAddButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn btn-primary')
&& JSON.stringify(node.props.children).includes('添加站点')
));
await act(async () => {
openAddButton.props.onClick();
});
await flushMicrotasks();
const urlInput = findPrimarySiteUrlInput(root);
await act(async () => {
urlInput.props.onChange({ target: { value: 'https://stale.example.com/v1' } });
});
await flushMicrotasks();
const detectButton = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn btn-ghost')
&& collectText(node).trim() === '自动检测'
)).at(-1);
expect(detectButton).toBeTruthy();
await act(async () => {
void detectButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.detectSite).toHaveBeenCalledWith('https://stale.example.com/v1');
const latestUrlInput = findPrimarySiteUrlInput(root);
await act(async () => {
latestUrlInput.props.onChange({ target: { value: 'https://fresh.example.com' } });
});
await flushMicrotasks();
await act(async () => {
pendingDetect.resolve({
platform: 'openai',
url: 'https://stale.example.com',
initializationPresetId: 'codingplan-openai',
});
await pendingDetect.promise;
});
await flushMicrotasks();
expect(findPrimarySiteUrlInput(root).props.value).toBe('https://fresh.example.com');
expect(findPlatformSelect(root).props.value).toBe('');
expect(toastMock.info).not.toHaveBeenCalledWith(expect.stringContaining('https://stale.example.com'));
expect(toastMock.success).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
it('ignores blur-started detect results after the operator manually selects a platform', async () => {
const pendingDetect = deferred<{
platform: string;
url: string;
initializationPresetId: string | null;
}>();
apiMock.detectSite.mockReturnValueOnce(pendingDetect.promise);
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/sites']}>
<ToastProvider>
<Sites />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const openAddButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn btn-primary')
&& JSON.stringify(node.props.children).includes('添加站点')
));
await act(async () => {
openAddButton.props.onClick();
});
await flushMicrotasks();
const urlInput = findPrimarySiteUrlInput(root);
await act(async () => {
urlInput.props.onChange({ target: { value: 'https://blur.example.com/v1' } });
});
await flushMicrotasks();
await act(async () => {
urlInput.props.onBlur();
});
await flushMicrotasks();
expect(apiMock.detectSite).toHaveBeenCalledWith('https://blur.example.com/v1');
const platformSelect = findPlatformSelect(root);
await act(async () => {
platformSelect.props.onChange('claude');
});
await flushMicrotasks();
await act(async () => {
pendingDetect.resolve({
platform: 'openai',
url: 'https://blur.example.com',
initializationPresetId: 'codingplan-openai',
});
await pendingDetect.promise;
});
await flushMicrotasks();
expect(findPlatformSelect(root).props.value).toBe('claude');
expect(toastMock.info).not.toHaveBeenCalledWith(expect.stringContaining('https://blur.example.com'));
expect(toastMock.success).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
});