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 { useUIStore } from '../stores/uiStore'
import { useUpdateStore } from '../stores/updateStore' import { useUpdateStore } from '../stores/updateStore'
import type { SavedProvider } from '../types/provider' import type { SavedProvider } from '../types/provider'
import type { ProviderPreset } from '../types/providerPreset'
const MOCK_DELETE_PROVIDER = vi.fn() const MOCK_DELETE_PROVIDER = vi.fn()
const MOCK_GET_SETTINGS = vi.fn()
const MOCK_UPDATE_SETTINGS = vi.fn()
const providerStoreState = { const providerStoreState = {
providers: [] as SavedProvider[], providers: [] as SavedProvider[],
activeId: null, activeId: null as string | null,
presets: [], hasLoadedProviders: true,
presets: [] as ProviderPreset[],
isLoading: false, isLoading: false,
isPresetsLoading: false, isPresetsLoading: false,
fetchProviders: vi.fn(), fetchProviders: vi.fn(),
@ -36,6 +40,17 @@ vi.mock('../stores/providerStore', () => ({
useProviderStore: () => providerStoreState, 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', () => ({ vi.mock('../pages/AdapterSettings', () => ({
AdapterSettings: () => <div>Adapter Settings Mock</div>, AdapterSettings: () => <div>Adapter Settings Mock</div>,
})) }))
@ -72,8 +87,11 @@ vi.mock('../components/chat/CodeViewer', () => ({
describe('Settings > General tab', () => { describe('Settings > General tab', () => {
beforeEach(() => { beforeEach(() => {
MOCK_DELETE_PROVIDER.mockReset() MOCK_DELETE_PROVIDER.mockReset()
MOCK_GET_SETTINGS.mockResolvedValue({})
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
providerStoreState.providers = [] providerStoreState.providers = []
providerStoreState.activeId = null providerStoreState.activeId = null
providerStoreState.hasLoadedProviders = true
providerStoreState.presets = [] providerStoreState.presets = []
providerStoreState.isLoading = false providerStoreState.isLoading = false
providerStoreState.isPresetsLoading = false providerStoreState.isPresetsLoading = false
@ -145,6 +163,8 @@ describe('Settings > General tab', () => {
describe('Settings > Providers tab', () => { describe('Settings > Providers tab', () => {
beforeEach(() => { beforeEach(() => {
MOCK_DELETE_PROVIDER.mockReset() MOCK_DELETE_PROVIDER.mockReset()
MOCK_GET_SETTINGS.mockResolvedValue({})
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
providerStoreState.providers = [ providerStoreState.providers = [
{ {
id: 'provider-1', id: 'provider-1',
@ -162,6 +182,28 @@ describe('Settings > Providers tab', () => {
notes: '', 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 () => { it('requires confirmation before deleting a provider', async () => {
@ -178,6 +220,38 @@ describe('Settings > Providers tab', () => {
expect(MOCK_DELETE_PROVIDER).toHaveBeenCalledWith('provider-1') 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', () => { 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> = { type DropdownItem<T extends string> = {
value: T value: T
@ -12,8 +12,9 @@ type DropdownProps<T extends string> = {
value: T value: T
onChange: (value: T) => void onChange: (value: T) => void
trigger: ReactNode trigger: ReactNode
width?: number width?: CSSProperties['width']
align?: 'left' | 'right' align?: 'left' | 'right'
className?: string
} }
export function Dropdown<T extends string>({ export function Dropdown<T extends string>({
@ -23,6 +24,7 @@ export function Dropdown<T extends string>({
trigger, trigger,
width = 320, width = 320,
align = 'left', align = 'left',
className = '',
}: DropdownProps<T>) { }: DropdownProps<T>) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
@ -46,7 +48,7 @@ export function Dropdown<T extends string>({
}, [open]) }, [open])
return ( return (
<div ref={ref} className="relative inline-block"> <div ref={ref} className={`relative ${className || 'inline-block'}`}>
<div onClick={() => setOpen(!open)} className="cursor-pointer"> <div onClick={() => setOpen(!open)} className="cursor-pointer">
{trigger} {trigger}
</div> </div>
@ -54,8 +56,8 @@ export function Dropdown<T extends string>({
{open && ( {open && (
<div <div
className={` className={`
absolute z-50 mt-1 py-1 rounded-[var(--radius-lg)] absolute z-50 mt-1 overflow-hidden rounded-[var(--radius-lg)]
bg-[var(--color-surface)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)]
shadow-[var(--shadow-dropdown)] shadow-[var(--shadow-dropdown)]
animate-in fade-in slide-in-from-top-1 animate-in fade-in slide-in-from-top-1
${align === 'right' ? 'right-0' : 'left-0'} ${align === 'right' ? 'right-0' : 'left-0'}
@ -67,12 +69,13 @@ export function Dropdown<T extends string>({
key={item.value} key={item.value}
onClick={() => { onChange(item.value); setOpen(false) }} onClick={() => { onChange(item.value); setOpen(false) }}
className={` className={`
w-full flex items-center gap-3 px-4 py-3 text-left transition-colors w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors
hover:bg-[var(--color-surface-hover)] 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)]' : ''} ${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="flex-1 min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">{item.label}</div> <div className="text-sm font-medium text-[var(--color-text-primary)]">{item.label}</div>
{item.description && ( {item.description && (
@ -80,7 +83,7 @@ export function Dropdown<T extends string>({
)} )}
</div> </div>
{item.value === value && ( {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> </button>
))} ))}

View File

@ -114,6 +114,7 @@ export const en = {
'settings.providers.baseUrlPlaceholder': 'https://api.example.com/anthropic', 'settings.providers.baseUrlPlaceholder': 'https://api.example.com/anthropic',
'settings.providers.apiKey': 'API Key', 'settings.providers.apiKey': 'API Key',
'settings.providers.apiKeyKeep': 'API Key (leave blank to keep current)', 'settings.providers.apiKeyKeep': 'API Key (leave blank to keep current)',
'settings.providers.getApiKey': 'Get API Key',
'settings.providers.modelMapping': 'Model Mapping', 'settings.providers.modelMapping': 'Model Mapping',
'settings.providers.mainModel': 'Main Model', 'settings.providers.mainModel': 'Main Model',
'settings.providers.haikuModel': 'Haiku 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.baseUrlPlaceholder': 'https://api.example.com/anthropic',
'settings.providers.apiKey': 'API 密钥', 'settings.providers.apiKey': 'API 密钥',
'settings.providers.apiKeyKeep': 'API 密钥(留空保持不变)', 'settings.providers.apiKeyKeep': 'API 密钥(留空保持不变)',
'settings.providers.getApiKey': '获取 API Key',
'settings.providers.modelMapping': '模型映射', 'settings.providers.modelMapping': '模型映射',
'settings.providers.mainModel': '主模型', 'settings.providers.mainModel': '主模型',
'settings.providers.haikuModel': 'Haiku 模型', 'settings.providers.haikuModel': 'Haiku 模型',

View File

@ -6,6 +6,7 @@ import { Modal } from '../components/shared/Modal'
import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { Input } from '../components/shared/Input' import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button' import { Button } from '../components/shared/Button'
import { Dropdown } from '../components/shared/Dropdown'
import type { PermissionMode, EffortLevel, ThemeMode } from '../types/settings' import type { PermissionMode, EffortLevel, ThemeMode } from '../types/settings'
import type { Locale } from '../i18n' import type { Locale } from '../i18n'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider' 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 { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
import { useUpdateStore } from '../stores/updateStore' import { useUpdateStore } from '../stores/updateStore'
import { formatBytes } from '../lib/formatBytes' import { formatBytes } from '../lib/formatBytes'
import { isTauriRuntime } from '../lib/desktopRuntime'
export function Settings() { export function Settings() {
const [activeTab, setActiveTab] = useState<SettingsTab>('providers') const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
@ -103,6 +105,7 @@ function ProviderSettings() {
const { const {
providers, providers,
activeId, activeId,
hasLoadedProviders,
presets, presets,
isLoading, isLoading,
isPresetsLoading, isPresetsLoading,
@ -169,7 +172,7 @@ function ProviderSettings() {
await fetchSettings() await fetchSettings()
} }
const isOfficialActive = activeId === null const isOfficialActive = hasLoadedProviders && activeId === null
return ( return (
<div className="max-w-2xl"> <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) { function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderFormProps) {
const { createProvider, updateProvider, testConfig } = useProviderStore() const { createProvider, updateProvider, testConfig } = useProviderStore()
const fetchSettings = useSettingsStore((s) => s.fetchAll) const fetchSettings = useSettingsStore((s) => s.fetchAll)
const t = useTranslation() const t = useTranslation()
const availablePresets = presets.filter((p) => p.id !== 'official') 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 const fallbackPreset = provider
? buildFallbackPreset(provider) ? buildFallbackPreset(provider)
: requirePreset(availablePresets[availablePresets.length - 1]) : 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 [name, setName] = useState(provider?.name ?? initialPreset.name)
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl) const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
const [apiFormat, setApiFormat] = useState<ApiFormat>(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic') 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 [notes, setNotes] = useState(provider?.notes ?? '')
const [models, setModels] = useState<ModelMapping>(provider?.models ?? { ...initialPreset.defaultModels }) const [models, setModels] = useState<ModelMapping>(provider?.models ?? { ...initialPreset.defaultModels })
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@ -383,13 +403,20 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
import('../api/providers').then(({ providersApi }) => { import('../api/providers').then(({ providersApi }) => {
providersApi.getSettings().then((settings) => { providersApi.getSettings().then((settings) => {
const needsProxy = apiFormat !== 'anthropic' 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 = { const merged = {
...settings, ...settings,
skipWebFetchPreflight: settings.skipWebFetchPreflight ?? true, skipWebFetchPreflight: settings.skipWebFetchPreflight ?? true,
env: { env: {
...((settings.env as Record<string, string>) || {}), ...cleanedEnv,
...(selectedPreset.defaultEnv ?? {}),
ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl, 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_MODEL: models.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
@ -414,7 +441,41 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
} }
const isCustom = selectedPreset.id === 'custom' 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 () => { const handleSubmit = async () => {
if (!canSubmit) return if (!canSubmit) return
@ -475,8 +536,13 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
apiFormat, apiFormat,
}) })
} else { } else {
if (!apiKey.trim()) return if (requiresApiKey && !apiKey.trim()) return
result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim(), apiFormat }) result = await testConfig({
baseUrl: baseUrl.trim(),
apiKey: apiKey.trim() || selectedPreset.defaultEnv?.ANTHROPIC_AUTH_TOKEN || 'local',
modelId: models.main.trim(),
apiFormat,
})
} }
setTestResult(result) setTestResult(result)
} catch { } catch {
@ -506,20 +572,15 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
{mode === 'create' && ( {mode === 'create' && (
<div> <div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.preset')}</label> <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"> <div className="flex flex-col gap-2">
{availablePresets.map((preset) => ( <div className="flex flex-wrap gap-2">
<button {regularPresets.map(renderPresetButton)}
key={preset.id} </div>
onClick={() => handlePresetChange(preset)} {featuredPresets.length > 0 && (
className={`px-3 py-1.5 text-xs font-medium rounded-full border transition-all ${ <div className="flex flex-wrap gap-2 border-t border-[var(--color-border)]/60 pt-2">
selectedPreset.id === preset.id {featuredPresets.map(renderPresetButton)}
? 'border-[var(--color-brand)] bg-[var(--color-surface-container-high)] text-[var(--color-brand)] shadow-[var(--shadow-focus-ring)]' </div>
: '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> </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')} /> <Input label={t('settings.providers.notes')} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t('settings.providers.notesPlaceholder')} />
{/* Base URL */} <Input label={t('settings.providers.baseUrl')} required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={t('settings.providers.baseUrlPlaceholder')} />
{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>
)}
{/* API Format */} {/* API Format */}
{(isCustom || mode === 'edit') ? ( {(isCustom || mode === 'edit') ? (
<div> <div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label> <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} value={apiFormat}
onChange={(e) => setApiFormat(e.target.value as ApiFormat)} onChange={setApiFormat}
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)]" width="100%"
> className="block w-full"
<option value="anthropic">{t('settings.providers.apiFormatAnthropic')}</option> trigger={
<option value="openai_chat">{t('settings.providers.apiFormatOpenaiChat')}</option> <button
<option value="openai_responses">{t('settings.providers.apiFormatOpenaiResponses')}</option> type="button"
</select> 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' && ( {apiFormat !== 'anthropic' && (
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.proxyHint')}</p> <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} ) : null}
<Input <Input
label={mode === 'edit' ? t('settings.providers.apiKeyKeep') : t('settings.providers.apiKey')} label={t('settings.providers.apiKey')}
required={mode === 'create'} required={mode === 'create' && requiresApiKey}
type="password" type="text"
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} 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 */} {/* Model Mapping */}
<div> <div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">{t('settings.providers.modelMapping')}</label> <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 = { type ProviderStore = {
providers: SavedProvider[] providers: SavedProvider[]
activeId: string | null activeId: string | null
hasLoadedProviders: boolean
presets: ProviderPreset[] presets: ProviderPreset[]
isLoading: boolean isLoading: boolean
isPresetsLoading: boolean isPresetsLoading: boolean
@ -35,6 +36,7 @@ type ProviderStore = {
export const useProviderStore = create<ProviderStore>((set, get) => ({ export const useProviderStore = create<ProviderStore>((set, get) => ({
providers: [], providers: [],
activeId: null, activeId: null,
hasLoadedProviders: false,
presets: [], presets: [],
isLoading: false, isLoading: false,
isPresetsLoading: false, isPresetsLoading: false,
@ -44,9 +46,12 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
set({ isLoading: true, error: null }) set({ isLoading: true, error: null })
try { try {
const { providers, activeId } = await providersApi.list() const { providers, activeId } = await providersApi.list()
set({ providers, activeId, isLoading: false }) set({ providers, activeId, hasLoadedProviders: true, isLoading: false })
} catch (err) { } 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 defaultModels: ModelMapping
needsApiKey: boolean needsApiKey: boolean
websiteUrl: string 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) 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', () => { 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 deepseek = PROVIDER_PRESETS.find((preset) => preset.id === 'deepseek')
const zhipu = PROVIDER_PRESETS.find((preset) => preset.id === 'zhipuglm') const zhipu = PROVIDER_PRESETS.find((preset) => preset.id === 'zhipuglm')
const kimi = PROVIDER_PRESETS.find((preset) => preset.id === 'kimi') const kimi = PROVIDER_PRESETS.find((preset) => preset.id === 'kimi')
const minimax = PROVIDER_PRESETS.find((preset) => preset.id === 'minimax') 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(lmstudio?.baseUrl).toBe('http://localhost:1234')
expect(deepseek?.defaultModels.haiku).toBe('deepseek-chat') 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.main).toBe('glm-5.1')
expect(zhipu?.defaultModels.haiku).toBe('glm-4.5-air') expect(zhipu?.defaultModels.haiku).toBe('glm-4.5-air')
expect(zhipu?.defaultModels.sonnet).toBe('glm-5-turbo') 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?.baseUrl).toBe('https://api.kimi.com/coding')
expect(kimi?.defaultModels.main).toBe('kimi-k2.6') expect(kimi?.defaultModels.main).toBe('kimi-k2.6')
expect(minimax?.defaultModels.main).toBe('MiniMax-M2.7') 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 () => { 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') 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 () => { test('should preserve existing settings.json fields on activation', async () => {
// Pre-seed settings with an extra field // Pre-seed settings with an extra field
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
@ -412,9 +437,10 @@ describe('Providers API', () => {
const res = await handleProvidersApi(req, url, segments) const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(200) 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).toHaveLength(1)
expect(body.providers[0].name).toBe('Test Provider') expect(body.providers[0].name).toBe('Test Provider')
expect(body.providers[0].apiKey).toBe('sk-test-key-123')
}) })
// ─── POST /api/providers ───────────────────────────────────────────────── // ─── POST /api/providers ─────────────────────────────────────────────────

View File

@ -27,18 +27,6 @@ import { ApiError, errorResponse } from '../middleware/errorHandler.js'
const providerService = new ProviderService() 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( export async function handleProvidersApi(
req: Request, req: Request,
_url: URL, _url: URL,
@ -87,7 +75,7 @@ export async function handleProvidersApi(
if (!id) { if (!id) {
if (req.method === 'GET') { if (req.method === 'GET') {
const { providers, activeId } = await providerService.listProviders() const { providers, activeId } = await providerService.listProviders()
return Response.json({ providers: providers.map(sanitizeProvider), activeId }) return Response.json({ providers, activeId })
} }
if (req.method === 'POST') { if (req.method === 'POST') {
return await handleCreate(req) return await handleCreate(req)
@ -117,7 +105,7 @@ export async function handleProvidersApi(
// /api/providers/:id // /api/providers/:id
if (req.method === 'GET') { if (req.method === 'GET') {
const provider = await providerService.getProvider(id) const provider = await providerService.getProvider(id)
return Response.json({ provider: sanitizeProvider(provider) }) return Response.json({ provider })
} }
if (req.method === 'PUT') { if (req.method === 'PUT') {
return await handleUpdate(req, id) return await handleUpdate(req, id)

View File

@ -19,13 +19,14 @@
"baseUrl": "https://api.deepseek.com/anthropic", "baseUrl": "https://api.deepseek.com/anthropic",
"apiFormat": "anthropic", "apiFormat": "anthropic",
"defaultModels": { "defaultModels": {
"main": "deepseek-chat", "main": "deepseek-v4-pro",
"haiku": "deepseek-chat", "haiku": "deepseek-v4-flash",
"sonnet": "deepseek-chat", "sonnet": "deepseek-v4-pro",
"opus": "deepseek-chat" "opus": "deepseek-v4-pro"
}, },
"needsApiKey": true, "needsApiKey": true,
"websiteUrl": "https://platform.deepseek.com" "websiteUrl": "https://platform.deepseek.com",
"apiKeyUrl": "https://platform.deepseek.com/api_keys"
}, },
{ {
"id": "zhipuglm", "id": "zhipuglm",
@ -39,7 +40,9 @@
"opus": "glm-5.1" "opus": "glm-5.1"
}, },
"needsApiKey": true, "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", "id": "kimi",
@ -53,7 +56,8 @@
"opus": "kimi-k2.6" "opus": "kimi-k2.6"
}, },
"needsApiKey": true, "needsApiKey": true,
"websiteUrl": "https://platform.moonshot.cn" "websiteUrl": "https://platform.moonshot.cn",
"apiKeyUrl": "https://platform.kimi.com/console/api-keys"
}, },
{ {
"id": "minimax", "id": "minimax",
@ -67,7 +71,82 @@
"opus": "MiniMax-M2.7" "opus": "MiniMax-M2.7"
}, },
"needsApiKey": true, "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", "id": "custom",

View File

@ -21,6 +21,10 @@ const ProviderPresetSchema = z.object({
defaultModels: ModelMappingSchema, defaultModels: ModelMappingSchema,
needsApiKey: z.boolean(), needsApiKey: z.boolean(),
websiteUrl: z.string(), 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) 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 { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.js'
import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesToAnthropic.js' import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesToAnthropic.js'
import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/types.js' import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/types.js'
import { PROVIDER_PRESETS } from '../config/providerPresets.js'
import type { import type {
SavedProvider, SavedProvider,
ProvidersIndex, ProvidersIndex,
@ -38,6 +39,20 @@ const MANAGED_ENV_KEYS = [
const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] } 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 { export class ProviderService {
private static serverPort = 3456 private static serverPort = 3456
@ -234,6 +249,7 @@ export class ProviderService {
: provider.baseUrl : provider.baseUrl
return { return {
...getPresetDefaultEnv(provider.presetId),
ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_API_KEY: needsProxy ? 'proxy-managed' : provider.apiKey, ANTHROPIC_API_KEY: needsProxy ? 'proxy-managed' : provider.apiKey,
ANTHROPIC_MODEL: provider.models.main, ANTHROPIC_MODEL: provider.models.main,
@ -255,7 +271,7 @@ export class ProviderService {
const existingEnv = (settings.env as Record<string, string>) || {} const existingEnv = (settings.env as Record<string, string>) || {}
const cleanedEnv = { ...existingEnv } const cleanedEnv = { ...existingEnv }
for (const key of MANAGED_ENV_KEYS) { for (const key of getManagedEnvKeys()) {
delete cleanedEnv[key] delete cleanedEnv[key]
} }
@ -271,7 +287,7 @@ export class ProviderService {
const settings = await this.readSettings() const settings = await this.readSettings()
const env = (settings.env as Record<string, string>) || {} const env = (settings.env as Record<string, string>) || {}
for (const key of MANAGED_ENV_KEYS) { for (const key of getManagedEnvKeys()) {
delete env[key] delete env[key]
} }