From aac130747096fd6b240751b6f73dd518f780f27a Mon Sep 17 00:00:00 2001 From: Cita Date: Wed, 1 Apr 2026 13:57:24 +0800 Subject: [PATCH] [codex] add vendor code entry presets follow-up (#363) * address coding plan review comments * add more vendor code entry presets --- src/server/routes/api/sites.proxyUrl.test.ts | 106 ++++++++++ src/server/routes/api/sites.ts | 31 +-- src/shared/siteInitializationPresets.d.ts | 11 +- src/shared/siteInitializationPresets.js | 186 ++++++++++++++++-- src/shared/siteInitializationPresets.test.ts | 112 ++++++++++- src/web/pages/Sites.tsx | 3 +- .../pages/accounts.codingplan-init.test.tsx | 63 ++++++ src/web/pages/sites.create-redirect.test.tsx | 59 ++++++ 8 files changed, 543 insertions(+), 28 deletions(-) diff --git a/src/server/routes/api/sites.proxyUrl.test.ts b/src/server/routes/api/sites.proxyUrl.test.ts index 45058da..b833b66 100644 --- a/src/server/routes/api/sites.proxyUrl.test.ts +++ b/src/server/routes/api/sites.proxyUrl.test.ts @@ -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 () => { const response = await app.inject({ method: 'POST', @@ -486,4 +546,50 @@ describe('sites proxy settings', () => { 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', + }); + }); }); diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index 9ab8745..a477bff 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -90,9 +90,15 @@ type ErrorLike = { 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; - const normalized = value.trim(); + const normalized = value.trim().toLowerCase(); return normalized || null; } @@ -347,15 +353,15 @@ 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 normalizedUrl = normalizeSiteUrl(url); - - let detectedPlatform = normalizeOptionalPlatform(platform); + const canonicalUrl = normalizeCanonicalSiteUrl(url); + const canonicalPlatform = normalizeSitePlatform(platform); + let detectedPlatform = canonicalPlatform; let responseInitializationPresetId: string | null = explicitInitializationPreset?.id || null; if (!detectedPlatform) { if (explicitInitializationPreset) { detectedPlatform = explicitInitializationPreset.platform; } else { - const detected = await detectSite(url); + const detected = await detectSite(canonicalUrl); detectedPlatform = detected?.platform ?? null; responseInitializationPresetId = detected?.initializationPresetId || null; } @@ -366,16 +372,16 @@ export async function sitesRoutes(app: FastifyInstance) { 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, @@ -388,7 +394,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; } @@ -459,9 +465,10 @@ export async function sitesRoutes(app: FastifyInstance) { 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 - ? normalizeOptionalPlatform(body.platform) + ? canonicalPlatform : existingSite.platform; if (body.platform !== undefined && !nextPlatform) { return reply.code(400).send({ error: 'Invalid platform. Expected non-empty string.' }); diff --git a/src/shared/siteInitializationPresets.d.ts b/src/shared/siteInitializationPresets.d.ts index ec45fa1..a9f4d8e 100644 --- a/src/shared/siteInitializationPresets.d.ts +++ b/src/shared/siteInitializationPresets.d.ts @@ -2,7 +2,16 @@ export type SiteInitializationPresetId = | 'codingplan-openai' | 'codingplan-claude' | '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 = { id: SiteInitializationPresetId; label: string; diff --git a/src/shared/siteInitializationPresets.js b/src/shared/siteInitializationPresets.js index 8b410bf..9bce365 100644 --- a/src/shared/siteInitializationPresets.js +++ b/src/shared/siteInitializationPresets.js @@ -27,6 +27,12 @@ function normalizePathname(pathname) { 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([ 'qwen3-coder-plus', 'qwen3-coder-next', @@ -41,6 +47,35 @@ const ZHIPU_CODING_PLAN_RECOMMENDED_MODELS = Object.freeze([ '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([ Object.freeze({ id: 'codingplan-openai', @@ -54,10 +89,7 @@ const SITE_INITIALIZATION_PRESETS = Object.freeze([ recommendedModels: CODINGPLAN_RECOMMENDED_MODELS, docsUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan-faq', matches(url) { - const parsed = parseUrlCandidate(url); - if (!parsed) return false; - return parsed.hostname === 'coding.dashscope.aliyuncs.com' - && normalizePathname(parsed.pathname) === '/v1'; + return matchesHostAndPaths(url, 'coding.dashscope.aliyuncs.com', ['/v1']); }, }), Object.freeze({ @@ -72,11 +104,7 @@ const SITE_INITIALIZATION_PRESETS = Object.freeze([ recommendedModels: CODINGPLAN_RECOMMENDED_MODELS, docsUrl: 'https://help.aliyun.com/zh/model-studio/coding-plan-faq', matches(url) { - const parsed = parseUrlCandidate(url); - if (!parsed) return false; - const pathname = normalizePathname(parsed.pathname); - return parsed.hostname === 'coding.dashscope.aliyuncs.com' - && (pathname === '/apps/anthropic' || pathname.startsWith('/apps/anthropic/')); + return matchesHostAndPaths(url, 'coding.dashscope.aliyuncs.com', ['/apps/anthropic']); }, }), Object.freeze({ @@ -91,10 +119,7 @@ const SITE_INITIALIZATION_PRESETS = Object.freeze([ recommendedModels: ZHIPU_CODING_PLAN_RECOMMENDED_MODELS, docsUrl: 'https://docs.bigmodel.cn/cn/coding-plan/faq', matches(url) { - const parsed = parseUrlCandidate(url); - if (!parsed) return false; - return parsed.hostname === 'open.bigmodel.cn' - && normalizePathname(parsed.pathname) === '/api/coding/paas/v4'; + return matchesHostAndPaths(url, 'open.bigmodel.cn', ['/api/coding/paas/v4']); }, }), Object.freeze({ @@ -112,6 +137,141 @@ const SITE_INITIALIZATION_PRESETS = Object.freeze([ 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) { diff --git a/src/shared/siteInitializationPresets.test.ts b/src/shared/siteInitializationPresets.test.ts index ddbf1f7..3d97d6e 100644 --- a/src/shared/siteInitializationPresets.test.ts +++ b/src/shared/siteInitializationPresets.test.ts @@ -7,13 +7,22 @@ import { } from './siteInitializationPresets.js'; 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); expect(presetIds).toEqual(expect.arrayContaining([ 'codingplan-openai', 'codingplan-claude', 'zhipu-coding-plan-openai', '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'); @@ -55,6 +64,64 @@ describe('siteInitializationPresets', () => { recommendedSkipModelFetch: true, }); 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', () => { @@ -80,4 +147,47 @@ describe('siteInitializationPresets', () => { }); 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', + }); + }); }); diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index 005d89b..7d4c6df 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -28,6 +28,7 @@ import { type SiteForm, } from './helpers/sitesEditor.js'; import { + detectSiteInitializationPreset, getSiteInitializationPreset, listSiteInitializationPresets, } from '../../shared/siteInitializationPresets.js'; @@ -388,7 +389,7 @@ export default function Sites() { const openEdit = (site: SiteRow) => { setEditor({ mode: 'edit', editingSiteId: site.id }); setForm(siteFormFromSite(site)); - setSelectedInitializationPresetId(null); + setSelectedInitializationPresetId(detectSiteInitializationPreset(site.url, site.platform)?.id || null); scrollToEditorTop(); // Load disabled models and discovered models independently so a best-effort // availability fetch cannot wipe the existing disabled-model state. diff --git a/src/web/pages/accounts.codingplan-init.test.tsx b/src/web/pages/accounts.codingplan-init.test.tsx index f97524a..7c48a4e 100644 --- a/src/web/pages/accounts.codingplan-init.test.tsx +++ b/src/web/pages/accounts.codingplan-init.test.tsx @@ -113,4 +113,67 @@ describe('Accounts CodingPlan initialization', () => { 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( + + + + + , + ); + }); + 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(); + } + }); }); diff --git a/src/web/pages/sites.create-redirect.test.tsx b/src/web/pages/sites.create-redirect.test.tsx index 65fa562..a03c85a 100644 --- a/src/web/pages/sites.create-redirect.test.tsx +++ b/src/web/pages/sites.create-redirect.test.tsx @@ -219,6 +219,15 @@ describe('Sites create redirect', () => { expect.objectContaining({ label: '阿里云 CodingPlan / Claude' }), expect.objectContaining({ label: '智谱 Coding Plan / OpenAI' }), 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 { @@ -554,4 +563,54 @@ describe('Sites create redirect', () => { 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( + + + + } /> + + + , + ); + }); + 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(); + } + }); });