forked from boos/BoosAPI
[codex] add coding plan site initialization flow (#351)
* add coding plan site initialization flow * fix ci for coding plan site init flow * address coding plan review comments
This commit is contained in:
@@ -259,4 +259,227 @@ describe('sites proxy settings', () => {
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { error?: string }).error).toContain('must use a string value');
|
||||
});
|
||||
|
||||
it('rejects create payloads whose name is not a string', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 123,
|
||||
url: 'https://typed-site.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { error?: string }).error).toContain('Invalid name');
|
||||
});
|
||||
|
||||
it('rejects update payloads whose url is not a string', async () => {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'typed-site',
|
||||
url: 'https://typed-site.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(200);
|
||||
const site = created.json() as { id: number };
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sites/${site.id}`,
|
||||
payload: {
|
||||
url: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { error?: string }).error).toContain('Invalid url');
|
||||
});
|
||||
|
||||
it('rejects create payloads that are not objects', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: null,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { error?: string }).error).toContain('Invalid site payload');
|
||||
});
|
||||
|
||||
it('rejects update payloads that are not objects', async () => {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'typed-site',
|
||||
url: 'https://typed-site.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(200);
|
||||
const site = created.json() as { id: number };
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sites/${site.id}`,
|
||||
payload: null,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect((response.json() as { error?: string }).error).toContain('Invalid site payload');
|
||||
});
|
||||
|
||||
it('detects Aliyun CodingPlan endpoints with initialization preset metadata', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites/detect',
|
||||
payload: {
|
||||
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
platform: 'openai',
|
||||
initializationPresetId: 'codingplan-openai',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates CodingPlan sites without explicit platform by using preset-backed detection', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'Aliyun CodingPlan',
|
||||
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
name: 'Aliyun CodingPlan',
|
||||
platform: 'openai',
|
||||
initializationPresetId: 'codingplan-openai',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not force CodingPlan preset metadata when the user explicitly chooses generic openai', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'Aliyun Generic OpenAI',
|
||||
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
platform: 'openai',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
name: 'Aliyun Generic OpenAI',
|
||||
platform: 'openai',
|
||||
});
|
||||
expect(response.json()).not.toHaveProperty('initializationPresetId');
|
||||
});
|
||||
|
||||
it('preserves explicit preset metadata even when the preset uses a custom gateway url', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'Aliyun CodingPlan Gateway',
|
||||
url: 'https://gateway.example.com/coding/v1',
|
||||
platform: 'openai',
|
||||
initializationPresetId: 'codingplan-openai',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
name: 'Aliyun CodingPlan Gateway',
|
||||
platform: 'openai',
|
||||
initializationPresetId: 'codingplan-openai',
|
||||
});
|
||||
});
|
||||
|
||||
it('canonicalizes create payload url and platform before persistence and conflict checks', async () => {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'Aliyun Generic OpenAI',
|
||||
url: ' coding.dashscope.aliyuncs.com/v1/ ',
|
||||
platform: ' OPENAI ',
|
||||
},
|
||||
});
|
||||
|
||||
expect(created.statusCode).toBe(200);
|
||||
expect(created.json()).toMatchObject({
|
||||
name: 'Aliyun Generic OpenAI',
|
||||
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
platform: 'openai',
|
||||
});
|
||||
|
||||
const duplicate = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'Aliyun Generic OpenAI Duplicate',
|
||||
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
platform: 'openai',
|
||||
},
|
||||
});
|
||||
|
||||
expect(duplicate.statusCode).toBe(409);
|
||||
expect((duplicate.json() as { error?: string }).error).toContain('already exists');
|
||||
});
|
||||
|
||||
it('canonicalizes update payload url and platform before saving', async () => {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites',
|
||||
payload: {
|
||||
name: 'Update Canonicalization',
|
||||
url: 'https://update-canonicalization.example.com',
|
||||
platform: 'new-api',
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(200);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sites/${(created.json() as { id: number }).id}`,
|
||||
payload: {
|
||||
url: ' coding.dashscope.aliyuncs.com/v1/ ',
|
||||
platform: ' OPENAI ',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
url: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
platform: 'openai',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects Zhipu Coding Plan OpenAI endpoint with initialization preset metadata', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sites/detect',
|
||||
payload: {
|
||||
url: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
platform: 'openai',
|
||||
initializationPresetId: 'zhipu-coding-plan-openai',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
||||
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
||||
import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||
import { getSiteInitializationPreset } from '../../../shared/siteInitializationPresets.js';
|
||||
import { stripTrailingSlashes } from '../../services/urlNormalization.js';
|
||||
|
||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||
if (input === undefined || input === null) return null;
|
||||
@@ -83,7 +85,14 @@ type ErrorLike = {
|
||||
};
|
||||
|
||||
function normalizeSiteUrl(url: string): string {
|
||||
return url.replace(/\/+$/, '');
|
||||
const trimmed = url.trim();
|
||||
const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}`;
|
||||
return stripTrailingSlashes(withScheme);
|
||||
}
|
||||
|
||||
function normalizeSitePlatform(platform?: string): string | undefined {
|
||||
if (platform === undefined) return undefined;
|
||||
return platform.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getErrorChain(error: unknown): ErrorLike[] {
|
||||
@@ -280,6 +289,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
name: string;
|
||||
url: string;
|
||||
platform?: string;
|
||||
initializationPresetId?: string | null;
|
||||
proxyUrl?: string | null;
|
||||
useSystemProxy?: boolean;
|
||||
customHeaders?: string | null;
|
||||
@@ -289,7 +299,32 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
sortOrder?: number;
|
||||
globalWeight?: number;
|
||||
} }>('/api/sites', async (request, reply) => {
|
||||
const { name, url, platform, proxyUrl, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
|
||||
if (!request.body || typeof request.body !== 'object' || Array.isArray(request.body)) {
|
||||
return reply.code(400).send({ error: 'Invalid site payload. Expected object.' });
|
||||
}
|
||||
const {
|
||||
name,
|
||||
url,
|
||||
platform,
|
||||
initializationPresetId,
|
||||
proxyUrl,
|
||||
useSystemProxy,
|
||||
customHeaders,
|
||||
externalCheckinUrl,
|
||||
status,
|
||||
isPinned,
|
||||
sortOrder,
|
||||
globalWeight,
|
||||
} = request.body;
|
||||
if (typeof name !== 'string' || !name.trim()) {
|
||||
return reply.code(400).send({ error: 'Invalid name. Expected non-empty string.' });
|
||||
}
|
||||
if (typeof url !== 'string' || !url.trim()) {
|
||||
return reply.code(400).send({ error: 'Invalid url. Expected non-empty string.' });
|
||||
}
|
||||
if (platform !== undefined && typeof platform !== 'string') {
|
||||
return reply.code(400).send({ error: 'Invalid platform. Expected string.' });
|
||||
}
|
||||
const normalizedStatus = normalizeSiteStatus(status);
|
||||
if (status !== undefined && !normalizedStatus) {
|
||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||
@@ -322,29 +357,45 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
if (!normalizedCustomHeaders.valid) {
|
||||
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
|
||||
}
|
||||
const explicitInitializationPreset = initializationPresetId == null || initializationPresetId === ''
|
||||
? null
|
||||
: getSiteInitializationPreset(initializationPresetId);
|
||||
if (initializationPresetId != null && initializationPresetId !== '' && !explicitInitializationPreset) {
|
||||
return reply.code(400).send({ error: 'Invalid initializationPresetId.' });
|
||||
}
|
||||
|
||||
const existingSites = await db.select().from(schema.sites).all();
|
||||
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
|
||||
const normalizedUrl = normalizeSiteUrl(url);
|
||||
const canonicalUrl = normalizeSiteUrl(url);
|
||||
const canonicalPlatform = normalizeSitePlatform(platform);
|
||||
|
||||
let detectedPlatform = platform;
|
||||
let detectedPlatform = canonicalPlatform;
|
||||
let responseInitializationPresetId: string | null = explicitInitializationPreset?.id || null;
|
||||
if (!detectedPlatform) {
|
||||
const detected = await detectSite(url);
|
||||
detectedPlatform = detected?.platform;
|
||||
if (explicitInitializationPreset) {
|
||||
detectedPlatform = explicitInitializationPreset.platform;
|
||||
} else {
|
||||
const detected = await detectSite(canonicalUrl);
|
||||
detectedPlatform = detected?.platform;
|
||||
responseInitializationPresetId = detected?.initializationPresetId || null;
|
||||
}
|
||||
}
|
||||
if (explicitInitializationPreset && explicitInitializationPreset.platform !== detectedPlatform) {
|
||||
return reply.code(400).send({ error: 'initializationPresetId does not match the selected platform.' });
|
||||
}
|
||||
if (!detectedPlatform) {
|
||||
return { error: 'Could not detect platform. Please specify manually.' };
|
||||
}
|
||||
const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, normalizedUrl);
|
||||
const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, canonicalUrl);
|
||||
if (conflictingSite) {
|
||||
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
|
||||
return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
|
||||
}
|
||||
|
||||
let inserted;
|
||||
try {
|
||||
inserted = await db.insert(schema.sites).values({
|
||||
name,
|
||||
url: normalizedUrl,
|
||||
url: canonicalUrl,
|
||||
platform: detectedPlatform,
|
||||
proxyUrl: normalizedProxyUrl.proxyUrl,
|
||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
||||
@@ -357,7 +408,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
}).run();
|
||||
} catch (error) {
|
||||
if (isSitesPlatformUrlConflict(error)) {
|
||||
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
|
||||
return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -370,7 +421,10 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
return reply.code(500).send({ error: 'Create site failed' });
|
||||
}
|
||||
invalidateSiteCaches();
|
||||
return result;
|
||||
return {
|
||||
...result,
|
||||
...(responseInitializationPresetId ? { initializationPresetId: responseInitializationPresetId } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
// Update a site
|
||||
@@ -398,7 +452,19 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const updates: any = {};
|
||||
if (!request.body || typeof request.body !== 'object' || Array.isArray(request.body)) {
|
||||
return reply.code(400).send({ error: 'Invalid site payload. Expected object.' });
|
||||
}
|
||||
const body = request.body;
|
||||
if (body.name !== undefined && (typeof body.name !== 'string' || !body.name.trim())) {
|
||||
return reply.code(400).send({ error: 'Invalid name. Expected non-empty string.' });
|
||||
}
|
||||
if (body.url !== undefined && (typeof body.url !== 'string' || !body.url.trim())) {
|
||||
return reply.code(400).send({ error: 'Invalid url. Expected non-empty string.' });
|
||||
}
|
||||
if (body.platform !== undefined && typeof body.platform !== 'string') {
|
||||
return reply.code(400).send({ error: 'Invalid platform. Expected string.' });
|
||||
}
|
||||
const normalizedStatus = normalizeSiteStatus(body.status);
|
||||
if (body.status !== undefined && !normalizedStatus) {
|
||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||
@@ -432,8 +498,9 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
|
||||
}
|
||||
|
||||
const canonicalPlatform = body.platform !== undefined ? normalizeSitePlatform(body.platform) : undefined;
|
||||
const nextUrl = body.url !== undefined ? normalizeSiteUrl(body.url) : existingSite.url;
|
||||
const nextPlatform = body.platform !== undefined ? body.platform : existingSite.platform;
|
||||
const nextPlatform = canonicalPlatform !== undefined ? canonicalPlatform : existingSite.platform;
|
||||
const siteIdentityChanged = nextUrl !== existingSite.url || nextPlatform !== existingSite.platform;
|
||||
if (siteIdentityChanged) {
|
||||
const siteRows = await db.select({
|
||||
@@ -449,7 +516,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
||||
|
||||
if (body.name !== undefined) updates.name = body.name;
|
||||
if (body.url !== undefined) updates.url = nextUrl;
|
||||
if (body.platform !== undefined) updates.platform = body.platform;
|
||||
if (body.platform !== undefined) updates.platform = canonicalPlatform;
|
||||
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
|
||||
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
||||
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
|
||||
|
||||
Reference in New Issue
Block a user