@swarmclawai/swarmclaw 1.2.3 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,9 +36,10 @@ function ModelCombobox({
36
36
  const fetched = useRef<string | null>(null)
37
37
 
38
38
  const effectiveEndpoint = endpointOverride || provider?.endpoint || null
39
+ const supportsModelDiscovery = Boolean(provider && provider.setupProvider !== 'custom')
39
40
 
40
41
  const fetchModels = useCallback(async (force?: boolean) => {
41
- if (!provider) return
42
+ if (!provider || !supportsModelDiscovery) return
42
43
  const cacheKey = `${provider.provider}|${effectiveEndpoint || ''}|${provider.credentialId || ''}`
43
44
  if (!force && fetched.current === cacheKey) return
44
45
  fetched.current = cacheKey
@@ -63,11 +64,21 @@ function ModelCombobox({
63
64
  } finally {
64
65
  setLoading(false)
65
66
  }
66
- }, [provider, effectiveEndpoint])
67
+ }, [provider, effectiveEndpoint, supportsModelDiscovery])
67
68
 
68
69
  useEffect(() => {
70
+ if (!provider) {
71
+ setModels([])
72
+ setFetchError('')
73
+ return
74
+ }
75
+ if (!supportsModelDiscovery) {
76
+ setModels(provider.defaultModel ? [provider.defaultModel] : [])
77
+ setFetchError('')
78
+ return
79
+ }
69
80
  fetchModels()
70
- }, [fetchModels])
81
+ }, [fetchModels, provider, supportsModelDiscovery])
71
82
 
72
83
  // Close dropdown on outside click
