From 3fc7200eb2cf22b8f74eeb3e3702f03e47127a09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 28 Apr 2026 15:58:25 +0800 Subject: [PATCH] feat: help users configure sponsored and local providers without guesswork Provider setup now carries the metadata needed for sponsored and local Anthropic-compatible providers, while keeping provider URLs and API keys editable in the desktop form. The desktop UI also exposes API-key links, optional sponsor copy, full key visibility, and local no-key presets that can be activated into runtime settings. Constraint: Local LM Studio and Ollama integrations require Anthropic-compatible root URLs rather than OpenAI /v1 URLs Rejected: Keep local providers at the top of the preset list | user requested them immediately before Custom Confidence: high Scope-risk: moderate Directive: Do not change local provider base URLs to /v1 without rechecking Anthropic compatibility docs Tested: bun test src/server/__tests__/provider-presets.test.ts src/server/__tests__/providers.test.ts Tested: cd desktop && bun run lint Tested: cd desktop && bun run test src/__tests__/generalSettings.test.tsx --run Tested: git diff --cached --check Tested: Web UI provider creation plus real sessions for LM Studio, Ollama, JiekouAI, and Shengsuanyun Not-tested: Production packaged Tauri build --- .../src/__tests__/generalSettings.test.tsx | 78 +++++++- desktop/src/components/shared/Dropdown.tsx | 21 ++- desktop/src/i18n/locales/en.ts | 1 + desktop/src/i18n/locales/zh.ts | 1 + desktop/src/pages/Settings.tsx | 176 +++++++++++++----- desktop/src/stores/providerStore.ts | 9 +- desktop/src/types/providerPreset.ts | 4 + src/server/__tests__/provider-presets.test.ts | 65 ++++++- src/server/__tests__/providers.test.ts | 28 ++- src/server/api/providers.ts | 16 +- src/server/config/providerPresets.json | 95 +++++++++- src/server/config/providerPresets.ts | 4 + src/server/services/providerService.ts | 20 +- 13 files changed, 434 insertions(+), 84 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 6b3d031c..0586b5c4 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -7,12 +7,16 @@ import { useSettingsStore } from '../stores/settingsStore' import { useUIStore } from '../stores/uiStore' import { useUpdateStore } from '../stores/updateStore' import type { SavedProvider } from '../types/provider' +import type { ProviderPreset } from '../types/providerPreset' const MOCK_DELETE_PROVIDER = vi.fn() +const MOCK_GET_SETTINGS = vi.fn() +const MOCK_UPDATE_SETTINGS = vi.fn() const providerStoreState = { providers: [] as SavedProvider[], - activeId: null, - presets: [], + activeId: null as string | null, + hasLoadedProviders: true, + presets: [] as ProviderPreset[], isLoading: false, isPresetsLoading: false, fetchProviders: vi.fn(), @@ -36,6 +40,17 @@ vi.mock('../stores/providerStore', () => ({ useProviderStore: () => providerStoreState, })) +vi.mock('../api/providers', () => ({ + providersApi: { + getSettings: MOCK_GET_SETTINGS, + updateSettings: MOCK_UPDATE_SETTINGS, + }, +})) + +vi.mock('../components/settings/ClaudeOfficialLogin', () => ({ + ClaudeOfficialLogin: () =>
, +})) + vi.mock('../pages/AdapterSettings', () => ({ AdapterSettings: () =>
Adapter Settings Mock
, })) @@ -72,8 +87,11 @@ vi.mock('../components/chat/CodeViewer', () => ({ describe('Settings > General tab', () => { beforeEach(() => { MOCK_DELETE_PROVIDER.mockReset() + MOCK_GET_SETTINGS.mockResolvedValue({}) + MOCK_UPDATE_SETTINGS.mockResolvedValue({}) providerStoreState.providers = [] providerStoreState.activeId = null + providerStoreState.hasLoadedProviders = true providerStoreState.presets = [] providerStoreState.isLoading = false providerStoreState.isPresetsLoading = false @@ -145,6 +163,8 @@ describe('Settings > General tab', () => { describe('Settings > Providers tab', () => { beforeEach(() => { MOCK_DELETE_PROVIDER.mockReset() + MOCK_GET_SETTINGS.mockResolvedValue({}) + MOCK_UPDATE_SETTINGS.mockResolvedValue({}) providerStoreState.providers = [ { id: 'provider-1', @@ -162,6 +182,28 @@ describe('Settings > Providers tab', () => { notes: '', }, ] + providerStoreState.activeId = null + providerStoreState.hasLoadedProviders = true + }) + + it('does not query official OAuth status before providers finish loading', () => { + providerStoreState.providers = [] + providerStoreState.activeId = null + providerStoreState.hasLoadedProviders = false + + render() + + expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument() + }) + + it('shows official OAuth status only after official provider is confirmed active', () => { + providerStoreState.providers = [] + providerStoreState.activeId = null + providerStoreState.hasLoadedProviders = true + + render() + + expect(screen.getByTestId('claude-official-login')).toBeInTheDocument() }) it('requires confirmation before deleting a provider', async () => { @@ -178,6 +220,38 @@ describe('Settings > Providers tab', () => { expect(MOCK_DELETE_PROVIDER).toHaveBeenCalledWith('provider-1') }) + + it('uses the shared dropdown for API format in the provider form', () => { + providerStoreState.presets = [ + { + id: 'custom', + name: 'Custom', + baseUrl: 'https://api.example.com/anthropic', + apiFormat: 'anthropic', + defaultModels: { + main: 'custom-main', + haiku: '', + sonnet: '', + opus: '', + }, + needsApiKey: true, + websiteUrl: '', + }, + ] + + render() + + fireEvent.click(screen.getByRole('button', { name: /Add Provider/i })) + + const dialog = screen.getByRole('dialog') + expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument() + + fireEvent.click(within(dialog).getByRole('button', { name: /Anthropic Messages \(native\)/i })) + fireEvent.click(within(dialog).getByRole('button', { name: /OpenAI Responses API \(proxy\)/i })) + + expect(within(dialog).getByRole('button', { name: /OpenAI Responses API \(proxy\)/i })).toBeInTheDocument() + expect(within(dialog).getByText('Requests will be translated via the local proxy')).toBeInTheDocument() + }) }) describe('Settings > About tab', () => { diff --git a/desktop/src/components/shared/Dropdown.tsx b/desktop/src/components/shared/Dropdown.tsx index fbbaf212..13f3f5bb 100644 --- a/desktop/src/components/shared/Dropdown.tsx +++ b/desktop/src/components/shared/Dropdown.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, type ReactNode } from 'react' +import { useState, useRef, useEffect, type CSSProperties, type ReactNode } from 'react' type DropdownItem = { value: T @@ -12,8 +12,9 @@ type DropdownProps = { value: T onChange: (value: T) => void trigger: ReactNode - width?: number + width?: CSSProperties['width'] align?: 'left' | 'right' + className?: string } export function Dropdown({ @@ -23,6 +24,7 @@ export function Dropdown({ trigger, width = 320, align = 'left', + className = '', }: DropdownProps) { const [open, setOpen] = useState(false) const ref = useRef(null) @@ -46,7 +48,7 @@ export function Dropdown({ }, [open]) return ( -
+
setOpen(!open)} className="cursor-pointer"> {trigger}
@@ -54,8 +56,8 @@ export function Dropdown({ {open && (
({ key={item.value} onClick={() => { onChange(item.value); setOpen(false) }} className={` - w-full flex items-center gap-3 px-4 py-3 text-left transition-colors - hover:bg-[var(--color-surface-hover)] + w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors + hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)] + ${item.value === value ? 'bg-[var(--color-model-option-selected-bg)]' : ''} ${i > 0 ? 'border-t border-[var(--color-border-separator)]' : ''} `} > - {item.icon && {item.icon}} + {item.icon && {item.icon}}
{item.label}
{item.description && ( @@ -80,7 +83,7 @@ export function Dropdown({ )}
{item.value === value && ( - + check_circle )} ))} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 454924c7..28dacb2c 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -114,6 +114,7 @@ export const en = { 'settings.providers.baseUrlPlaceholder': 'https://api.example.com/anthropic', 'settings.providers.apiKey': 'API Key', 'settings.providers.apiKeyKeep': 'API Key (leave blank to keep current)', + 'settings.providers.getApiKey': 'Get API Key', 'settings.providers.modelMapping': 'Model Mapping', 'settings.providers.mainModel': 'Main Model', 'settings.providers.haikuModel': 'Haiku Model', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index ee9c0954..e133416d 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -116,6 +116,7 @@ export const zh: Record = { 'settings.providers.baseUrlPlaceholder': 'https://api.example.com/anthropic', 'settings.providers.apiKey': 'API 密钥', 'settings.providers.apiKeyKeep': 'API 密钥(留空保持不变)', + 'settings.providers.getApiKey': '获取 API Key', 'settings.providers.modelMapping': '模型映射', 'settings.providers.mainModel': '主模型', 'settings.providers.haikuModel': 'Haiku 模型', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index e01f582e..5c5b1047 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -6,6 +6,7 @@ import { Modal } from '../components/shared/Modal' import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { Input } from '../components/shared/Input' import { Button } from '../components/shared/Button' +import { Dropdown } from '../components/shared/Dropdown' import type { PermissionMode, EffortLevel, ThemeMode } from '../types/settings' import type { Locale } from '../i18n' import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider' @@ -28,6 +29,7 @@ import { useUIStore, type SettingsTab } from '../stores/uiStore' import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin' import { useUpdateStore } from '../stores/updateStore' import { formatBytes } from '../lib/formatBytes' +import { isTauriRuntime } from '../lib/desktopRuntime' export function Settings() { const [activeTab, setActiveTab] = useState('providers') @@ -103,6 +105,7 @@ function ProviderSettings() { const { providers, activeId, + hasLoadedProviders, presets, isLoading, isPresetsLoading, @@ -169,7 +172,7 @@ function ProviderSettings() { await fetchSettings() } - const isOfficialActive = activeId === null + const isOfficialActive = hasLoadedProviders && activeId === null return (
@@ -344,12 +347,29 @@ function buildFallbackPreset(provider?: SavedProvider): ProviderPreset { } } +function openExternalUrl(url: string) { + if (!isTauriRuntime()) { + window.open(url, '_blank', 'noopener,noreferrer') + return + } + + void import('@tauri-apps/plugin-shell') + .then((mod) => mod.open(url)) + .catch(() => window.open(url, '_blank', 'noopener,noreferrer')) +} + function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderFormProps) { const { createProvider, updateProvider, testConfig } = useProviderStore() const fetchSettings = useSettingsStore((s) => s.fetchAll) const t = useTranslation() const availablePresets = presets.filter((p) => p.id !== 'official') + const regularPresets = availablePresets.filter((p) => !p.featured) + const featuredPresets = availablePresets.filter((p) => p.featured) + const presetDefaultEnvKeys = useMemo( + () => new Set(presets.flatMap((preset) => Object.keys(preset.defaultEnv ?? {}))), + [presets], + ) const fallbackPreset = provider ? buildFallbackPreset(provider) : requirePreset(availablePresets[availablePresets.length - 1]) @@ -363,7 +383,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF const [name, setName] = useState(provider?.name ?? initialPreset.name) const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl) const [apiFormat, setApiFormat] = useState(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic') - const [apiKey, setApiKey] = useState('') + const [apiKey, setApiKey] = useState(provider?.apiKey ?? '') const [notes, setNotes] = useState(provider?.notes ?? '') const [models, setModels] = useState(provider?.models ?? { ...initialPreset.defaultModels }) const [isSubmitting, setIsSubmitting] = useState(false) @@ -383,13 +403,20 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF import('../api/providers').then(({ providersApi }) => { providersApi.getSettings().then((settings) => { const needsProxy = apiFormat !== 'anthropic' + const existingEnv = (settings.env as Record) || {} + const cleanedEnv = Object.fromEntries( + Object.entries(existingEnv).filter(([key]) => !presetDefaultEnvKeys.has(key)), + ) const merged = { ...settings, skipWebFetchPreflight: settings.skipWebFetchPreflight ?? true, env: { - ...((settings.env as Record) || {}), + ...cleanedEnv, + ...(selectedPreset.defaultEnv ?? {}), ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl, - ANTHROPIC_AUTH_TOKEN: needsProxy ? 'proxy-managed' : (apiKey || '(your API key)'), + ANTHROPIC_AUTH_TOKEN: needsProxy + ? 'proxy-managed' + : (apiKey || selectedPreset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || (selectedPreset.needsApiKey ? '(your API key)' : '')), ANTHROPIC_MODEL: models.main, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, @@ -414,7 +441,41 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF } const isCustom = selectedPreset.id === 'custom' - const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.main.trim() && !settingsJsonError + const requiresApiKey = selectedPreset.needsApiKey !== false + const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || !requiresApiKey || apiKey.trim()) && models.main.trim() && !settingsJsonError + const apiKeyUrl = selectedPreset.apiKeyUrl?.trim() + const promoText = selectedPreset.promoText?.trim() + const apiFormatItems = [ + { + value: 'anthropic' as const, + label: t('settings.providers.apiFormatAnthropic'), + icon: hub, + }, + { + value: 'openai_chat' as const, + label: t('settings.providers.apiFormatOpenaiChat'), + icon: forum, + }, + { + value: 'openai_responses' as const, + label: t('settings.providers.apiFormatOpenaiResponses'), + icon: route, + }, + ] + const selectedApiFormatLabel = apiFormatItems.find((item) => item.value === apiFormat)?.label ?? t('settings.providers.apiFormatAnthropic') + const renderPresetButton = (preset: ProviderPreset) => ( + + ) const handleSubmit = async () => { if (!canSubmit) return @@ -475,8 +536,13 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF apiFormat, }) } else { - if (!apiKey.trim()) return - result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim(), apiFormat }) + if (requiresApiKey && !apiKey.trim()) return + result = await testConfig({ + baseUrl: baseUrl.trim(), + apiKey: apiKey.trim() || selectedPreset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || 'local', + modelId: models.main.trim(), + apiFormat, + }) } setTestResult(result) } catch { @@ -506,20 +572,15 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF {mode === 'create' && (
-
- {availablePresets.map((preset) => ( - - ))} +
+
+ {regularPresets.map(renderPresetButton)} +
+ {featuredPresets.length > 0 && ( +
+ {featuredPresets.map(renderPresetButton)} +
+ )}
)} @@ -528,31 +589,28 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF setNotes(e.target.value)} placeholder={t('settings.providers.notesPlaceholder')} /> - {/* Base URL */} - {isCustom || mode === 'edit' ? ( - setBaseUrl(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} /> - ) : ( -
- -
- {baseUrl} -
-
- )} + setBaseUrl(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} /> {/* API Format */} {(isCustom || mode === 'edit') ? (
- + onChange={setApiFormat} + width="100%" + className="block w-full" + trigger={ + + } + /> {apiFormat !== 'anthropic' && (

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

)} @@ -567,14 +625,44 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF ) : null} setApiKey(e.target.value)} - placeholder={mode === 'edit' ? '****' : 'sk-...'} + placeholder="sk-..." /> + {(apiKeyUrl || promoText) && ( +
+ {apiKeyUrl && ( + + )} + {promoText && ( + + )} +
+ )} + {/* Model Mapping */}
diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts index e4214d33..e9953e82 100644 --- a/desktop/src/stores/providerStore.ts +++ b/desktop/src/stores/providerStore.ts @@ -16,6 +16,7 @@ import type { ProviderPreset } from '../types/providerPreset' type ProviderStore = { providers: SavedProvider[] activeId: string | null + hasLoadedProviders: boolean presets: ProviderPreset[] isLoading: boolean isPresetsLoading: boolean @@ -35,6 +36,7 @@ type ProviderStore = { export const useProviderStore = create((set, get) => ({ providers: [], activeId: null, + hasLoadedProviders: false, presets: [], isLoading: false, isPresetsLoading: false, @@ -44,9 +46,12 @@ export const useProviderStore = create((set, get) => ({ set({ isLoading: true, error: null }) try { const { providers, activeId } = await providersApi.list() - set({ providers, activeId, isLoading: false }) + set({ providers, activeId, hasLoadedProviders: true, isLoading: false }) } catch (err) { - set({ isLoading: false, error: err instanceof Error ? err.message : String(err) }) + set({ + isLoading: false, + error: err instanceof Error ? err.message : String(err), + }) } }, diff --git a/desktop/src/types/providerPreset.ts b/desktop/src/types/providerPreset.ts index c27382cf..c3afcb25 100644 --- a/desktop/src/types/providerPreset.ts +++ b/desktop/src/types/providerPreset.ts @@ -15,4 +15,8 @@ export type ProviderPreset = { defaultModels: ModelMapping needsApiKey: boolean websiteUrl: string + apiKeyUrl?: string + promoText?: string + featured?: boolean + defaultEnv?: Record } diff --git a/src/server/__tests__/provider-presets.test.ts b/src/server/__tests__/provider-presets.test.ts index c18b32e8..3078d6ae 100644 --- a/src/server/__tests__/provider-presets.test.ts +++ b/src/server/__tests__/provider-presets.test.ts @@ -54,14 +54,32 @@ describe('provider presets API', () => { expect(PROVIDER_PRESETS.some((preset) => preset.id === 'custom')).toBe(true) }) + test('local Anthropic-compatible presets appear immediately before custom', () => { + expect(PROVIDER_PRESETS.at(-3)?.id).toBe('lmstudio') + expect(PROVIDER_PRESETS.at(-2)?.id).toBe('ollama') + expect(PROVIDER_PRESETS.at(-1)?.id).toBe('custom') + }) + test('configured presets keep current default model ids aligned with official provider docs', () => { + const lmstudio = PROVIDER_PRESETS.find((preset) => preset.id === 'lmstudio') + const ollama = PROVIDER_PRESETS.find((preset) => preset.id === 'ollama') 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') + const jiekouai = PROVIDER_PRESETS.find((preset) => preset.id === 'jiekouai') + const shengsuanyun = PROVIDER_PRESETS.find((preset) => preset.id === 'shengsuanyun') - expect(deepseek?.defaultModels.main).toBe('deepseek-chat') - expect(deepseek?.defaultModels.haiku).toBe('deepseek-chat') + expect(lmstudio?.baseUrl).toBe('http://localhost:1234') + expect(lmstudio?.apiFormat).toBe('anthropic') + expect(lmstudio?.defaultModels.main).toBe('qwen/qwen3.6-27b') + expect(ollama?.baseUrl).toBe('http://localhost:11434') + expect(ollama?.apiFormat).toBe('anthropic') + expect(ollama?.defaultModels.main).toBe('qwen3.6:27b') + expect(deepseek?.defaultModels.main).toBe('deepseek-v4-pro') + expect(deepseek?.defaultModels.haiku).toBe('deepseek-v4-flash') + expect(deepseek?.defaultModels.sonnet).toBe('deepseek-v4-pro') + expect(deepseek?.defaultModels.opus).toBe('deepseek-v4-pro') 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') @@ -69,6 +87,49 @@ describe('provider presets API', () => { expect(kimi?.baseUrl).toBe('https://api.kimi.com/coding') expect(kimi?.defaultModels.main).toBe('kimi-k2.6') expect(minimax?.defaultModels.main).toBe('MiniMax-M2.7') + expect(jiekouai?.baseUrl).toBe('https://api.jiekou.ai/anthropic') + expect(jiekouai?.defaultModels.main).toBe('claude-sonnet-4-6') + expect(jiekouai?.defaultModels.opus).toBe('claude-opus-4-7') + expect(shengsuanyun?.baseUrl).toBe('https://router.shengsuanyun.com/api') + expect(shengsuanyun?.defaultModels.main).toBe('anthropic/claude-sonnet-4.6') + expect(shengsuanyun?.defaultModels.haiku).toBe('anthropic/claude-haiku-4.5:thinking') + }) + + test('configured presets can expose optional API key and promo metadata', () => { + const lmstudio = PROVIDER_PRESETS.find((preset) => preset.id === 'lmstudio') + const ollama = PROVIDER_PRESETS.find((preset) => preset.id === 'ollama') + 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') + const jiekouai = PROVIDER_PRESETS.find((preset) => preset.id === 'jiekouai') + const shengsuanyun = PROVIDER_PRESETS.find((preset) => preset.id === 'shengsuanyun') + const custom = PROVIDER_PRESETS.find((preset) => preset.id === 'custom') + + expect(lmstudio?.needsApiKey).toBe(false) + expect(lmstudio?.promoText).toContain('http://localhost:1234') + expect(lmstudio?.promoText).toContain('200K') + expect(lmstudio?.defaultEnv).toEqual({ ANTHROPIC_AUTH_TOKEN: 'lmstudio' }) + expect(ollama?.needsApiKey).toBe(false) + expect(ollama?.promoText).toContain('http://localhost:11434') + expect(ollama?.promoText).toContain('200K') + expect(ollama?.defaultEnv).toEqual({ ANTHROPIC_AUTH_TOKEN: 'ollama' }) + expect(deepseek?.apiKeyUrl).toBe('https://platform.deepseek.com/api_keys') + expect(zhipu?.apiKeyUrl).toBe('https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D') + expect(zhipu?.promoText).toContain('cc-haha') + expect(kimi?.apiKeyUrl).toBe('https://platform.kimi.com/console/api-keys') + expect(minimax?.apiKeyUrl).toBe('https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link') + expect(jiekouai?.apiKeyUrl).toBe('https://jiekou.ai/referral?invited_code=OBNU3K') + expect(jiekouai?.promoText).toContain('官方 8 折') + expect(jiekouai?.featured).toBe(true) + expect(shengsuanyun?.apiKeyUrl).toBe('https://www.shengsuanyun.com/?from=CH_LEJ88KWR') + expect(shengsuanyun?.promoText).toContain('首充 10%') + expect(shengsuanyun?.featured).toBe(true) + expect(shengsuanyun?.defaultEnv).toEqual({ + API_TIMEOUT_MS: '3000000', + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + }) + expect(custom?.promoText).toBeUndefined() }) test('GET and PUT /api/providers/settings read and write cc-haha settings.json', async () => { diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts index 426f8a3c..852dff4c 100644 --- a/src/server/__tests__/providers.test.ts +++ b/src/server/__tests__/providers.test.ts @@ -317,6 +317,31 @@ describe('ProviderService', () => { expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('model-opus') }) + test('should include preset default env on activation and runtime env', async () => { + const svc = new ProviderService() + const provider = await svc.addProvider(sampleInput({ + presetId: 'shengsuanyun', + baseUrl: 'https://router.shengsuanyun.com/api', + })) + + await svc.activateProvider(provider.id) + + const settings = await readSettings() + const env = settings.env as Record + expect(env.API_TIMEOUT_MS).toBe('3000000') + expect(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe('1') + + const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id) + expect(runtimeEnv.API_TIMEOUT_MS).toBe('3000000') + expect(runtimeEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe('1') + + await svc.activateOfficial() + const clearedSettings = await readSettings() + const clearedEnv = (clearedSettings.env as Record | undefined) ?? {} + expect(clearedEnv.API_TIMEOUT_MS).toBeUndefined() + expect(clearedEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBeUndefined() + }) + test('should preserve existing settings.json fields on activation', async () => { // Pre-seed settings with an extra field await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) @@ -412,9 +437,10 @@ describe('Providers API', () => { const res = await handleProvidersApi(req, url, segments) expect(res.status).toBe(200) - const body = (await res.json()) as { providers: { name: string }[] } + const body = (await res.json()) as { providers: { name: string; apiKey: string }[] } expect(body.providers).toHaveLength(1) expect(body.providers[0].name).toBe('Test Provider') + expect(body.providers[0].apiKey).toBe('sk-test-key-123') }) // ─── POST /api/providers ───────────────────────────────────────────────── diff --git a/src/server/api/providers.ts b/src/server/api/providers.ts index 1747828f..2c574f5b 100644 --- a/src/server/api/providers.ts +++ b/src/server/api/providers.ts @@ -27,18 +27,6 @@ import { ApiError, errorResponse } from '../middleware/errorHandler.js' const providerService = new ProviderService() -function maskApiKey(key: string): string { - if (key.length <= 8) return '****' - return key.slice(0, 4) + '****' + key.slice(-4) -} - -function sanitizeProvider(provider: Record): Record { - if (typeof provider.apiKey === 'string') { - return { ...provider, apiKey: maskApiKey(provider.apiKey) } - } - return provider -} - export async function handleProvidersApi( req: Request, _url: URL, @@ -87,7 +75,7 @@ export async function handleProvidersApi( if (!id) { if (req.method === 'GET') { const { providers, activeId } = await providerService.listProviders() - return Response.json({ providers: providers.map(sanitizeProvider), activeId }) + return Response.json({ providers, activeId }) } if (req.method === 'POST') { return await handleCreate(req) @@ -117,7 +105,7 @@ export async function handleProvidersApi( // /api/providers/:id if (req.method === 'GET') { const provider = await providerService.getProvider(id) - return Response.json({ provider: sanitizeProvider(provider) }) + return Response.json({ provider }) } if (req.method === 'PUT') { return await handleUpdate(req, id) diff --git a/src/server/config/providerPresets.json b/src/server/config/providerPresets.json index 74352280..c77361ec 100644 --- a/src/server/config/providerPresets.json +++ b/src/server/config/providerPresets.json @@ -19,13 +19,14 @@ "baseUrl": "https://api.deepseek.com/anthropic", "apiFormat": "anthropic", "defaultModels": { - "main": "deepseek-chat", - "haiku": "deepseek-chat", - "sonnet": "deepseek-chat", - "opus": "deepseek-chat" + "main": "deepseek-v4-pro", + "haiku": "deepseek-v4-flash", + "sonnet": "deepseek-v4-pro", + "opus": "deepseek-v4-pro" }, "needsApiKey": true, - "websiteUrl": "https://platform.deepseek.com" + "websiteUrl": "https://platform.deepseek.com", + "apiKeyUrl": "https://platform.deepseek.com/api_keys" }, { "id": "zhipuglm", @@ -39,7 +40,9 @@ "opus": "glm-5.1" }, "needsApiKey": true, - "websiteUrl": "https://open.bigmodel.cn" + "websiteUrl": "https://open.bigmodel.cn", + "apiKeyUrl": "https://www.bigmodel.cn/invite?icode=d41B2qi8Z5xNwTGLNPPF3OZLO2QH3C0EBTSr%2BArzMw4%3D", + "promoText": "智谱 GLM 为 cc-haha 用户准备了专属邀请福利,使用此链接注册后可领取新用户权益。" }, { "id": "kimi", @@ -53,7 +56,8 @@ "opus": "kimi-k2.6" }, "needsApiKey": true, - "websiteUrl": "https://platform.moonshot.cn" + "websiteUrl": "https://platform.moonshot.cn", + "apiKeyUrl": "https://platform.kimi.com/console/api-keys" }, { "id": "minimax", @@ -67,7 +71,82 @@ "opus": "MiniMax-M2.7" }, "needsApiKey": true, - "websiteUrl": "https://platform.minimaxi.com" + "websiteUrl": "https://platform.minimaxi.com", + "apiKeyUrl": "https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link" + }, + { + "id": "jiekouai", + "name": "接口AI", + "baseUrl": "https://api.jiekou.ai/anthropic", + "apiFormat": "anthropic", + "defaultModels": { + "main": "claude-sonnet-4-6", + "haiku": "claude-haiku-4-5-20251001", + "sonnet": "claude-sonnet-4-6", + "opus": "claude-opus-4-7" + }, + "needsApiKey": true, + "websiteUrl": "https://jiekou.ai", + "apiKeyUrl": "https://jiekou.ai/referral?invited_code=OBNU3K", + "promoText": "接口AI 提供官方资源与稳定高性能体验,订阅包价格为官方 8 折;绑定 GitHub 后还可领取 3 美元优惠券。", + "featured": true + }, + { + "id": "shengsuanyun", + "name": "胜算云", + "baseUrl": "https://router.shengsuanyun.com/api", + "apiFormat": "anthropic", + "defaultModels": { + "main": "anthropic/claude-sonnet-4.6", + "haiku": "anthropic/claude-haiku-4.5:thinking", + "sonnet": "anthropic/claude-sonnet-4.6", + "opus": "anthropic/claude-opus-4.7" + }, + "needsApiKey": true, + "websiteUrl": "https://www.shengsuanyun.com", + "apiKeyUrl": "https://www.shengsuanyun.com/?from=CH_LEJ88KWR", + "promoText": "胜算云为 cc-haha 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!", + "featured": true, + "defaultEnv": { + "API_TIMEOUT_MS": "3000000", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" + } + }, + { + "id": "lmstudio", + "name": "LM Studio", + "baseUrl": "http://localhost:1234", + "apiFormat": "anthropic", + "defaultModels": { + "main": "qwen/qwen3.6-27b", + "haiku": "qwen/qwen3.6-27b", + "sonnet": "qwen/qwen3.6-27b", + "opus": "qwen/qwen3.6-27b" + }, + "needsApiKey": false, + "websiteUrl": "https://lmstudio.ai/docs/integrations/claude-code", + "promoText": "LM Studio 使用 Anthropic 兼容协议,Base URL 填 http://localhost:1234,不要追加 /v1。Claude Code 的提示词、工具和 Skill 会占用较多上下文,请在本地模型设置里把 Context Window 调大,建议至少 200K。", + "defaultEnv": { + "ANTHROPIC_AUTH_TOKEN": "lmstudio" + } + }, + { + "id": "ollama", + "name": "Ollama", + "baseUrl": "http://localhost:11434", + "apiFormat": "anthropic", + "defaultModels": { + "main": "qwen3.6:27b", + "haiku": "qwen3.6:27b", + "sonnet": "qwen3.6:27b", + "opus": "qwen3.6:27b" + }, + "needsApiKey": false, + "websiteUrl": "https://docs.ollama.com/integrations/claude-code", + "promoText": "Ollama 使用 Anthropic 兼容协议,Base URL 填 http://localhost:11434,不要追加 /v1。Claude Code 的提示词、工具和 Skill 会占用较多上下文,请在本地模型设置里把 Context Window 调大,建议至少 200K。", + "defaultEnv": { + "ANTHROPIC_AUTH_TOKEN": "ollama" + } }, { "id": "custom", diff --git a/src/server/config/providerPresets.ts b/src/server/config/providerPresets.ts index c743fc48..efe3ed7e 100644 --- a/src/server/config/providerPresets.ts +++ b/src/server/config/providerPresets.ts @@ -21,6 +21,10 @@ const ProviderPresetSchema = z.object({ defaultModels: ModelMappingSchema, needsApiKey: z.boolean(), websiteUrl: z.string(), + apiKeyUrl: z.string().optional(), + promoText: z.string().optional(), + featured: z.boolean().optional(), + defaultEnv: z.record(z.string(), z.string()).optional(), }) const ProviderPresetsSchema = z.array(ProviderPresetSchema) diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index c06d5815..e80739e0 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -15,6 +15,7 @@ import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenai import { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.js' import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesToAnthropic.js' import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/types.js' +import { PROVIDER_PRESETS } from '../config/providerPresets.js' import type { SavedProvider, ProvidersIndex, @@ -38,6 +39,20 @@ const MANAGED_ENV_KEYS = [ const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] } +function getPresetDefaultEnv(presetId: string): Record { + return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.defaultEnv ?? {} +} + +function getManagedEnvKeys(): string[] { + const keys = new Set(MANAGED_ENV_KEYS) + for (const preset of PROVIDER_PRESETS) { + for (const key of Object.keys(preset.defaultEnv ?? {})) { + keys.add(key) + } + } + return [...keys] +} + export class ProviderService { private static serverPort = 3456 @@ -234,6 +249,7 @@ export class ProviderService { : provider.baseUrl return { + ...getPresetDefaultEnv(provider.presetId), ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_API_KEY: needsProxy ? 'proxy-managed' : provider.apiKey, ANTHROPIC_MODEL: provider.models.main, @@ -255,7 +271,7 @@ export class ProviderService { const existingEnv = (settings.env as Record) || {} const cleanedEnv = { ...existingEnv } - for (const key of MANAGED_ENV_KEYS) { + for (const key of getManagedEnvKeys()) { delete cleanedEnv[key] } @@ -271,7 +287,7 @@ export class ProviderService { const settings = await this.readSettings() const env = (settings.env as Record) || {} - for (const key of MANAGED_ENV_KEYS) { + for (const key of getManagedEnvKeys()) { delete env[key] }