[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({
|
||||
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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user