73
84
  useEffect(() => {
@@ -116,38 +127,40 @@ function ModelCombobox({
116
127
  </svg>
117
128
  </a>
118
129
  )}
119
- <button
120
- type="button"
121
- onClick={() => {
122
- if (models.length > 0 && !loading) {
123
- setOpen(!open)
124
- if (!open) setSearch(value)
125
- } else {
126
- fetchModels(true)
127
- }
128
- }}
129
- disabled={loading}
130
- className="text-text-3 hover:text-accent-bright transition-colors bg-transparent border-none cursor-pointer disabled:opacity-40"
131
- title={models.length > 0 ? 'Show models' : 'Fetch available models'}
132
- >
133
- {loading ? (
134
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="animate-spin">
135
- <circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" opacity="0.25" />
136
- <path d="M12.5 7A5.5 5.5 0 0 0 7 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
137
- </svg>
138
- ) : models.length > 0 ? (
139
- <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
140
- <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
141
- </svg>
142
- ) : (
143
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
144
- <path d="M1.5 7A5.5 5.5 0 1 1 7 12.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
145
- <path d="M1.5 12.5V9.5H4.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
146
- </svg>
147
- )}
148
- </button>
130
+ {supportsModelDiscovery && (
131
+ <button
132
+ type="button"
133
+ onClick={() => {
134
+ if (models.length > 0 && !loading) {
135
+ setOpen(!open)
136
+ if (!open) setSearch(value)
137
+ } else {
138
+ fetchModels(true)
139
+ }
140
+ }}
141
+ disabled={loading}
142
+ className="text-text-3 hover:text-accent-bright transition-colors bg-transparent border-none cursor-pointer disabled:opacity-40"
143
+ title={models.length > 0 ? 'Show models' : 'Fetch available models'}
144
+ >
145
+ {loading ? (
146
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="animate-spin">
147
+ <circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" opacity="0.25" />
148
+ <path d="M12.5 7A5.5 5.5 0 0 0 7 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
149
+ </svg>
150
+ ) : models.length > 0 ? (
151
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
152
+ <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
153
+ </svg>
154
+ ) : (
155
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
156
+ <path d="M1.5 7A5.5 5.5 0 1 1 7 12.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
157
+ <path d="M1.5 12.5V9.5H4.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
158
+ </svg>
159
+ )}
160
+ </button>
161
+ )}
149
162
  </div>
150
- {fetchError && models.length === 0 && (
163
+ {fetchError && models.length === 0 && supportsModelDiscovery && (
151
164
  <div className="mt-1 text-[11px] text-amber-300/80">{fetchError}</div>
152
165
  )}
153
166
  {open && filtered.length > 0 && (
@@ -423,7 +436,7 @@ export function StepAgents({
423
436
  focus:border-accent-bright/30 focus:shadow-[0_0_30px_rgba(99,102,241,0.1)]"
424
437
  />
425
438
  </div>
426
- {matchedProvider?.provider === 'openclaw' ? (
439
+ {matchedProvider?.setupProvider === 'openclaw' ? (
427
440
  <div className="md:col-span-2">
428
441
  <label className="block text-[12px] text-text-3 font-500 mb-1.5 ml-1">Model</label>
429
442
  <div className="flex items-center gap-3 px-4 py-3 rounded-[12px] border border-white/[0.08] bg-bg">
@@ -448,7 +461,7 @@ export function StepAgents({
448
461
  provider={matchedProvider}
449
462
  endpointOverride={draft.apiEndpoint}
450
463
  onChange={(model) => onUpdateDraft(draft.id, { model })}
451
- modelLibraryUrl={matchedProvider ? SETUP_PROVIDERS.find((sp) => sp.id === matchedProvider.provider)?.modelLibraryUrl : null}
464
+ modelLibraryUrl={matchedProvider ? SETUP_PROVIDERS.find((sp) => sp.id === matchedProvider.setupProvider)?.modelLibraryUrl : null}
452
465
  />
453
466
  </div>
454
467
  )}
@@ -457,7 +470,7 @@ export function StepAgents({
457
470
  value={draft.soul}
458
471
  onChange={(soul) => onUpdateDraft(draft.id, { soul })}
459
472
  />
460
- {matchedProvider?.provider === 'openclaw' && (
473
+ {matchedProvider?.setupProvider === 'openclaw' && (
461
474
  <p className="mt-1.5 ml-1 text-[11px] text-text-3/70">
462
475
  Synced to the gateway as SOUL.md on save.
463
476
  </p>
@@ -5,7 +5,7 @@ import { api } from '@/lib/app/api-client'
5
5
  import { dedup, errorMessage } from '@/lib/shared-utils'
6
6
  import { getDefaultModelForProvider } from '@/lib/setup-defaults'
7
7
  import { OpenClawDeployPanel } from '@/components/openclaw/openclaw-deploy-panel'
8
- import type { Credential, Credentials, GatewayProfile, ProviderConfig } from '@/types'
8
+ import type { Credential, Credentials, GatewayProfile, ProviderId, ProviderConfig } from '@/types'
9
9
  import type { StepConnectProps, CheckState, ProviderCheckResponse, ConfiguredProvider } from './types'
10
10
  import {
11
11
  formatEndpointHost,
@@ -35,7 +35,9 @@ export function StepConnect({
35
35
  const [checkMessage, setCheckMessage] = useState('')
36
36
  const [checkErrorCode, setCheckErrorCode] = useState<string | null>(null)
37
37
  const [openclawDeviceId, setOpenclawDeviceId] = useState<string | null>(null)
38
- const [providerSuggestedModel, setProviderSuggestedModel] = useState(provider === 'custom' ? '' : getDefaultModelForProvider(provider))
38
+ const [providerSuggestedModel, setProviderSuggestedModel] = useState(
39
+ editingProvider?.defaultModel || (provider === 'custom' ? '' : getDefaultModelForProvider(provider)),
40
+ )
39
41
  const [saving, setSaving] = useState(false)
40
42
  const [error, setError] = useState('')
41
43
  const [existingCredentials, setExistingCredentials] = useState<Credential[]>([])
@@ -63,6 +65,7 @@ export function StepConnect({
63
65
  const supportsEndpoint = selectedProvider.supportsEndpoint || isCustom
64
66
  const keyIsOptional = (selectedProvider.optionalKey && !isOllamaCloud) || isCustom
65
67
  const requiresVerifiedConnection = provider === 'openclaw'
68
+ const canCheckConnection = !isCustom
66
69
  const openClawEndpointValue = provider === 'openclaw'
67
70
  ? (endpoint.trim() || selectedProvider.defaultEndpoint || 'http://localhost:18789/v1')
68
71
  : null
@@ -162,6 +165,11 @@ export function StepConnect({
162
165
  return
163
166
  }
164
167
 
168
+ if (isCustom && !providerSuggestedModel.trim()) {
169
+ setError('Custom providers need a default model ID.')
170
+ return
171
+ }
172
+
165
173
  setSaving(true)
166
174
  setError('')
167
175
  try {
@@ -187,17 +195,17 @@ export function StepConnect({
187
195
  }
188
196
 
189
197
  // Custom providers: create a ProviderConfig in the DB so agents can reference it
190
- let resolvedProvider = provider
198
+ let resolvedProvider: ProviderId = provider
191
199
  if (isCustom) {
192
200
  const customConfig = await api<ProviderConfig>('POST', '/providers', {
193
201
  name: providerLabel.trim() || 'Custom Provider',
194
202
  baseUrl: endpoint.trim(),
195
- models: providerSuggestedModel ? [providerSuggestedModel] : [],
196
- requiresApiKey: !!apiKey.trim(),
203
+ models: providerSuggestedModel.trim() ? [providerSuggestedModel.trim()] : [],
204
+ requiresApiKey: hasKeyOrCredential,
197
205
  credentialId: nextCredentialId || null,
198
206
  isEnabled: true,
199
207
  })
200
- resolvedProvider = customConfig.id as typeof provider
208
+ resolvedProvider = customConfig.id as ProviderId
201
209
  }
202
210
 
203
211
  // Build a tokenized dashboard URL for OpenClaw so step-agents can link to it
@@ -209,11 +217,12 @@ export function StepConnect({
209
217
 
210
218
  const configured: ConfiguredProvider = {
211
219
  id: crypto.randomUUID(),
220
+ setupProvider: provider,
212
221
  provider: resolvedProvider,
213
222
  name: providerLabel.trim() || selectedProvider.name,
214
223
  credentialId: nextCredentialId || null,
215
224
  endpoint: supportsEndpoint ? (endpoint.trim() || selectedProvider.defaultEndpoint || null) : null,
216
- defaultModel: providerSuggestedModel || (isCustom ? '' : getDefaultModelForProvider(provider)),
225
+ defaultModel: providerSuggestedModel.trim() || (isCustom ? '' : getDefaultModelForProvider(provider)),
217
226
  gatewayProfileId: null,
218
227
  notes: providerNotes.trim() || null,
219
228
  tags: providerTags,
@@ -323,6 +332,26 @@ export function StepConnect({
323
332
  </div>
324
333
  )}
325
334
 
335
+ {isCustom && (
336
+ <div>
337
+ <label className="block text-[12px] text-text-3 font-500 mb-1.5 ml-1">
338
+ Default model
339
+ </label>
340
+ <input
341
+ type="text"
342
+ value={providerSuggestedModel}
343
+ onChange={(e) => setProviderSuggestedModel(e.target.value)}
344
+ placeholder="e.g. gpt-4o-mini"
345
+ className="w-full px-4 py-3 rounded-[12px] border border-white/[0.08] bg-surface
346
+ text-text text-[14px] font-mono outline-none transition-all duration-200
347
+ focus:border-accent-bright/30 focus:shadow-[0_0_30px_rgba(99,102,241,0.1)]"
348
+ />
349
+ <p className="mt-1.5 text-[11px] text-text-3">
350
+ Save the model ID you want starter agents to use with this provider. You can change it later per agent.
351
+ </p>
352
+ </div>
353
+ )}
354
+
326
355
  {provider === 'openclaw' && (
327
356
  <div className="rounded-[14px] border border-white/[0.08] bg-surface p-4 space-y-4">
328
357
  <OpenClawDeployPanel
@@ -536,17 +565,19 @@ export function StepConnect({
536
565
  >
537
566
  Back
538
567
  </button>
539
- <button
540
- onClick={runConnectionCheck}
541
- disabled={checkState === 'checking' || saving}
542
- className="px-6 py-3.5 rounded-[14px] border border-white/[0.08] bg-white/[0.03] text-text text-[14px]
543
- font-display font-600 cursor-pointer hover:bg-white/[0.06] transition-all duration-200 disabled:opacity-40"
544
- >
545
- {checkState === 'checking' ? 'Checking...' : 'Check Connection'}
546
- </button>
568
+ {canCheckConnection && (
569
+ <button
570
+ onClick={runConnectionCheck}
571
+ disabled={checkState === 'checking' || saving}
572
+ className="px-6 py-3.5 rounded-[14px] border border-white/[0.08] bg-white/[0.03] text-text text-[14px]
573
+ font-display font-600 cursor-pointer hover:bg-white/[0.06] transition-all duration-200 disabled:opacity-40"
574
+ >
575
+ {checkState === 'checking' ? 'Checking...' : 'Check Connection'}
576
+ </button>
577
+ )}
547
578
  <button
548
579
  onClick={saveProvider}
549
- disabled={(requiresKey && !hasKeyOrCredential) || saving}
580
+ disabled={(requiresKey && !hasKeyOrCredential) || (isCustom && !providerSuggestedModel.trim()) || saving}
550
581
  className="px-8 py-3.5 rounded-[14px] border-none bg-accent-bright text-white text-[15px] font-display font-600
551
582
  cursor-pointer hover:brightness-110 active:scale-[0.97] transition-all duration-200
552
583
  shadow-[0_6px_28px_rgba(99,102,241,0.3)] disabled:opacity-30"
@@ -1,4 +1,4 @@
1
- import type { GatewayProfile } from '@/types'
1
+ import type { GatewayProfile, ProviderId } from '@/types'
2
2
  import type { SetupProvider } from '@/lib/setup-defaults'
3
3
 
4
4
  export type SetupStep = 'profile' | 'providers' | 'connect' | 'agents' | 'next' | 'done'
@@ -34,7 +34,8 @@ export interface SetupWizardProps {
34
34
 
35
35
  export interface ConfiguredProvider {
36
36
  id: string
37
- provider: SetupProvider
37
+ setupProvider: SetupProvider
38
+ provider: ProviderId
38
39
  name: string
39
40
  credentialId: string | null
40
41
  endpoint: string | null
@@ -56,7 +57,8 @@ export interface StarterDraftAgent {
56
57
  systemPrompt: string
57
58
  soul: string
58
59
  providerConfigId: string | null
59
- provider: SetupProvider | null
60
+ setupProvider: SetupProvider | null
61
+ provider: ProviderId | null
60
62
  model: string
61
63
  credentialId: string | null
62
64
  apiEndpoint: string | null
@@ -77,7 +79,7 @@ export interface StarterDraftAgent {
77
79
  export interface CreatedAgentSummary {
78
80
  id: string
79
81
  name: string
80
- provider: SetupProvider
82
+ provider: ProviderId
81
83
  providerName: string
82
84
  }
83
85
 
@@ -6,6 +6,7 @@ import {
6
6
  isLocalOpenClawEndpoint,
7
7
  resolveOpenClawDashboardUrl,
8
8
  getOpenClawErrorHint,
9
+ requiresSetupProviderVerification,
9
10
  withHttpScheme,
10
11
  buildStarterDrafts,
11
12
  preferredConfiguredProvider,
@@ -165,21 +166,24 @@ test('withHttpScheme preserves wss://', () => {
165
166
  // buildStarterDrafts — OpenClaw provider handling
166
167
  // ---------------------------------------------------------------------------
167
168
 
168
- function makeConfiguredProvider(overrides: Partial<ConfiguredProvider> & { provider: ConfiguredProvider['provider'] }): ConfiguredProvider {
169
+ function makeConfiguredProvider(overrides: Partial<ConfiguredProvider> & { setupProvider: ConfiguredProvider['setupProvider']; provider?: ConfiguredProvider['provider'] }): ConfiguredProvider {
170
+ const { setupProvider, provider = setupProvider, ...rest } = overrides
169
171
  return {
170
172
  id: 'cp-1',
173
+ setupProvider,
174
+ provider,
171
175
  name: 'Test Provider',
172
176
  credentialId: null,
173
177
  endpoint: null,
174
178
  defaultModel: '',
175
179
  gatewayProfileId: null,
176
180
  verified: true,
177
- ...overrides,
181
+ ...rest,
178
182
  }
179
183
  }
180
184
 
181
185
  test('buildStarterDrafts assigns OpenClaw provider to drafts', () => {
182
- const cp = makeConfiguredProvider({ provider: 'openclaw', endpoint: 'http://localhost:18789' })
186
+ const cp = makeConfiguredProvider({ setupProvider: 'openclaw', endpoint: 'http://localhost:18789' })
183
187
  const drafts = buildStarterDrafts({
184
188
  starterKitId: 'personal_assistant',
185
189
  intentText: '',
@@ -188,12 +192,13 @@ test('buildStarterDrafts assigns OpenClaw provider to drafts', () => {
188
192
  assert.ok(drafts.length > 0, 'should produce at least one draft')
189
193
  for (const d of drafts) {
190
194
  assert.equal(d.provider, 'openclaw')
195
+ assert.equal(d.setupProvider, 'openclaw')
191
196
  assert.equal(d.providerConfigId, cp.id)
192
197
  }
193
198
  })
194
199
 
195
200
  test('buildStarterDrafts OpenClaw drafts use empty model (not "default")', () => {
196
- const cp = makeConfiguredProvider({ provider: 'openclaw', defaultModel: '' })
201
+ const cp = makeConfiguredProvider({ setupProvider: 'openclaw', defaultModel: '' })
197
202
  const drafts = buildStarterDrafts({
198
203
  starterKitId: 'personal_assistant',
199
204
  intentText: '',
@@ -206,7 +211,7 @@ test('buildStarterDrafts OpenClaw drafts use empty model (not "default")', () =>
206
211
  })
207
212
 
208
213
  test('buildStarterDrafts OpenClaw drafts inherit endpoint from provider', () => {
209
- const cp = makeConfiguredProvider({ provider: 'openclaw', endpoint: 'http://10.0.0.5:18789' })
214
+ const cp = makeConfiguredProvider({ setupProvider: 'openclaw', endpoint: 'http://10.0.0.5:18789' })
210
215
  const drafts = buildStarterDrafts({
211
216
  starterKitId: 'personal_assistant',
212
217
  intentText: '',
@@ -219,7 +224,7 @@ test('buildStarterDrafts OpenClaw drafts inherit endpoint from provider', () =>
219
224
 
220
225
  test('buildStarterDrafts carries dashboardUrl through from ConfiguredProvider', () => {
221
226
  const cp = makeConfiguredProvider({
222
- provider: 'openclaw',
227
+ setupProvider: 'openclaw',
223
228
  endpoint: 'http://localhost:18789',
224
229
  dashboardUrl: 'http://localhost:18789?token=my-secret',
225
230
  })
@@ -228,11 +233,36 @@ test('buildStarterDrafts carries dashboardUrl through from ConfiguredProvider',
228
233
  })
229
234
 
230
235
  test('preferredConfiguredProvider picks openclaw provider for openclaw template', () => {
231
- const openclawCp = makeConfiguredProvider({ id: 'oc-1', provider: 'openclaw' })
232
- const openaiCp = makeConfiguredProvider({ id: 'oai-1', provider: 'openai' })
236
+ const openclawCp = makeConfiguredProvider({ id: 'oc-1', setupProvider: 'openclaw' })
237
+ const openaiCp = makeConfiguredProvider({ id: 'oai-1', setupProvider: 'openai' })
233
238
  const result = preferredConfiguredProvider(
234
239
  { id: 'tmpl-1', name: 'Test', description: '', systemPrompt: '', tools: [], recommendedProviders: ['openclaw'] },
235
240
  [openaiCp, openclawCp],
236
241
  )
237
242
  assert.equal(result?.id, 'oc-1')
238
243
  })
244
+
245
+ test('buildStarterDrafts carries custom runtime provider ids alongside custom setup provider state', () => {
246
+ const cp = makeConfiguredProvider({
247
+ setupProvider: 'custom',
248
+ provider: 'custom-openrouter',
249
+ defaultModel: 'openai/gpt-4.1',
250
+ })
251
+ const drafts = buildStarterDrafts({
252
+ starterKitId: 'personal_assistant',
253
+ intentText: '',
254
+ configuredProviders: [cp],
255
+ })
256
+
257
+ for (const draft of drafts) {
258
+ assert.equal(draft.setupProvider, 'custom')
259
+ assert.equal(draft.provider, 'custom-openrouter')
260
+ assert.equal(draft.model, 'openai/gpt-4.1')
261
+ }
262
+ })
263
+
264
+ test('requiresSetupProviderVerification skips custom providers', () => {
265
+ assert.equal(requiresSetupProviderVerification('custom'), false)
266
+ assert.equal(requiresSetupProviderVerification('openclaw'), false)
267
+ assert.equal(requiresSetupProviderVerification('openai'), true)
268
+ })
@@ -91,6 +91,10 @@ export function getOpenClawErrorHint(message: string): string | null {
91
91
  return null
92
92
  }
93
93
 
94
+ export function requiresSetupProviderVerification(provider: SetupProvider | null | undefined): boolean {
95
+ return provider != null && provider !== 'openclaw' && provider !== 'custom'
96
+ }
97
+
94
98
  export function preferredConfiguredProvider(
95
99
  template: StarterKitAgentTemplate,
96
100
  configuredProviders: ConfiguredProvider[],
@@ -103,12 +107,12 @@ export function preferredConfiguredProvider(
103
107
  }
104
108
 
105
109
  if (fallbackProvider) {
106
- const exact = configuredProviders.find((candidate) => candidate.provider === fallbackProvider)
110
+ const exact = configuredProviders.find((candidate) => candidate.setupProvider === fallbackProvider)
107
111
  if (exact) return exact
108
112
  }
109
113
 
110
114
  for (const provider of template.recommendedProviders || []) {
111
- const exact = configuredProviders.find((candidate) => candidate.provider === provider)
115
+ const exact = configuredProviders.find((candidate) => candidate.setupProvider === provider)
112
116
  if (exact) return exact
113
117
  }
114
118
 
@@ -134,14 +138,15 @@ export function buildStarterDrafts(args: {
134
138
  template,
135
139
  configuredProviders,
136
140
  previous?.providerConfigId,
137
- previous?.provider,
141
+ previous?.setupProvider,
138
142
  )
139
- const oldProvider = previous?.provider || null
143
+ const oldProvider = previous?.setupProvider || null
144
+ const previousModel = previous?.model || ''
140
145
  const oldProviderDefault = oldProvider ? getDefaultModelForProvider(oldProvider) : ''
141
146
  const nextProviderDefault = configuredProvider?.defaultModel || ''
142
147
  const shouldRefreshModel =
143
- !previous?.model
144
- || (oldProvider !== configuredProvider?.provider && previous.model === oldProviderDefault)
148
+ !previousModel.trim()
149
+ || (oldProvider !== configuredProvider?.setupProvider && previousModel === oldProviderDefault)
145
150
 
146
151
  return {
147
152
  id,
@@ -151,8 +156,9 @@ export function buildStarterDrafts(args: {
151
156
  systemPrompt: previous?.systemPrompt || applyIntentContext(template.systemPrompt, intentText),
152
157
  soul: previous?.soul || '',
153
158
  providerConfigId: configuredProvider?.id || null,
159
+ setupProvider: configuredProvider?.setupProvider || null,
154
160
  provider: configuredProvider?.provider || null,
155
- model: shouldRefreshModel ? nextProviderDefault : previous.model,
161
+ model: shouldRefreshModel ? nextProviderDefault : previousModel,
156
162
  credentialId: configuredProvider?.credentialId || null,
157
163
  apiEndpoint: configuredProvider?.endpoint || null,
158
164
  gatewayProfileId: configuredProvider?.gatewayProfileId || null,
@@ -66,13 +66,13 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
66
66
  const handleToggle = async (e: React.MouseEvent, id: string, currentEnabled: boolean) => {
67
67
  e.stopPropagation()
68
68
  await api('PUT', `/providers/${id}`, { isEnabled: !currentEnabled })
69
- await loadProviderConfigs()
69
+ await Promise.all([loadProviderConfigs(), loadProviders()])
70
70
  }
71
71
 
72
72
  const handleDelete = async (e: React.MouseEvent, id: string) => {
73
73
  e.stopPropagation()
74
74
  await api('DELETE', `/providers/${id}`)
75
- await loadProviderConfigs()
75
+ await Promise.all([loadProviderConfigs(), loadProviders()])
76
76
  }
77
77
 
78
78
  const handleEditGateway = (id: string | null) => {
@@ -219,18 +219,27 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
219
219
  }
220
220
  }
221
221
 
222
- // Merge built-in providers with custom configs
223
- const builtinItems = providers.map((p) => ({
222
+ const customProviderConfigs = providerConfigs.filter((config) => config.type === 'custom')
223
+ const customConfigIds = new Set(customProviderConfigs.map((config) => config.id))
224
+ const builtinOverrides = new Map(
225
+ providerConfigs
226
+ .filter((config) => config.type === 'builtin')
227
+ .map((config) => [config.id, config]),
228
+ )
229
+
230
+ const builtinItems = providers
231
+ .filter((provider) => !customConfigIds.has(String(provider.id)))
232
+ .map((p) => ({
224
233
  id: p.id,
225
234
  name: p.name,
226
235
  type: 'builtin' as const,
227
236
  models: p.models,
228
237
  requiresApiKey: p.requiresApiKey,
229
- isEnabled: true,
238
+ isEnabled: builtinOverrides.get(String(p.id))?.isEnabled !== false,
230
239
  isConnected: !p.requiresApiKey || Object.values(credentials).some((c) => c.provider === p.id),
231
- }))
240
+ }))
232
241
 
233
- const customItems = providerConfigs.map((c) => ({
242
+ const customItems = customProviderConfigs.map((c) => ({
234
243
  id: c.id,
235
244
  name: c.name,
236
245
  type: 'custom' as const,
@@ -241,6 +250,8 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
241
250
  }))
242
251
 
243
252
  const allItems = [...builtinItems, ...customItems]
253
+ const enabledItems = allItems.filter((item) => item.isEnabled)
254
+ const disabledItems = allItems.filter((item) => !item.isEnabled)
244
255
  const gatewayNameById = new Map(gatewayProfiles.map((gateway) => [gateway.id, gateway.name]))
245
256
  const runtimeHealthByGateway = externalAgents.reduce<Record<string, { total: number; active: number; lastHeartbeatAt: number | null }>>((acc, runtime) => {
246
257
  if (!runtime.gatewayProfileId) return acc
@@ -273,7 +284,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
273
284
  )}
274
285
  </div>
275
286
  <div className={inSidebar ? 'space-y-2' : 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3'}>
276
- {allItems.map((item, idx) => (
287
+ {enabledItems.map((item, idx) => (
277
288
  <div
278
289
  key={item.id}
279
290
  role="button"
@@ -299,7 +310,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
299
310
  ${item.type === 'builtin' ? 'bg-white/[0.04] text-text-3' : 'bg-accent-bright/10 text-[#6366F1]'}`}>
300
311
  {item.type === 'builtin' ? 'Built-in' : 'Custom'}
301
312
  </span>
302
- {!inSidebar && item.type === 'custom' && (
313
+ {!inSidebar && (
303
314
  <>
304
315
  <div
305
316
  onClick={(e) => handleToggle(e, item.id, item.isEnabled)}
@@ -311,15 +322,17 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
311
322
  style={item.isEnabled ? { animation: 'spring-in 0.3s var(--ease-spring)' } : undefined}
312
323
  />
313
324
  </div>
314
- <button
315
- onClick={(e) => handleDelete(e, item.id)}
316
- className="text-text-3/40 hover:text-red-400 transition-colors p-0.5"
317
- title="Delete provider"
318
- >
319
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
320
- <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
321
- </svg>
322
- </button>
325
+ {item.type === 'custom' && (
326
+ <button
327
+ onClick={(e) => handleDelete(e, item.id)}
328
+ className="text-text-3/40 hover:text-red-400 transition-colors p-0.5"
329
+ title="Delete provider"
330
+ >
331
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
332
+ <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
333
+ </svg>
334
+ </button>
335
+ )}
323
336
  </>
324
337
  )}
325
338
  <StatusDot
@@ -339,6 +352,67 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
339
352
  </div>
340
353
  ))}
341
354
  </div>
355
+ {!inSidebar && disabledItems.length > 0 && (
356
+ <>
357
+ <div className="mt-8 mb-4 flex items-center justify-between">
358
+ <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">Disabled Providers</div>
359
+ </div>
360
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
361
+ {disabledItems.map((item, idx) => (
362
+ <div
363
+ key={item.id}
364
+ role="button"
365
+ tabIndex={0}
366
+ onClick={() => handleEdit(item.id)}
367
+ onKeyDown={(e) => {
368
+ if (e.key === 'Enter' || e.key === ' ') {
369
+ e.preventDefault()
370
+ handleEdit(item.id)
371
+ }
372
+ }}
373
+ className="w-full text-left p-4 rounded-[14px] border transition-all duration-200
374
+ cursor-pointer bg-surface/60 border-white/[0.06] hover:bg-white/[0.02] hover:border-white/[0.12]"
375
+ style={{
376
+ animation: 'spring-in 0.5s var(--ease-spring) both',
377
+ animationDelay: `${(enabledItems.length + idx) * 0.05}s`
378
+ }}
379
+ >
380
+ <div className="flex items-center justify-between mb-1.5">
381
+ <span className="font-display text-[14px] font-600 text-text truncate">{item.name}</span>
382
+ <div className="flex items-center gap-2 shrink-0">
383
+ <span className={`text-[10px] font-600 px-2 py-0.5 rounded-[5px] uppercase tracking-wider
384
+ ${item.type === 'builtin' ? 'bg-white/[0.04] text-text-3' : 'bg-accent-bright/10 text-[#6366F1]'}`}>
385
+ {item.type === 'builtin' ? 'Built-in' : 'Custom'}
386
+ </span>
387
+ <div
388
+ onClick={(e) => handleToggle(e, item.id, item.isEnabled)}
389
+ className="w-9 h-5 rounded-full transition-all relative cursor-pointer shrink-0 bg-white/[0.08]"
390
+ >
391
+ <div className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-all" />
392
+ </div>
393
+ {item.type === 'custom' && (
394
+ <button
395
+ onClick={(e) => handleDelete(e, item.id)}
396
+ className="text-text-3/40 hover:text-red-400 transition-colors p-0.5"
397
+ title="Delete provider"
398
+ >
399
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
400
+ <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
401
+ </svg>
402
+ </button>
403
+ )}
404
+ <StatusDot status="idle" pulse={false} />
405
+ </div>
406
+ </div>
407
+ <div className="text-[12px] text-text-3/60 font-mono truncate">
408
+ {item.models.slice(0, 3).join(', ')}
409
+ {item.models.length > 3 && ` +${item.models.length - 3}`}
410
+ </div>
411
+ </div>
412
+ ))}
413
+ </div>
414
+ </>
415
+ )}
342
416
 
343
417
  <div className="mt-8 mb-4 flex items-center justify-between">
344
418
  <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">OpenClaw Gateways</div>