@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.
package/README.md CHANGED
@@ -190,6 +190,12 @@ The building blocks are the same: **agents, tools, memory, delegation, schedules
190
190
 
191
191
  ## Release Notes
192
192
 
193
+ ### v1.2.4 Highlights
194
+
195
+ - **Custom providers in agent config**: agent setup and inline model switching now merge saved custom provider configs into the selectable provider list, so custom providers show up reliably even when the built-in provider feed is stale or incomplete.
196
+ - **Custom provider save-only flow**: the Providers screen no longer forces connection tests or live model discovery for custom providers; operators can save the endpoint, linked key, and manual model list directly.
197
+ - **Custom provider runtime routing**: saved custom-provider model lists and linked credentials now flow through the agent UI and runtime resolution paths consistently, including legacy `provider_configs` records normalized on load.
198
+
193
199
  ### v1.2.3 Highlights
194
200
 
195
201
  - **Standalone asset staging repair**: `swarmclaw server` now copies `.next/static` and `public/` into the Next.js standalone runtime after the first build, preventing blank UI loads and 503s for CSS, JS, and image assets.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Self-hosted AI runtime for OpenClaw, delegation, autonomy, runtime skills, crypto wallets, and chat platform connectors.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -0,0 +1,60 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
5
+
6
+ test('provider models route updates custom provider configs without creating model overrides', () => {
7
+ const output = runWithTempDataDir<{
8
+ customModels: string[]
9
+ overrideKeys: string[]
10
+ getPayload: { models: string[]; hasOverride: boolean }
11
+ }>(`
12
+ const storageMod = await import('./src/lib/server/storage')
13
+ const routeMod = await import('./src/app/api/providers/[id]/models/route')
14
+ const storage = storageMod.default || storageMod
15
+ const route = routeMod.default || routeMod
16
+
17
+ const now = Date.now()
18
+ storage.saveProviderConfigs({
19
+ 'custom-llama': {
20
+ id: 'custom-llama',
21
+ name: 'Llama.cpp',
22
+ type: 'custom',
23
+ baseUrl: 'http://localhost:8080/v1',
24
+ models: ['old-model'],
25
+ requiresApiKey: false,
26
+ credentialId: null,
27
+ isEnabled: true,
28
+ createdAt: now,
29
+ updatedAt: now,
30
+ },
31
+ })
32
+
33
+ await route.PUT(
34
+ new Request('http://local/api/providers/custom-llama/models', {
35
+ method: 'PUT',
36
+ headers: { 'content-type': 'application/json' },
37
+ body: JSON.stringify({ models: ['llama-3.1-8b', 'llama-3.1-70b'] }),
38
+ }),
39
+ { params: Promise.resolve({ id: 'custom-llama' }) },
40
+ )
41
+
42
+ const getResponse = await route.GET(
43
+ new Request('http://local/api/providers/custom-llama/models'),
44
+ { params: Promise.resolve({ id: 'custom-llama' }) },
45
+ )
46
+
47
+ console.log(JSON.stringify({
48
+ customModels: storage.loadProviderConfigs()['custom-llama'].models,
49
+ overrideKeys: Object.keys(storage.loadModelOverrides()),
50
+ getPayload: await getResponse.json(),
51
+ }))
52
+ `, { prefix: 'swarmclaw-provider-model-route-test-' })
53
+
54
+ assert.deepEqual(output.customModels, ['llama-3.1-8b', 'llama-3.1-70b'])
55
+ assert.deepEqual(output.overrideKeys, [])
56
+ assert.deepEqual(output.getPayload, {
57
+ models: ['llama-3.1-8b', 'llama-3.1-70b'],
58
+ hasOverride: false,
59
+ })
60
+ })
@@ -1,11 +1,17 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { loadModelOverrides, saveModelOverrides } from '@/lib/server/storage'
2
+ import { loadModelOverrides, loadProviderConfigs, saveModelOverrides, saveProviderConfigs } from '@/lib/server/storage'
3
3
  import { notFound } from '@/lib/server/collection-helpers'
