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:
程序员阿江(Relakkes) 2026-04-07 12:28:37 +08:00
commit 56b4fe3585
13 changed files with 2202 additions and 690 deletions

View File

@ -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
}
]
}

View File

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

View 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: '',
},
]

View File

@ -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} &middot; {provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
{provider.baseUrl} &middot; {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>
)
}

View File

@ -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()
},

View File

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

File diff suppressed because it is too large Load Diff

View 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

View File

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

View File

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

View 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: '',
},
]

View File

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

View File

@ -1,46 +1,48 @@
/**
* Provider
* Provider types preset-based provider configuration.
*
* Provider API Base URLAPI 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