mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
908b206f07
commit
3fc7200eb2
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 模型',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@ -15,4 +15,8 @@ export type ProviderPreset = {
|
||||
defaultModels: ModelMapping
|
||||
needsApiKey: boolean
|
||||
websiteUrl: string
|
||||
apiKeyUrl?: string
|
||||
promoText?: string
|
||||
featured?: boolean
|
||||
defaultEnv?: Record<string, string>
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user