4
4
  import { getProviderList } from '@/lib/providers'
5
5
  import { safeParseBody } from '@/lib/server/safe-parse-body'
6
6
 
7
7
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
8
8
  const { id } = await params
9
+ const providerConfigs = loadProviderConfigs()
10
+ const customProvider = providerConfigs[id]
11
+ if (customProvider?.type === 'custom') {
12
+ return NextResponse.json({ models: customProvider.models || [], hasOverride: false })
13
+ }
14
+
9
15
  const overrides = loadModelOverrides()
10
16
  const providers = getProviderList()
11
17
  const provider = providers.find((p) => p.id === id)
@@ -17,6 +23,19 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
17
23
  const { id } = await params
18
24
  const { data: body, error } = await safeParseBody<{ models?: string[] }>(req)
19
25
  if (error) return error
26
+
27
+ const providerConfigs = loadProviderConfigs()
28
+ const customProvider = providerConfigs[id]
29
+ if (customProvider?.type === 'custom') {
30
+ providerConfigs[id] = {
31
+ ...customProvider,
32
+ models: body.models || [],
33
+ updatedAt: Date.now(),
34
+ }
35
+ saveProviderConfigs(providerConfigs)
36
+ return NextResponse.json({ models: providerConfigs[id].models })
37
+ }
38
+
20
39
  const overrides = loadModelOverrides()
21
40
  overrides[id] = body.models || []
22
41
  saveModelOverrides(overrides)
@@ -25,6 +44,19 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
25
44
 
26
45
  export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
27
46
  const { id } = await params
47
+
48
+ const providerConfigs = loadProviderConfigs()
49
+ const customProvider = providerConfigs[id]
50
+ if (customProvider?.type === 'custom') {
51
+ providerConfigs[id] = {
52
+ ...customProvider,
53
+ models: [],
54
+ updatedAt: Date.now(),
55
+ }
56
+ saveProviderConfigs(providerConfigs)
57
+ return NextResponse.json({ ok: true })
58
+ }
59
+
28
60
  const overrides = loadModelOverrides()
29
61
  delete overrides[id]
30
62
  saveModelOverrides(overrides)
@@ -1,7 +1,9 @@
1
1
  import { NextResponse } from 'next/server'
2
+ import { PROVIDERS } from '@/lib/providers'
2
3
  import { loadProviderConfigs, saveProviderConfigs } from '@/lib/server/storage'
3
4
  import { mutateItem, deleteItem, notFound, badRequest, type CollectionOps } from '@/lib/server/collection-helpers'
4
5
  import { safeParseBody } from '@/lib/server/safe-parse-body'
6
+ import { notify } from '@/lib/server/ws-hub'
5
7
 
6
8
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
9
  const ops: CollectionOps<any> = { load: loadProviderConfigs, save: saveProviderConfigs, topic: 'providers' }
@@ -18,8 +20,35 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
18
20
  const { id } = await params
19
21
  const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
20
22
  if (error) return error
23
+ if (!ops.load()[id]) {
24
+ const builtin = PROVIDERS[id]
25
+ if (!builtin) return notFound()
26
+
27
+ const now = Date.now()
28
+ const configs = loadProviderConfigs()
29
+ configs[id] = {
30
+ ...body,
31
+ id,
32
+ name: builtin.name,
33
+ type: 'builtin',
34
+ baseUrl: builtin.defaultEndpoint || '',
35
+ models: [...builtin.models],
36
+ requiresApiKey: builtin.requiresApiKey,
37
+ credentialId: null,
38
+ isEnabled: body.isEnabled !== false,
39
+ createdAt: now,
40
+ updatedAt: now,
41
+ }
42
+ saveProviderConfigs(configs)
43
+ notify('providers')
44
+ return NextResponse.json(configs[id])
45
+ }
21
46
  const result = mutateItem(ops, id, (existing) => ({
22
- ...existing, ...body, id, updatedAt: Date.now(),
47
+ ...existing,
48
+ ...body,
49
+ id,
50
+ type: existing.type === 'builtin' ? 'builtin' : 'custom',
51
+ updatedAt: Date.now(),
23
52
  }))
