diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts index 6531a4a2..ce36f1dc 100644 --- a/desktop/src/api/providers.ts +++ b/desktop/src/api/providers.ts @@ -8,7 +8,7 @@ import type { TestProviderConfigInput, ProviderTestResult, } from '../types/provider' -import type { ProviderPreset } from '../config/providerPresets' +import type { ProviderPreset } from '../types/providerPreset' type ProvidersResponse = { providers: SavedProvider[]; activeId: string | null } type ProviderResponse = { provider: SavedProvider } diff --git a/desktop/src/config/providerPresets.ts b/desktop/src/config/providerPresets.ts deleted file mode 100644 index 984514de..00000000 --- a/desktop/src/config/providerPresets.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Provider presets inspired by cc-switch (https://github.com/farion1231/cc-switch) -// Original work by Jason Young, MIT License - -import type { ApiFormat } from '../types/provider' - -export type ModelMapping = { - main: string - haiku: string - sonnet: string - opus: string -} - -export type ProviderPreset = { - id: string - name: string - baseUrl: string - apiFormat: ApiFormat - defaultModels: ModelMapping - needsApiKey: boolean - websiteUrl: string -} - -export const PROVIDER_PRESETS: ProviderPreset[] = [ - { - id: 'official', - name: 'Claude Official', - baseUrl: '', - apiFormat: 'anthropic', - defaultModels: { main: '', haiku: '', sonnet: '', opus: '' }, - needsApiKey: false, - websiteUrl: 'https://www.anthropic.com/claude-code', - }, - { - id: 'deepseek', - name: 'DeepSeek', - baseUrl: 'https://api.deepseek.com/anthropic', - apiFormat: 'anthropic', - defaultModels: { main: 'DeepSeek-V3.2', haiku: 'DeepSeek-V3.2', sonnet: 'DeepSeek-V3.2', opus: 'DeepSeek-V3.2' }, - needsApiKey: true, - websiteUrl: 'https://platform.deepseek.com', - }, - { - id: 'zhipuglm', - name: 'Zhipu GLM', - baseUrl: 'https://open.bigmodel.cn/api/anthropic', - apiFormat: 'anthropic', - defaultModels: { main: 'glm-5', haiku: 'glm-5', sonnet: 'glm-5', opus: 'glm-5' }, - needsApiKey: true, - websiteUrl: 'https://open.bigmodel.cn', - }, - { - id: 'kimi', - name: 'Kimi', - baseUrl: 'https://api.moonshot.cn/anthropic', - apiFormat: 'anthropic', - defaultModels: { main: 'kimi-k2.5', haiku: 'kimi-k2.5', sonnet: 'kimi-k2.5', opus: 'kimi-k2.5' }, - needsApiKey: true, - websiteUrl: 'https://platform.moonshot.cn', - }, - { - id: 'minimax', - name: 'MiniMax', - baseUrl: 'https://api.minimaxi.com/anthropic', - apiFormat: 'anthropic', - defaultModels: { main: 'MiniMax-M2.7', haiku: 'MiniMax-M2.7', sonnet: 'MiniMax-M2.7', opus: 'MiniMax-M2.7' }, - needsApiKey: true, - websiteUrl: 'https://platform.minimaxi.com', - }, - { - id: 'custom', - name: 'Custom', - baseUrl: '', - apiFormat: 'anthropic', - defaultModels: { main: '', haiku: '', sonnet: '', opus: '' }, - needsApiKey: true, - websiteUrl: '', - }, -] diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 6c059d63..ea851904 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -7,9 +7,8 @@ import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' import type { PermissionMode, EffortLevel, ThemeMode } from '../types/settings' import type { Locale } from '../i18n' -import { PROVIDER_PRESETS } from '../config/providerPresets' -import type { ProviderPreset } from '../config/providerPresets' import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider' +import type { ProviderPreset } from '../types/providerPreset' import { AdapterSettings } from './AdapterSettings' import { useAgentStore } from '../stores/agentStore' import { useSessionStore } from '../stores/sessionStore' @@ -100,14 +99,34 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri // ─── Provider Settings ────────────────────────────────────── function ProviderSettings() { - const { providers, activeId, isLoading, fetchProviders, deleteProvider, activateProvider, activateOfficial, testProvider } = useProviderStore() + const { + providers, + activeId, + presets, + isLoading, + isPresetsLoading, + fetchProviders, + fetchPresets, + deleteProvider, + activateProvider, + activateOfficial, + testProvider, + } = useProviderStore() const fetchSettings = useSettingsStore((s) => s.fetchAll) const t = useTranslation() const [editingProvider, setEditingProvider] = useState(null) const [showCreateModal, setShowCreateModal] = useState(false) const [testResults, setTestResults] = useState>({}) - useEffect(() => { fetchProviders() }, [fetchProviders]) + useEffect(() => { + void fetchProviders() + void fetchPresets() + }, [fetchPresets, fetchProviders]) + + const presetMap = useMemo( + () => new Map(presets.map((preset) => [preset.id, preset])), + [presets], + ) const handleDelete = async (provider: SavedProvider) => { if (activeId === provider.id) return @@ -144,7 +163,7 @@ function ProviderSettings() {

{t('settings.providers.title')}

{t('settings.providers.description')}

- @@ -191,7 +210,7 @@ function ProviderSettings() { {providers.map((provider) => { const isActive = activeId === provider.id const test = testResults[provider.id] - const preset = PROVIDER_PRESETS.find((p) => p.id === provider.presetId) + const preset = presetMap.get(provider.presetId) return (
setShowCreateModal(false)} mode="create" /> + setShowCreateModal(false)} mode="create" presets={presets} /> )} {/* Edit Modal */} {editingProvider && ( - setEditingProvider(null)} mode="edit" provider={editingProvider} /> + setEditingProvider(null)} mode="edit" provider={editingProvider} presets={presets} /> )}
) @@ -273,6 +292,7 @@ type ProviderFormProps = { onClose: () => void mode: 'create' | 'edit' provider?: SavedProvider + presets: ProviderPreset[] } function requirePreset(preset: ProviderPreset | undefined): ProviderPreset { @@ -282,15 +302,27 @@ function requirePreset(preset: ProviderPreset | undefined): ProviderPreset { return preset } -function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) { +function buildFallbackPreset(provider?: SavedProvider): ProviderPreset { + return { + id: provider?.presetId ?? 'custom', + name: provider?.name ?? 'Custom', + baseUrl: provider?.baseUrl ?? '', + apiFormat: provider?.apiFormat ?? 'anthropic', + defaultModels: provider?.models ?? { main: '', haiku: '', sonnet: '', opus: '' }, + needsApiKey: true, + websiteUrl: '', + } +} + +function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderFormProps) { const { createProvider, updateProvider, testConfig } = useProviderStore() const fetchSettings = useSettingsStore((s) => s.fetchAll) const t = useTranslation() - const availablePresets = PROVIDER_PRESETS.filter((p) => p.id !== 'official') - const fallbackPreset = requirePreset( - availablePresets[availablePresets.length - 1] ?? PROVIDER_PRESETS[0], - ) + const availablePresets = presets.filter((p) => p.id !== 'official') + const fallbackPreset = provider + ? buildFallbackPreset(provider) + : requirePreset(availablePresets[availablePresets.length - 1]) const initialPreset = requirePreset( provider ? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts index 82db219d..32771ebf 100644 --- a/desktop/src/stores/providerStore.ts +++ b/desktop/src/stores/providerStore.ts @@ -10,6 +10,7 @@ import type { TestProviderConfigInput, ProviderTestResult, } from '../types/provider' +import type { ProviderPreset } from '../types/providerPreset' // 与后端 src/server/api/models.ts 的 DEFAULT_MODEL 保持一致: // 切回"官方"时把聊天页的 currentModel 重置到这个,避免残留第三方 provider @@ -19,10 +20,13 @@ const OFFICIAL_DEFAULT_MODEL_ID = 'claude-opus-4-7' type ProviderStore = { providers: SavedProvider[] activeId: string | null + presets: ProviderPreset[] isLoading: boolean + isPresetsLoading: boolean error: string | null fetchProviders: () => Promise + fetchPresets: () => Promise createProvider: (input: CreateProviderInput) => Promise updateProvider: (id: string, input: UpdateProviderInput) => Promise deleteProvider: (id: string) => Promise @@ -35,7 +39,9 @@ type ProviderStore = { export const useProviderStore = create((set, get) => ({ providers: [], activeId: null, + presets: [], isLoading: false, + isPresetsLoading: false, error: null, fetchProviders: async () => { @@ -48,6 +54,16 @@ export const useProviderStore = create((set, get) => ({ } }, + fetchPresets: async () => { + set({ isPresetsLoading: true, error: null }) + try { + const { presets } = await providersApi.presets() + set({ presets, isPresetsLoading: false }) + } catch (err) { + set({ isPresetsLoading: false, error: err instanceof Error ? err.message : String(err) }) + } + }, + createProvider: async (input) => { const { provider } = await providersApi.create(input) await get().fetchProviders() diff --git a/desktop/src/types/providerPreset.ts b/desktop/src/types/providerPreset.ts new file mode 100644 index 00000000..c27382cf --- /dev/null +++ b/desktop/src/types/providerPreset.ts @@ -0,0 +1,18 @@ +import type { ApiFormat } from './provider' + +export type ModelMapping = { + main: string + haiku: string + sonnet: string + opus: string +} + +export type ProviderPreset = { + id: string + name: string + baseUrl: string + apiFormat: ApiFormat + defaultModels: ModelMapping + needsApiKey: boolean + websiteUrl: string +} diff --git a/src/server/__tests__/provider-presets.test.ts b/src/server/__tests__/provider-presets.test.ts new file mode 100644 index 00000000..2eb34ea1 --- /dev/null +++ b/src/server/__tests__/provider-presets.test.ts @@ -0,0 +1,42 @@ +import { describe, test, expect } from 'bun:test' + +import { handleProvidersApi } from '../api/providers.js' +import { PROVIDER_PRESETS } from '../config/providerPresets.js' + +function makeRequest(method: string, urlStr: string): { req: Request; url: URL; segments: string[] } { + const url = new URL(urlStr, 'http://localhost:3456') + const req = new Request(url.toString(), { method }) + const segments = url.pathname.split('/').filter(Boolean) + return { req, url, segments } +} + +describe('provider presets API', () => { + test('GET /api/providers/presets returns the configured presets', async () => { + const { req, url, segments } = makeRequest('GET', '/api/providers/presets') + const response = await handleProvidersApi(req, url, segments) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ presets: PROVIDER_PRESETS }) + }) + + test('configured presets include built-in official and custom entries', () => { + expect(PROVIDER_PRESETS.some((preset) => preset.id === 'official')).toBe(true) + expect(PROVIDER_PRESETS.some((preset) => preset.id === 'custom')).toBe(true) + }) + + test('configured presets keep current default model ids aligned with official provider docs', () => { + const deepseek = PROVIDER_PRESETS.find((preset) => preset.id === 'deepseek') + const zhipu = PROVIDER_PRESETS.find((preset) => preset.id === 'zhipuglm') + const kimi = PROVIDER_PRESETS.find((preset) => preset.id === 'kimi') + const minimax = PROVIDER_PRESETS.find((preset) => preset.id === 'minimax') + + expect(deepseek?.defaultModels.main).toBe('deepseek-chat') + expect(deepseek?.defaultModels.haiku).toBe('deepseek-chat') + expect(zhipu?.defaultModels.main).toBe('glm-5.1') + expect(zhipu?.defaultModels.haiku).toBe('glm-4.5-air') + expect(zhipu?.defaultModels.sonnet).toBe('glm-5-turbo') + expect(zhipu?.defaultModels.opus).toBe('glm-5.1') + expect(kimi?.defaultModels.main).toBe('kimi-k2.6') + expect(minimax?.defaultModels.main).toBe('MiniMax-M2.7') + }) +}) diff --git a/src/server/config/providerPresets.json b/src/server/config/providerPresets.json new file mode 100644 index 00000000..17dc457d --- /dev/null +++ b/src/server/config/providerPresets.json @@ -0,0 +1,86 @@ +[ + { + "id": "official", + "name": "Claude Official", + "baseUrl": "", + "apiFormat": "anthropic", + "defaultModels": { + "main": "", + "haiku": "", + "sonnet": "", + "opus": "" + }, + "needsApiKey": false, + "websiteUrl": "https://www.anthropic.com/claude-code" + }, + { + "id": "deepseek", + "name": "DeepSeek", + "baseUrl": "https://api.deepseek.com/anthropic", + "apiFormat": "anthropic", + "defaultModels": { + "main": "deepseek-chat", + "haiku": "deepseek-chat", + "sonnet": "deepseek-chat", + "opus": "deepseek-chat" + }, + "needsApiKey": true, + "websiteUrl": "https://platform.deepseek.com" + }, + { + "id": "zhipuglm", + "name": "Zhipu GLM", + "baseUrl": "https://open.bigmodel.cn/api/anthropic", + "apiFormat": "anthropic", + "defaultModels": { + "main": "glm-5.1", + "haiku": "glm-4.5-air", + "sonnet": "glm-5-turbo", + "opus": "glm-5.1" + }, + "needsApiKey": true, + "websiteUrl": "https://open.bigmodel.cn" + }, + { + "id": "kimi", + "name": "Kimi", + "baseUrl": "https://api.moonshot.cn/anthropic", + "apiFormat": "anthropic", + "defaultModels": { + "main": "kimi-k2.6", + "haiku": "kimi-k2.6", + "sonnet": "kimi-k2.6", + "opus": "kimi-k2.6" + }, + "needsApiKey": true, + "websiteUrl": "https://platform.moonshot.cn" + }, + { + "id": "minimax", + "name": "MiniMax", + "baseUrl": "https://api.minimaxi.com/anthropic", + "apiFormat": "anthropic", + "defaultModels": { + "main": "MiniMax-M2.7", + "haiku": "MiniMax-M2.7", + "sonnet": "MiniMax-M2.7", + "opus": "MiniMax-M2.7" + }, + "needsApiKey": true, + "websiteUrl": "https://platform.minimaxi.com" + }, + { + "id": "custom", + "name": "Custom", + "baseUrl": "", + "apiFormat": "anthropic", + "defaultModels": { + "main": "", + "haiku": "", + "sonnet": "", + "opus": "" + }, + "needsApiKey": true, + "websiteUrl": "" + } +] diff --git a/src/server/config/providerPresets.ts b/src/server/config/providerPresets.ts index 7c3e327b..c743fc48 100644 --- a/src/server/config/providerPresets.ts +++ b/src/server/config/providerPresets.ts @@ -1,78 +1,31 @@ // Provider presets inspired by cc-switch (https://github.com/farion1231/cc-switch) // Original work by Jason Young, MIT License -import type { ApiFormat } from '../types/provider.js' +import { z } from 'zod' -export type ModelMapping = { - main: string - haiku: string - sonnet: string - opus: string -} +import providerPresetsJson from './providerPresets.json' +import { ApiFormatSchema } from '../types/provider.js' -export type ProviderPreset = { - id: string - name: string - baseUrl: string - apiFormat: ApiFormat - defaultModels: ModelMapping - needsApiKey: boolean - websiteUrl: string -} +const ModelMappingSchema = z.object({ + main: z.string(), + haiku: z.string(), + sonnet: z.string(), + opus: z.string(), +}) -export const PROVIDER_PRESETS: ProviderPreset[] = [ - { - id: 'official', - name: 'Claude Official', - baseUrl: '', - apiFormat: 'anthropic', - defaultModels: { main: '', haiku: '', sonnet: '', opus: '' }, - needsApiKey: false, - websiteUrl: 'https://www.anthropic.com/claude-code', - }, - { - id: 'deepseek', - name: 'DeepSeek', - baseUrl: 'https://api.deepseek.com/anthropic', - apiFormat: 'anthropic', - defaultModels: { main: 'DeepSeek-V3.2', haiku: 'DeepSeek-V3.2', sonnet: 'DeepSeek-V3.2', opus: 'DeepSeek-V3.2' }, - needsApiKey: true, - websiteUrl: 'https://platform.deepseek.com', - }, - { - id: 'zhipuglm', - name: 'Zhipu GLM', - baseUrl: 'https://open.bigmodel.cn/api/anthropic', - apiFormat: 'anthropic', - defaultModels: { main: 'glm-5', haiku: 'glm-5', sonnet: 'glm-5', opus: 'glm-5' }, - needsApiKey: true, - websiteUrl: 'https://open.bigmodel.cn', - }, - { - id: 'kimi', - name: 'Kimi', - baseUrl: 'https://api.moonshot.cn/anthropic', - apiFormat: 'anthropic', - defaultModels: { main: 'kimi-k2.5', haiku: 'kimi-k2.5', sonnet: 'kimi-k2.5', opus: 'kimi-k2.5' }, - needsApiKey: true, - websiteUrl: 'https://platform.moonshot.cn', - }, - { - id: 'minimax', - name: 'MiniMax', - baseUrl: 'https://api.minimaxi.com/anthropic', - apiFormat: 'anthropic', - defaultModels: { main: 'MiniMax-M2.7', haiku: 'MiniMax-M2.7', sonnet: 'MiniMax-M2.7', opus: 'MiniMax-M2.7' }, - needsApiKey: true, - websiteUrl: 'https://platform.minimaxi.com', - }, - { - id: 'custom', - name: 'Custom', - baseUrl: '', - apiFormat: 'anthropic', - defaultModels: { main: '', haiku: '', sonnet: '', opus: '' }, - needsApiKey: true, - websiteUrl: '', - }, -] +const ProviderPresetSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + baseUrl: z.string(), + apiFormat: ApiFormatSchema, + defaultModels: ModelMappingSchema, + needsApiKey: z.boolean(), + websiteUrl: z.string(), +}) + +const ProviderPresetsSchema = z.array(ProviderPresetSchema) + +export type ModelMapping = z.infer +export type ProviderPreset = z.infer + +export const PROVIDER_PRESETS = ProviderPresetsSchema.parse(providerPresetsJson)