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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-28 15:58:25 +08:00
parent 908b206f07
commit 3fc7200eb2
13 changed files with 434 additions and 84 deletions

View File

@ -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: () => <div data-testid="claude-official-login" />,
}))
vi.mock('../pages/AdapterSettings', () => ({
AdapterSettings: () => <div>Adapter Settings Mock</div>,
}))
@ -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(<Settings />)
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(<Settings />)
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(<Settings />)
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', () => {

View File

@ -1,4 +1,4 @@
import { useState, useRef, useEffect, type ReactNode } from 'react'
import { useState, useRef, useEffect, type CSSProperties, type ReactNode } from 'react'
type DropdownItem<T extends string> = {
value: T
@ -12,8 +12,9 @@ type DropdownProps<T extends string> = {
value: T
onChange: (value: T) => void
trigger: ReactNode
width?: number
width?: CSSProperties['width']
align?: 'left' | 'right'
className?: string
}
export function Dropdown<T extends string>({
@ -23,6 +24,7 @@ export function Dropdown<T extends string>({
trigger,
width = 320,
align = 'left',
className = '',
}: DropdownProps<T>) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
@ -46,7 +48,7 @@ export function Dropdown<T extends string>({
}, [open])
return (
<div ref={ref} className="relative inline-block">
<div ref={ref} className={`relative ${className || 'inline-block'}`}>
<div onClick={() => setOpen(!open)} className="cursor-pointer">
{trigger}
</div>
@ -54,8 +56,8 @@ export function Dropdown<T extends string>({
{open && (
<div
className={`
absolute z-50 mt-1 py-1 rounded-[var(--radius-lg)]
bg-[var(--color-surface)] border border-[var(--color-border)]
absolute z-50 mt-1 overflow-hidden rounded-[var(--radius-lg)]
bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)]
shadow-[var(--shadow-dropdown)]
animate-in fade-in slide-in-from-top-1
${align === 'right' ? 'right-0' : 'left-0'}
@ -67,12 +69,13 @@ export function Dropdown<T extends string>({
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 && <span className="text-lg flex-shrink-0">{item.icon}</span>}
{item.icon && <span className="flex h-5 w-5 flex-shrink-0 items-center justify-center text-[var(--color-text-secondary)]">{item.icon}</span>}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">{item.label}</div>
{item.description && (
@ -80,7 +83,7 @@ export function Dropdown<T extends string>({
)}
</div>
{item.value === value && (
<span className="text-[var(--color-text-primary)] text-sm flex-shrink-0"></span>
<span className="material-symbols-outlined flex-shrink-0 text-[16px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
)}
</button>
))}

View File

@ -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',

View File

@ -116,6 +116,7 @@ export const zh: Record<TranslationKey, string> = {
'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 模型',

View File

@ -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<SettingsTab>('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 (
<div className="max-w-2xl">
@ -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<ApiFormat>(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<ModelMapping>(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<string, string>) || {}
const cleanedEnv = Object.fromEntries(
Object.entries(existingEnv).filter(([key]) => !presetDefaultEnvKeys.has(key)),
)
const merged = {
...settings,
skipWebFetchPreflight: settings.skipWebFetchPreflight ?? true,
env: {
...((settings.env as Record<string, string>) || {}),
...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: <span className="material-symbols-outlined text-[17px]">hub</span>,
},
{
value: 'openai_chat' as const,
label: t('settings.providers.apiFormatOpenaiChat'),
icon: <span className="material-symbols-outlined text-[17px]">forum</span>,
},
{
value: 'openai_responses' as const,
label: t('settings.providers.apiFormatOpenaiResponses'),
icon: <span className="material-symbols-outlined text-[17px]">route</span>,
},
]
const selectedApiFormatLabel = apiFormatItems.find((item) => item.value === apiFormat)?.label ?? t('settings.providers.apiFormatAnthropic')
const renderPresetButton = (preset: ProviderPreset) => (
<button
key={preset.id}
onClick={() => handlePresetChange(preset)}
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-all ${
selectedPreset.id === preset.id
? 'border-[var(--color-brand)] bg-[var(--color-surface-container-high)] text-[var(--color-brand)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{preset.name}
</button>
)
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' && (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.preset')}</label>
<div className="flex flex-wrap gap-2">
{availablePresets.map((preset) => (
<button
key={preset.id}
onClick={() => handlePresetChange(preset)}
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-all ${
selectedPreset.id === preset.id
? 'border-[var(--color-brand)] bg-[var(--color-surface-container-high)] text-[var(--color-brand)] shadow-[var(--shadow-focus-ring)]'
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
}`}
>
{preset.name}
</button>
))}
<div className="flex flex-col gap-2">
<div className="flex flex-wrap gap-2">
{regularPresets.map(renderPresetButton)}
</div>
{featuredPresets.length > 0 && (
<div className="flex flex-wrap gap-2 border-t border-[var(--color-border)]/60 pt-2">
{featuredPresets.map(renderPresetButton)}
</div>
)}
</div>
</div>
)}
@ -528,31 +589,28 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
<Input label={t('settings.providers.notes')} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t('settings.providers.notesPlaceholder')} />
{/* Base URL */}
{isCustom || mode === 'edit' ? (
<Input label={t('settings.providers.baseUrl')} required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} />
) : (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.baseUrl')}</label>
<div className="text-xs text-[var(--color-text-tertiary)] px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)]">
{baseUrl}
</div>
</div>
)}
<Input label={t('settings.providers.baseUrl')} required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} />
{/* API Format */}
{(isCustom || mode === 'edit') ? (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
<select
<Dropdown<ApiFormat>
items={apiFormatItems}
value={apiFormat}
onChange={(e) => setApiFormat(e.target.value as ApiFormat)}
className="w-full text-sm px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
>
<option value="anthropic">{t('settings.providers.apiFormatAnthropic')}</option>
<option value="openai_chat">{t('settings.providers.apiFormatOpenaiChat')}</option>
<option value="openai_responses">{t('settings.providers.apiFormatOpenaiResponses')}</option>
</select>
onChange={setApiFormat}
width="100%"
className="block w-full"
trigger={
<button
type="button"
className="flex h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="min-w-0 flex-1 truncate">{selectedApiFormatLabel}</span>
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
</button>
}
/>
{apiFormat !== 'anthropic' && (
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.proxyHint')}</p>
)}
@ -567,14 +625,44 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
) : null}
<Input
label={mode === 'edit' ? t('settings.providers.apiKeyKeep') : t('settings.providers.apiKey')}
required={mode === 'create'}
type="password"
label={t('settings.providers.apiKey')}
required={mode === 'create' && requiresApiKey}
type="text"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={mode === 'edit' ? '****' : 'sk-...'}
placeholder="sk-..."
/>
{(apiKeyUrl || promoText) && (
<div className="-mt-2 flex flex-col gap-1.5">
{apiKeyUrl && (
<button
type="button"
onClick={() => openExternalUrl(apiKeyUrl)}
className="group inline-flex h-6 w-fit cursor-pointer items-center gap-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 text-[11px] font-medium leading-none text-[var(--color-brand)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus:outline-none focus:shadow-[var(--shadow-focus-ring)]"
>
<span className="material-symbols-outlined text-[13px]">key</span>
{t('settings.providers.getApiKey')}
<span className="material-symbols-outlined text-[9px] opacity-60 transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5">arrow_outward</span>
</button>
)}
{promoText && (
<button
type="button"
onClick={() => apiKeyUrl && openExternalUrl(apiKeyUrl)}
disabled={!apiKeyUrl}
className="group flex w-full cursor-pointer items-start gap-1.5 rounded-[var(--radius-sm)] border border-[var(--color-brand)]/25 bg-[var(--color-brand)]/8 px-2.5 py-1.5 text-left text-[11px] leading-5 text-[var(--color-text-primary)] transition-colors hover:border-[var(--color-brand)]/45 hover:bg-[var(--color-brand)]/12 focus:outline-none focus:shadow-[var(--shadow-focus-ring)] disabled:cursor-default disabled:hover:border-[var(--color-brand)]/25 disabled:hover:bg-[var(--color-brand)]/8"
>
<span className="material-symbols-outlined mt-0.5 text-[13px] text-[var(--color-brand)]">tips_and_updates</span>
<span>{promoText}</span>
{apiKeyUrl && (
<span className="material-symbols-outlined ml-auto mt-1 text-[10px] text-[var(--color-brand)] opacity-45 transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5">arrow_outward</span>
)}
</button>
)}
</div>
)}
{/* Model Mapping */}
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelMapping')}</label>

View File

@ -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<ProviderStore>((set, get) => ({
providers: [],
activeId: null,
hasLoadedProviders: false,
presets: [],
isLoading: false,
isPresetsLoading: false,
@ -44,9 +46,12 @@ export const useProviderStore = create<ProviderStore>((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),
})
}
},

View File

@ -15,4 +15,8 @@ export type ProviderPreset = {
defaultModels: ModelMapping
needsApiKey: boolean
websiteUrl: string
apiKeyUrl?: string
promoText?: string
featured?: boolean
defaultEnv?: Record<string, string>
}

View File

@ -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 () => {

View File

@ -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<string, string>
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<string, string> | 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 ─────────────────────────────────────────────────

View File

@ -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<string, unknown>): Record<string, unknown> {
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)

View File

@ -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",

View File

@ -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)

View File

@ -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<string, string> {
return PROVIDER_PRESETS.find((preset) => preset.id === presetId)?.defaultEnv ?? {}
}
function getManagedEnvKeys(): string[] {
const keys = new Set<string>(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<string, string>) || {}
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<string, string>) || {}
for (const key of MANAGED_ENV_KEYS) {
for (const key of getManagedEnvKeys()) {
delete env[key]
}