24
53
  if (!result) return notFound()
25
54
  return NextResponse.json(result)
@@ -28,6 +28,7 @@ import { resolveStoredOllamaMode } from '@/lib/ollama-mode'
28
28
  import { errorMessage } from '@/lib/shared-utils'
29
29
  import { getDefaultAgentToolIds } from '@/lib/agent-default-tools'
30
30
  import { getEnabledExtensionIds, getEnabledToolIds } from '@/lib/capability-selection'
31
+ import { buildAgentSelectableProviders, resolveAgentSelectableProviderCredentials } from '@/lib/agent-provider-options'
31
32
 
32
33
  const HB_PRESETS = [1800, 3600, 7200, 21600, 43200] as const
33
34
  const FALLBACK_ELEVENLABS_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb'
@@ -46,6 +47,7 @@ const AUTO_SYNC_MODEL_PROVIDER_IDS = new Set<ProviderType>([
46
47
  'ollama',
47
48
  ])
48
49
  const CONNECTION_TEST_TIMEOUT_MS = 40_000
50
+ type AgentProviderId = string
49
51
 
50
52
  type SafeAgentWallet = Omit<AgentWallet, 'encryptedPrivateKey'> & {
51
53
  balanceAtomic?: string
@@ -171,6 +173,8 @@ export function AgentSheet() {
171
173
  const loadProjects = useAppStore((s) => s.loadProjects)
172
174
  const providers = useAppStore((s) => s.providers)
173
175
  const loadProviders = useAppStore((s) => s.loadProviders)
176
+ const providerConfigs = useAppStore((s) => s.providerConfigs)
177
+ const loadProviderConfigs = useAppStore((s) => s.loadProviderConfigs)
174
178
  const gatewayProfiles = useAppStore((s) => s.gatewayProfiles)
175
179
  const loadGatewayProfiles = useAppStore((s) => s.loadGatewayProfiles)
176
180
  const credentials = useAppStore((s) => s.credentials)
@@ -197,7 +201,7 @@ export function AgentSheet() {
197
201
  const [soulInitial, setSoulInitial] = useState('')
198
202
  const [soulSaveState, setSoulSaveState] = useState<'idle' | 'saved'>('idle')
199
203
  const [systemPrompt, setSystemPrompt] = useState('')
200
- const [provider, setProvider] = useState<ProviderType>('claude-cli')
204
+ const [provider, setProvider] = useState<AgentProviderId>('claude-cli')
201
205
  const [model, setModel] = useState('')
202
206
  const [credentialId, setCredentialId] = useState<string | null>(null)
203
207
  const [apiEndpoint, setApiEndpoint] = useState<string | null>(null)
@@ -308,8 +312,15 @@ export function AgentSheet() {
308
312
  }
309
313
  }, [])
310
314
 
311
- const currentProvider = providers.find((p) => p.id === provider)
312
- const providerCredentials = Object.values(credentials).filter((c) => c.provider === provider)
315
+ const agentSelectableProviders = useMemo(
316
+ () => buildAgentSelectableProviders(providers, providerConfigs),
317
+ [providers, providerConfigs],
318
+ )
319
+ const currentProvider = agentSelectableProviders.find((p) => p.id === provider)
320
+ const providerCredentials = useMemo(
321
+ () => resolveAgentSelectableProviderCredentials(provider, credentials, providerConfigs),
322
+ [credentials, provider, providerConfigs],
323
+ )
313
324
  const openclawCredentials = Object.values(credentials).filter((c) => c.provider === 'openclaw')
314
325
  const openclawGatewayProfiles = gatewayProfiles.filter((item) => item.provider === 'openclaw')
315
326
  const setAgentPrefill = useAppStore((s) => s.setAgentPrefill)
@@ -327,15 +338,15 @@ export function AgentSheet() {
327
338
  ? 'Global default'
328
339
  : 'Built-in fallback'
329
340
  const syncLiveProviderModels = useCallback(async (
330
- providerId: ProviderType,
341
+ providerId: string,
331
342
  nextCredentialId: string | null,
332
343
  nextEndpoint: string | null,
333
344
  nextOllamaMode: 'local' | 'cloud',
334
345
  force = false,
335
346
  ): Promise<{ synced: boolean; models: string[] } | null> => {
336
347
  if (openclawEnabled) return null
337
- if (!AUTO_SYNC_MODEL_PROVIDER_IDS.has(providerId)) return null
338
- const providerInfo = providers.find((item) => item.id === providerId)
348
+ if (!AUTO_SYNC_MODEL_PROVIDER_IDS.has(providerId as ProviderType)) return null
349
+ const providerInfo = agentSelectableProviders.find((item) => item.id === providerId)
339
350
  if (!providerInfo?.supportsModelDiscovery) return null
340
351
 
341
352
  const result = await fetchProviderModelDiscovery({
@@ -358,7 +369,7 @@ export function AgentSheet() {
358
369
 
359
370
  setModel((currentModel) => currentModel.trim() || result.models[0] || '')
360
371
  return { synced: !sameModels, models: result.models }
361
- }, [loadProviders, openclawEnabled, providers])
372
+ }, [agentSelectableProviders, loadProviders, openclawEnabled])
362
373
 
363
374
  const providerNeedsKey = !editing && (
364
375
  (currentProvider?.requiresApiKey && providerCredentials.length === 0 && !addingKey) ||
@@ -371,7 +382,7 @@ export function AgentSheet() {
371
382
  return
372
383
  }
373
384
  if (openclawEnabled) return
374
- if (!AUTO_SYNC_MODEL_PROVIDER_IDS.has(provider)) return
385
+ if (!AUTO_SYNC_MODEL_PROVIDER_IDS.has(provider as ProviderType)) return
375
386
  if (!currentProvider?.supportsModelDiscovery) return
376
387
 
377
388
  const requiresCredential = currentProvider.requiresApiKey || (provider === 'ollama' && ollamaMode === 'cloud')
@@ -388,6 +399,7 @@ export function AgentSheet() {
388
399
  if (open) {
389
400
  loadSettings()
390
401
  loadProviders()
402
+ loadProviderConfigs()
391
403
  loadGatewayProfiles()
392
404
  loadCredentials()
393
405
  loadSkills()
@@ -637,7 +649,7 @@ export function AgentSheet() {
637
649
  setModel(currentProvider.models[0])
638
650
  }
639
651
  // eslint-disable-next-line react-hooks/exhaustive-deps
640
- }, [provider, providers])
652
+ }, [provider, agentSelectableProviders])
641
653
 
642
654
  // Reset test status when connection params change
643
655
  useEffect(() => {
@@ -873,13 +885,18 @@ export function AgentSheet() {
873
885
 
874
886
  const handleExport = () => {
875
887
  if (!editing) return
888
+ const recommendedProviders = agentSelectableProviders.some((providerOption) => (
889
+ providerOption.id === editing.provider && providerOption.type === 'builtin'
890
+ ))
891
+ ? [editing.provider as ProviderType]
892
+ : undefined
876
893
  const pack: AgentPackManifest = {
877
894
  schemaVersion: 1,
878
895
  kind: 'swarmclaw-agent-pack',
879
896
  name: `${editing.name} Pack`,
880
897
  description: editing.description || undefined,
881
898
  exportedAt: Date.now(),
882
- recommendedProviders: [editing.provider],
899
+ recommendedProviders,
883
900
  agents: [{
884
901
  id: editing.name.replace(/\s+/g, '-').toLowerCase(),
885
902
  name: editing.name,
@@ -1213,7 +1230,7 @@ export function AgentSheet() {
1213
1230
  if (!apiEndpoint) setApiEndpoint('http://localhost:18789')
1214
1231
  } else {
1215
1232
  setOpenclawEnabled(false)
1216
- const first = providers[0]?.id || 'claude-cli'
1233
+ const first = agentSelectableProviders[0]?.id || 'claude-cli'
1217
1234
  setProvider(first)
1218
1235
  setModel('')
1219
1236
  setApiEndpoint(null)
@@ -1445,13 +1462,17 @@ export function AgentSheet() {
1445
1462
  {!openclawEnabled && <div className="mb-8">
1446
1463
  <SectionLabel>Provider</SectionLabel>
1447
1464
  <div className="grid grid-cols-3 gap-3">
1448
- {providers.map((p) => {
1449
- const isConnected = !p.requiresApiKey || Object.values(credentials).some((c) => c.provider === p.id)
1465
+ {agentSelectableProviders.map((p) => {
1466
+ const nextCredentials = resolveAgentSelectableProviderCredentials(p.id, credentials, providerConfigs)
1467
+ const isConnected = !p.requiresApiKey || nextCredentials.length > 0
1450
1468
  return (
1451
1469
  <button
1452
1470
  key={p.id}
1453
1471
  onClick={() => {
1454
1472
  setProvider(p.id)
1473
+ if (!nextCredentials.some((item) => item.id === credentialId)) {
1474
+ setCredentialId(nextCredentials[0]?.id || null)
1475
+ }
1455
1476
  setGatewayProfileId(null)
1456
1477
  }}
1457
1478
  className={`relative py-3.5 px-4 rounded-[14px] text-center cursor-pointer transition-all duration-200
@@ -2161,7 +2182,7 @@ export function AgentSheet() {
2161
2182
  </div>
2162
2183
  <div className="space-y-3">
2163
2184
  {routingTargets.map((target, index) => {
2164
- const targetCredentials = Object.values(credentials).filter((item) => item.provider === target.provider)
2185
+ const targetCredentials = resolveAgentSelectableProviderCredentials(target.provider, credentials, providerConfigs)
2165
2186
  return (
2166
2187
  <div key={target.id} className="p-4 rounded-[12px] border border-white/[0.08] bg-white/[0.02] space-y-3">
2167
2188
  <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
@@ -2182,19 +2203,24 @@ export function AgentSheet() {
2182
2203
  <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
2183
2204
  <select
2184
2205
  value={target.provider}
2185
- onChange={(e) => updateRoutingTarget(target.id, {
2186
- provider: e.target.value as ProviderType,
2187
- gatewayProfileId: e.target.value === 'openclaw' ? target.gatewayProfileId : null,
2188
- ollamaMode: e.target.value === 'ollama'
2206
+ onChange={(e) => {
2207
+ const nextProviderId = e.target.value
2208
+ const nextCredentials = resolveAgentSelectableProviderCredentials(nextProviderId, credentials, providerConfigs)
2209
+ updateRoutingTarget(target.id, {
2210
+ provider: nextProviderId,
2211
+ credentialId: nextCredentials[0]?.id || null,
2212
+ gatewayProfileId: nextProviderId === 'openclaw' ? target.gatewayProfileId : null,
2213
+ ollamaMode: nextProviderId === 'ollama'
2189
2214
  ? resolveStoredOllamaMode({
2190
2215
  ollamaMode: target.ollamaMode ?? null,
2191
2216
  apiEndpoint: target.apiEndpoint ?? null,
2192
2217
  })
2193
2218
  : null,
2194
- })}
2219
+ })
2220
+ }}
2195
2221
  className={inputClass}
2196
2222
  >
2197
- {providers.map((item) => (
2223
+ {agentSelectableProviders.map((item) => (
2198
2224
  <option key={item.id} value={item.id}>{item.name}</option>
2199
2225
  ))}
2200
2226
  </select>
@@ -26,6 +26,7 @@ import { ModelCombobox } from '@/components/shared/model-combobox'
26
26
  import { buildOpenClawMainSessionKey } from '@/lib/openclaw/openclaw-agent-id'
27
27
  import { StructuredSessionLauncher } from '@/components/protocols/structured-session-launcher'
28
28
  import { useWs } from '@/hooks/use-ws'
29
+ import { buildAgentSelectableProviders } from '@/lib/agent-provider-options'
29
30
 
30
31
  interface Props {
31
32
  agent: Agent
@@ -61,17 +62,27 @@ const PROVIDER_LABELS: Record<string, string> = {
61
62
  function ModelSwitcherInline({ session, agent }: { session: Session; agent: Agent }) {
62
63
  const providers = useAppStore((s) => s.providers)
63
64
  const loadProviders = useAppStore((s) => s.loadProviders)
65
+ const providerConfigs = useAppStore((s) => s.providerConfigs)
66
+ const loadProviderConfigs = useAppStore((s) => s.loadProviderConfigs)
64
67
  const refreshSession = useAppStore((s) => s.refreshSession)
65
68
  const streaming = useChatStore((s) => s.streaming)
66
69
  const [expanded, setExpanded] = useState(false)
67
70
  const [selectedProvider, setSelectedProvider] = useState(agent.provider)
68
71
  const [saving, setSaving] = useState(false)
69
72
 
70
- useEffect(() => { if (!providers.length) void loadProviders() }, [providers.length, loadProviders])
73
+ useEffect(() => {
74
+ void loadProviders()
75
+ void loadProviderConfigs()
76
+ }, [loadProviderConfigs, loadProviders])
71
77
  useEffect(() => { setSelectedProvider(agent.provider) }, [agent.provider])
72
78
 
73
- const currentProviderInfo = providers.find((p) => p.id === selectedProvider)
74
- const providerLabel = PROVIDER_LABELS[agent.provider] || agent.provider.replace(/-/g, ' ')
79
+ const agentSelectableProviders = useMemo(
80
+ () => buildAgentSelectableProviders(providers, providerConfigs),
81
+ [providerConfigs, providers],
82
+ )
83
+ const currentProviderInfo = agentSelectableProviders.find((p) => p.id === selectedProvider)
84
+ const activeAgentProvider = agentSelectableProviders.find((p) => p.id === agent.provider)
85
+ const providerLabel = PROVIDER_LABELS[agent.provider] || activeAgentProvider?.name || agent.provider.replace(/-/g, ' ')
75
86
 
76
87
  const handleModelChange = async (model: string) => {
77
88
  if (saving) return
@@ -122,7 +133,7 @@ function ModelSwitcherInline({ session, agent }: { session: Session; agent: Agen
122
133
  </button>
123
134
  </div>
124
135
  <div className="flex flex-wrap gap-1 mb-2">
125
- {providers.filter((p) => p.models.length > 0).map((p) => (
136
+ {agentSelectableProviders.filter((p) => p.models.length > 0).map((p) => (
126
137
  <button
127
138
  key={p.id}
128
139
  type="button"
@@ -4,7 +4,7 @@ import { useMemo, useState } from 'react'
4
4
  import { api } from '@/lib/app/api-client'
5
5
  import { useAppStore } from '@/stores/use-app-store'
6
6
  import { dedup, errorMessage } from '@/lib/shared-utils'
7
- import type { ProviderType, GatewayProfile } from '@/types'
7
+ import type { ProviderId, GatewayProfile } from '@/types'
8
8
  import {
9
9
  SETUP_PROVIDERS,
10
10
  SWARMCLAW_ASSISTANT_PROMPT,
@@ -21,7 +21,7 @@ import type {
21
21
  ProviderCheckResponse,
22
22
  } from './types'
23
23
  import { STEP_ORDER } from './types'
24
- import { stepIndex } from './utils'
24
+ import { requiresSetupProviderVerification, stepIndex } from './utils'
25
25
  import { SparkleIcon } from './shared'
26
26
  import { StepProgress } from './step-progress'
27
27
  import { StepProviders } from './step-providers'
@@ -53,7 +53,7 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
53
53
  [editingProviderId, configuredProviders],
54
54
  )
55
55
  const totalSteps = STEP_ORDER.length
56
- const configuredProviderIds = new Set(configuredProviders.map((cp) => cp.provider))
56
+ const configuredProviderIds = new Set(configuredProviders.map((cp) => cp.setupProvider))
57
57
  const canContinueFromProviders = configuredProviders.length > 0
58
58
 
59
59
  const skip = async () => {
@@ -76,7 +76,7 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
76
76
 
77
77
  const selectProvider = (nextProvider: SetupProvider) => {
78
78
  const meta = SETUP_PROVIDERS.find((candidate) => candidate.id === nextProvider)
79
- const existing = configuredProviders.find((cp) => cp.provider === nextProvider)
79
+ const existing = configuredProviders.find((cp) => cp.setupProvider === nextProvider)
80
80
  setActiveProvider(nextProvider)
81
81
  setEditingProviderId(existing?.id || null)
82
82
  setActiveProviderLabel(existing?.name || meta?.name || '')
@@ -94,6 +94,7 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
94
94
  return {
95
95
  ...draft,
96
96
  providerConfigId: fallback?.id || null,
97
+ setupProvider: fallback?.setupProvider || null,
97
98
  provider: fallback?.provider || null,
98
99
  credentialId: fallback?.credentialId || null,
99
100
  apiEndpoint: fallback?.endpoint || null,
@@ -125,6 +126,7 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
125
126
  systemPrompt: SWARMCLAW_ASSISTANT_PROMPT,
126
127
  soul: '',
127
128
  providerConfigId: cp.id,
129
+ setupProvider: cp.setupProvider,
128
130
  provider: cp.provider,
129
131
  model: cp.defaultModel,
130
132
  credentialId: cp.credentialId,
@@ -149,6 +151,7 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
149
151
  if (draft.providerConfigId !== editingProviderId) return draft
150
152
  return {
151
153
  ...draft,
154
+ setupProvider: configured.setupProvider,
152
155
  provider: configured.provider,
153
156
  credentialId: configured.credentialId,
154
157
  apiEndpoint: configured.endpoint,
@@ -186,6 +189,7 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
186
189
  systemPrompt: '',
187
190
  soul: '',
188
191
  providerConfigId: defaultProvider?.id || null,
192
+ setupProvider: defaultProvider?.setupProvider || null,
189
193
  provider: defaultProvider?.provider || null,
190
194
  model: defaultProvider?.defaultModel || '',
191
195
  credentialId: defaultProvider?.credentialId || null,
@@ -224,13 +228,14 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
224
228
 
225
229
  setDraftAgents((current) => current.map((draft) => {
226
230
  if (draft.id !== id) return draft
227
- const previousDefault = draft.provider ? getDefaultModelForProvider(draft.provider) : ''
231
+ const previousDefault = draft.setupProvider ? getDefaultModelForProvider(draft.setupProvider) : ''
228
232
  const nextModel = !draft.model || draft.model === previousDefault
229
233
  ? configuredProvider.defaultModel
230
234
  : draft.model
231
235
  return {
232
236
  ...draft,
233
237
  providerConfigId: configuredProvider.id,
238
+ setupProvider: configuredProvider.setupProvider,
234
239
  provider: configuredProvider.provider,
235
240
  credentialId: configuredProvider.credentialId,
236
241
  apiEndpoint: configuredProvider.endpoint,
@@ -246,6 +251,10 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
246
251
  setError('Every enabled agent needs a provider assignment before you continue.')
247
252
  return
248
253
  }
254
+ if (enabledDrafts.some((draft) => draft.setupProvider === 'custom' && !draft.model.trim())) {
255
+ setError('Every custom-provider agent needs a model before you continue.')
256
+ return
257
+ }
249
258
 
250
259
  setSaving(true)
251
260
  setError('')
@@ -254,12 +263,12 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
254
263
  const checkedCombos = new Map<string, ProviderCheckResponse>()
255
264
  for (const draft of enabledDrafts) {
256
265
  const cp = configuredProviders.find((c) => c.id === draft.providerConfigId)
257
- if (!cp || cp.provider === 'openclaw') continue
258
- const comboKey = `${cp.provider}|${draft.apiEndpoint || cp.endpoint || ''}|${draft.model}`
266
+ if (!cp || !requiresSetupProviderVerification(cp.setupProvider)) continue
267
+ const comboKey = `${cp.setupProvider}|${draft.apiEndpoint || cp.endpoint || ''}|${draft.model}`
259
268
  if (checkedCombos.has(comboKey)) continue
260
269
  try {
261
270
  const result = await api<ProviderCheckResponse>('POST', '/setup/check-provider', {
262
- provider: cp.provider,
271
+ provider: cp.setupProvider,
263
272
  credentialId: cp.credentialId || undefined,
264
273
  endpoint: draft.apiEndpoint || cp.endpoint || undefined,
265
274
  model: draft.model || undefined,
@@ -276,7 +285,7 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
276
285
  }
277
286
 
278
287
  const gatewayProfileIdsByProviderConfig = new Map<string, string>()
279
- const openClawProviders = configuredProviders.filter((candidate) => candidate.provider === 'openclaw')
288
+ const openClawProviders = configuredProviders.filter((candidate) => candidate.setupProvider === 'openclaw')
280
289
  if (openClawProviders.length > 0) {
281
290
  const existingGateways = await api<GatewayProfile[]>('GET', '/gateways')
282
291
  let shouldCreateDefault = existingGateways.length === 0
@@ -322,8 +331,8 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
322
331
  description: draft.description.trim(),
323
332
  systemPrompt: draft.systemPrompt.trim(),
324
333
  soul: draft.soul.trim() || undefined,
325
- provider: draft.provider as ProviderType,
326
- model: draft.model.trim() || getDefaultModelForProvider(draft.provider as SetupProvider),
334
+ provider: draft.provider,
335
+ model: draft.model.trim() || (draft.setupProvider ? getDefaultModelForProvider(draft.setupProvider) : ''),
327
336
  credentialId: draft.credentialId || null,
328
337
  tools: draft.tools,
329
338
  capabilities: draft.capabilities,
@@ -355,7 +364,7 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
355
364
  }
356
365
 
357
366
  // Push soul and identity files to the OpenClaw gateway (non-fatal)
358
- if (draft.provider === 'openclaw') {
367
+ if (draft.setupProvider === 'openclaw') {
359
368
  try {
360
369
  if (draft.soul.trim()) {
361
370
  await api('PUT', '/openclaw/agent-files', { agentId, filename: 'SOUL.md', content: draft.soul.trim() })
@@ -373,8 +382,8 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
373
382
  created.push({
374
383
  id: agentId,
375
384
  name: draft.name.trim(),
376
- provider: draft.provider as SetupProvider,
377
- providerName: configuredProviders.find((candidate) => candidate.id === draft.providerConfigId)?.name || draft.provider as SetupProvider,
385
+ provider: draft.provider as ProviderId,
386
+ providerName: configuredProviders.find((candidate) => candidate.id === draft.providerConfigId)?.name || String(draft.provider || ''),
378
387
  })
379
388
  }
380
389
 
@@ -69,10 +69,10 @@ export function ConfiguredProviderChips({
69
69
  {formatEndpointHost(cp.endpoint)
70
70
  ? `· ${formatEndpointHost(cp.endpoint)}`
71
71
  : ''}
72
- {cp.provider === 'openclaw' && cp.deployment?.useCase
72
+ {cp.setupProvider === 'openclaw' && cp.deployment?.useCase
73
73
  ? ` · ${OPENCLAW_USE_CASE_LABELS[cp.deployment.useCase]}`
74
74
  : ''}
75
- {cp.provider === 'openclaw' && cp.deployment?.exposure
75
+ {cp.setupProvider === 'openclaw' && cp.deployment?.exposure
76
76
  ? ` · ${OPENCLAW_EXPOSURE_LABELS[cp.deployment.exposure]}`
77
77
  : ''}
78
78
  {cp.defaultModel ? ` · ${cp.defaultModel}` : ''}