[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:
@@ -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({
|
const created = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/sites',
|
url: '/api/sites',
|
||||||
@@ -528,7 +528,7 @@ describe('sites proxy settings', () => {
|
|||||||
expect(created.statusCode).toBe(200);
|
expect(created.statusCode).toBe(200);
|
||||||
expect(created.json()).toMatchObject({
|
expect(created.json()).toMatchObject({
|
||||||
name: 'Aliyun Generic OpenAI',
|
name: 'Aliyun Generic OpenAI',
|
||||||
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
url: 'https://coding.dashscope.aliyuncs.com',
|
||||||
platform: 'openai',
|
platform: 'openai',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -537,7 +537,7 @@ describe('sites proxy settings', () => {
|
|||||||
url: '/api/sites',
|
url: '/api/sites',
|
||||||
payload: {
|
payload: {
|
||||||
name: 'Aliyun Generic OpenAI Duplicate',
|
name: 'Aliyun Generic OpenAI Duplicate',
|
||||||
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
url: 'https://coding.dashscope.aliyuncs.com',
|
||||||
platform: 'openai',
|
platform: 'openai',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -546,7 +546,7 @@ describe('sites proxy settings', () => {
|
|||||||
expect((duplicate.json() as { error?: string }).error).toContain('already exists');
|
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({
|
const created = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/sites',
|
url: '/api/sites',
|
||||||
@@ -569,7 +569,75 @@ describe('sites proxy settings', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toMatchObject({
|
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',
|
platform: 'openai',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { db, schema } from '../../db/index.js';
|
|||||||
import { getInsertedRowId } from '../../db/insertHelpers.js';
|
import { getInsertedRowId } from '../../db/insertHelpers.js';
|
||||||
import { and, asc, eq, inArray } from 'drizzle-orm';
|
import { and, asc, eq, inArray } from 'drizzle-orm';
|
||||||
import { detectSite } from '../../services/siteDetector.js';
|
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 { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
||||||
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '../../contracts/siteRoutePayloads.js';
|
} from '../../contracts/siteRoutePayloads.js';
|
||||||
import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js';
|
import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js';
|
||||||
import { normalizeSiteApiEndpointBaseUrl } from '../../services/siteApiEndpointService.js';
|
import { normalizeSiteApiEndpointBaseUrl } from '../../services/siteApiEndpointService.js';
|
||||||
|
import { analyzePrimarySiteUrl } from '../../../shared/sitePrimaryUrl.js';
|
||||||
|
|
||||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||||
if (input === undefined || input === null) return null;
|
if (input === undefined || input === null) return null;
|
||||||
@@ -93,9 +94,7 @@ type ErrorLike = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function normalizeCanonicalSiteUrl(value: string): string {
|
function normalizeCanonicalSiteUrl(value: string): string {
|
||||||
const trimmed = value.trim();
|
return analyzePrimarySiteUrl(value).persistedUrl;
|
||||||
const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}`;
|
|
||||||
return normalizeSiteUrl(withScheme);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSitePlatform(value: string | undefined): string | null {
|
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 existingSites = await db.select().from(schema.sites).all();
|
||||||
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
|
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);
|
const canonicalPlatform = normalizeSitePlatform(platform);
|
||||||
let detectedPlatform = canonicalPlatform;
|
let detectedPlatform = canonicalPlatform;
|
||||||
let responseInitializationPresetId: string | null = explicitInitializationPreset?.id || null;
|
let responseInitializationPresetId: string | null = explicitInitializationPreset?.id || null;
|
||||||
@@ -528,7 +529,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
if (explicitInitializationPreset) {
|
if (explicitInitializationPreset) {
|
||||||
detectedPlatform = explicitInitializationPreset.platform;
|
detectedPlatform = explicitInitializationPreset.platform;
|
||||||
} else {
|
} else {
|
||||||
const detected = await detectSite(canonicalUrl);
|
const detected = await detectSite(detectionUrl);
|
||||||
detectedPlatform = detected?.platform ?? null;
|
detectedPlatform = detected?.platform ?? null;
|
||||||
responseInitializationPresetId = detected?.initializationPresetId || null;
|
responseInitializationPresetId = detected?.initializationPresetId || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,6 +259,64 @@ describe('Sub2ApiAdapter', () => {
|
|||||||
expect(models).toEqual(['gpt-4o', 'claude-3-opus']);
|
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 () => {
|
it('fetches models via api key discovered from /api/v1/keys when JWT cannot call /v1/models directly', async () => {
|
||||||
await startServer((req, res) => {
|
await startServer((req, res) => {
|
||||||
const auth = req.headers.authorization || '';
|
const auth = req.headers.authorization || '';
|
||||||
@@ -299,6 +357,94 @@ describe('Sub2ApiAdapter', () => {
|
|||||||
expect(models).toEqual(['gpt-4o-mini', 'claude-3-5-sonnet']);
|
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 () => {
|
it('handles non-zero code as error in /api/v1/auth/me', async () => {
|
||||||
await startServer((req, res) => {
|
await startServer((req, res) => {
|
||||||
if (req.url === '/api/v1/auth/me') {
|
if (req.url === '/api/v1/auth/me') {
|
||||||
|
|||||||
@@ -436,12 +436,64 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const models = rawModels
|
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: unknown) => String(value || '').trim())
|
||||||
|
.map((value: string) => value.replace(/^models\//i, ''))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return Array.from(new Set(models));
|
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 }>> {
|
private async listApiKeys(baseUrl: string, accessToken: string): Promise<Array<{ id: number; key: string; name: string; enabled: boolean; tokenGroup: string | null }>> {
|
||||||
const endpoints = [
|
const endpoints = [
|
||||||
'/api/v1/keys?page=1&page_size=100',
|
'/api/v1/keys?page=1&page_size=100',
|
||||||
@@ -467,10 +519,9 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
|||||||
const authToken = this.normalizeTokenKeyForCompare(token);
|
const authToken = this.normalizeTokenKeyForCompare(token);
|
||||||
if (!authToken) return [];
|
if (!authToken) return [];
|
||||||
|
|
||||||
const endpoints = ['/v1/models', '/api/v1/models'];
|
for (const url of this.resolveModelEndpoints(baseUrl)) {
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, {
|
const res = await this.fetchJson<any>(url, {
|
||||||
headers: { Authorization: `Bearer ${authToken}` },
|
headers: { Authorization: `Bearer ${authToken}` },
|
||||||
});
|
});
|
||||||
const models = this.extractModelIds(res);
|
const models = this.extractModelIds(res);
|
||||||
@@ -679,11 +730,12 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
|
|||||||
// --- Models: Standard OpenAI-compatible endpoint ---
|
// --- Models: Standard OpenAI-compatible endpoint ---
|
||||||
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
|
async getModels(baseUrl: string, apiToken: string): Promise<string[]> {
|
||||||
const normalizedBase = normalizeBaseUrl(baseUrl);
|
const normalizedBase = normalizeBaseUrl(baseUrl);
|
||||||
|
const managementBase = this.resolveManagementBaseUrl(normalizedBase);
|
||||||
const directModels = await this.fetchModelsByToken(normalizedBase, apiToken);
|
const directModels = await this.fetchModelsByToken(normalizedBase, apiToken);
|
||||||
if (directModels.length > 0) return directModels;
|
if (directModels.length > 0) return directModels;
|
||||||
|
|
||||||
// Session JWT cannot access /v1/models directly; discover a user key first.
|
// 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 (!discoveredApiToken) return [];
|
||||||
if (this.normalizeTokenKeyForCompare(discoveredApiToken) === this.normalizeTokenKeyForCompare(apiToken)) {
|
if (this.normalizeTokenKeyForCompare(discoveredApiToken) === this.normalizeTokenKeyForCompare(apiToken)) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { detectPlatform } from './platforms/index.js';
|
import { detectPlatform } from './platforms/index.js';
|
||||||
import { detectSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
|
import { detectSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
|
||||||
import { stripTrailingSlashes } from './urlNormalization.js';
|
import { analyzePrimarySiteUrl } from '../../shared/sitePrimaryUrl.js';
|
||||||
|
|
||||||
export async function detectSite(url: string) {
|
export async function detectSite(url: string) {
|
||||||
const normalizedUrl = stripTrailingSlashes(url);
|
const analyzed = analyzePrimarySiteUrl(url);
|
||||||
const preset = detectSiteInitializationPreset(normalizedUrl);
|
const detectionUrl = analyzed.canonicalUrl;
|
||||||
|
const persistedUrl = analyzed.persistedUrl || detectionUrl;
|
||||||
|
const preset = detectSiteInitializationPreset(detectionUrl);
|
||||||
if (preset) {
|
if (preset) {
|
||||||
return {
|
return {
|
||||||
url: normalizedUrl,
|
url: persistedUrl,
|
||||||
platform: preset.platform,
|
platform: preset.platform,
|
||||||
initializationPresetId: preset.id,
|
initializationPresetId: preset.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const adapter = await detectPlatform(normalizedUrl);
|
const adapter = await detectPlatform(detectionUrl);
|
||||||
if (!adapter) return null;
|
if (!adapter) return null;
|
||||||
return { url: normalizedUrl, platform: adapter.platformName };
|
return { url: persistedUrl, platform: adapter.platformName };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { analyzePrimarySiteUrl } from './sitePrimaryUrl.js';
|
||||||
|
|
||||||
function normalizeUrlCandidate(url) {
|
function normalizeUrlCandidate(url) {
|
||||||
return typeof url === 'string' ? url.trim() : '';
|
return typeof url === 'string' ? url.trim() : '';
|
||||||
}
|
}
|
||||||
@@ -298,5 +300,17 @@ export function detectSiteInitializationPreset(url, platform) {
|
|||||||
if (normalizedPlatform && preset.platform !== normalizedPlatform) continue;
|
if (normalizedPlatform && preset.platform !== normalizedPlatform) continue;
|
||||||
if (preset.matches(url)) return clonePreset(preset);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,4 +190,15 @@ describe('siteInitializationPresets', () => {
|
|||||||
platform: 'openai',
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+15
@@ -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;
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ type ModernSelectProps = {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
options: ModernSelectOption[];
|
options: ModernSelectOption[];
|
||||||
|
'data-testid'?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
emptyLabel?: string;
|
emptyLabel?: string;
|
||||||
@@ -28,6 +29,7 @@ export default function ModernSelect({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
|
'data-testid': dataTestId,
|
||||||
placeholder = 'Select',
|
placeholder = 'Select',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
emptyLabel = 'No options',
|
emptyLabel = 'No options',
|
||||||
@@ -112,6 +114,7 @@ export default function ModernSelect({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
|
data-testid={dataTestId}
|
||||||
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
|
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
+90
-8
@@ -35,6 +35,7 @@ import {
|
|||||||
getSiteInitializationPreset,
|
getSiteInitializationPreset,
|
||||||
listSiteInitializationPresets,
|
listSiteInitializationPresets,
|
||||||
} from '../../shared/siteInitializationPresets.js';
|
} from '../../shared/siteInitializationPresets.js';
|
||||||
|
import { analyzePrimarySiteUrl } from '../../shared/sitePrimaryUrl.js';
|
||||||
|
|
||||||
type SiteSubscriptionSummary = {
|
type SiteSubscriptionSummary = {
|
||||||
activeCount: number;
|
activeCount: number;
|
||||||
@@ -294,6 +295,22 @@ export default function Sites() {
|
|||||||
() => getSiteInitializationPreset(selectedInitializationPresetId),
|
() => getSiteInitializationPreset(selectedInitializationPresetId),
|
||||||
[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]);
|
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
|
||||||
|
|
||||||
@@ -529,7 +546,7 @@ export default function Sites() {
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
url: form.url.trim(),
|
url: primarySiteUrlAnalysis.persistedUrl || form.url.trim(),
|
||||||
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
||||||
platform: form.platform.trim(),
|
platform: form.platform.trim(),
|
||||||
initializationPresetId: selectedInitializationPresetId,
|
initializationPresetId: selectedInitializationPresetId,
|
||||||
@@ -550,6 +567,13 @@ export default function Sites() {
|
|||||||
if (action.kind === 'add') {
|
if (action.kind === 'add') {
|
||||||
const created = await api.addSite(action.payload);
|
const created = await api.addSite(action.payload);
|
||||||
toast.success(`站点 "${payload.name}" 已添加`);
|
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;
|
const createdSiteId = Number(created?.id) || 0;
|
||||||
if (createdSiteId > 0) {
|
if (createdSiteId > 0) {
|
||||||
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
|
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
|
||||||
@@ -567,8 +591,15 @@ export default function Sites() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await api.updateSite(action.id, action.payload);
|
const updated = await api.updateSite(action.id, action.payload);
|
||||||
toast.success(`站点 "${payload.name}" 已更新`);
|
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();
|
closeEditor();
|
||||||
await load();
|
await load();
|
||||||
@@ -684,22 +715,48 @@ export default function Sites() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDetect = async () => {
|
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');
|
toast.error('请先输入 URL');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const requestedPrimarySiteUrl = analyzePrimarySiteUrl(requestedUrl);
|
||||||
setDetecting(true);
|
setDetecting(true);
|
||||||
try {
|
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) {
|
if (result?.platform) {
|
||||||
const detectedPreset = getSiteInitializationPreset(result?.initializationPresetId);
|
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) => {
|
setSelectedInitializationPresetId((current) => {
|
||||||
if (detectedPreset) return detectedPreset.id;
|
if (detectedPreset) return detectedPreset.id;
|
||||||
const activePreset = getSiteInitializationPreset(current);
|
const activePreset = getSiteInitializationPreset(current);
|
||||||
if (activePreset && activePreset.platform !== result.platform) return null;
|
if (activePreset && activePreset.platform !== result.platform) return null;
|
||||||
return current;
|
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(
|
toast.success(
|
||||||
detectedPreset
|
detectedPreset
|
||||||
? `检测到平台: ${result.platform}(${detectedPreset.label})`
|
? `检测到平台: ${result.platform}(${detectedPreset.label})`
|
||||||
@@ -1035,7 +1092,7 @@ export default function Sites() {
|
|||||||
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
|
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||||
<input
|
<input
|
||||||
data-testid="site-primary-url-input"
|
data-testid="site-primary-url-input"
|
||||||
placeholder="站点 URL(面板/登录/签到地址,如 https://console.example.com)"
|
placeholder="准确主站点 URL(面板/登录/签到地址,如 https://nih.cc)"
|
||||||
value={form.url}
|
value={form.url}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
@@ -1064,6 +1121,7 @@ export default function Sites() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModernSelect
|
<ModernSelect
|
||||||
|
data-testid="site-platform-select"
|
||||||
value={platformSelectValue}
|
value={platformSelectValue}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (value.startsWith('preset:')) {
|
if (value.startsWith('preset:')) {
|
||||||
@@ -1109,8 +1167,32 @@ export default function Sites() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||||
主站点 URL 用于登录、签到、面板接口和系统访问令牌管理;如果 API 请求地址和主站点不同,请在下面的 API 请求地址池里填写。
|
请填写准确的主站点 URL。这里填写主站点/面板/登录地址,用于登录、签到、面板接口和系统访问令牌管理;不要把 OpenAI/Gemini 请求路径直接填到主站点 URL;如果 API 请求地址和主站点不同,请在下面的 API 请求地址池里填写。
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -1153,7 +1235,7 @@ export default function Sites() {
|
|||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
|
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
|
||||||
<input
|
<input
|
||||||
placeholder="API 请求地址(如 https://api.example.com)"
|
placeholder="API 请求地址(如 https://api.nih.cc)"
|
||||||
value={endpoint.url}
|
value={endpoint.url}
|
||||||
onChange={(e) => updateApiEndpointRow(index, { url: e.target.value })}
|
onChange={(e) => updateApiEndpointRow(index, { url: e.target.value })}
|
||||||
style={{ ...formInputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
|
style={{ ...formInputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ describe('Sites centered modal adoption', () => {
|
|||||||
|
|
||||||
expect(source).toContain('API 请求地址池');
|
expect(source).toContain('API 请求地址池');
|
||||||
expect(source).toContain('+ 添加 API 地址');
|
expect(source).toContain('+ 添加 API 地址');
|
||||||
expect(source).toContain('站点 URL(面板/登录/签到地址,如 https://console.example.com)');
|
expect(source).toContain('准确主站点 URL(面板/登录/签到地址,如 https://nih.cc)');
|
||||||
expect(source).toContain('API 请求地址(如 https://api.example.com)');
|
expect(source).toContain('API 请求地址(如 https://api.nih.cc)');
|
||||||
expect(source).toContain('label="API 请求地址"');
|
expect(source).toContain('label="API 请求地址"');
|
||||||
expect(source).toContain('API 地址: {buildSiteApiEndpointSummary(site)}');
|
expect(source).toContain('API 地址: {buildSiteApiEndpointSummary(site)}');
|
||||||
expect(source).not.toContain('nih.cc');
|
expect(source).not.toContain('站点 URL(面板/登录/签到地址,如 https://console.example.com)');
|
||||||
expect(source).not.toContain('api.nih.cc');
|
expect(source).not.toContain('API 请求地址(如 https://api.example.com)');
|
||||||
expect(source).not.toContain('AI 请求地址池');
|
expect(source).not.toContain('AI 请求地址池');
|
||||||
expect(source).not.toContain('+ 添加 AI 地址');
|
expect(source).not.toContain('+ 添加 AI 地址');
|
||||||
expect(source).not.toContain('label="AI 请求地址"');
|
expect(source).not.toContain('label="AI 请求地址"');
|
||||||
|
|||||||
@@ -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() {
|
function LocationProbe() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
return <div>{`${location.pathname}${location.search}`}</div>;
|
return <div>{`${location.pathname}${location.search}`}</div>;
|
||||||
@@ -106,34 +115,14 @@ async function createSiteAndClickModalChoice(
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
// Find the created-site modal and click the appropriate button
|
const targetButton = choice === 'session'
|
||||||
const modalContent = root.root.find((node) => (
|
? findClickableButtonByText(root, createdSite.platform === 'codex' ? '添加 OAuth 连接' : '添加账号(用户名密码登录)')
|
||||||
typeof node.props.className === 'string'
|
: choice === 'apikey'
|
||||||
&& node.props.className.includes('modal-content')
|
? findClickableButtonByText(root, '添加 API Key')
|
||||||
&& collectText(node).includes('站点创建成功')
|
: findClickableButtonByText(root, '稍后配置');
|
||||||
));
|
|
||||||
const modalButtons = modalContent.findAll((node) => (
|
|
||||||
node.type === 'button'
|
|
||||||
&& typeof node.props.onClick === 'function'
|
|
||||||
&& node.props['aria-label'] !== '关闭弹框'
|
|
||||||
));
|
|
||||||
|
|
||||||
// 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 () => {
|
await act(async () => {
|
||||||
targetButton!.props.onClick();
|
targetButton.props.onClick();
|
||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
@@ -574,7 +563,7 @@ describe('Sites create redirect', () => {
|
|||||||
{
|
{
|
||||||
id: 36,
|
id: 36,
|
||||||
name: 'DeepSeek Official',
|
name: 'DeepSeek Official',
|
||||||
url: 'https://api.deepseek.com/v1',
|
url: 'https://api.deepseek.com',
|
||||||
platform: 'openai',
|
platform: 'openai',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user