[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 };
}