mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Merge branch 'feat/provider-model-config' into main
Provider preset redesign: - Preset-based providers (DeepSeek, ZhipuGLM, Kimi, MiniMax, Custom) - Storage moved to ~/.claude/cc-haha/providers.json - Full 6-key env sync to ~/.claude/settings.json (merge, not overwrite) - Official provider card with one-click activation (clears env) - Editable settings.json preview in provider form - Remove redundant Model tab from Settings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
56b4fe3585
@ -6,14 +6,7 @@
|
||||
"prompt": "test prompt",
|
||||
"createdAt": 1775497208158,
|
||||
"recurring": true,
|
||||
"name": "test-name",
|
||||
"description": "test description",
|
||||
"folder": "/test/folder",
|
||||
"model": "claude-opus-4-6",
|
||||
"permissionMode": "ask",
|
||||
"worktree": false,
|
||||
"frequency": "daily",
|
||||
"scheduledTime": "09:00"
|
||||
"lastFiredAt": 1775525400260
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
// desktop/src/api/providers.ts
|
||||
|
||||
import { api } from './client'
|
||||
import type {
|
||||
Provider,
|
||||
SavedProvider,
|
||||
CreateProviderInput,
|
||||
UpdateProviderInput,
|
||||
TestProviderConfigInput,
|
||||
ProviderTestResult,
|
||||
} from '../types/provider'
|
||||
import type { ProviderPreset } from '../config/providerPresets'
|
||||
|
||||
type ProvidersResponse = { providers: Provider[] }
|
||||
type ProviderResponse = { provider: Provider }
|
||||
type ProvidersResponse = { providers: SavedProvider[]; activeId: string | null }
|
||||
type ProviderResponse = { provider: SavedProvider }
|
||||
type PresetsResponse = { presets: ProviderPreset[] }
|
||||
type TestResultResponse = { result: ProviderTestResult }
|
||||
|
||||
export const providersApi = {
|
||||
@ -16,8 +20,8 @@ export const providersApi = {
|
||||
return api.get<ProvidersResponse>('/api/providers')
|
||||
},
|
||||
|
||||
get(id: string) {
|
||||
return api.get<ProviderResponse>(`/api/providers/${id}`)
|
||||
presets() {
|
||||
return api.get<PresetsResponse>('/api/providers/presets')
|
||||
},
|
||||
|
||||
create(input: CreateProviderInput) {
|
||||
@ -32,8 +36,12 @@ export const providersApi = {
|
||||
return api.delete<{ ok: true }>(`/api/providers/${id}`)
|
||||
},
|
||||
|
||||
activate(id: string, modelId: string) {
|
||||
return api.post<{ ok: true }>(`/api/providers/${id}/activate`, { modelId })
|
||||
activate(id: string) {
|
||||
return api.post<{ ok: true }>(`/api/providers/${id}/activate`)
|
||||
},
|
||||
|
||||
activateOfficial() {
|
||||
return api.post<{ ok: true }>('/api/providers/official')
|
||||
},
|
||||
|
||||
test(id: string) {
|
||||
|
||||
69
desktop/src/config/providerPresets.ts
Normal file
69
desktop/src/config/providerPresets.ts
Normal file
@ -0,0 +1,69 @@
|
||||
// Provider presets inspired by cc-switch (https://github.com/farion1231/cc-switch)
|
||||
// Original work by Jason Young, MIT License
|
||||
|
||||
export type ModelMapping = {
|
||||
main: string
|
||||
haiku: string
|
||||
sonnet: string
|
||||
opus: string
|
||||
}
|
||||
|
||||
export type ProviderPreset = {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
defaultModels: ModelMapping
|
||||
needsApiKey: boolean
|
||||
websiteUrl: string
|
||||
}
|
||||
|
||||
export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{
|
||||
id: 'official',
|
||||
name: 'Claude Official',
|
||||
baseUrl: '',
|
||||
defaultModels: { main: '', haiku: '', sonnet: '', opus: '' },
|
||||
needsApiKey: false,
|
||||
websiteUrl: 'https://www.anthropic.com/claude-code',
|
||||
},
|
||||
{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com/anthropic',
|
||||
defaultModels: { main: 'DeepSeek-V3.2', haiku: 'DeepSeek-V3.2', sonnet: 'DeepSeek-V3.2', opus: 'DeepSeek-V3.2' },
|
||||
needsApiKey: true,
|
||||
websiteUrl: 'https://platform.deepseek.com',
|
||||
},
|
||||
{
|
||||
id: 'zhipuglm',
|
||||
name: 'Zhipu GLM',
|
||||
baseUrl: 'https://open.bigmodel.cn/api/anthropic',
|
||||
defaultModels: { main: 'glm-5', haiku: 'glm-5', sonnet: 'glm-5', opus: 'glm-5' },
|
||||
needsApiKey: true,
|
||||
websiteUrl: 'https://open.bigmodel.cn',
|
||||
},
|
||||
{
|
||||
id: 'kimi',
|
||||
name: 'Kimi',
|
||||
baseUrl: 'https://api.moonshot.cn/anthropic',
|
||||
defaultModels: { main: 'kimi-k2.5', haiku: 'kimi-k2.5', sonnet: 'kimi-k2.5', opus: 'kimi-k2.5' },
|
||||
needsApiKey: true,
|
||||
websiteUrl: 'https://platform.moonshot.cn',
|
||||
},
|
||||
{
|
||||
id: 'minimax',
|
||||
name: 'MiniMax',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
defaultModels: { main: 'MiniMax-M2.7', haiku: 'MiniMax-M2.7', sonnet: 'MiniMax-M2.7', opus: 'MiniMax-M2.7' },
|
||||
needsApiKey: true,
|
||||
websiteUrl: 'https://platform.minimaxi.com',
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom',
|
||||
baseUrl: '',
|
||||
defaultModels: { main: '', haiku: '', sonnet: '', opus: '' },
|
||||
needsApiKey: true,
|
||||
websiteUrl: '',
|
||||
},
|
||||
]
|
||||
@ -5,10 +5,11 @@ import { useUIStore } from '../stores/uiStore'
|
||||
import { Modal } from '../components/shared/Modal'
|
||||
import { Input } from '../components/shared/Input'
|
||||
import { Button } from '../components/shared/Button'
|
||||
import type { PermissionMode, EffortLevel, ModelInfo } from '../types/settings'
|
||||
import type { Provider, ProviderModel, UpdateProviderInput, ProviderTestResult } from '../types/provider'
|
||||
import type { PermissionMode, EffortLevel } from '../types/settings'
|
||||
import { PROVIDER_PRESETS } from '../config/providerPresets'
|
||||
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider'
|
||||
|
||||
type SettingsTab = 'providers' | 'model' | 'permissions' | 'general'
|
||||
type SettingsTab = 'providers' | 'permissions' | 'general'
|
||||
|
||||
export function Settings() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
||||
@ -31,7 +32,6 @@ export function Settings() {
|
||||
{/* Tab navigation */}
|
||||
<div className="w-48 border-r border-[var(--color-border)] py-3 flex-shrink-0">
|
||||
<TabButton icon="dns" label="Providers" active={activeTab === 'providers'} onClick={() => setActiveTab('providers')} />
|
||||
<TabButton icon="auto_awesome" label="Model" active={activeTab === 'model'} onClick={() => setActiveTab('model')} />
|
||||
<TabButton icon="shield" label="Permissions" active={activeTab === 'permissions'} onClick={() => setActiveTab('permissions')} />
|
||||
<TabButton icon="tune" label="General" active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
|
||||
</div>
|
||||
@ -39,7 +39,6 @@ export function Settings() {
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-y-auto px-8 py-6">
|
||||
{activeTab === 'providers' && <ProviderSettings />}
|
||||
{activeTab === 'model' && <ModelSettings />}
|
||||
{activeTab === 'permissions' && <PermissionSettings />}
|
||||
{activeTab === 'general' && <GeneralSettings />}
|
||||
</div>
|
||||
@ -67,26 +66,21 @@ function TabButton({ icon, label, active, onClick }: { icon: string; label: stri
|
||||
// ─── Provider Settings ──────────────────────────────────────
|
||||
|
||||
function ProviderSettings() {
|
||||
const { providers, isLoading, fetchProviders, deleteProvider, activateProvider, testProvider } = useProviderStore()
|
||||
const { providers, activeId, isLoading, fetchProviders, deleteProvider, activateProvider, activateOfficial, testProvider } = useProviderStore()
|
||||
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null)
|
||||
const [editingProvider, setEditingProvider] = useState<SavedProvider | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [activatingProvider, setActivatingProvider] = useState<Provider | null>(null)
|
||||
const [testResults, setTestResults] = useState<Record<string, { loading: boolean; result?: ProviderTestResult }>>({})
|
||||
|
||||
useEffect(() => { fetchProviders() }, [fetchProviders])
|
||||
|
||||
const handleDelete = async (provider: Provider) => {
|
||||
if (provider.isActive) return
|
||||
const handleDelete = async (provider: SavedProvider) => {
|
||||
if (activeId === provider.id) return
|
||||
if (!window.confirm(`Delete provider "${provider.name}"? This cannot be undone.`)) return
|
||||
try {
|
||||
await deleteProvider(provider.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete provider:', err)
|
||||
}
|
||||
await deleteProvider(provider.id).catch(console.error)
|
||||
}
|
||||
|
||||
const handleTest = async (provider: Provider) => {
|
||||
const handleTest = async (provider: SavedProvider) => {
|
||||
setTestResults((r) => ({ ...r, [provider.id]: { loading: true } }))
|
||||
try {
|
||||
const result = await testProvider(provider.id)
|
||||
@ -96,12 +90,18 @@ function ProviderSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleActivate = async (providerId: string, modelId: string) => {
|
||||
await activateProvider(providerId, modelId)
|
||||
const handleActivate = async (id: string) => {
|
||||
await activateProvider(id)
|
||||
await fetchSettings()
|
||||
setActivatingProvider(null)
|
||||
}
|
||||
|
||||
const handleActivateOfficial = async () => {
|
||||
await activateOfficial()
|
||||
await fetchSettings()
|
||||
}
|
||||
|
||||
const isOfficialActive = activeId === null
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@ -115,64 +115,75 @@ function ProviderSettings() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && providers.length === 0 ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
||||
{/* Official provider — always visible at top */}
|
||||
<div
|
||||
className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all mb-2 ${
|
||||
isOfficialActive
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)]'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
|
||||
}`}
|
||||
onClick={() => !isOfficialActive && handleActivateOfficial()}
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">Claude Official</span>
|
||||
{isOfficialActive && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">ACTIVE</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">Anthropic native — no API key required</div>
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed border-[var(--color-border)] rounded-xl">
|
||||
<span className="material-symbols-outlined text-[36px] text-[var(--color-text-tertiary)] mb-2 block">dns</span>
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mb-3">No providers configured</p>
|
||||
<Button size="sm" onClick={() => setShowCreateModal(true)}>Add your first provider</Button>
|
||||
</div>
|
||||
|
||||
{/* Saved providers */}
|
||||
{isLoading && providers.length === 0 ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{providers.map((provider) => {
|
||||
const isActive = activeId === provider.id
|
||||
const test = testResults[provider.id]
|
||||
const preset = PROVIDER_PRESETS.find((p) => p.id === provider.presetId)
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={`relative flex items-center gap-4 px-4 py-3.5 rounded-xl border transition-all group ${
|
||||
provider.isActive
|
||||
isActive
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)]'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)]'
|
||||
}`}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${provider.isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
||||
|
||||
{/* Info */}
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)] truncate">{provider.name}</span>
|
||||
{provider.isActive && (
|
||||
{preset && preset.id !== 'custom' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none">{preset.name}</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">ACTIVE</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
|
||||
{provider.baseUrl} · {provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
|
||||
{provider.baseUrl} · {provider.models.main}
|
||||
</div>
|
||||
{/* Test result inline */}
|
||||
{test && !test.loading && test.result && (
|
||||
<div className={`text-xs mt-1 ${test.result.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
|
||||
{test.result.success ? `Connected (${test.result.latencyMs}ms)` : `Failed: ${test.result.error}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
{!provider.isActive && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setActivatingProvider(provider)}>Activate</Button>
|
||||
{!isActive && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleActivate(provider.id)}>Activate</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>
|
||||
Test
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleTest(provider)} loading={test?.loading}>Test</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditingProvider(provider)}>Edit</Button>
|
||||
{!provider.isActive && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">
|
||||
Delete
|
||||
</Button>
|
||||
{!isActive && (
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(provider)} className="text-[var(--color-error)] hover:text-[var(--color-error)]">Delete</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -181,44 +192,14 @@ function ProviderSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<ProviderFormModal
|
||||
open={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
mode="create"
|
||||
/>
|
||||
{/* Create Modal — conditionally rendered so state resets on close */}
|
||||
{showCreateModal && (
|
||||
<ProviderFormModal open={true} onClose={() => setShowCreateModal(false)} mode="create" />
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingProvider && (
|
||||
<ProviderFormModal
|
||||
key={editingProvider.id}
|
||||
open={true}
|
||||
onClose={() => setEditingProvider(null)}
|
||||
mode="edit"
|
||||
provider={editingProvider}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Activate — model picker */}
|
||||
{activatingProvider && (
|
||||
<Modal open={true} onClose={() => setActivatingProvider(null)} title={`Activate ${activatingProvider.name}`} width={420}>
|
||||
<p className="text-sm text-[var(--color-text-secondary)] mb-3">Select a model to use with this provider:</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{activatingProvider.models.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => handleActivate(activatingProvider.id, m.id)}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] text-left transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">smart_toy</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)]">{m.name}</div>
|
||||
{m.description && <div className="text-xs text-[var(--color-text-tertiary)]">{m.description}{m.context ? ` · ${m.context}` : ''}</div>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
<ProviderFormModal key={editingProvider.id} open={true} onClose={() => setEditingProvider(null)} mode="edit" provider={editingProvider} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@ -230,48 +211,98 @@ type ProviderFormProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
mode: 'create' | 'edit'
|
||||
provider?: Provider
|
||||
provider?: SavedProvider
|
||||
}
|
||||
|
||||
function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) {
|
||||
const { createProvider, updateProvider, testConfig } = useProviderStore()
|
||||
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
||||
|
||||
const [name, setName] = useState(provider?.name ?? '')
|
||||
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? '')
|
||||
const availablePresets = PROVIDER_PRESETS.filter((p) => p.id !== 'official')
|
||||
const initialPreset = provider ? availablePresets.find((p) => p.id === provider.presetId) || availablePresets.at(-1)! : availablePresets[0]
|
||||
|
||||
const [selectedPreset, setSelectedPreset] = useState(initialPreset)
|
||||
const [name, setName] = useState(provider?.name ?? initialPreset.name)
|
||||
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [notes, setNotes] = useState(provider?.notes ?? '')
|
||||
const [models, setModels] = useState<ProviderModel[]>(provider?.models ?? [{ id: '', name: '', description: '', context: '' }])
|
||||
const [models, setModels] = useState<ModelMapping>(provider?.models ?? { ...initialPreset.defaultModels })
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<ProviderTestResult | null>(null)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
const [settingsJson, setSettingsJson] = useState('')
|
||||
const [settingsJsonError, setSettingsJsonError] = useState<string | null>(null)
|
||||
|
||||
const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.some((m) => m.id.trim() && m.name.trim())
|
||||
// Load current settings.json and merge provider env vars
|
||||
useEffect(() => {
|
||||
import('../api/settings').then(({ settingsApi }) => {
|
||||
settingsApi.getUser().then((settings) => {
|
||||
const merged = {
|
||||
...settings,
|
||||
env: {
|
||||
...((settings.env as Record<string, string>) || {}),
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey || '(your API key)',
|
||||
ANTHROPIC_MODEL: models.main,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
|
||||
},
|
||||
}
|
||||
setSettingsJson(JSON.stringify(merged, null, 2))
|
||||
}).catch(() => {
|
||||
setSettingsJson(JSON.stringify({}, null, 2))
|
||||
})
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedPreset.id])
|
||||
|
||||
const handlePresetChange = (preset: typeof initialPreset) => {
|
||||
setSelectedPreset(preset)
|
||||
setName(preset.name)
|
||||
setBaseUrl(preset.baseUrl)
|
||||
setModels({ ...preset.defaultModels })
|
||||
setTestResult(null)
|
||||
}
|
||||
|
||||
const isCustom = selectedPreset.id === 'custom'
|
||||
const canSubmit = name.trim() && baseUrl.trim() && (mode === 'edit' || apiKey.trim()) && models.main.trim() && !settingsJsonError
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const validModels = models.filter((m) => m.id.trim() && m.name.trim())
|
||||
// Write the edited settings.json first (for all presets including official)
|
||||
if (settingsJson.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(settingsJson)
|
||||
const { settingsApi } = await import('../api/settings')
|
||||
await settingsApi.updateUser(parsed)
|
||||
} catch {
|
||||
// JSON validation already prevents this
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'create') {
|
||||
await createProvider({
|
||||
presetId: selectedPreset.id,
|
||||
name: name.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
apiKey: apiKey.trim(),
|
||||
models: validModels,
|
||||
baseUrl: baseUrl.trim(),
|
||||
models,
|
||||
notes: notes.trim() || undefined,
|
||||
})
|
||||
} else if (provider) {
|
||||
const input: UpdateProviderInput = {
|
||||
name: name.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
models: validModels,
|
||||
models,
|
||||
notes: notes.trim() || undefined,
|
||||
}
|
||||
if (apiKey.trim()) input.apiKey = apiKey.trim()
|
||||
await updateProvider(provider.id, input)
|
||||
if (provider.isActive) await fetchSettings()
|
||||
}
|
||||
await fetchSettings()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Failed to save provider:', err)
|
||||
@ -281,12 +312,17 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
}
|
||||
|
||||
const handleTest = async () => {
|
||||
const firstModel = models.find((m) => m.id.trim())
|
||||
if (!baseUrl.trim() || !apiKey.trim() || !firstModel) return
|
||||
if (!baseUrl.trim() || !models.main.trim()) return
|
||||
setIsTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: firstModel.id.trim() })
|
||||
let result: ProviderTestResult
|
||||
if (mode === 'edit' && provider && !apiKey.trim()) {
|
||||
result = await useProviderStore.getState().testProvider(provider.id)
|
||||
} else {
|
||||
if (!apiKey.trim()) return
|
||||
result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim() })
|
||||
}
|
||||
setTestResult(result)
|
||||
} catch {
|
||||
setTestResult({ success: false, latencyMs: 0, error: 'Request failed' })
|
||||
@ -295,18 +331,12 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
}
|
||||
}
|
||||
|
||||
const addModel = () => setModels([...models, { id: '', name: '', description: '', context: '' }])
|
||||
const removeModel = (index: number) => setModels(models.filter((_, i) => i !== index))
|
||||
const updateModel = (index: number, field: keyof ProviderModel, value: string) => {
|
||||
setModels(models.map((m, i) => i === index ? { ...m, [field]: value } : m))
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={mode === 'create' ? 'Add Provider' : 'Edit Provider'}
|
||||
width={600}
|
||||
width={720}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||
@ -317,66 +347,67 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. OpenRouter, Anthropic Official" />
|
||||
<Input label="Base URL" required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.anthropic.com" />
|
||||
{/* Preset chips */}
|
||||
{mode === 'create' && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">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
|
||||
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)]'
|
||||
}`}
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} placeholder="Provider name" />
|
||||
|
||||
<Input label="Notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional notes..." />
|
||||
|
||||
{/* Base URL */}
|
||||
{isCustom || mode === 'edit' ? (
|
||||
<Input label="Base URL" required value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://api.example.com/anthropic" />
|
||||
) : (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">Base URL</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={mode === 'edit' ? 'API Key (leave blank to keep current)' : 'API Key'}
|
||||
required={mode === 'create'}
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={mode === 'edit' ? '****' : 'sk-ant-...'}
|
||||
placeholder={mode === 'edit' ? '****' : 'sk-...'}
|
||||
/>
|
||||
<Input label="Notes" value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Optional notes..." />
|
||||
|
||||
{/* Models */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
Models <span className="text-[var(--color-error)]">*</span>
|
||||
</label>
|
||||
<button onClick={addModel} className="text-xs text-[var(--color-brand)] hover:underline">+ Add model</button>
|
||||
{/* Model Mapping */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">Model Mapping</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input label="Main Model" required value={models.main} onChange={(e) => setModels({ ...models, main: e.target.value })} placeholder="Model ID" />
|
||||
<Input label="Haiku Model" value={models.haiku} onChange={(e) => setModels({ ...models, haiku: e.target.value })} placeholder="Same as main" />
|
||||
<Input label="Sonnet Model" value={models.sonnet} onChange={(e) => setModels({ ...models, sonnet: e.target.value })} placeholder="Same as main" />
|
||||
<Input label="Opus Model" value={models.opus} onChange={(e) => setModels({ ...models, opus: e.target.value })} placeholder="Same as main" />
|
||||
</div>
|
||||
{models.map((m, i) => (
|
||||
<div key={i} className="flex gap-2 items-start">
|
||||
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||
<input
|
||||
value={m.id}
|
||||
onChange={(e) => updateModel(i, 'id', e.target.value)}
|
||||
placeholder="Model ID *"
|
||||
className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
|
||||
/>
|
||||
<input
|
||||
value={m.name}
|
||||
onChange={(e) => updateModel(i, 'name', e.target.value)}
|
||||
placeholder="Display name *"
|
||||
className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
|
||||
/>
|
||||
<input
|
||||
value={m.description ?? ''}
|
||||
onChange={(e) => updateModel(i, 'description', e.target.value)}
|
||||
placeholder="Description"
|
||||
className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
|
||||
/>
|
||||
<input
|
||||
value={m.context ?? ''}
|
||||
onChange={(e) => updateModel(i, 'context', e.target.value)}
|
||||
placeholder="Context (e.g. 200k)"
|
||||
className="h-8 px-2 text-xs rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-border-focus)]"
|
||||
/>
|
||||
</div>
|
||||
{models.length > 1 && (
|
||||
<button onClick={() => removeModel(i)} className="mt-1 p-1 text-[var(--color-text-tertiary)] hover:text-[var(--color-error)] transition-colors">
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Test connection */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="secondary" size="sm" onClick={handleTest} loading={isTesting} disabled={!baseUrl.trim() || !apiKey.trim() || !models.some((m) => m.id.trim())}>
|
||||
<Button variant="secondary" size="sm" onClick={handleTest} loading={isTesting} disabled={!baseUrl.trim() || !models.main.trim()}>
|
||||
Test Connection
|
||||
</Button>
|
||||
{testResult && (
|
||||
@ -385,143 +416,39 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings JSON — editable, shown for all presets including official */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-2 block">Settings JSON</label>
|
||||
<textarea
|
||||
value={settingsJson}
|
||||
onChange={(e) => {
|
||||
setSettingsJson(e.target.value)
|
||||
try {
|
||||
JSON.parse(e.target.value)
|
||||
setSettingsJsonError(null)
|
||||
} catch (err) {
|
||||
setSettingsJsonError(err instanceof Error ? err.message : 'Invalid JSON')
|
||||
}
|
||||
}}
|
||||
rows={16}
|
||||
spellCheck={false}
|
||||
className={`w-full text-xs px-3 py-3 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border font-mono leading-relaxed resize-y text-[var(--color-text-secondary)] outline-none ${
|
||||
settingsJsonError
|
||||
? 'border-[var(--color-error)] focus:border-[var(--color-error)]'
|
||||
: 'border-[var(--color-border)] focus:border-[var(--color-border-focus)]'
|
||||
}`}
|
||||
/>
|
||||
{settingsJsonError && (
|
||||
<p className="text-[11px] text-[var(--color-error)] mt-1">JSON syntax error: {settingsJsonError}</p>
|
||||
)}
|
||||
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">~/.claude/settings.json — edit directly, will be written on save.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Model Settings ──────────────────────────────────────
|
||||
|
||||
function ModelSettings() {
|
||||
const { availableModels, currentModel, effortLevel, activeProviderName, setModel, setEffort, fetchAll } = useSettingsStore()
|
||||
const [customModelId, setCustomModelId] = useState('')
|
||||
const [showCustom, setShowCustom] = useState(false)
|
||||
|
||||
useEffect(() => { fetchAll() }, [fetchAll])
|
||||
|
||||
const handleSelectModel = async (model: ModelInfo) => {
|
||||
await setModel(model.id)
|
||||
}
|
||||
|
||||
const handleCustomModel = async () => {
|
||||
const id = customModelId.trim()
|
||||
if (!id) return
|
||||
await setModel(id)
|
||||
setShowCustom(false)
|
||||
setCustomModelId('')
|
||||
}
|
||||
|
||||
const MODEL_ICONS: Record<string, string> = {
|
||||
'opus': 'diamond',
|
||||
'sonnet': 'auto_awesome',
|
||||
'haiku': 'bolt',
|
||||
} as const
|
||||
|
||||
const getModelIcon = (id: string) => {
|
||||
if (id.includes('opus')) return MODEL_ICONS['opus']
|
||||
if (id.includes('sonnet')) return MODEL_ICONS['sonnet']
|
||||
if (id.includes('haiku')) return MODEL_ICONS['haiku']
|
||||
return 'smart_toy'
|
||||
}
|
||||
|
||||
const EFFORT_LABELS: Record<EffortLevel, string> = {
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
max: 'Max',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">Model</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
|
||||
Choose the AI model for new conversations.
|
||||
{activeProviderName && (
|
||||
<span className="ml-1 text-[var(--color-text-secondary)]">
|
||||
(via <span className="font-medium">{activeProviderName}</span>)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 mb-6">
|
||||
{availableModels.map((model) => {
|
||||
const isSelected = currentModel?.id === model.id
|
||||
return (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleSelectModel(model)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-all text-left ${
|
||||
isSelected
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-primary-fixed)]'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-secondary)]">
|
||||
{getModelIcon(model.id)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{model.name}</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)]">{model.description} · {model.context} context</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
check_circle
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Custom model */}
|
||||
{showCustom ? (
|
||||
<div className="flex items-center gap-2 px-4 py-3 rounded-xl border border-[var(--color-border)]">
|
||||
<input
|
||||
type="text"
|
||||
value={customModelId}
|
||||
onChange={(e) => setCustomModelId(e.target.value)}
|
||||
placeholder="Enter model ID (e.g. claude-sonnet-4-6-20250514)"
|
||||
className="flex-1 text-sm bg-transparent outline-none text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)]"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCustomModel()}
|
||||
/>
|
||||
<button onClick={handleCustomModel} className="px-3 py-1 text-xs font-semibold text-white bg-[var(--color-brand)] rounded-lg hover:opacity-90">
|
||||
Apply
|
||||
</button>
|
||||
<button onClick={() => setShowCustom(false)} className="px-2 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowCustom(true)}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl border border-dashed border-[var(--color-border)] hover:border-[var(--color-border-focus)] text-left transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-tertiary)]">add</span>
|
||||
<span className="text-sm text-[var(--color-text-secondary)]">Use custom model ID</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Effort level */}
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">Effort Level</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">Controls how much computation the model uses.</p>
|
||||
<div className="flex gap-2">
|
||||
{(['low', 'medium', 'high', 'max'] as EffortLevel[]).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => setEffort(level)}
|
||||
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
|
||||
effortLevel === level
|
||||
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
{EFFORT_LABELS[level]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Permission Settings ──────────────────────────────────────
|
||||
|
||||
@ -574,12 +501,34 @@ function PermissionSettings() {
|
||||
// ─── General Settings ──────────────────────────────────────
|
||||
|
||||
function GeneralSettings() {
|
||||
const { effortLevel, setEffort } = useSettingsStore()
|
||||
|
||||
const EFFORT_LABELS: Record<EffortLevel, string> = {
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
max: 'Max',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">General</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
|
||||
API configuration is now managed through the <strong>Providers</strong> tab.
|
||||
</p>
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">Effort Level</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">Controls how much computation the model uses.</p>
|
||||
<div className="flex gap-2">
|
||||
{(['low', 'medium', 'high', 'max'] as EffortLevel[]).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => setEffort(level)}
|
||||
className={`flex-1 py-2 text-xs font-semibold rounded-lg border transition-all ${
|
||||
effortLevel === level
|
||||
? 'bg-[var(--color-brand)] text-white border-[var(--color-brand)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
{EFFORT_LABELS[level]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
// desktop/src/stores/providerStore.ts
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { providersApi } from '../api/providers'
|
||||
import type {
|
||||
Provider,
|
||||
SavedProvider,
|
||||
CreateProviderInput,
|
||||
UpdateProviderInput,
|
||||
TestProviderConfigInput,
|
||||
@ -9,27 +11,30 @@ import type {
|
||||
} from '../types/provider'
|
||||
|
||||
type ProviderStore = {
|
||||
providers: Provider[]
|
||||
providers: SavedProvider[]
|
||||
activeId: string | null
|
||||
isLoading: boolean
|
||||
|
||||
fetchProviders: () => Promise<void>
|
||||
createProvider: (input: CreateProviderInput) => Promise<Provider>
|
||||
updateProvider: (id: string, input: UpdateProviderInput) => Promise<Provider>
|
||||
createProvider: (input: CreateProviderInput) => Promise<SavedProvider>
|
||||
updateProvider: (id: string, input: UpdateProviderInput) => Promise<SavedProvider>
|
||||
deleteProvider: (id: string) => Promise<void>
|
||||
activateProvider: (id: string, modelId: string) => Promise<void>
|
||||
activateProvider: (id: string) => Promise<void>
|
||||
activateOfficial: () => Promise<void>
|
||||
testProvider: (id: string) => Promise<ProviderTestResult>
|
||||
testConfig: (input: TestProviderConfigInput) => Promise<ProviderTestResult>
|
||||
}
|
||||
|
||||
export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
providers: [],
|
||||
activeId: null,
|
||||
isLoading: false,
|
||||
|
||||
fetchProviders: async () => {
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
const { providers } = await providersApi.list()
|
||||
set({ providers, isLoading: false })
|
||||
const { providers, activeId } = await providersApi.list()
|
||||
set({ providers, activeId, isLoading: false })
|
||||
} catch {
|
||||
set({ isLoading: false })
|
||||
}
|
||||
@ -52,8 +57,13 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
await get().fetchProviders()
|
||||
},
|
||||
|
||||
activateProvider: async (id, modelId) => {
|
||||
await providersApi.activate(id, modelId)
|
||||
activateProvider: async (id) => {
|
||||
await providersApi.activate(id)
|
||||
await get().fetchProviders()
|
||||
},
|
||||
|
||||
activateOfficial: async () => {
|
||||
await providersApi.activateOfficial()
|
||||
await get().fetchProviders()
|
||||
},
|
||||
|
||||
|
||||
@ -1,37 +1,36 @@
|
||||
// Source: src/server/types/provider.ts
|
||||
// desktop/src/types/provider.ts
|
||||
|
||||
export type ProviderModel = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
context?: string
|
||||
export type ModelMapping = {
|
||||
main: string
|
||||
haiku: string
|
||||
sonnet: string
|
||||
opus: string
|
||||
}
|
||||
|
||||
export type Provider = {
|
||||
export type SavedProvider = {
|
||||
id: string
|
||||
presetId: string
|
||||
name: string
|
||||
apiKey: string // masked from server
|
||||
baseUrl: string
|
||||
apiKey: string // masked from server: "sk-a****xyz"
|
||||
models: ProviderModel[]
|
||||
isActive: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
models: ModelMapping
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export type CreateProviderInput = {
|
||||
presetId: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
models: ProviderModel[]
|
||||
baseUrl: string
|
||||
models: ModelMapping
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export type UpdateProviderInput = {
|
||||
name?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
models?: ProviderModel[]
|
||||
baseUrl?: string
|
||||
models?: ModelMapping
|
||||
notes?: string
|
||||
}
|
||||
|
||||
|
||||
1333
docs/superpowers/plans/2026-04-07-provider-preset-redesign.md
Normal file
1333
docs/superpowers/plans/2026-04-07-provider-preset-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
184
docs/superpowers/specs/2026-04-07-provider-preset-redesign.md
Normal file
184
docs/superpowers/specs/2026-04-07-provider-preset-redesign.md
Normal file
@ -0,0 +1,184 @@
|
||||
# Provider Preset Redesign
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the manual provider configuration UI with a preset-based system inspired by [cc-switch](https://github.com/farion1231/cc-switch) (MIT License, Copyright 2025 Jason Young). Users select a preset provider, enter an API key, and the system writes the correct env vars to `~/.claude/settings.json`.
|
||||
|
||||
## Presets
|
||||
|
||||
Six presets, each with pre-configured baseUrl and model mapping:
|
||||
|
||||
| ID | Name | baseUrl | Main Model | API Key Required |
|
||||
|---|---|---|---|---|
|
||||
| `official` | Claude Official | _(clear env)_ | _(default)_ | No |
|
||||
| `deepseek` | DeepSeek | `https://api.deepseek.com/anthropic` | `DeepSeek-V3.2` | Yes |
|
||||
| `zhipuglm` | Zhipu GLM | `https://open.bigmodel.cn/api/anthropic` | `glm-5` | Yes |
|
||||
| `kimi` | Kimi | `https://api.moonshot.cn/anthropic` | `kimi-k2.5` | Yes |
|
||||
| `minimax` | MiniMax | `https://api.minimaxi.com/anthropic` | `MiniMax-M2.7` | Yes |
|
||||
| `custom` | Custom | User-provided | User-provided | Yes |
|
||||
|
||||
Each preset defaults Haiku/Sonnet/Opus to the same as main model. Users can override via advanced settings.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Preset Provider List (frontend hardcoded)
|
||||
|
|
||||
User selects preset -> enters API Key -> optionally customizes model mapping
|
||||
|
|
||||
Save -> write ~/.claude/cc-haha/providers.json (index of saved providers)
|
||||
-> write ~/.claude/settings.json env block (active provider only)
|
||||
|
|
||||
Claude Code reads settings.json on next session
|
||||
```
|
||||
|
||||
## Storage
|
||||
|
||||
### Index File: `~/.claude/cc-haha/providers.json`
|
||||
|
||||
Stores all saved provider profiles for switching. Lightweight — no full settingsConfig, just what's needed to reconstruct the env block.
|
||||
|
||||
```json
|
||||
{
|
||||
"activeId": "deepseek-1",
|
||||
"providers": [
|
||||
{
|
||||
"id": "deepseek-1",
|
||||
"presetId": "deepseek",
|
||||
"name": "DeepSeek",
|
||||
"apiKey": "sk-xxx",
|
||||
"baseUrl": "https://api.deepseek.com/anthropic",
|
||||
"models": {
|
||||
"main": "DeepSeek-V3.2",
|
||||
"haiku": "DeepSeek-V3.2",
|
||||
"sonnet": "DeepSeek-V3.2",
|
||||
"opus": "DeepSeek-V3.2"
|
||||
},
|
||||
"notes": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For `custom` preset, `baseUrl` is user-provided. For other presets, `baseUrl` comes from the preset default but can be overridden.
|
||||
|
||||
### settings.json env block (written on activation)
|
||||
|
||||
Third-party provider activation writes 6 keys:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-xxx",
|
||||
"ANTHROPIC_MODEL": "DeepSeek-V3.2",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "DeepSeek-V3.2",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "DeepSeek-V3.2",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "DeepSeek-V3.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Official provider activation removes these 6 keys from env, preserving all other settings.json fields.
|
||||
|
||||
## Frontend UI
|
||||
|
||||
### ProviderSettings Component
|
||||
|
||||
Replaces current provider list + modal with:
|
||||
|
||||
1. **Preset chip bar** — horizontal row: `官方` | `DeepSeek` | `ZhipuGLM` | `Kimi` | `MiniMax` | `自定义`
|
||||
2. **Saved provider list** — cards showing each saved provider with active badge, switch/edit/delete actions
|
||||
3. **Add/Edit form** (inline or modal):
|
||||
- Preset selector (chips, pre-fills form)
|
||||
- API Key input (not shown for official)
|
||||
- Base URL input (only shown for custom preset, or editable in advanced)
|
||||
- Model mapping (collapsible advanced section): Main / Haiku / Sonnet / Opus inputs, pre-filled from preset
|
||||
- Notes (optional)
|
||||
- Test Connection button
|
||||
- Save / Cancel buttons
|
||||
|
||||
### Activation Flow
|
||||
|
||||
- Click "Activate" on a saved provider card
|
||||
- Backend writes env to settings.json
|
||||
- UI updates active badge
|
||||
- Official preset clears env keys
|
||||
|
||||
### Delete Provider
|
||||
|
||||
- Cannot delete the active provider (must switch first, or switch to official)
|
||||
- Confirmation dialog before delete
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### New: `src/server/config/providerPresets.ts`
|
||||
|
||||
Shared preset definitions used by both API validation and settings generation.
|
||||
|
||||
```typescript
|
||||
export type ProviderPreset = {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
models: { main: string; haiku: string; sonnet: string; opus: string }
|
||||
needsApiKey: boolean
|
||||
websiteUrl: string
|
||||
}
|
||||
```
|
||||
|
||||
### Modified: `src/server/services/providerService.ts`
|
||||
|
||||
- Change storage path from `~/.claude/providers.json` to `~/.claude/cc-haha/providers.json`
|
||||
- Simplify provider data structure (remove ProviderModel array, use models record)
|
||||
- `syncToSettings()` writes all 6 env keys
|
||||
- New `clearProviderFromSettings()` for official preset — removes the 6 env keys
|
||||
- Remove `activateProvider(id, modelId)` modelId param — models stored in provider config
|
||||
- Ensure `~/.claude/cc-haha/` directory is created on first write
|
||||
|
||||
### Modified: `src/server/api/providers.ts`
|
||||
|
||||
- Update activate endpoint: `POST /api/providers/:id/activate` (no body needed)
|
||||
- Add preset list endpoint: `GET /api/providers/presets` (returns preset definitions)
|
||||
|
||||
### Deleted after migration
|
||||
|
||||
- Old `~/.claude/providers.json` is not migrated — users reconfigure (acceptable since this is pre-release)
|
||||
|
||||
## Frontend File Changes
|
||||
|
||||
| Action | File | Description |
|
||||
|--------|------|-------------|
|
||||
| New | `desktop/src/config/providerPresets.ts` | Frontend preset definitions |
|
||||
| Rewrite | `desktop/src/pages/Settings.tsx` ProviderSettings section | Preset-based UI |
|
||||
| Modify | `desktop/src/types/provider.ts` | Simplify types (remove ProviderModel, add models record) |
|
||||
| Modify | `desktop/src/stores/providerStore.ts` | Align with new API shape |
|
||||
| Modify | `desktop/src/api/providers.ts` | Update activate call (remove modelId), add presets endpoint |
|
||||
|
||||
## Backend File Changes
|
||||
|
||||
| Action | File | Description |
|
||||
|--------|------|-------------|
|
||||
| New | `src/server/config/providerPresets.ts` | Preset definitions |
|
||||
| Modify | `src/server/services/providerService.ts` | New storage path, simplified model structure, full env write |
|
||||
| Modify | `src/server/types/provider.ts` | Simplify schema |
|
||||
| Modify | `src/server/api/providers.ts` | Update routes |
|
||||
|
||||
## Attribution
|
||||
|
||||
```typescript
|
||||
// Provider presets inspired by cc-switch (https://github.com/farion1231/cc-switch)
|
||||
// Original work by Jason Young, MIT License
|
||||
```
|
||||
|
||||
Added as comment header in preset configuration files.
|
||||
|
||||
## Testing
|
||||
|
||||
1. Select DeepSeek preset, enter API key, save — verify `~/.claude/cc-haha/providers.json` and `~/.claude/settings.json` written correctly
|
||||
2. Switch to Kimi — verify settings.json updated, index updated
|
||||
3. Switch to Official — verify env keys removed from settings.json
|
||||
4. Edit a provider's model mapping — verify changes persisted
|
||||
5. Test Connection — verify connectivity check works
|
||||
6. Delete non-active provider — verify removed from index
|
||||
7. Custom preset — verify baseUrl input appears, full flow works
|
||||
@ -86,10 +86,18 @@ export async function handleModelsApi(
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleModelsList(): Promise<Response> {
|
||||
const activeProvider = await providerService.getActiveProvider()
|
||||
const { providers, activeId } = await providerService.listProviders()
|
||||
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
|
||||
if (activeProvider) {
|
||||
// Convert ModelMapping to model list for API compatibility
|
||||
const modelList = [
|
||||
{ id: activeProvider.models.main, name: activeProvider.models.main, description: 'Main model', context: '' },
|
||||
...(activeProvider.models.haiku !== activeProvider.models.main ? [{ id: activeProvider.models.haiku, name: activeProvider.models.haiku, description: 'Haiku model', context: '' }] : []),
|
||||
...(activeProvider.models.sonnet !== activeProvider.models.main ? [{ id: activeProvider.models.sonnet, name: activeProvider.models.sonnet, description: 'Sonnet model', context: '' }] : []),
|
||||
...(activeProvider.models.opus !== activeProvider.models.main ? [{ id: activeProvider.models.opus, name: activeProvider.models.opus, description: 'Opus model', context: '' }] : []),
|
||||
]
|
||||
return Response.json({
|
||||
models: activeProvider.models,
|
||||
models: modelList,
|
||||
provider: { id: activeProvider.id, name: activeProvider.name },
|
||||
})
|
||||
}
|
||||
@ -103,8 +111,11 @@ async function handleCurrentModel(req: Request): Promise<Response> {
|
||||
const contextTier = (settings.modelContext as string) || undefined
|
||||
|
||||
// Build the full model list: prefer active provider's models, fall back to defaults
|
||||
const activeProvider = await providerService.getActiveProvider()
|
||||
const availableModels = activeProvider ? activeProvider.models : DEFAULT_MODELS
|
||||
const { providers, activeId } = await providerService.listProviders()
|
||||
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
|
||||
const availableModels = activeProvider
|
||||
? [{ id: activeProvider.models.main, name: activeProvider.models.main, description: 'Main model', context: '' }]
|
||||
: DEFAULT_MODELS
|
||||
|
||||
// Reconstruct composite ID for lookup (e.g. 'claude-opus-4-6-20250610:1m')
|
||||
const lookupId = contextTier ? `${baseModelId}:${contextTier}` : baseModelId
|
||||
|
||||
@ -1,30 +1,29 @@
|
||||
/**
|
||||
* Providers REST API
|
||||
*
|
||||
* GET /api/providers — 列出所有 provider
|
||||
* GET /api/providers/:id — 获取单个 provider
|
||||
* POST /api/providers — 添加 provider
|
||||
* PUT /api/providers/:id — 更新 provider
|
||||
* DELETE /api/providers/:id — 删除 provider
|
||||
* POST /api/providers/:id/activate — 激活 provider
|
||||
* POST /api/providers/:id/test — 测试已保存 provider
|
||||
* POST /api/providers/test — 测试未保存的配置
|
||||
* GET /api/providers — list all saved providers + activeId
|
||||
* GET /api/providers/presets — list available presets
|
||||
* POST /api/providers — add a provider
|
||||
* PUT /api/providers/:id — update a provider
|
||||
* DELETE /api/providers/:id — delete a provider
|
||||
* POST /api/providers/:id/activate — activate a saved provider
|
||||
* POST /api/providers/official — activate official (clear env)
|
||||
* POST /api/providers/:id/test — test a saved provider
|
||||
* POST /api/providers/test — test unsaved config
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
import { PROVIDER_PRESETS } from '../config/providerPresets.js'
|
||||
import {
|
||||
CreateProviderSchema,
|
||||
UpdateProviderSchema,
|
||||
TestProviderSchema,
|
||||
ActivateProviderSchema,
|
||||
} from '../types/provider.js'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
const providerService = new ProviderService()
|
||||
|
||||
// ─── Sanitization ─────────────────────────────────────────────────────────────
|
||||
|
||||
function maskApiKey(key: string): string {
|
||||
if (key.length <= 8) return '****'
|
||||
return key.slice(0, 4) + '****' + key.slice(-4)
|
||||
@ -37,27 +36,36 @@ function sanitizeProvider(provider: Record<string, unknown>): Record<string, unk
|
||||
return provider
|
||||
}
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleProvidersApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
_url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const id = segments[2] // provider ID or 'test'
|
||||
const action = segments[3] // 'activate' | 'test' | undefined
|
||||
const id = segments[2]
|
||||
const action = segments[3]
|
||||
|
||||
// ── POST /api/providers/test (test unsaved configuration) ──────────
|
||||
// POST /api/providers/test
|
||||
if (id === 'test' && req.method === 'POST') {
|
||||
return await handleTestUnsaved(req)
|
||||
}
|
||||
|
||||
// ── /api/providers (no ID) ────────────────────────────────────────
|
||||
// GET /api/providers/presets
|
||||
if (id === 'presets' && req.method === 'GET') {
|
||||
return Response.json({ presets: PROVIDER_PRESETS })
|
||||
}
|
||||
|
||||
// POST /api/providers/official
|
||||
if (id === 'official' && req.method === 'POST') {
|
||||
await providerService.activateOfficial()
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
// /api/providers (no ID)
|
||||
if (!id) {
|
||||
if (req.method === 'GET') {
|
||||
const providers = await providerService.listProviders()
|
||||
return Response.json({ providers: providers.map(sanitizeProvider) })
|
||||
const { providers, activeId } = await providerService.listProviders()
|
||||
return Response.json({ providers: providers.map(sanitizeProvider), activeId })
|
||||
}
|
||||
if (req.method === 'POST') {
|
||||
return await handleCreate(req)
|
||||
@ -65,20 +73,21 @@ export async function handleProvidersApi(
|
||||
throw methodNotAllowed(req.method)
|
||||
}
|
||||
|
||||
// ── /api/providers/:id/activate ───────────────────────────────────
|
||||
// /api/providers/:id/activate
|
||||
if (action === 'activate') {
|
||||
if (req.method !== 'POST') throw methodNotAllowed(req.method)
|
||||
return await handleActivate(req, id)
|
||||
await providerService.activateProvider(id)
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
// ── /api/providers/:id/test ───────────────────────────────────────
|
||||
// /api/providers/:id/test
|
||||
if (action === 'test') {
|
||||
if (req.method !== 'POST') throw methodNotAllowed(req.method)
|
||||
const result = await providerService.testProvider(id)
|
||||
return Response.json({ result })
|
||||
}
|
||||
|
||||
// ── /api/providers/:id (no action) ────────────────────────────────
|
||||
// /api/providers/:id
|
||||
if (req.method === 'GET') {
|
||||
const provider = await providerService.getProvider(id)
|
||||
return Response.json({ provider: sanitizeProvider(provider) })
|
||||
@ -97,8 +106,6 @@ export async function handleProvidersApi(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleCreate(req: Request): Promise<Response> {
|
||||
const body = await parseJsonBody(req)
|
||||
try {
|
||||
@ -106,9 +113,7 @@ async function handleCreate(req: Request): Promise<Response> {
|
||||
const provider = await providerService.addProvider(input)
|
||||
return Response.json({ provider }, { status: 201 })
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
|
||||
}
|
||||
if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@ -120,23 +125,7 @@ async function handleUpdate(req: Request, id: string): Promise<Response> {
|
||||
const provider = await providerService.updateProvider(id, input)
|
||||
return Response.json({ provider })
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivate(req: Request, id: string): Promise<Response> {
|
||||
const body = await parseJsonBody(req)
|
||||
try {
|
||||
const input = ActivateProviderSchema.parse(body)
|
||||
await providerService.activateProvider(id, input.modelId)
|
||||
return Response.json({ ok: true })
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
|
||||
}
|
||||
if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@ -148,15 +137,11 @@ async function handleTestUnsaved(req: Request): Promise<Response> {
|
||||
const result = await providerService.testProviderConfig(input)
|
||||
return Response.json({ result })
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
|
||||
}
|
||||
if (err instanceof z.ZodError) throw ApiError.badRequest(err.issues.map((i) => i.message).join('; '))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return (await req.json()) as Record<string, unknown>
|
||||
|
||||
69
src/server/config/providerPresets.ts
Normal file
69
src/server/config/providerPresets.ts
Normal file
@ -0,0 +1,69 @@
|
||||
// Provider presets inspired by cc-switch (https://github.com/farion1231/cc-switch)
|
||||
// Original work by Jason Young, MIT License
|
||||
|
||||
export type ModelMapping = {
|
||||
main: string
|
||||
haiku: string
|
||||
sonnet: string
|
||||
opus: string
|
||||
}
|
||||
|
||||
export type ProviderPreset = {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
defaultModels: ModelMapping
|
||||
needsApiKey: boolean
|
||||
websiteUrl: string
|
||||
}
|
||||
|
||||
export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{
|
||||
id: 'official',
|
||||
name: 'Claude Official',
|
||||
baseUrl: '',
|
||||
defaultModels: { main: '', haiku: '', sonnet: '', opus: '' },
|
||||
needsApiKey: false,
|
||||
websiteUrl: 'https://www.anthropic.com/claude-code',
|
||||
},
|
||||
{
|
||||
id: 'deepseek',
|
||||
name: 'DeepSeek',
|
||||
baseUrl: 'https://api.deepseek.com/anthropic',
|
||||
defaultModels: { main: 'DeepSeek-V3.2', haiku: 'DeepSeek-V3.2', sonnet: 'DeepSeek-V3.2', opus: 'DeepSeek-V3.2' },
|
||||
needsApiKey: true,
|
||||
websiteUrl: 'https://platform.deepseek.com',
|
||||
},
|
||||
{
|
||||
id: 'zhipuglm',
|
||||
name: 'Zhipu GLM',
|
||||
baseUrl: 'https://open.bigmodel.cn/api/anthropic',
|
||||
defaultModels: { main: 'glm-5', haiku: 'glm-5', sonnet: 'glm-5', opus: 'glm-5' },
|
||||
needsApiKey: true,
|
||||
websiteUrl: 'https://open.bigmodel.cn',
|
||||
},
|
||||
{
|
||||
id: 'kimi',
|
||||
name: 'Kimi',
|
||||
baseUrl: 'https://api.moonshot.cn/anthropic',
|
||||
defaultModels: { main: 'kimi-k2.5', haiku: 'kimi-k2.5', sonnet: 'kimi-k2.5', opus: 'kimi-k2.5' },
|
||||
needsApiKey: true,
|
||||
websiteUrl: 'https://platform.moonshot.cn',
|
||||
},
|
||||
{
|
||||
id: 'minimax',
|
||||
name: 'MiniMax',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
defaultModels: { main: 'MiniMax-M2.7', haiku: 'MiniMax-M2.7', sonnet: 'MiniMax-M2.7', opus: 'MiniMax-M2.7' },
|
||||
needsApiKey: true,
|
||||
websiteUrl: 'https://platform.minimaxi.com',
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom',
|
||||
baseUrl: '',
|
||||
defaultModels: { main: '', haiku: '', sonnet: '', opus: '' },
|
||||
needsApiKey: true,
|
||||
websiteUrl: '',
|
||||
},
|
||||
]
|
||||
@ -1,11 +1,8 @@
|
||||
/**
|
||||
* Provider Service — AI provider configuration management
|
||||
* Provider Service — preset-based provider configuration
|
||||
*
|
||||
* Manages custom API providers (base URL, API key, models).
|
||||
* When a provider is activated, its configuration is synced to
|
||||
* ~/.claude/settings.json so that Claude Code uses it.
|
||||
*
|
||||
* Storage: ~/.claude/providers.json (atomic write via .tmp + rename)
|
||||
* Storage: ~/.claude/cc-haha/providers.json (lightweight index)
|
||||
* Active provider env vars written to ~/.claude/settings.json
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises'
|
||||
@ -13,296 +10,237 @@ import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import type {
|
||||
Provider,
|
||||
SavedProvider,
|
||||
ProvidersIndex,
|
||||
CreateProviderInput,
|
||||
UpdateProviderInput,
|
||||
ProvidersConfig,
|
||||
TestProviderInput,
|
||||
ProviderTestResult,
|
||||
} from '../types/provider.js'
|
||||
|
||||
const DEFAULT_CONFIG: ProvidersConfig = {
|
||||
providers: [],
|
||||
version: 1,
|
||||
}
|
||||
const MANAGED_ENV_KEYS = [
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||
] as const
|
||||
|
||||
const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] }
|
||||
|
||||
export class ProviderService {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Config directory, overridable via CLAUDE_CONFIG_DIR (for testing) */
|
||||
private getConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
}
|
||||
|
||||
/** Path to the providers configuration file */
|
||||
private getProvidersPath(): string {
|
||||
return path.join(this.getConfigDir(), 'providers.json')
|
||||
private getCcHahaDir(): string {
|
||||
return path.join(this.getConfigDir(), 'cc-haha')
|
||||
}
|
||||
|
||||
/** Read providers config; returns empty defaults when the file does not exist */
|
||||
private async readProvidersConfig(): Promise<ProvidersConfig> {
|
||||
private getIndexPath(): string {
|
||||
return path.join(this.getCcHahaDir(), 'providers.json')
|
||||
}
|
||||
|
||||
private getSettingsPath(): string {
|
||||
return path.join(this.getConfigDir(), 'settings.json')
|
||||
}
|
||||
|
||||
private async readIndex(): Promise<ProvidersIndex> {
|
||||
try {
|
||||
const raw = await fs.readFile(this.getProvidersPath(), 'utf-8')
|
||||
return JSON.parse(raw) as ProvidersConfig
|
||||
const raw = await fs.readFile(this.getIndexPath(), 'utf-8')
|
||||
return JSON.parse(raw) as ProvidersIndex
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { ...DEFAULT_CONFIG, providers: [] }
|
||||
return { ...DEFAULT_INDEX, providers: [] }
|
||||
}
|
||||
throw ApiError.internal(
|
||||
`Failed to read providers config: ${err}`,
|
||||
)
|
||||
throw ApiError.internal(`Failed to read providers index: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomic write: write to .tmp first, then rename */
|
||||
private async writeProvidersConfig(config: ProvidersConfig): Promise<void> {
|
||||
const filePath = this.getProvidersPath()
|
||||
private async writeIndex(index: ProvidersIndex): Promise<void> {
|
||||
const filePath = this.getIndexPath()
|
||||
const dir = path.dirname(filePath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
|
||||
const tmpFile = `${filePath}.tmp.${Date.now()}`
|
||||
try {
|
||||
await fs.writeFile(tmpFile, JSON.stringify(config, null, 2) + '\n', 'utf-8')
|
||||
await fs.writeFile(tmpFile, JSON.stringify(index, null, 2) + '\n', 'utf-8')
|
||||
await fs.rename(tmpFile, filePath)
|
||||
} catch (err) {
|
||||
await fs.unlink(tmpFile).catch(() => {})
|
||||
throw ApiError.internal(`Failed to write providers config: ${err}`)
|
||||
throw ApiError.internal(`Failed to write providers index: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** List all providers */
|
||||
async listProviders(): Promise<Provider[]> {
|
||||
const config = await this.readProvidersConfig()
|
||||
return config.providers
|
||||
}
|
||||
|
||||
/** Get a single provider by id; throws 404 if not found */
|
||||
async getProvider(id: string): Promise<Provider> {
|
||||
const config = await this.readProvidersConfig()
|
||||
const provider = config.providers.find((p) => p.id === id)
|
||||
if (!provider) {
|
||||
throw ApiError.notFound(`Provider not found: ${id}`)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
/** Get the currently active provider, or null if none */
|
||||
async getActiveProvider(): Promise<Provider | null> {
|
||||
const config = await this.readProvidersConfig()
|
||||
return config.providers.find((p) => p.isActive) ?? null
|
||||
}
|
||||
|
||||
/** Add a new provider. If it is the first one, activate it automatically. */
|
||||
async addProvider(input: CreateProviderInput): Promise<Provider> {
|
||||
const config = await this.readProvidersConfig()
|
||||
const now = Date.now()
|
||||
const isFirst = config.providers.length === 0
|
||||
|
||||
const provider: Provider = {
|
||||
id: crypto.randomUUID(),
|
||||
name: input.name,
|
||||
baseUrl: input.baseUrl,
|
||||
apiKey: input.apiKey,
|
||||
models: input.models,
|
||||
isActive: isFirst,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...(input.notes !== undefined && { notes: input.notes }),
|
||||
}
|
||||
|
||||
config.providers.push(provider)
|
||||
|
||||
// Auto-activate the first provider with its first model
|
||||
if (isFirst && provider.models.length > 0) {
|
||||
config.activeModel = provider.models[0].id
|
||||
}
|
||||
|
||||
await this.writeProvidersConfig(config)
|
||||
|
||||
// Sync to settings if this provider was auto-activated
|
||||
if (isFirst && provider.models.length > 0) {
|
||||
await this.syncToSettings(provider, provider.models[0].id)
|
||||
}
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
/** Update an existing provider */
|
||||
async updateProvider(id: string, input: UpdateProviderInput): Promise<Provider> {
|
||||
const config = await this.readProvidersConfig()
|
||||
const index = config.providers.findIndex((p) => p.id === id)
|
||||
if (index === -1) {
|
||||
throw ApiError.notFound(`Provider not found: ${id}`)
|
||||
}
|
||||
|
||||
const existing = config.providers[index]
|
||||
const updated: Provider = {
|
||||
...existing,
|
||||
...(input.name !== undefined && { name: input.name }),
|
||||
...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }),
|
||||
...(input.apiKey !== undefined && { apiKey: input.apiKey }),
|
||||
...(input.models !== undefined && { models: input.models }),
|
||||
...(input.notes !== undefined && { notes: input.notes }),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
config.providers[index] = updated
|
||||
|
||||
// If the updated provider is active, validate activeModel still exists
|
||||
if (updated.isActive && config.activeModel) {
|
||||
const modelStillExists = updated.models.some((m) => m.id === config.activeModel)
|
||||
if (!modelStillExists) {
|
||||
config.activeModel = updated.models[0]?.id
|
||||
}
|
||||
}
|
||||
|
||||
await this.writeProvidersConfig(config)
|
||||
|
||||
// Re-sync settings if active
|
||||
if (updated.isActive && config.activeModel) {
|
||||
await this.syncToSettings(updated, config.activeModel)
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
/** Delete a provider; refuses to delete an active provider */
|
||||
async deleteProvider(id: string): Promise<void> {
|
||||
const config = await this.readProvidersConfig()
|
||||
const index = config.providers.findIndex((p) => p.id === id)
|
||||
if (index === -1) {
|
||||
throw ApiError.notFound(`Provider not found: ${id}`)
|
||||
}
|
||||
|
||||
if (config.providers[index].isActive) {
|
||||
throw ApiError.conflict(
|
||||
'Cannot delete an active provider. Deactivate it first by activating another provider.',
|
||||
)
|
||||
}
|
||||
|
||||
config.providers.splice(index, 1)
|
||||
await this.writeProvidersConfig(config)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Activation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Activate a provider with a specific model.
|
||||
*
|
||||
* 1. Validate provider exists and modelId belongs to it
|
||||
* 2. Deactivate all providers
|
||||
* 3. Activate the target provider
|
||||
* 4. Set config.activeModel
|
||||
* 5. Persist providers.json
|
||||
* 6. Sync env to settings.json
|
||||
*/
|
||||
async activateProvider(id: string, modelId: string): Promise<void> {
|
||||
const config = await this.readProvidersConfig()
|
||||
const provider = config.providers.find((p) => p.id === id)
|
||||
if (!provider) {
|
||||
throw ApiError.notFound(`Provider not found: ${id}`)
|
||||
}
|
||||
|
||||
const model = provider.models.find((m) => m.id === modelId)
|
||||
if (!model) {
|
||||
throw ApiError.badRequest(
|
||||
`Model "${modelId}" not found in provider "${provider.name}". Available models: ${provider.models.map((m) => m.id).join(', ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Deactivate all, then activate target
|
||||
for (const p of config.providers) {
|
||||
p.isActive = false
|
||||
}
|
||||
provider.isActive = true
|
||||
config.activeModel = modelId
|
||||
|
||||
await this.writeProvidersConfig(config)
|
||||
await this.syncToSettings(provider, modelId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings sync
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sync the active provider's configuration to settings.json.
|
||||
*
|
||||
* Preserves all existing fields; only updates `env` (merging into existing
|
||||
* env vars) and `model`.
|
||||
*/
|
||||
private async syncToSettings(provider: Provider, modelId: string): Promise<void> {
|
||||
const settingsPath = path.join(this.getConfigDir(), 'settings.json')
|
||||
|
||||
// Read existing settings
|
||||
let settings: Record<string, unknown> = {}
|
||||
private async readSettings(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const raw = await fs.readFile(settingsPath, 'utf-8')
|
||||
settings = JSON.parse(raw) as Record<string, unknown>
|
||||
const raw = await fs.readFile(this.getSettingsPath(), 'utf-8')
|
||||
return JSON.parse(raw) as Record<string, unknown>
|
||||
} catch (err: unknown) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw ApiError.internal(`Failed to read settings.json: ${err}`)
|
||||
}
|
||||
// File doesn't exist yet — start with empty object
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {}
|
||||
throw ApiError.internal(`Failed to read settings.json: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge env: preserve existing env vars, override only ours
|
||||
const existingEnv = (settings.env as Record<string, string>) || {}
|
||||
settings.env = {
|
||||
...existingEnv,
|
||||
ANTHROPIC_BASE_URL: provider.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: provider.apiKey,
|
||||
}
|
||||
|
||||
// Set model
|
||||
settings.model = modelId
|
||||
|
||||
// Atomic write
|
||||
const dir = path.dirname(settingsPath)
|
||||
private async writeSettings(settings: Record<string, unknown>): Promise<void> {
|
||||
const filePath = this.getSettingsPath()
|
||||
const dir = path.dirname(filePath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
|
||||
const tmpFile = `${settingsPath}.tmp.${Date.now()}`
|
||||
const tmpFile = `${filePath}.tmp.${Date.now()}`
|
||||
try {
|
||||
await fs.writeFile(tmpFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8')
|
||||
await fs.rename(tmpFile, settingsPath)
|
||||
await fs.rename(tmpFile, filePath)
|
||||
} catch (err) {
|
||||
await fs.unlink(tmpFile).catch(() => {})
|
||||
throw ApiError.internal(`Failed to write settings.json: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connectivity testing
|
||||
// ---------------------------------------------------------------------------
|
||||
// --- CRUD ---
|
||||
|
||||
/** Test connectivity of a saved provider */
|
||||
async testProvider(id: string): Promise<ProviderTestResult> {
|
||||
const provider = await this.getProvider(id)
|
||||
async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> {
|
||||
const index = await this.readIndex()
|
||||
return { providers: index.providers, activeId: index.activeId }
|
||||
}
|
||||
|
||||
if (provider.models.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: 0,
|
||||
error: 'Provider has no models configured',
|
||||
}
|
||||
async getProvider(id: string): Promise<SavedProvider> {
|
||||
const index = await this.readIndex()
|
||||
const provider = index.providers.find((p) => p.id === id)
|
||||
if (!provider) throw ApiError.notFound(`Provider not found: ${id}`)
|
||||
return provider
|
||||
}
|
||||
|
||||
async addProvider(input: CreateProviderInput): Promise<SavedProvider> {
|
||||
const index = await this.readIndex()
|
||||
|
||||
const provider: SavedProvider = {
|
||||
id: crypto.randomUUID(),
|
||||
presetId: input.presetId,
|
||||
name: input.name,
|
||||
apiKey: input.apiKey,
|
||||
baseUrl: input.baseUrl,
|
||||
models: input.models,
|
||||
...(input.notes !== undefined && { notes: input.notes }),
|
||||
}
|
||||
|
||||
index.providers.push(provider)
|
||||
await this.writeIndex(index)
|
||||
return provider
|
||||
}
|
||||
|
||||
async updateProvider(id: string, input: UpdateProviderInput): Promise<SavedProvider> {
|
||||
const index = await this.readIndex()
|
||||
const idx = index.providers.findIndex((p) => p.id === id)
|
||||
if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`)
|
||||
|
||||
const existing = index.providers[idx]
|
||||
const updated: SavedProvider = {
|
||||
...existing,
|
||||
...(input.name !== undefined && { name: input.name }),
|
||||
...(input.apiKey !== undefined && { apiKey: input.apiKey }),
|
||||
...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }),
|
||||
...(input.models !== undefined && { models: input.models }),
|
||||
...(input.notes !== undefined && { notes: input.notes }),
|
||||
}
|
||||
|
||||
index.providers[idx] = updated
|
||||
await this.writeIndex(index)
|
||||
|
||||
if (index.activeId === id) {
|
||||
await this.syncToSettings(updated)
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
async deleteProvider(id: string): Promise<void> {
|
||||
const index = await this.readIndex()
|
||||
const idx = index.providers.findIndex((p) => p.id === id)
|
||||
if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`)
|
||||
|
||||
if (index.activeId === id) {
|
||||
throw ApiError.conflict('Cannot delete the active provider. Switch to another provider first.')
|
||||
}
|
||||
|
||||
index.providers.splice(idx, 1)
|
||||
await this.writeIndex(index)
|
||||
}
|
||||
|
||||
// --- Activation ---
|
||||
|
||||
async activateProvider(id: string): Promise<void> {
|
||||
const index = await this.readIndex()
|
||||
const provider = index.providers.find((p) => p.id === id)
|
||||
if (!provider) throw ApiError.notFound(`Provider not found: ${id}`)
|
||||
|
||||
index.activeId = id
|
||||
await this.writeIndex(index)
|
||||
|
||||
if (provider.presetId === 'official') {
|
||||
await this.clearProviderFromSettings()
|
||||
} else {
|
||||
await this.syncToSettings(provider)
|
||||
}
|
||||
}
|
||||
|
||||
async activateOfficial(): Promise<void> {
|
||||
const index = await this.readIndex()
|
||||
index.activeId = null
|
||||
await this.writeIndex(index)
|
||||
await this.clearProviderFromSettings()
|
||||
}
|
||||
|
||||
// --- Settings sync ---
|
||||
|
||||
private async syncToSettings(provider: SavedProvider): Promise<void> {
|
||||
const settings = await this.readSettings()
|
||||
const existingEnv = (settings.env as Record<string, string>) || {}
|
||||
|
||||
settings.env = {
|
||||
...existingEnv,
|
||||
ANTHROPIC_BASE_URL: provider.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: provider.apiKey,
|
||||
ANTHROPIC_MODEL: provider.models.main,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus,
|
||||
}
|
||||
|
||||
await this.writeSettings(settings)
|
||||
}
|
||||
|
||||
private async clearProviderFromSettings(): Promise<void> {
|
||||
const settings = await this.readSettings()
|
||||
const env = (settings.env as Record<string, string>) || {}
|
||||
|
||||
for (const key of MANAGED_ENV_KEYS) {
|
||||
delete env[key]
|
||||
}
|
||||
|
||||
settings.env = env
|
||||
if (Object.keys(env).length === 0) {
|
||||
delete settings.env
|
||||
}
|
||||
|
||||
await this.writeSettings(settings)
|
||||
}
|
||||
|
||||
// --- Test ---
|
||||
|
||||
async testProvider(id: string): Promise<ProviderTestResult> {
|
||||
const provider = await this.getProvider(id)
|
||||
if (!provider.baseUrl || !provider.apiKey) {
|
||||
return { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' }
|
||||
}
|
||||
return this.testProviderConfig({
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
modelId: provider.models[0].id,
|
||||
modelId: provider.models.main,
|
||||
})
|
||||
}
|
||||
|
||||
/** Test connectivity with an arbitrary configuration */
|
||||
async testProviderConfig(input: TestProviderInput): Promise<ProviderTestResult> {
|
||||
const url = `${input.baseUrl.replace(/\/+$/, '')}/v1/messages`
|
||||
const start = Date.now()
|
||||
@ -326,54 +264,28 @@ export class ProviderService {
|
||||
const latencyMs = Date.now() - start
|
||||
|
||||
if (response.ok) {
|
||||
return {
|
||||
success: true,
|
||||
latencyMs,
|
||||
modelUsed: input.modelId,
|
||||
httpStatus: response.status,
|
||||
}
|
||||
return { success: true, latencyMs, modelUsed: input.modelId, httpStatus: response.status }
|
||||
}
|
||||
|
||||
// Non-OK response — try to extract error message
|
||||
let errorMessage = `HTTP ${response.status}`
|
||||
try {
|
||||
const body = (await response.json()) as Record<string, unknown>
|
||||
if (body.error && typeof body.error === 'object') {
|
||||
const errObj = body.error as Record<string, unknown>
|
||||
errorMessage = (errObj.message as string) || errorMessage
|
||||
errorMessage = ((body.error as Record<string, unknown>).message as string) || errorMessage
|
||||
} else if (typeof body.message === 'string') {
|
||||
errorMessage = body.message
|
||||
}
|
||||
} catch {
|
||||
// Could not parse body — use status text
|
||||
errorMessage = `HTTP ${response.status} ${response.statusText}`
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
latencyMs,
|
||||
error: errorMessage,
|
||||
modelUsed: input.modelId,
|
||||
httpStatus: response.status,
|
||||
}
|
||||
return { success: false, latencyMs, error: errorMessage, modelUsed: input.modelId, httpStatus: response.status }
|
||||
} catch (err: unknown) {
|
||||
const latencyMs = Date.now() - start
|
||||
|
||||
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs,
|
||||
error: 'Request timed out after 15 seconds',
|
||||
modelUsed: input.modelId,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
latencyMs,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
modelUsed: input.modelId,
|
||||
return { success: false, latencyMs, error: 'Request timed out after 15 seconds', modelUsed: input.modelId }
|
||||
}
|
||||
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: input.modelId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,46 +1,48 @@
|
||||
/**
|
||||
* Provider 类型定义
|
||||
* Provider types — preset-based provider configuration.
|
||||
*
|
||||
* Provider 是自定义 API 供应商的配置单元,包含 Base URL、API Key 和可用模型列表。
|
||||
* 激活 Provider 时,其配置会写入 ~/.claude/settings.json 的 env 字段。
|
||||
* Providers are stored in ~/.claude/cc-haha/providers.json as a lightweight index.
|
||||
* The active provider's env vars are written to ~/.claude/settings.json.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
// ─── Zod Schemas ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const ProviderModelSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
context: z.string().optional(),
|
||||
export const ModelMappingSchema = z.object({
|
||||
main: z.string(),
|
||||
haiku: z.string(),
|
||||
sonnet: z.string(),
|
||||
opus: z.string(),
|
||||
})
|
||||
|
||||
export const ProviderSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
export const SavedProviderSchema = z.object({
|
||||
id: z.string(),
|
||||
presetId: z.string(),
|
||||
name: z.string().min(1),
|
||||
baseUrl: z.string().url(),
|
||||
apiKey: z.string().min(1),
|
||||
models: z.array(ProviderModelSchema).min(1),
|
||||
isActive: z.boolean(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string(),
|
||||
models: ModelMappingSchema,
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
export const ProvidersIndexSchema = z.object({
|
||||
activeId: z.string().nullable(),
|
||||
providers: z.array(SavedProviderSchema),
|
||||
})
|
||||
|
||||
export const CreateProviderSchema = z.object({
|
||||
presetId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
baseUrl: z.string().url(),
|
||||
apiKey: z.string().min(1),
|
||||
models: z.array(ProviderModelSchema).min(1),
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string(),
|
||||
models: ModelMappingSchema,
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
export const UpdateProviderSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
baseUrl: z.string().url().optional(),
|
||||
apiKey: z.string().min(1).optional(),
|
||||
models: z.array(ProviderModelSchema).min(1).optional(),
|
||||
apiKey: z.string().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
models: ModelMappingSchema.optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
@ -50,25 +52,13 @@ export const TestProviderSchema = z.object({
|
||||
modelId: z.string().min(1),
|
||||
})
|
||||
|
||||
export const ActivateProviderSchema = z.object({
|
||||
modelId: z.string().min(1),
|
||||
})
|
||||
|
||||
export const ProvidersConfigSchema = z.object({
|
||||
providers: z.array(ProviderSchema),
|
||||
activeModel: z.string().optional(),
|
||||
version: z.number(),
|
||||
})
|
||||
|
||||
// ─── TypeScript Types ─────────────────────────────────────────────────────────
|
||||
|
||||
export type ProviderModel = z.infer<typeof ProviderModelSchema>
|
||||
export type Provider = z.infer<typeof ProviderSchema>
|
||||
// TypeScript types
|
||||
export type ModelMapping = z.infer<typeof ModelMappingSchema>
|
||||
export type SavedProvider = z.infer<typeof SavedProviderSchema>
|
||||
export type ProvidersIndex = z.infer<typeof ProvidersIndexSchema>
|
||||
export type CreateProviderInput = z.infer<typeof CreateProviderSchema>
|
||||
export type UpdateProviderInput = z.infer<typeof UpdateProviderSchema>
|
||||
export type TestProviderInput = z.infer<typeof TestProviderSchema>
|
||||
export type ActivateProviderInput = z.infer<typeof ActivateProviderSchema>
|
||||
export type ProvidersConfig = z.infer<typeof ProvidersConfigSchema>
|
||||
|
||||
export interface ProviderTestResult {
|
||||
success: boolean
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user