@swarmclawai/swarmclaw 1.9.19 → 1.9.21

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.
@@ -10,7 +10,7 @@ import { sleep } from '@/lib/shared-utils'
10
10
  import { BottomSheet } from '@/components/shared/bottom-sheet'
11
11
  import { toast } from 'sonner'
12
12
  import { ModelCombobox } from '@/components/shared/model-combobox'
13
- import type { ProviderType, ClaudeSkill, AgentPackManifest, AgentRoutingStrategy, AgentRoutingTarget } from '@/types'
13
+ import type { ProviderType, ProviderDiagnosticStep, ClaudeSkill, AgentPackManifest, AgentRoutingStrategy, AgentRoutingTarget } from '@/types'
14
14
  import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
15
15
  import { MCP_INJECTION_PROVIDER_IDS, NATIVE_CAPABILITY_PROVIDER_IDS, NON_LANGGRAPH_PROVIDER_IDS, WORKER_ONLY_PROVIDER_IDS } from '@/lib/provider-sets'
16
16
  import { isOrchestratorProviderEligible } from '@/lib/orchestrator-config'
@@ -33,6 +33,7 @@ import { buildAgentSelectableProviders, resolveAgentSelectableProviderCredential
33
33
  import { AgentSocialSettings } from '@/features/swarmfeed/agent-social-settings'
34
34
  import { AgentMarketplaceSettings } from '@/features/swarmdock/agent-marketplace-settings'
35
35
  import type { ConfigVersion } from '@/types/config-version'
36
+ import { ProviderDiagnosticsList } from '@/components/providers/provider-diagnostics-list'
36
37
 
37
38
  const HB_PRESETS = [1800, 3600, 7200, 21600, 43200] as const
38
39
  const FALLBACK_ELEVENLABS_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb'
@@ -50,6 +51,7 @@ const AUTO_SYNC_MODEL_PROVIDER_IDS = new Set<ProviderType>([
50
51
  'nebius',
51
52
  'deepinfra',
52
53
  'hermes',
54
+ 'lmstudio',
53
55
  'ollama',
54
56
  ])
55
57
  const CONNECTION_TEST_TIMEOUT_MS = 40_000
@@ -283,6 +285,7 @@ export function AgentSheet() {
283
285
  const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'pass' | 'fail'>('idle')
284
286
  const [testMessage, setTestMessage] = useState('')
285
287
  const [testErrorCode, setTestErrorCode] = useState<string | null>(null)
288
+ const [testDiagnostics, setTestDiagnostics] = useState<ProviderDiagnosticStep[]>([])
286
289
  const [testDeviceId, setTestDeviceId] = useState<string | null>(null)
287
290
  const [openclawDeviceId, setOpenclawDeviceId] = useState<string | null>(null)
288
291
  const [configCopied, setConfigCopied] = useState(false)
@@ -425,6 +428,7 @@ export function AgentSheet() {
425
428
  .catch(() => {})
426
429
  setTestStatus('idle')
427
430
  setTestMessage('')
431
+ setTestDiagnostics([])
428
432
  setShowAdvancedSettings(false)
429
433
  if (editing) {
430
434
  setName(editing.name)
@@ -689,6 +693,7 @@ export function AgentSheet() {
689
693
  useEffect(() => {
690
694
  setTestStatus('idle')
691
695
  setTestMessage('')
696
+ setTestDiagnostics([])
692
697
  }, [provider, credentialId, apiEndpoint])
693
698
 
694
699
  // Fetch MCP tools when selected servers change
@@ -745,6 +750,24 @@ export function AgentSheet() {
745
750
  if (!model) setModel('default')
746
751
  }
747
752
 
753
+ const applyDirectProviderSelection = (nextProviderId: string) => {
754
+ const nextProvider = agentSelectableProviders.find((item) => item.id === nextProviderId)
755
+ const nextCredentials = resolveAgentSelectableProviderCredentials(nextProviderId, credentials, providerConfigs)
756
+ setProvider(nextProviderId)
757
+ setModel(nextProvider?.models[0] || '')
758
+ setCredentialId(nextCredentials[0]?.id || null)
759
+ setFallbackCredentialIds([])
760
+ setGatewayProfileId(null)
761
+ setApiEndpoint(nextProvider?.requiresEndpoint ? nextProvider.defaultEndpoint || null : null)
762
+ setTestStatus('idle')
763
+ setTestMessage('')
764
+ setTestErrorCode(null)
765
+ setTestDiagnostics([])
766
+ setAddingKey(false)
767
+ setNewKeyName('')
768
+ setNewKeyValue('')
769
+ }
770
+
748
771
  const updateRoutingTarget = (targetId: string, patch: Partial<AgentRoutingTarget>) => {
749
772
  setRoutingTargets((current) => current.map((target) => (
750
773
  target.id === targetId
@@ -778,7 +801,8 @@ export function AgentSheet() {
778
801
 
779
802
  const handleSave = async () => {
780
803
  // For any endpoint, just ensure bare host:port gets a protocol prepended
781
- let normalizedEndpoint = apiEndpoint
804
+ const providerAllowsAgentEndpoint = Boolean(openclawEnabled || currentProvider?.requiresEndpoint || currentProvider?.optionalEndpoint)
805
+ let normalizedEndpoint = providerAllowsAgentEndpoint ? apiEndpoint : null
782
806
  if (normalizedEndpoint) {
783
807
  const url = normalizedEndpoint.trim().replace(/\/+$/, '')
784
808
  normalizedEndpoint = /^(https?|wss?):\/\//i.test(url) ? url : `http://${url}`
@@ -1028,8 +1052,9 @@ export function AgentSheet() {
1028
1052
  setTestStatus('testing')
1029
1053
  setTestMessage('')
1030
1054
  setTestErrorCode(null)
1055
+ setTestDiagnostics([])
1031
1056
  try {
1032
- const result = await api<{ ok: boolean; message: string; errorCode?: string; deviceId?: string }>('POST', '/setup/check-provider', {
1057
+ const result = await api<{ ok: boolean; message: string; errorCode?: string; deviceId?: string; diagnostics?: ProviderDiagnosticStep[] }>('POST', '/setup/check-provider', {
1033
1058
  provider,
1034
1059
  credentialId,
1035
1060
  endpoint: apiEndpoint,
@@ -1038,6 +1063,7 @@ export function AgentSheet() {
1038
1063
  }, {
1039
1064
  timeoutMs: CONNECTION_TEST_TIMEOUT_MS,
1040
1065
  })
1066
+ setTestDiagnostics(result.diagnostics ?? [])
1041
1067
  if (result.deviceId) setTestDeviceId(result.deviceId)
1042
1068
  if (result.ok) {
1043
1069
  let syncedModels: string[] = []
@@ -1065,6 +1091,7 @@ export function AgentSheet() {
1065
1091
  const msg = err instanceof Error ? err.message : 'Connection test failed'
1066
1092
  setTestStatus('fail')
1067
1093
  setTestMessage(msg)
1094
+ setTestDiagnostics([])
1068
1095
  toast.error(msg)
1069
1096
  return false
1070
1097
  }
@@ -1298,6 +1325,10 @@ export function AgentSheet() {
1298
1325
  <button
1299
1326
  type="button"
1300
1327
  onClick={() => {
1328
+ setTestStatus('idle')
1329
+ setTestMessage('')
1330
+ setTestErrorCode(null)
1331
+ setTestDiagnostics([])
1301
1332
  if (!openclawEnabled) {
1302
1333
  setOpenclawEnabled(true)
1303
1334
  setProvider('openclaw')
@@ -1311,9 +1342,6 @@ export function AgentSheet() {
1311
1342
  setApiEndpoint(null)
1312
1343
  setCredentialId(null)
1313
1344
  setGatewayProfileId(null)
1314
- setTestStatus('idle')
1315
- setTestMessage('')
1316
- setTestErrorCode(null)
1317
1345
  }
1318
1346
  }}
1319
1347
  className={`relative h-6 w-11 rounded-full border-none transition-colors duration-200 ${openclawEnabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`}
@@ -1454,6 +1482,7 @@ export function AgentSheet() {
1454
1482
  <p className="text-[14px] text-emerald-400 font-600">Connected</p>
1455
1483
  </div>
1456
1484
  <p className="text-[13px] text-text-2/80 leading-[1.6]">Gateway is reachable and this device is paired. Tools and models are managed by the OpenClaw instance.</p>
1485
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
1457
1486
  </div>
1458
1487
  )}
1459
1488
  {testStatus === 'fail' && (
@@ -1529,6 +1558,7 @@ export function AgentSheet() {
1529
1558
  </p>
1530
1559
  </div>
1531
1560
  )}
1561
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
1532
1562
  </div>
1533
1563
  )}
1534
1564
  </div>
@@ -1543,13 +1573,7 @@ export function AgentSheet() {
1543
1573
  return (
1544
1574
  <button
1545
1575
  key={p.id}
1546
- onClick={() => {
1547
- setProvider(p.id)
1548
- if (!nextCredentials.some((item) => item.id === credentialId)) {
1549
- setCredentialId(nextCredentials[0]?.id || null)
1550
- }
1551
- setGatewayProfileId(null)
1552
- }}
1576
+ onClick={() => applyDirectProviderSelection(p.id)}
1553
1577
  className={`relative py-3.5 px-4 rounded-[14px] text-center cursor-pointer transition-all duration-200
1554
1578
  active:scale-[0.97] text-[14px] font-600 border
1555
1579
  ${provider === p.id
@@ -1731,7 +1755,7 @@ export function AgentSheet() {
1731
1755
 
1732
1756
  {(currentProvider?.requiresEndpoint || currentProvider?.optionalEndpoint) && (provider !== 'ollama' || ollamaMode === 'local') && (
1733
1757
  <div className="mb-8">
1734
- <SectionLabel>{provider === 'openclaw' ? 'OpenClaw Endpoint' : provider === 'hermes' ? 'Hermes API Endpoint' : 'Endpoint'}</SectionLabel>
1758
+ <SectionLabel>{provider === 'openclaw' ? 'OpenClaw Endpoint' : provider === 'hermes' ? 'Hermes API Endpoint' : provider === 'lmstudio' ? 'LM Studio Endpoint' : 'Endpoint'}</SectionLabel>
1735
1759
  <input type="text" value={apiEndpoint || ''} onChange={(e) => setApiEndpoint(e.target.value || null)} placeholder={currentProvider.defaultEndpoint || 'http://localhost:11434'} className={`${inputClass} font-mono text-[14px]`} />
1736
1760
  {provider === 'openclaw' && (
1737
1761
  <p className="text-[13px] text-text-3/70 mt-2">The URL of your OpenClaw gateway</p>
@@ -1739,6 +1763,9 @@ export function AgentSheet() {
1739
1763
  {provider === 'hermes' && (
1740
1764
  <p className="text-[13px] text-text-3/70 mt-2">Point this at the Hermes API server, usually <code className="text-text-2">http://127.0.0.1:8642/v1</code>.</p>
1741
1765
  )}
1766
+ {provider === 'lmstudio' && (
1767
+ <p className="text-[13px] text-text-3/70 mt-2">Point this at the LM Studio local server. A bare host is normalized to <code className="text-text-2">/v1</code>.</p>
1768
+ )}
1742
1769
  </div>
1743
1770
  )}
1744
1771
 
@@ -2949,11 +2976,13 @@ export function AgentSheet() {
2949
2976
  {!openclawEnabled && testStatus === 'fail' && (
2950
2977
  <div className="mb-4 p-3 rounded-[12px] bg-red-500/[0.08] border border-red-500/20">
2951
2978
  <p className="text-[13px] text-red-400">{testMessage || 'Connection test failed'}</p>
2979
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
2952
2980
  </div>
2953
2981
  )}
2954
2982
  {!openclawEnabled && testStatus === 'pass' && (
2955
2983
  <div className="mb-4 p-3 rounded-[12px] bg-emerald-500/[0.08] border border-emerald-500/20">
2956
2984
  <p className="text-[13px] text-emerald-400">{testMessage || 'Connected successfully'}</p>
2985
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
2957
2986
  </div>
2958
2987
  )}
2959
2988
 
@@ -28,6 +28,7 @@ const SETUP_PROVIDERS_WITH_MODEL_DISCOVERY = new Set([
28
28
  'ollama',
29
29
  'openclaw',
30
30
  'hermes',
31
+ 'lmstudio',
31
32
  ])
32
33
 
33
34
  /* ── Model combobox: search discovered models or type a custom one ── */
@@ -5,7 +5,8 @@ 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, ProviderId, ProviderConfig } from '@/types'
8
+ import { ProviderDiagnosticsList } from '@/components/providers/provider-diagnostics-list'
9
+ import type { Credential, Credentials, GatewayProfile, ProviderDiagnosticStep, ProviderId, ProviderConfig } from '@/types'
9
10
  import type { StepConnectProps, CheckState, ProviderCheckResponse, ConfiguredProvider } from './types'
10
11
  import {
11
12
  formatEndpointHost,
@@ -34,6 +35,7 @@ export function StepConnect({
34
35
  const [checkState, setCheckState] = useState<CheckState>(editingProvider?.verified ? 'ok' : 'idle')
35
36
  const [checkMessage, setCheckMessage] = useState('')
36
37
  const [checkErrorCode, setCheckErrorCode] = useState<string | null>(null)
38
+ const [checkDiagnostics, setCheckDiagnostics] = useState<ProviderDiagnosticStep[]>([])
37
39
  const [openclawDeviceId, setOpenclawDeviceId] = useState<string | null>(null)
38
40
  const [providerSuggestedModel, setProviderSuggestedModel] = useState(
39
41
  editingProvider?.defaultModel || (provider === 'custom' ? '' : getDefaultModelForProvider(provider)),
@@ -111,6 +113,7 @@ export function StepConnect({
111
113
  setCheckState('idle')
112
114
  setCheckMessage('')
113
115
  setCheckErrorCode(null)
116
+ setCheckDiagnostics([])
114
117
  setError('')
115
118
  }
116
119
 
@@ -118,12 +121,14 @@ export function StepConnect({
118
121
  if (requiresKey && !hasKeyOrCredential) {
119
122
  setCheckState('error')
120
123
  setCheckMessage('Please paste your API key or select a saved key first.')
124
+ setCheckDiagnostics([])
121
125
  return false
122
126
  }
123
127
 
124
128
  setCheckState('checking')
125
129
  setCheckMessage('')
126
130
  setCheckErrorCode(null)
131
+ setCheckDiagnostics([])
127
132
  setError('')
128
133
  try {
129
134
  const result = await api<ProviderCheckResponse>('POST', '/setup/check-provider', {
@@ -140,6 +145,7 @@ export function StepConnect({
140
145
  setProviderSuggestedModel(result.recommendedModel)
141
146
  }
142
147
  setCheckErrorCode(result.errorCode || null)
148
+ setCheckDiagnostics(result.diagnostics ?? [])
143
149
  setOpenclawDeviceId(result.deviceId || null)
144
150
  setCheckState(result.ok ? 'ok' : 'error')
145
151
  setCheckMessage(result.message || (result.ok ? 'Connected successfully.' : 'Connection failed.'))
@@ -148,6 +154,7 @@ export function StepConnect({
148
154
  setCheckState('error')
149
155
  setCheckMessage(errorMessage(err))
150
156
  setCheckErrorCode(null)
157
+ setCheckDiagnostics([])
151
158
  return false
152
159
  }
153
160
  }
@@ -280,7 +287,7 @@ export function StepConnect({
280
287
  <input
281
288
  type="text"
282
289
  value={endpoint}
283
- onChange={(e) => { setEndpoint(e.target.value); setCheckState('idle'); setCheckMessage('') }}
290
+ onChange={(e) => { setEndpoint(e.target.value); setCheckState('idle'); setCheckMessage(''); setCheckDiagnostics([]) }}
284
291
  placeholder={selectedProvider.defaultEndpoint || ''}
285
292
  className="w-full px-4 py-3 rounded-[12px] border border-white/[0.08] bg-surface
286
293
  text-text text-[14px] font-mono outline-none transition-all duration-200
@@ -295,6 +302,7 @@ export function StepConnect({
295
302
  setEndpoint(isCloud ? (selectedProvider.defaultEndpoint || '') : selectedProvider.cloudEndpoint!)
296
303
  setCheckState('idle')
297
304
  setCheckMessage('')
305
+ setCheckDiagnostics([])
298
306
  }}
299
307
  className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] border text-[12px] font-500 cursor-pointer transition-all duration-200 bg-transparent
300
308
  border-white/[0.08] text-text-2 hover:bg-white/[0.04]"
@@ -335,6 +343,12 @@ export function StepConnect({
335
343
  <p className="text-[12px] text-text-3">Use any reachable local or remote API-server endpoint exposed by Hermes.</p>
336
344
  </div>
337
345
  )}
346
+ {provider === 'lmstudio' && (
347
+ <div className="mt-2 space-y-0.5">
348
+ <p className="text-[12px] text-text-3">LM Studio&apos;s local server defaults to <code className="text-text-2">http://127.0.0.1:1234/v1</code>.</p>
349
+ <p className="text-[12px] text-text-3">If you paste a host without <code className="text-text-2">/v1</code>, SwarmClaw normalizes it before testing and chat.</p>
350
+ </div>
351
+ )}
338
352
  </div>
339
353
  )}
340
354
 
@@ -434,6 +448,7 @@ export function StepConnect({
434
448
  setApiKey('')
435
449
  setCheckState('idle')
436
450
  setCheckMessage('')
451
+ setCheckDiagnostics([])
437
452
  }
438
453
  }}
439
454
  className="w-full px-4 py-3 rounded-[12px] border border-white/[0.08] bg-surface
@@ -460,7 +475,7 @@ export function StepConnect({
460
475
  <input
461
476
  type="password"
462
477
  value={apiKey}
463
- onChange={(e) => { setApiKey(e.target.value); setCredentialId(null); setCheckState('idle'); setCheckMessage(''); setError('') }}
478
+ onChange={(e) => { setApiKey(e.target.value); setCredentialId(null); setCheckState('idle'); setCheckMessage(''); setCheckDiagnostics([]); setError('') }}
464
479
  placeholder={selectedProvider.keyPlaceholder || (provider === 'openclaw' ? 'Paste OpenClaw bearer token' : 'sk-...')}
465
480
  className="w-full px-4 py-3 rounded-[12px] border border-white/[0.08] bg-surface
466
481
  text-text text-[14px] font-mono outline-none transition-all duration-200
@@ -524,6 +539,7 @@ export function StepConnect({
524
539
  Device paired as <code className="text-text-2">{openclawDeviceId.slice(0, 12)}...</code>.
525
540
  </p>
526
541
  )}
542
+ <ProviderDiagnosticsList diagnostics={checkDiagnostics} />
527
543
  </div>
528
544
  )}
529
545
 
@@ -1,17 +1,10 @@
1
- import type { GatewayProfile, ProviderId } from '@/types'
1
+ import type { GatewayProfile, ProviderCheckResult, ProviderId } from '@/types'
2
2
  import type { SetupProvider } from '@/lib/setup-defaults'
3
3
 
4
4
  export type SetupStep = 'profile' | 'path' | 'providers' | 'connect' | 'agents' | 'next' | 'done'
5
5
  export type CheckState = 'idle' | 'checking' | 'ok' | 'error'
6
6
 
7
- export interface ProviderCheckResponse {
8
- ok: boolean
9
- message: string
10
- normalizedEndpoint?: string
11
- recommendedModel?: string
12
- errorCode?: string
13
- deviceId?: string
14
- }
7
+ export type ProviderCheckResponse = ProviderCheckResult
15
8
 
16
9
  export interface SetupDoctorCheck {
17
10
  id: string
@@ -0,0 +1,58 @@
1
+ 'use client'
2
+
3
+ import type { ProviderDiagnosticStep } from '@/types'
4
+
5
+ const STATUS_CLASSES: Record<ProviderDiagnosticStep['status'], string> = {
6
+ pass: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-300',
7
+ warn: 'border-amber-500/25 bg-amber-500/10 text-amber-300',
8
+ fail: 'border-red-500/25 bg-red-500/10 text-red-300',
9
+ }
10
+
11
+ const STATUS_LABELS: Record<ProviderDiagnosticStep['status'], string> = {
12
+ pass: 'Pass',
13
+ warn: 'Warn',
14
+ fail: 'Fail',
15
+ }
16
+
17
+ export function ProviderDiagnosticsList({
18
+ diagnostics,
19
+ className = '',
20
+ }: {
21
+ diagnostics?: ProviderDiagnosticStep[] | null
22
+ className?: string
23
+ }) {
24
+ if (!diagnostics?.length) return null
25
+
26
+ return (
27
+ <div className={`mt-3 border-t border-white/[0.06] pt-3 ${className}`}>
28
+ <div className="mb-2 text-[11px] font-700 uppercase tracking-[0.1em] text-text-3/70">
29
+ Diagnostics
30
+ </div>
31
+ <ol className="space-y-2">
32
+ {diagnostics.map((step) => (
33
+ <li key={step.id} className="grid gap-1 text-left sm:grid-cols-[64px_minmax(0,1fr)] sm:gap-3">
34
+ <div>
35
+ <span className={`inline-flex min-w-[54px] items-center justify-center rounded-[999px] border px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.08em] ${STATUS_CLASSES[step.status]}`}>
36
+ {STATUS_LABELS[step.status]}
37
+ </span>
38
+ </div>
39
+ <div className="min-w-0">
40
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
41
+ <span className="text-[12px] font-650 text-text-2">{step.label}</span>
42
+ {typeof step.durationMs === 'number' && (
43
+ <span className="text-[11px] text-text-3">{step.durationMs} ms</span>
44
+ )}
45
+ </div>
46
+ {step.target && (
47
+ <div className="mt-0.5 break-all font-mono text-[11px] text-text-3">{step.target}</div>
48
+ )}
49
+ {step.detail && (
50
+ <div className="mt-0.5 text-[11px] leading-relaxed text-text-3">{step.detail}</div>
51
+ )}
52
+ </div>
53
+ </li>
54
+ ))}
55
+ </ol>
56
+ </div>
57
+ )
58
+ }
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { BottomSheet } from '@/components/shared/bottom-sheet'
6
6
  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
7
+ import { ProviderDiagnosticsList } from '@/components/providers/provider-diagnostics-list'
7
8
  import { toast } from 'sonner'
8
9
  import { errorMessage } from '@/lib/shared-utils'
9
10
  import {
@@ -17,6 +18,7 @@ import {
17
18
  useSaveCustomProviderMutation,
18
19
  } from '@/features/providers/queries'
19
20
  import { useCreateCredentialMutation, useCredentialsQuery } from '@/features/credentials/queries'
21
+ import type { ProviderDiagnosticStep } from '@/types'
20
22
 
21
23
  export function ProviderSheet() {
22
24
  const open = useAppStore((s) => s.providerSheetOpen)
@@ -54,6 +56,7 @@ export function ProviderSheet() {
54
56
  const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'pass' | 'fail'>('idle')
55
57
  const [testMessage, setTestMessage] = useState('')
56
58
  const [testModel, setTestModel] = useState('')
59
+ const [testDiagnostics, setTestDiagnostics] = useState<ProviderDiagnosticStep[]>([])
57
60
 
58
61
  const [liveModels, setLiveModels] = useState<string[]>([])
59
62
  const [liveLoading, setLiveLoading] = useState(false)
@@ -77,6 +80,7 @@ export function ProviderSheet() {
77
80
  setLiveCached(false)
78
81
  setTestStatus('idle')
79
82
  setTestMessage('')
83
+ setTestDiagnostics([])
80
84
  if (editingCustom) {
81
85
  setName(editingCustom.name)
82
86
  setBaseUrl(editingCustom.baseUrl || '')
@@ -108,6 +112,7 @@ export function ProviderSheet() {
108
112
  useEffect(() => {
109
113
  setTestStatus('idle')
110
114
  setTestMessage('')
115
+ setTestDiagnostics([])
111
116
  }, [credentialId, baseUrl])
112
117
 
113
118
  useEffect(() => {
@@ -121,6 +126,7 @@ export function ProviderSheet() {
121
126
  if (!isBuiltin) return
122
127
  setTestStatus('testing')
123
128
  setTestMessage('')
129
+ setTestDiagnostics([])
124
130
  try {
125
131
  const result = await checkProviderConnectionMutation.mutateAsync({
126
132
  provider: editingId || 'custom',
@@ -128,6 +134,7 @@ export function ProviderSheet() {
128
134
  endpoint: baseUrl,
129
135
  model: testModel || undefined,
130
136
  })
137
+ setTestDiagnostics(result.diagnostics ?? [])
131
138
  if (result.ok) {
132
139
  setTestStatus('pass')
133
140
  setTestMessage(result.message)
@@ -141,6 +148,7 @@ export function ProviderSheet() {
141
148
  const msg = err instanceof Error ? err.message : 'Connection test failed'
142
149
  setTestStatus('fail')
143
150
  setTestMessage(msg)
151
+ setTestDiagnostics([])
144
152
  toast.error(msg)
145
153
  }
146
154
  }
@@ -271,7 +279,10 @@ export function ProviderSheet() {
271
279
  const modelList = models.split(',').map((m) => m.trim()).filter(Boolean)
272
280
  const showApiKey = isBuiltin ? editingBuiltin?.requiresApiKey || editingBuiltin?.optionalApiKey : requiresApiKey
273
281
  const canDiscoverModels = Boolean(isBuiltin && editingBuiltin?.supportsModelDiscovery)
274
- const showTestButton = Boolean(isBuiltin && showApiKey && credentialId)
282
+ const showTestButton = Boolean(
283
+ isBuiltin
284
+ && (editingBuiltin?.requiresApiKey ? credentialId : (showApiKey || editingBuiltin?.requiresEndpoint || editingBuiltin?.optionalEndpoint)),
285
+ )
275
286
 
276
287
  const inputClass = "w-full px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow"
277
288
 
@@ -527,7 +538,7 @@ export function ProviderSheet() {
527
538
  </label>
528
539
  <select
529
540
  value={testModel}
530
- onChange={(e) => { setTestModel(e.target.value); setTestStatus('idle'); setTestMessage('') }}
541
+ onChange={(e) => { setTestModel(e.target.value); setTestStatus('idle'); setTestMessage(''); setTestDiagnostics([]) }}
531
542
  className={`${inputClass} appearance-none cursor-pointer`}
532
543
  style={{ fontFamily: 'inherit' }}
533
544
  >
@@ -543,11 +554,13 @@ export function ProviderSheet() {
543
554
  {isBuiltin && testStatus === 'fail' && (
544
555
  <div className="mb-4 p-3 rounded-[12px] bg-red-500/[0.08] border border-red-500/20">
545
556
  <p className="text-[13px] text-red-400">{testMessage || 'Connection test failed'}</p>
557
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
546
558
  </div>
547
559
  )}
548
560
  {isBuiltin && testStatus === 'pass' && (
549
561
  <div className="mb-4 p-3 rounded-[12px] bg-emerald-500/[0.08] border border-emerald-500/20">
550
562
  <p className="text-[13px] text-emerald-400">{testMessage || 'Connected successfully'}</p>
563
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
551
564
  </div>
552
565
  )}
553
566
 
@@ -13,6 +13,7 @@ import {
13
13
  import { fetchProviders } from '@/lib/chat/chats'
14
14
  import type {
15
15
  ProviderConfig,
16
+ ProviderCheckResult,
16
17
  ProviderInfo,
17
18
  ProviderModelDiscoveryResult,
18
19
  } from '@/types'
@@ -86,7 +87,7 @@ export function useSaveBuiltinProviderMutation() {
86
87
  await api('PUT', `/providers/${id}/models`, { models })
87
88
  return api('PUT', `/providers/${id}`, {
88
89
  isEnabled,
89
- ...(baseUrl ? { baseUrl } : {}),
90
+ ...(typeof baseUrl === 'string' ? { baseUrl } : {}),
90
91
  })
91
92
  },
92
93
  onSuccess: async () => {
@@ -129,7 +130,7 @@ export function useResetProviderModelsMutation() {
129
130
  export function useCheckProviderConnectionMutation() {
130
131
  return useMutation({
131
132
  mutationFn: ({ provider, credentialId, endpoint, model }: CheckProviderConnectionInput) =>
132
- api<{ ok: boolean; message: string }>('POST', '/setup/check-provider', {
133
+ api<ProviderCheckResult>('POST', '/setup/check-provider', {
133
134
  provider,
134
135
  credentialId,
135
136
  endpoint,
@@ -77,6 +77,34 @@ test('builtin provider override records do not surface as custom providers', ()
77
77
  assert.equal(output.openAiCount, 1)
78
78
  })
79
79
 
80
+ test('LM Studio is available as a first-class local OpenAI-compatible provider', () => {
81
+ const output = runWithTempDataDir<{
82
+ providerName: string | null
83
+ defaultEndpoint: string | null
84
+ requiresApiKey: boolean | null
85
+ optionalApiKey: boolean | null
86
+ supportsModelDiscovery: boolean | null
87
+ }>(`
88
+ const providersModule = await import('@/lib/providers/index')
89
+ const providers = providersModule.default || providersModule
90
+ const provider = providers.getProviderList().find((entry) => entry.id === 'lmstudio')
91
+
92
+ console.log(JSON.stringify({
93
+ providerName: provider?.name ?? null,
94
+ defaultEndpoint: provider?.defaultEndpoint ?? null,
95
+ requiresApiKey: provider?.requiresApiKey ?? null,
96
+ optionalApiKey: provider?.optionalApiKey ?? null,
97
+ supportsModelDiscovery: provider?.supportsModelDiscovery ?? null,
98
+ }))
99
+ `)
100
+
101
+ assert.equal(output.providerName, 'LM Studio')
102
+ assert.equal(output.defaultEndpoint, 'http://127.0.0.1:1234/v1')
103
+ assert.equal(output.requiresApiKey, false)
104
+ assert.equal(output.optionalApiKey, true)
105
+ assert.equal(output.supportsModelDiscovery, true)
106
+ })
107
+
80
108
  test('custom provider resolution includes defaultEndpoint and optionalApiKey', () => {
81
109
  const output = runWithTempDataDir<{
82
110
  defaultEndpoint: string | null
@@ -14,6 +14,7 @@ import { streamOpenAiChat } from './openai'
14
14
  import { streamOllamaChat } from './ollama'
15
15
  import { streamAnthropicChat } from './anthropic'
16
16
  import { streamOpenClawChat } from './openclaw'
17
+ import { normalizeLmStudioEndpoint, normalizeOpenAiCompatibleV1Endpoint } from './openai-compatible-endpoint'
17
18
  import { errorMessage, sleep, jitteredBackoff } from '@/lib/shared-utils'
18
19
  import { classifyProviderError } from './error-classification'
19
20
  import { log } from '@/lib/server/logger'
@@ -173,6 +174,24 @@ export const PROVIDERS: Record<string, BuiltinProviderConfig> = {
173
174
  },
174
175
  },
175
176
  },
177
+ lmstudio: {
178
+ id: 'lmstudio',
179
+ name: 'LM Studio',
180
+ models: ['local-model', 'google/gemma-4-e4b'],
181
+ requiresApiKey: false,
182
+ optionalApiKey: true,
183
+ requiresEndpoint: true,
184
+ defaultEndpoint: 'http://127.0.0.1:1234/v1',
185
+ handler: {
186
+ streamChat: (opts) => {
187
+ const patchedSession = {
188
+ ...opts.session,
189
+ apiEndpoint: normalizeLmStudioEndpoint(opts.session.apiEndpoint),
190
+ }
191
+ return streamOpenAiChat({ ...opts, session: patchedSession })
192
+ },
193
+ },
194
+ },
176
195
  'opencode-cli': {
177
196
  id: 'opencode-cli',
178
197
  name: 'OpenCode CLI',
@@ -560,22 +579,29 @@ export function getProvider(id: string): BuiltinProviderConfig | null {
560
579
  if (builtin) {
561
580
  const pConfigs = loadProviderConfigs()
562
581
  const pConfig = pConfigs[id]
563
- if (pConfig?.baseUrl && pConfig.baseUrl !== builtin.defaultEndpoint) {
564
- const originalHandler = builtin.handler
565
- return {
566
- ...builtin,
567
- handler: {
568
- streamChat: (opts) => {
569
- const patchedSession = {
570
- ...opts.session,
571
- apiEndpoint: opts.session.apiEndpoint || pConfig.baseUrl,
572
- }
573
- return originalHandler.streamChat({ ...opts, session: patchedSession })
574
- },
582
+ const originalHandler = builtin.handler
583
+ return {
584
+ ...builtin,
585
+ handler: {
586
+ streamChat: (opts) => {
587
+ const configuredEndpoint = typeof pConfig?.baseUrl === 'string' && pConfig.baseUrl.trim()
588
+ ? normalizeProviderRuntimeEndpoint(id, pConfig.baseUrl)
589
+ : null
590
+ const sessionEndpoint = typeof opts.session.apiEndpoint === 'string' && opts.session.apiEndpoint.trim()
591
+ ? normalizeProviderRuntimeEndpoint(id, opts.session.apiEndpoint)
592
+ : null
593
+ const honorsAgentEndpoint = Boolean(builtin.requiresEndpoint || builtin.optionalEndpoint)
594
+ const apiEndpoint = honorsAgentEndpoint
595
+ ? sessionEndpoint || configuredEndpoint
596
+ : configuredEndpoint
597
+ const patchedSession = {
598
+ ...opts.session,
599
+ apiEndpoint: apiEndpoint || undefined,
600
+ }
601
+ return originalHandler.streamChat({ ...opts, session: patchedSession })
575
602
  },
576
- }
603
+ },
577
604
  }
578
- return builtin
579
605
  }
580
606
 
581
607
  // Check custom providers
@@ -619,6 +645,12 @@ export function getProvider(id: string): BuiltinProviderConfig | null {
619
645
  return null
620
646
  }
621
647
 
648
+ function normalizeProviderRuntimeEndpoint(providerId: string, endpoint: string): string {
649
+ if (providerId === 'lmstudio') return normalizeLmStudioEndpoint(endpoint)
650
+ if (providerId === 'openai') return normalizeOpenAiCompatibleV1Endpoint(endpoint, 'https://api.openai.com/v1')
651
+ return endpoint.replace(/\/+$/, '')
652
+ }
653
+
622
654
  /**
623
655
  * Stream chat with automatic failover to fallback credentials on retryable errors.
624
656
  * Falls back through fallbackCredentialIds on 401/429/500/502/503 errors.