@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.
- package/README.md +23 -3
- package/package.json +3 -3
- package/src/app/api/setup/check-provider/route.test.ts +44 -0
- package/src/app/api/setup/check-provider/route.ts +255 -64
- package/src/components/agents/agent-sheet.tsx +43 -14
- package/src/components/auth/setup-wizard/step-agents.tsx +1 -0
- package/src/components/auth/setup-wizard/step-connect.tsx +19 -3
- package/src/components/auth/setup-wizard/types.ts +2 -9
- package/src/components/providers/provider-diagnostics-list.tsx +58 -0
- package/src/components/providers/provider-sheet.tsx +15 -2
- package/src/features/providers/queries.ts +3 -2
- package/src/lib/providers/index.test.ts +28 -0
- package/src/lib/providers/index.ts +46 -14
- package/src/lib/providers/openai-compatible-endpoint.ts +67 -0
- package/src/lib/providers/openai.ts +6 -1
- package/src/lib/server/build-llm.test.ts +36 -0
- package/src/lib/server/build-llm.ts +9 -1
- package/src/lib/server/provider-diagnostics.test.ts +39 -0
- package/src/lib/server/provider-diagnostics.ts +114 -0
- package/src/lib/server/provider-endpoint.ts +26 -7
- package/src/lib/server/provider-health.test.ts +2 -1
- package/src/lib/server/provider-health.ts +8 -2
- package/src/lib/server/provider-model-discovery.test.ts +21 -0
- package/src/lib/server/provider-model-discovery.ts +6 -1
- package/src/lib/setup-defaults.ts +21 -0
- package/src/types/provider.ts +22 -1
|
@@ -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
|
-
|
|
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
|
|
|
@@ -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
|
|
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'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
|
|
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(
|
|
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<
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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.
|