[codex] add vendor code entry presets follow-up (#363)
* address coding plan review comments * add more vendor code entry presets
This commit is contained in:
@@ -471,6 +471,66 @@ describe('sites proxy settings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it('detects Zhipu Coding Plan OpenAI endpoint with initialization preset metadata', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -486,4 +546,50 @@ describe('sites proxy settings', () => {
|
|||||||
initializationPresetId: 'zhipu-coding-plan-openai',
|
initializationPresetId: 'zhipu-coding-plan-openai',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects additional vendor-specific code endpoints with initialization preset metadata', async () => {
|
||||||
|
const cases = [
|
||||||
|
{ url: 'https://api.deepseek.com/v1', platform: 'openai', initializationPresetId: 'deepseek-openai' },
|
||||||
|
{ url: 'https://api.deepseek.com/anthropic', platform: 'claude', initializationPresetId: 'deepseek-claude' },
|
||||||
|
{ url: 'https://api.moonshot.cn/v1', platform: 'openai', initializationPresetId: 'moonshot-openai' },
|
||||||
|
{ url: 'https://api.moonshot.cn/anthropic', platform: 'claude', initializationPresetId: 'moonshot-claude' },
|
||||||
|
{ url: 'https://api.minimaxi.com/v1', platform: 'openai', initializationPresetId: 'minimax-openai' },
|
||||||
|
{ url: 'https://api.minimaxi.com/anthropic', platform: 'claude', initializationPresetId: 'minimax-claude' },
|
||||||
|
{ url: 'https://api-inference.modelscope.cn/v1', platform: 'openai', initializationPresetId: 'modelscope-openai' },
|
||||||
|
{ url: 'https://api-inference.modelscope.cn', platform: 'claude', initializationPresetId: 'modelscope-claude' },
|
||||||
|
{ url: 'https://ark.cn-beijing.volces.com/api/coding/v3', platform: 'openai', initializationPresetId: 'doubao-coding-openai' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites/detect',
|
||||||
|
payload: { url: testCase.url },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
platform: testCase.platform,
|
||||||
|
initializationPresetId: testCase.initializationPresetId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates Doubao Coding Plan sites without explicit platform by using preset-backed detection', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 'Doubao Coding Plan',
|
||||||
|
url: 'https://ark.cn-beijing.volces.com/api/coding/v3',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
name: 'Doubao Coding Plan',
|
||||||
|
platform: 'openai',
|
||||||
|
initializationPresetId: 'doubao-coding-openai',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,9 +90,15 @@ type ErrorLike = {
|
|||||||
cause?: unknown;
|
cause?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeOptionalPlatform(value: string | undefined): string | null {
|
function normalizeCanonicalSiteUrl(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const withScheme = trimmed.includes('://') ? trimmed : `https://${trimmed}`;
|
||||||
|
return normalizeSiteUrl(withScheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSitePlatform(value: string | undefined): string | null {
|
||||||
if (value === undefined) return null;
|
if (value === undefined) return null;
|
||||||
const normalized = value.trim();
|
const normalized = value.trim().toLowerCase();
|
||||||
return normalized || null;
|
return normalized || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,15 +353,15 @@ 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 normalizedUrl = normalizeSiteUrl(url);
|
const canonicalUrl = normalizeCanonicalSiteUrl(url);
|
||||||
|
const canonicalPlatform = normalizeSitePlatform(platform);
|
||||||
let detectedPlatform = normalizeOptionalPlatform(platform);
|
let detectedPlatform = canonicalPlatform;
|
||||||
let responseInitializationPresetId: string | null = explicitInitializationPreset?.id || null;
|
let responseInitializationPresetId: string | null = explicitInitializationPreset?.id || null;
|
||||||
if (!detectedPlatform) {
|
if (!detectedPlatform) {
|
||||||
if (explicitInitializationPreset) {
|
if (explicitInitializationPreset) {
|
||||||
detectedPlatform = explicitInitializationPreset.platform;
|
detectedPlatform = explicitInitializationPreset.platform;
|
||||||
} else {
|
} else {
|
||||||
const detected = await detectSite(url);
|
const detected = await detectSite(canonicalUrl);
|
||||||
detectedPlatform = detected?.platform ?? null;
|
detectedPlatform = detected?.platform ?? null;
|
||||||
responseInitializationPresetId = detected?.initializationPresetId || null;
|
responseInitializationPresetId = detected?.initializationPresetId || null;
|
||||||
}
|
}
|
||||||
@@ -366,16 +372,16 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
if (!detectedPlatform) {
|
if (!detectedPlatform) {
|
||||||
return { error: 'Could not detect platform. Please specify manually.' };
|
return { error: 'Could not detect platform. Please specify manually.' };
|
||||||
}
|
}
|
||||||
const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, normalizedUrl);
|
const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, canonicalUrl);
|
||||||
if (conflictingSite) {
|
if (conflictingSite) {
|
||||||
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
|
return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
let inserted;
|
let inserted;
|
||||||
try {
|
try {
|
||||||
inserted = await db.insert(schema.sites).values({
|
inserted = await db.insert(schema.sites).values({
|
||||||
name,
|
name,
|
||||||
url: normalizedUrl,
|
url: canonicalUrl,
|
||||||
platform: detectedPlatform,
|
platform: detectedPlatform,
|
||||||
proxyUrl: normalizedProxyUrl.proxyUrl,
|
proxyUrl: normalizedProxyUrl.proxyUrl,
|
||||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
useSystemProxy: normalizedUseSystemProxy ?? false,
|
||||||
@@ -388,7 +394,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
}).run();
|
}).run();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isSitesPlatformUrlConflict(error)) {
|
if (isSitesPlatformUrlConflict(error)) {
|
||||||
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
|
return sendSiteBindingConflict(reply, detectedPlatform, canonicalUrl);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -459,9 +465,10 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
|
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextUrl = body.url !== undefined ? normalizeSiteUrl(body.url) : existingSite.url;
|
const canonicalPlatform = normalizeSitePlatform(body.platform);
|
||||||
|
const nextUrl = body.url !== undefined ? normalizeCanonicalSiteUrl(body.url) : existingSite.url;
|
||||||
const nextPlatform = body.platform !== undefined
|
const nextPlatform = body.platform !== undefined
|
||||||
? normalizeOptionalPlatform(body.platform)
|
? canonicalPlatform
|
||||||
: existingSite.platform;
|
: existingSite.platform;
|
||||||
if (body.platform !== undefined && !nextPlatform) {
|
if (body.platform !== undefined && !nextPlatform) {
|
||||||
return reply.code(400).send({ error: 'Invalid platform. Expected non-empty string.' });
|
return reply.code(400).send({ error: 'Invalid platform. Expected non-empty string.' });
|
||||||
|
|||||||
+10
-1
@@ -2,7 +2,16 @@ export type SiteInitializationPresetId =
|
|||||||
| 'codingplan-openai'
|
| 'codingplan-openai'
|
||||||
| 'codingplan-claude'
|
| 'codingplan-claude'
|
||||||
| 'zhipu-coding-plan-openai'
|
| 'zhipu-coding-plan-openai'
|
||||||
| 'zhipu-coding-plan-claude';
|
| 'zhipu-coding-plan-claude'
|
||||||
|
| 'deepseek-openai'
|
||||||
|
| 'deepseek-claude'
|
||||||
|
| 'moonshot-openai'
|
||||||
|
| 'moonshot-claude'
|
||||||
|
| 'minimax-openai'
|
||||||
|
| 'minimax-claude'
|
||||||
|
| 'modelscope-openai'
|
||||||
|
| 'modelscope-claude'
|
||||||
|
| 'doubao-coding-openai';
|
||||||
export type SiteInitializationPreset = {
|
export type SiteInitializationPreset = {
|
||||||
id: SiteInitializationPresetId;
|
id: SiteInitializationPresetId;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ function normalizePathname(pathname) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesHostAndPaths(url, hostname, paths) {
|
||||||
|
const parsed = parseUrlCandidate(url);
|
||||||
|
if (!parsed) return false;
|
||||||
|
return parsed.hostname === hostname && paths.includes(normalizePathname(parsed.pathname));
|
||||||
|
}
|
||||||
|
|
||||||
const CODINGPLAN_RECOMMENDED_MODELS = Object.freeze([
|
const CODINGPLAN_RECOMMENDED_MODELS = Object.freeze([
|
||||||
'qwen3-coder-plus',
|
'qwen3-coder-plus',
|
||||||
'qwen3-coder-next',
|
'qwen3-coder-next',
|
||||||
@@ -41,6 +47,35 @@ const ZHIPU_CODING_PLAN_RECOMMENDED_MODELS = Object.freeze([
|
|||||||
'glm-4.5-air',
|
'glm-4.5-air',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const DEEPSEEK_RECOMMENDED_MODELS = Object.freeze([
|
||||||
|
'deepseek-chat',
|
||||||
|
'deepseek-reasoner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MOONSHOT_RECOMMENDED_MODELS = Object.freeze([
|
||||||
|
'kimi-k2.5',
|
||||||
|
'kimi-k2',
|
||||||
|
'kimi-k2-thinking',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MINIMAX_RECOMMENDED_MODELS = Object.freeze([
|
||||||
|
'MiniMax-M2.7',
|
||||||
|
'MiniMax-M2.5',
|
||||||
|
'MiniMax-M2.1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MODELSCOPE_RECOMMENDED_MODELS = Object.freeze([
|
||||||
|
'Qwen/Qwen3-32B',
|
||||||
|
'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||||
|
'deepseek-ai/DeepSeek-V3.2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DOUBAO_CODING_RECOMMENDED_MODELS = Object.freeze([
|
||||||
|
'ark-code-latest',
|
||||||
|
'doubao-seed-2.0-code',
|
||||||
|
'doubao-seed-2.0-pro',
|
||||||
|
]);
|
||||||
|
|
||||||
const SITE_INITIALIZATION_PRESETS = Object.freeze([
|
const SITE_INITIALIZATION_PRESETS = Object.freeze([
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'codingplan-openai',
|
id: 'codingplan-openai',
|
||||||
@@ -54,10 +89,7 @@ const SITE_INITIALIZATION_PRESETS = Object.freeze([
|
|||||||
recommendedModels: CODINGPLAN_RECOMMENDED_MODELS,
|
recommendedModels: CODINGPLAN_RECOMMENDED_MODELS,
|
||||||
docsUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan-faq',
|
docsUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan-faq',
|
||||||
matches(url) {
|
matches(url) {
|
||||||
const parsed = parseUrlCandidate(url);
|
return matchesHostAndPaths(url, 'coding.dashscope.aliyuncs.com', ['/v1']);
|
||||||
if (!parsed) return false;
|
|
||||||
return parsed.hostname === 'coding.dashscope.aliyuncs.com'
|
|
||||||
&& normalizePathname(parsed.pathname) === '/v1';
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
@@ -72,11 +104,7 @@ const SITE_INITIALIZATION_PRESETS = Object.freeze([
|
|||||||
recommendedModels: CODINGPLAN_RECOMMENDED_MODELS,
|
recommendedModels: CODINGPLAN_RECOMMENDED_MODELS,
|
||||||
docsUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan-faq',
|
docsUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan-faq',
|
||||||
matches(url) {
|
matches(url) {
|
||||||
const parsed = parseUrlCandidate(url);
|
return matchesHostAndPaths(url, 'coding.dashscope.aliyuncs.com', ['/apps/anthropic']);
|
||||||
if (!parsed) return false;
|
|
||||||
const pathname = normalizePathname(parsed.pathname);
|
|
||||||
return parsed.hostname === 'coding.dashscope.aliyuncs.com'
|
|
||||||
&& (pathname === '/apps/anthropic' || pathname.startsWith('/apps/anthropic/'));
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
@@ -91,10 +119,7 @@ const SITE_INITIALIZATION_PRESETS = Object.freeze([
|
|||||||
recommendedModels: ZHIPU_CODING_PLAN_RECOMMENDED_MODELS,
|
recommendedModels: ZHIPU_CODING_PLAN_RECOMMENDED_MODELS,
|
||||||
docsUrl: 'https://docs.bigmodel.cn/cn/coding-plan/faq',
|
docsUrl: 'https://docs.bigmodel.cn/cn/coding-plan/faq',
|
||||||
matches(url) {
|
matches(url) {
|
||||||
const parsed = parseUrlCandidate(url);
|
return matchesHostAndPaths(url, 'open.bigmodel.cn', ['/api/coding/paas/v4']);
|
||||||
if (!parsed) return false;
|
|
||||||
return parsed.hostname === 'open.bigmodel.cn'
|
|
||||||
&& normalizePathname(parsed.pathname) === '/api/coding/paas/v4';
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
@@ -112,6 +137,141 @@ const SITE_INITIALIZATION_PRESETS = Object.freeze([
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'deepseek-openai',
|
||||||
|
label: 'DeepSeek / OpenAI',
|
||||||
|
providerLabel: 'DeepSeek',
|
||||||
|
description: '适合 DeepSeek 官方 OpenAI 兼容入口,建议直接添加 API Key,并优先补入官方常用编程模型。',
|
||||||
|
platform: 'openai',
|
||||||
|
defaultUrl: 'https://api.deepseek.com/v1',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
recommendedModels: DEEPSEEK_RECOMMENDED_MODELS,
|
||||||
|
docsUrl: 'https://api-docs.deepseek.com/',
|
||||||
|
matches(url) {
|
||||||
|
return matchesHostAndPaths(url, 'api.deepseek.com', ['/', '/v1']);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'deepseek-claude',
|
||||||
|
label: 'DeepSeek / Claude',
|
||||||
|
providerLabel: 'DeepSeek',
|
||||||
|
description: '适合 DeepSeek 官方 Anthropic 兼容入口,便于 Claude Code 一类工具直接接入。',
|
||||||
|
platform: 'claude',
|
||||||
|
defaultUrl: 'https://api.deepseek.com/anthropic',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
recommendedModels: DEEPSEEK_RECOMMENDED_MODELS,
|
||||||
|
docsUrl: 'https://api-docs.deepseek.com/guides/anthropic_api',
|
||||||
|
matches(url) {
|
||||||
|
return matchesHostAndPaths(url, 'api.deepseek.com', ['/anthropic']);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'moonshot-openai',
|
||||||
|
label: 'Moonshot(Kimi) / OpenAI',
|
||||||
|
providerLabel: 'Moonshot / Kimi',
|
||||||
|
description: '适合 Moonshot 官方 OpenAI 兼容入口,推荐优先使用 Kimi 系列编程与 Agent 模型。',
|
||||||
|
platform: 'openai',
|
||||||
|
defaultUrl: 'https://api.moonshot.cn/v1',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
recommendedModels: MOONSHOT_RECOMMENDED_MODELS,
|
||||||
|
docsUrl: 'https://platform.moonshot.cn/',
|
||||||
|
matches(url) {
|
||||||
|
return matchesHostAndPaths(url, 'api.moonshot.cn', ['/', '/v1']);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'moonshot-claude',
|
||||||
|
label: 'Moonshot(Kimi) / Claude',
|
||||||
|
providerLabel: 'Moonshot / Kimi',
|
||||||
|
description: '适合 Moonshot 官方 Anthropic 兼容入口,便于 Claude Code 与同类工具接入 Kimi。',
|
||||||
|
platform: 'claude',
|
||||||
|
defaultUrl: 'https://api.moonshot.cn/anthropic',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
recommendedModels: MOONSHOT_RECOMMENDED_MODELS,
|
||||||
|
docsUrl: 'https://platform.moonshot.cn/blog/posts/kimi-k2-0905',
|
||||||
|
matches(url) {
|
||||||
|
return matchesHostAndPaths(url, 'api.moonshot.cn', ['/anthropic']);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'minimax-openai',
|
||||||
|
label: 'MiniMax / OpenAI',
|
||||||
|
providerLabel: 'MiniMax',
|
||||||
|
description: '适合 MiniMax 官方 OpenAI 兼容入口,建议直接添加 API Key 后补入常用 M2 编程模型。',
|
||||||
|
platform: 'openai',
|
||||||
|
defaultUrl: 'https://api.minimaxi.com/v1',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
recommendedModels: MINIMAX_RECOMMENDED_MODELS,
|
||||||
|
docsUrl: 'https://platform.minimaxi.com/docs/api-reference/api-overview',
|
||||||
|
matches(url) {
|
||||||
|
return matchesHostAndPaths(url, 'api.minimaxi.com', ['/', '/v1']);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'minimax-claude',
|
||||||
|
label: 'MiniMax / Claude',
|
||||||
|
providerLabel: 'MiniMax',
|
||||||
|
description: '适合 MiniMax 官方 Anthropic 兼容入口,适配 Claude Code 等编程工具场景。',
|
||||||
|
platform: 'claude',
|
||||||
|
defaultUrl: 'https://api.minimaxi.com/anthropic',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
recommendedModels: MINIMAX_RECOMMENDED_MODELS,
|
||||||
|
docsUrl: 'https://platform.minimaxi.com/docs/api-reference/text-anthropic-api',
|
||||||
|
matches(url) {
|
||||||
|
return matchesHostAndPaths(url, 'api.minimaxi.com', ['/anthropic']);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'modelscope-openai',
|
||||||
|
label: 'ModelScope / OpenAI',
|
||||||
|
providerLabel: 'ModelScope',
|
||||||
|
description: '适合 ModelScope API-Inference 的 OpenAI 兼容入口,适合直接接入常用开源编程模型。',
|
||||||
|
platform: 'openai',
|
||||||
|
defaultUrl: 'https://api-inference.modelscope.cn/v1',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
recommendedModels: MODELSCOPE_RECOMMENDED_MODELS,
|
||||||
|
docsUrl: 'https://www.modelscope.cn/docs/model-service/API-Inference/intro',
|
||||||
|
matches(url) {
|
||||||
|
return matchesHostAndPaths(url, 'api-inference.modelscope.cn', ['/v1']);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'modelscope-claude',
|
||||||
|
label: 'ModelScope / Claude',
|
||||||
|
providerLabel: 'ModelScope',
|
||||||
|
description: '适合 ModelScope API-Inference 的 Claude 兼容入口,便于接入 Claude Code 一类工具。',
|
||||||
|
platform: 'claude',
|
||||||
|
defaultUrl: 'https://api-inference.modelscope.cn',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
recommendedModels: MODELSCOPE_RECOMMENDED_MODELS,
|
||||||
|
docsUrl: 'https://www.modelscope.cn/docs/model-service/API-Inference/intro',
|
||||||
|
matches(url) {
|
||||||
|
return matchesHostAndPaths(url, 'api-inference.modelscope.cn', ['/']);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'doubao-coding-openai',
|
||||||
|
label: '豆包 Coding Plan / OpenAI',
|
||||||
|
providerLabel: '豆包 Coding Plan',
|
||||||
|
description: '适合火山方舟 Coding Plan 的 OpenAI 兼容入口,推荐优先使用 ark-code 与豆包编程模型。',
|
||||||
|
platform: 'openai',
|
||||||
|
defaultUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
recommendedModels: DOUBAO_CODING_RECOMMENDED_MODELS,
|
||||||
|
docsUrl: 'https://www.volcengine.com/docs/82379/2205646?lang=zh',
|
||||||
|
matches(url) {
|
||||||
|
return matchesHostAndPaths(url, 'ark.cn-beijing.volces.com', ['/api/coding/v3']);
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function clonePreset(preset) {
|
function clonePreset(preset) {
|
||||||
|
|||||||
@@ -7,13 +7,22 @@ import {
|
|||||||
} from './siteInitializationPresets.js';
|
} from './siteInitializationPresets.js';
|
||||||
|
|
||||||
describe('siteInitializationPresets', () => {
|
describe('siteInitializationPresets', () => {
|
||||||
it('exposes CodingPlan presets with recommended API-key-first initialization', () => {
|
it('exposes vendor presets with recommended API-key-first initialization', () => {
|
||||||
const presetIds = listSiteInitializationPresets().map((preset) => preset.id);
|
const presetIds = listSiteInitializationPresets().map((preset) => preset.id);
|
||||||
expect(presetIds).toEqual(expect.arrayContaining([
|
expect(presetIds).toEqual(expect.arrayContaining([
|
||||||
'codingplan-openai',
|
'codingplan-openai',
|
||||||
'codingplan-claude',
|
'codingplan-claude',
|
||||||
'zhipu-coding-plan-openai',
|
'zhipu-coding-plan-openai',
|
||||||
'zhipu-coding-plan-claude',
|
'zhipu-coding-plan-claude',
|
||||||
|
'deepseek-openai',
|
||||||
|
'deepseek-claude',
|
||||||
|
'moonshot-openai',
|
||||||
|
'moonshot-claude',
|
||||||
|
'minimax-openai',
|
||||||
|
'minimax-claude',
|
||||||
|
'modelscope-openai',
|
||||||
|
'modelscope-claude',
|
||||||
|
'doubao-coding-openai',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
const openaiPreset = getSiteInitializationPreset('codingplan-openai');
|
const openaiPreset = getSiteInitializationPreset('codingplan-openai');
|
||||||
@@ -55,6 +64,64 @@ describe('siteInitializationPresets', () => {
|
|||||||
recommendedSkipModelFetch: true,
|
recommendedSkipModelFetch: true,
|
||||||
});
|
});
|
||||||
expect(zhipuClaudePreset?.recommendedModels).toEqual(['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air']);
|
expect(zhipuClaudePreset?.recommendedModels).toEqual(['glm-4.7', 'glm-4.6', 'glm-4.5', 'glm-4.5-air']);
|
||||||
|
|
||||||
|
const deepseekPreset = getSiteInitializationPreset('deepseek-openai');
|
||||||
|
expect(deepseekPreset).toMatchObject({
|
||||||
|
id: 'deepseek-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
defaultUrl: 'https://api.deepseek.com/v1',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
});
|
||||||
|
expect(deepseekPreset?.recommendedModels).toEqual(['deepseek-chat', 'deepseek-reasoner']);
|
||||||
|
|
||||||
|
const moonshotPreset = getSiteInitializationPreset('moonshot-openai');
|
||||||
|
expect(moonshotPreset).toMatchObject({
|
||||||
|
id: 'moonshot-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
defaultUrl: 'https://api.moonshot.cn/v1',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
});
|
||||||
|
expect(moonshotPreset?.recommendedModels).toEqual(['kimi-k2.5', 'kimi-k2', 'kimi-k2-thinking']);
|
||||||
|
|
||||||
|
const minimaxPreset = getSiteInitializationPreset('minimax-claude');
|
||||||
|
expect(minimaxPreset).toMatchObject({
|
||||||
|
id: 'minimax-claude',
|
||||||
|
platform: 'claude',
|
||||||
|
defaultUrl: 'https://api.minimaxi.com/anthropic',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
});
|
||||||
|
expect(minimaxPreset?.recommendedModels).toEqual(['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2.1']);
|
||||||
|
|
||||||
|
const modelscopePreset = getSiteInitializationPreset('modelscope-openai');
|
||||||
|
expect(modelscopePreset).toMatchObject({
|
||||||
|
id: 'modelscope-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
defaultUrl: 'https://api-inference.modelscope.cn/v1',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
});
|
||||||
|
expect(modelscopePreset?.recommendedModels).toEqual([
|
||||||
|
'Qwen/Qwen3-32B',
|
||||||
|
'Qwen/Qwen2.5-Coder-32B-Instruct',
|
||||||
|
'deepseek-ai/DeepSeek-V3.2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const doubaoPreset = getSiteInitializationPreset('doubao-coding-openai');
|
||||||
|
expect(doubaoPreset).toMatchObject({
|
||||||
|
id: 'doubao-coding-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
defaultUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3',
|
||||||
|
initialSegment: 'apikey',
|
||||||
|
recommendedSkipModelFetch: true,
|
||||||
|
});
|
||||||
|
expect(doubaoPreset?.recommendedModels).toEqual([
|
||||||
|
'ark-code-latest',
|
||||||
|
'doubao-seed-2.0-code',
|
||||||
|
'doubao-seed-2.0-pro',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects Aliyun CodingPlan endpoints by URL', () => {
|
it('detects Aliyun CodingPlan endpoints by URL', () => {
|
||||||
@@ -80,4 +147,47 @@ describe('siteInitializationPresets', () => {
|
|||||||
});
|
});
|
||||||
expect(detectSiteInitializationPreset('https://open.bigmodel.cn/api/anthropic')).toBeNull();
|
expect(detectSiteInitializationPreset('https://open.bigmodel.cn/api/anthropic')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects vendor-specific OpenAI and Claude endpoints by URL', () => {
|
||||||
|
expect(detectSiteInitializationPreset('https://api.deepseek.com/v1')).toMatchObject({
|
||||||
|
id: 'deepseek-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
});
|
||||||
|
expect(detectSiteInitializationPreset('https://api.deepseek.com/anthropic')).toMatchObject({
|
||||||
|
id: 'deepseek-claude',
|
||||||
|
platform: 'claude',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detectSiteInitializationPreset('https://api.moonshot.cn/v1/')).toMatchObject({
|
||||||
|
id: 'moonshot-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
});
|
||||||
|
expect(detectSiteInitializationPreset('https://api.moonshot.cn/anthropic')).toMatchObject({
|
||||||
|
id: 'moonshot-claude',
|
||||||
|
platform: 'claude',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detectSiteInitializationPreset('https://api.minimaxi.com/v1')).toMatchObject({
|
||||||
|
id: 'minimax-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
});
|
||||||
|
expect(detectSiteInitializationPreset('https://api.minimaxi.com/anthropic')).toMatchObject({
|
||||||
|
id: 'minimax-claude',
|
||||||
|
platform: 'claude',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detectSiteInitializationPreset('https://api-inference.modelscope.cn/v1')).toMatchObject({
|
||||||
|
id: 'modelscope-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
});
|
||||||
|
expect(detectSiteInitializationPreset('https://api-inference.modelscope.cn')).toMatchObject({
|
||||||
|
id: 'modelscope-claude',
|
||||||
|
platform: 'claude',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(detectSiteInitializationPreset('https://ark.cn-beijing.volces.com/api/coding/v3')).toMatchObject({
|
||||||
|
id: 'doubao-coding-openai',
|
||||||
|
platform: 'openai',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
type SiteForm,
|
type SiteForm,
|
||||||
} from './helpers/sitesEditor.js';
|
} from './helpers/sitesEditor.js';
|
||||||
import {
|
import {
|
||||||
|
detectSiteInitializationPreset,
|
||||||
getSiteInitializationPreset,
|
getSiteInitializationPreset,
|
||||||
listSiteInitializationPresets,
|
listSiteInitializationPresets,
|
||||||
} from '../../shared/siteInitializationPresets.js';
|
} from '../../shared/siteInitializationPresets.js';
|
||||||
@@ -388,7 +389,7 @@ export default function Sites() {
|
|||||||
const openEdit = (site: SiteRow) => {
|
const openEdit = (site: SiteRow) => {
|
||||||
setEditor({ mode: 'edit', editingSiteId: site.id });
|
setEditor({ mode: 'edit', editingSiteId: site.id });
|
||||||
setForm(siteFormFromSite(site));
|
setForm(siteFormFromSite(site));
|
||||||
setSelectedInitializationPresetId(null);
|
setSelectedInitializationPresetId(detectSiteInitializationPreset(site.url, site.platform)?.id || null);
|
||||||
scrollToEditorTop();
|
scrollToEditorTop();
|
||||||
// Load disabled models and discovered models independently so a best-effort
|
// Load disabled models and discovered models independently so a best-effort
|
||||||
// availability fetch cannot wipe the existing disabled-model state.
|
// availability fetch cannot wipe the existing disabled-model state.
|
||||||
|
|||||||
@@ -113,4 +113,67 @@ describe('Accounts CodingPlan initialization', () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preloads vendor-specific recommendations for new code presets such as DeepSeek', async () => {
|
||||||
|
apiMock.getSites.mockResolvedValue([
|
||||||
|
{ id: 11, name: 'DeepSeek Official', url: 'https://api.deepseek.com/v1', platform: 'openai', status: 'active' },
|
||||||
|
]);
|
||||||
|
apiMock.addAccount.mockResolvedValue({
|
||||||
|
id: 19,
|
||||||
|
siteId: 11,
|
||||||
|
tokenType: 'apikey',
|
||||||
|
queued: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/accounts?segment=apikey&create=1&siteId=11&initPreset=deepseek-openai']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Accounts />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const rendered = JSON.stringify(root.toJSON());
|
||||||
|
expect(rendered).toContain('DeepSeek / OpenAI');
|
||||||
|
expect(rendered).toContain('deepseek-chat');
|
||||||
|
expect(rendered).toContain('deepseek-reasoner');
|
||||||
|
|
||||||
|
const tokenInput = root.root.find((node) => (
|
||||||
|
node.type === 'textarea'
|
||||||
|
&& node.props.placeholder === '粘贴 API Key'
|
||||||
|
));
|
||||||
|
const addButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('添加连接')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
tokenInput.props.onChange({ target: { value: 'sk-deepseek-demo' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await addButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.addAccount).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
siteId: 11,
|
||||||
|
accessToken: 'sk-deepseek-demo',
|
||||||
|
credentialMode: 'apikey',
|
||||||
|
skipModelFetch: true,
|
||||||
|
}));
|
||||||
|
expect(apiMock.addAccountAvailableModels).toHaveBeenCalledWith(
|
||||||
|
19,
|
||||||
|
['deepseek-chat', 'deepseek-reasoner'],
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -219,6 +219,15 @@ describe('Sites create redirect', () => {
|
|||||||
expect.objectContaining({ label: '阿里云 CodingPlan / Claude' }),
|
expect.objectContaining({ label: '阿里云 CodingPlan / Claude' }),
|
||||||
expect.objectContaining({ label: '智谱 Coding Plan / OpenAI' }),
|
expect.objectContaining({ label: '智谱 Coding Plan / OpenAI' }),
|
||||||
expect.objectContaining({ label: '智谱 Coding Plan / Claude' }),
|
expect.objectContaining({ label: '智谱 Coding Plan / Claude' }),
|
||||||
|
expect.objectContaining({ label: 'DeepSeek / OpenAI' }),
|
||||||
|
expect.objectContaining({ label: 'DeepSeek / Claude' }),
|
||||||
|
expect.objectContaining({ label: 'Moonshot(Kimi) / OpenAI' }),
|
||||||
|
expect.objectContaining({ label: 'Moonshot(Kimi) / Claude' }),
|
||||||
|
expect.objectContaining({ label: 'MiniMax / OpenAI' }),
|
||||||
|
expect.objectContaining({ label: 'MiniMax / Claude' }),
|
||||||
|
expect.objectContaining({ label: 'ModelScope / OpenAI' }),
|
||||||
|
expect.objectContaining({ label: 'ModelScope / Claude' }),
|
||||||
|
expect.objectContaining({ label: '豆包 Coding Plan / OpenAI' }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -554,4 +563,54 @@ describe('Sites create redirect', () => {
|
|||||||
|
|
||||||
root.unmount();
|
root.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('re-selects the matching vendor preset when editing an existing vendor-specific site', async () => {
|
||||||
|
apiMock.getSites.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 36,
|
||||||
|
name: 'DeepSeek Official',
|
||||||
|
url: 'https://api.deepseek.com/v1',
|
||||||
|
platform: 'openai',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<ToastProvider>
|
||||||
|
<MemoryRouter initialEntries={['/sites']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/sites" element={<Sites />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const editButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('编辑')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
editButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const platformSelect = root.root.findAllByType(ModernSelect).at(-1);
|
||||||
|
const presetAlert = root.root.find((node) => (
|
||||||
|
typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('alert alert-info')
|
||||||
|
));
|
||||||
|
expect(platformSelect?.props.value).toBe('preset:deepseek-openai');
|
||||||
|
expect(collectText(presetAlert)).toContain('已应用官方预设');
|
||||||
|
expect(collectText(presetAlert)).toContain('DeepSeek / OpenAI');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user