@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
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const DEFAULT_LM_STUDIO_ENDPOINT = 'http://127.0.0.1:1234/v1'
|
|
2
|
+
|
|
3
|
+
const OPENAI_COMPATIBLE_ENDPOINT_SUFFIXES = [
|
|
4
|
+
'/chat/completions',
|
|
5
|
+
'/responses',
|
|
6
|
+
'/models',
|
|
7
|
+
'/completions',
|
|
8
|
+
'/embeddings',
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
function clean(value: string | null | undefined): string {
|
|
12
|
+
return typeof value === 'string' ? value.trim() : ''
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function trimTrailingSlashes(value: string): string {
|
|
16
|
+
let output = value
|
|
17
|
+
while (output.endsWith('/') && output.length > 1) output = output.slice(0, -1)
|
|
18
|
+
return output
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toUrl(value: string): URL | null {
|
|
22
|
+
const trimmed = clean(value)
|
|
23
|
+
if (!trimmed) return null
|
|
24
|
+
try {
|
|
25
|
+
return new URL(trimmed)
|
|
26
|
+
} catch {
|
|
27
|
+
try {
|
|
28
|
+
return new URL(`http://${trimmed}`)
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function stripKnownEndpointPath(pathname: string): string {
|
|
36
|
+
let path = trimTrailingSlashes(pathname || '/')
|
|
37
|
+
const lower = path.toLowerCase()
|
|
38
|
+
for (const suffix of OPENAI_COMPATIBLE_ENDPOINT_SUFFIXES) {
|
|
39
|
+
if (lower === suffix || lower.endsWith(suffix)) {
|
|
40
|
+
path = path.slice(0, path.length - suffix.length)
|
|
41
|
+
break
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
path = trimTrailingSlashes(path)
|
|
45
|
+
return path || '/'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function normalizeOpenAiCompatibleV1Endpoint(
|
|
49
|
+
input: string | null | undefined,
|
|
50
|
+
fallback = DEFAULT_LM_STUDIO_ENDPOINT,
|
|
51
|
+
): string {
|
|
52
|
+
const parsed = toUrl(clean(input) || fallback) || toUrl(fallback)
|
|
53
|
+
if (!parsed) return trimTrailingSlashes(clean(input) || fallback)
|
|
54
|
+
|
|
55
|
+
const cleanedPath = stripKnownEndpointPath(parsed.pathname)
|
|
56
|
+
parsed.pathname = cleanedPath.toLowerCase().endsWith('/v1')
|
|
57
|
+
? cleanedPath
|
|
58
|
+
: `${cleanedPath === '/' ? '' : cleanedPath}/v1`
|
|
59
|
+
parsed.search = ''
|
|
60
|
+
parsed.hash = ''
|
|
61
|
+
return trimTrailingSlashes(parsed.toString())
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeLmStudioEndpoint(input?: string | null): string {
|
|
65
|
+
return normalizeOpenAiCompatibleV1Endpoint(input, DEFAULT_LM_STUDIO_ENDPOINT)
|
|
66
|
+
}
|
|
67
|
+
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
createReasoningContentMetadata,
|
|
8
8
|
shouldUseDeepSeekReasoningBridge,
|
|
9
9
|
} from '@/lib/providers/deepseek-reasoning-chat-openai'
|
|
10
|
+
import { normalizeLmStudioEndpoint, normalizeOpenAiCompatibleV1Endpoint } from '@/lib/providers/openai-compatible-endpoint'
|
|
10
11
|
|
|
11
12
|
const TAG = 'provider-openai'
|
|
12
13
|
|
|
@@ -58,7 +59,11 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
|
|
|
58
59
|
let fullResponse = ''
|
|
59
60
|
|
|
60
61
|
// Support custom base URLs for custom providers
|
|
61
|
-
const baseUrl = session.
|
|
62
|
+
const baseUrl = session.provider === 'lmstudio'
|
|
63
|
+
? normalizeLmStudioEndpoint(session.apiEndpoint)
|
|
64
|
+
: session.provider === 'openai'
|
|
65
|
+
? normalizeOpenAiCompatibleV1Endpoint(session.apiEndpoint, PROVIDER_DEFAULTS.openai)
|
|
66
|
+
: session.apiEndpoint || PROVIDER_DEFAULTS.openai
|
|
62
67
|
const url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`
|
|
63
68
|
|
|
64
69
|
// OpenClaw endpoints behind Hostinger's proxy use express.json() middleware
|
|
@@ -263,6 +263,42 @@ test('buildChatModel uses a reasoning_content-preserving bridge for DeepSeek', (
|
|
|
263
263
|
assert.equal(completionBridge.completions?.constructor?.name, 'DeepSeekReasoningChatOpenAICompletions')
|
|
264
264
|
})
|
|
265
265
|
|
|
266
|
+
test('buildChatModel ignores stale per-agent endpoints for fixed cloud providers', () => {
|
|
267
|
+
const llm = buildChatModel({
|
|
268
|
+
provider: 'deepseek',
|
|
269
|
+
model: 'deepseek-chat',
|
|
270
|
+
apiKey: 'deepseek-key',
|
|
271
|
+
apiEndpoint: 'http://127.0.0.1:1234/v1',
|
|
272
|
+
}) as ChatOpenAI
|
|
273
|
+
|
|
274
|
+
assert.equal(llm.model, 'deepseek-chat')
|
|
275
|
+
assert.equal(llm.clientConfig?.baseURL, 'https://api.deepseek.com/v1')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('buildChatModel normalizes OpenAI-compatible OpenAI overrides to the v1 API', () => {
|
|
279
|
+
const llm = buildChatModel({
|
|
280
|
+
provider: 'openai',
|
|
281
|
+
model: 'google/gemma-4-e4b',
|
|
282
|
+
apiKey: 'local-key',
|
|
283
|
+
apiEndpoint: 'http://10.2.0.2:1234',
|
|
284
|
+
}) as ChatOpenAI
|
|
285
|
+
|
|
286
|
+
assert.equal(llm.model, 'google/gemma-4-e4b')
|
|
287
|
+
assert.equal(llm.clientConfig?.baseURL, 'http://10.2.0.2:1234/v1')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test('buildChatModel normalizes LM Studio base URLs to the OpenAI-compatible v1 API', () => {
|
|
291
|
+
const llm = buildChatModel({
|
|
292
|
+
provider: 'lmstudio',
|
|
293
|
+
model: 'google/gemma-4-e4b',
|
|
294
|
+
apiKey: 'lm-studio-key',
|
|
295
|
+
apiEndpoint: 'http://10.2.0.2:1234',
|
|
296
|
+
}) as ChatOpenAI
|
|
297
|
+
|
|
298
|
+
assert.equal(llm.model, 'google/gemma-4-e4b')
|
|
299
|
+
assert.equal(llm.clientConfig?.baseURL, 'http://10.2.0.2:1234/v1')
|
|
300
|
+
})
|
|
301
|
+
|
|
266
302
|
test('buildChatModel uses Ollama Cloud only when explicit cloud mode is selected', () => {
|
|
267
303
|
saveCredentials({
|
|
268
304
|
'cred-1': {
|
|
@@ -74,7 +74,15 @@ export function buildChatModel(opts: {
|
|
|
74
74
|
const resolvedApiKey = apiKey ?? resolveApiKeyFromCredential(resolvedCredentialId)
|
|
75
75
|
const providers = getProviderList()
|
|
76
76
|
const providerInfo = providers.find((p) => p.id === provider)
|
|
77
|
-
const endpointRaw =
|
|
77
|
+
const endpointRaw = provider === 'ollama'
|
|
78
|
+
? apiEndpoint || providerInfo?.defaultEndpoint || null
|
|
79
|
+
: resolveProviderApiEndpoint({
|
|
80
|
+
provider,
|
|
81
|
+
model,
|
|
82
|
+
ollamaMode: ollamaMode ?? null,
|
|
83
|
+
credentialId: resolvedCredentialId,
|
|
84
|
+
apiEndpoint,
|
|
85
|
+
}) || providerInfo?.defaultEndpoint || null
|
|
78
86
|
const endpoint = provider === 'openclaw'
|
|
79
87
|
? normalizeOpenClawEndpoint(endpointRaw)
|
|
80
88
|
: endpointRaw
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createProviderDiagnostics,
|
|
6
|
+
sanitizeProviderDiagnosticTarget,
|
|
7
|
+
sanitizeProviderDiagnosticText,
|
|
8
|
+
} from './provider-diagnostics'
|
|
9
|
+
|
|
10
|
+
test('provider diagnostics records sanitized ordered steps', () => {
|
|
11
|
+
const diagnostics = createProviderDiagnostics()
|
|
12
|
+
diagnostics.pass('Endpoint resolved', { target: 'http://user:pass@127.0.0.1:1234/v1/models?token=sk-secret' })
|
|
13
|
+
diagnostics.fail('Chat failed', { detail: 'Malformed token sk-abc123456789 provided', durationMs: 12.4 })
|
|
14
|
+
|
|
15
|
+
const steps = diagnostics.toJSON()
|
|
16
|
+
assert.equal(steps.length, 2)
|
|
17
|
+
assert.deepEqual(
|
|
18
|
+
steps.map((step) => step.id),
|
|
19
|
+
['diag-1', 'diag-2'],
|
|
20
|
+
)
|
|
21
|
+
assert.equal(steps[0].status, 'pass')
|
|
22
|
+
assert.equal(steps[0].target, 'http://127.0.0.1:1234/v1/models')
|
|
23
|
+
assert.equal(steps[1].detail, 'Malformed token sk-... provided')
|
|
24
|
+
assert.equal(steps[1].durationMs, 12)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('sanitizeProviderDiagnosticText redacts common provider token prefixes', () => {
|
|
28
|
+
assert.equal(
|
|
29
|
+
sanitizeProviderDiagnosticText('bad keys: sk-test123 gsk_live456 hf_token789 AIzaSySecret'),
|
|
30
|
+
'bad keys: sk-... gsk_... hf_... AIza...',
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('sanitizeProviderDiagnosticTarget removes credentials, query, and hash from URLs', () => {
|
|
35
|
+
assert.equal(
|
|
36
|
+
sanitizeProviderDiagnosticTarget('https://user:secret@example.com/v1/models?api_key=sk-secret#frag'),
|
|
37
|
+
'https://example.com/v1/models',
|
|
38
|
+
)
|
|
39
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ProviderDiagnosticStatus, ProviderDiagnosticStep } from '@/types/provider'
|
|
2
|
+
|
|
3
|
+
const SECRET_PREFIXES = [
|
|
4
|
+
'sk-',
|
|
5
|
+
'sk_',
|
|
6
|
+
'gsk_',
|
|
7
|
+
'hf_',
|
|
8
|
+
'xai-',
|
|
9
|
+
'or-',
|
|
10
|
+
'pat_',
|
|
11
|
+
'ghp_',
|
|
12
|
+
'gho_',
|
|
13
|
+
'AIza',
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
const MAX_DETAIL_LENGTH = 300
|
|
17
|
+
const MAX_TARGET_LENGTH = 220
|
|
18
|
+
|
|
19
|
+
function isSecretBoundary(char: string | undefined): boolean {
|
|
20
|
+
if (!char) return true
|
|
21
|
+
return char === ' ' || char === '\n' || char === '\r' || char === '\t'
|
|
22
|
+
|| char === '"' || char === "'" || char === '`'
|
|
23
|
+
|| char === ',' || char === ';' || char === ')' || char === ']'
|
|
24
|
+
|| char === '}' || char === '<' || char === '>'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sanitizeProviderDiagnosticText(value: unknown, maxLength = MAX_DETAIL_LENGTH): string {
|
|
28
|
+
const input = typeof value === 'string' ? value : String(value ?? '')
|
|
29
|
+
let out = ''
|
|
30
|
+
for (let i = 0; i < input.length;) {
|
|
31
|
+
const prefix = SECRET_PREFIXES.find((candidate) => input.startsWith(candidate, i))
|
|
32
|
+
if (!prefix) {
|
|
33
|
+
out += input[i]
|
|
34
|
+
i++
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let end = i + prefix.length
|
|
39
|
+
while (end < input.length && !isSecretBoundary(input[end]) && end - i < 160) end++
|
|
40
|
+
out += `${prefix}...`
|
|
41
|
+
i = end
|
|
42
|
+
}
|
|
43
|
+
const collapsed = out.split(/\s+/).join(' ').trim()
|
|
44
|
+
return collapsed.length > maxLength ? `${collapsed.slice(0, Math.max(0, maxLength - 1))}...` : collapsed
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function sanitizeProviderDiagnosticTarget(value: unknown): string {
|
|
48
|
+
const raw = sanitizeProviderDiagnosticText(value, MAX_TARGET_LENGTH)
|
|
49
|
+
try {
|
|
50
|
+
const url = new URL(raw)
|
|
51
|
+
url.username = ''
|
|
52
|
+
url.password = ''
|
|
53
|
+
url.search = ''
|
|
54
|
+
url.hash = ''
|
|
55
|
+
return sanitizeProviderDiagnosticText(url.toString(), MAX_TARGET_LENGTH)
|
|
56
|
+
} catch {
|
|
57
|
+
return raw
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ProviderDiagnostics {
|
|
62
|
+
add: (
|
|
63
|
+
label: string,
|
|
64
|
+
status: ProviderDiagnosticStatus,
|
|
65
|
+
options?: {
|
|
66
|
+
detail?: unknown
|
|
67
|
+
target?: unknown
|
|
68
|
+
durationMs?: number
|
|
69
|
+
},
|
|
70
|
+
) => ProviderDiagnosticStep
|
|
71
|
+
pass: (label: string, options?: { detail?: unknown; target?: unknown; durationMs?: number }) => ProviderDiagnosticStep
|
|
72
|
+
warn: (label: string, options?: { detail?: unknown; target?: unknown; durationMs?: number }) => ProviderDiagnosticStep
|
|
73
|
+
fail: (label: string, options?: { detail?: unknown; target?: unknown; durationMs?: number }) => ProviderDiagnosticStep
|
|
74
|
+
toJSON: () => ProviderDiagnosticStep[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createProviderDiagnostics(): ProviderDiagnostics {
|
|
78
|
+
const steps: ProviderDiagnosticStep[] = []
|
|
79
|
+
let sequence = 0
|
|
80
|
+
|
|
81
|
+
function add(
|
|
82
|
+
label: string,
|
|
83
|
+
status: ProviderDiagnosticStatus,
|
|
84
|
+
options: {
|
|
85
|
+
detail?: unknown
|
|
86
|
+
target?: unknown
|
|
87
|
+
durationMs?: number
|
|
88
|
+
} = {},
|
|
89
|
+
): ProviderDiagnosticStep {
|
|
90
|
+
sequence++
|
|
91
|
+
const step: ProviderDiagnosticStep = {
|
|
92
|
+
id: `diag-${sequence}`,
|
|
93
|
+
label: sanitizeProviderDiagnosticText(label, 80),
|
|
94
|
+
status,
|
|
95
|
+
}
|
|
96
|
+
const detail = sanitizeProviderDiagnosticText(options.detail, MAX_DETAIL_LENGTH)
|
|
97
|
+
const target = sanitizeProviderDiagnosticTarget(options.target)
|
|
98
|
+
if (detail) step.detail = detail
|
|
99
|
+
if (target) step.target = target
|
|
100
|
+
if (typeof options.durationMs === 'number' && Number.isFinite(options.durationMs)) {
|
|
101
|
+
step.durationMs = Math.max(0, Math.round(options.durationMs))
|
|
102
|
+
}
|
|
103
|
+
steps.push(step)
|
|
104
|
+
return step
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
add,
|
|
109
|
+
pass: (label, options) => add(label, 'pass', options),
|
|
110
|
+
warn: (label, options) => add(label, 'warn', options),
|
|
111
|
+
fail: (label, options) => add(label, 'fail', options),
|
|
112
|
+
toJSON: () => steps.map((step) => ({ ...step })),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { normalizeProviderEndpoint } from '@/lib/openclaw/openclaw-endpoint'
|
|
2
2
|
import { getProvider } from '@/lib/providers'
|
|
3
|
+
import { normalizeLmStudioEndpoint, normalizeOpenAiCompatibleV1Endpoint } from '@/lib/providers/openai-compatible-endpoint'
|
|
3
4
|
import { loadCredential } from '@/lib/server/credentials/credential-repository'
|
|
4
5
|
import { listCredentialIdsByProvider, resolveCredentialSecret } from '@/lib/server/credentials/credential-service'
|
|
5
6
|
import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
|
|
@@ -71,8 +72,17 @@ export function resolveProviderApiEndpoint(input: {
|
|
|
71
72
|
const provider = clean(input.provider)
|
|
72
73
|
if (!provider) return null
|
|
73
74
|
|
|
74
|
-
const
|
|
75
|
-
|
|
75
|
+
const pConfigs = loadProviderConfigs()
|
|
76
|
+
const pConfig = pConfigs[provider]
|
|
77
|
+
const providerInfo = getProvider(provider)
|
|
78
|
+
const honorsAgentEndpoint = Boolean(
|
|
79
|
+
pConfig?.type === 'custom'
|
|
80
|
+
|| providerInfo?.requiresEndpoint
|
|
81
|
+
|| providerInfo?.optionalEndpoint,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const explicitEndpoint = normalizeRuntimeEndpoint(provider, input.apiEndpoint ?? null)
|
|
85
|
+
if (explicitEndpoint && honorsAgentEndpoint) return explicitEndpoint
|
|
76
86
|
|
|
77
87
|
if (provider === 'ollama') {
|
|
78
88
|
const credentialId = resolveProviderCredentialId(input)
|
|
@@ -86,15 +96,24 @@ export function resolveProviderApiEndpoint(input: {
|
|
|
86
96
|
}
|
|
87
97
|
|
|
88
98
|
// Prefer provider config's custom baseUrl over the hardcoded defaultEndpoint
|
|
89
|
-
const pConfigs = loadProviderConfigs()
|
|
90
|
-
const pConfig = pConfigs[provider]
|
|
91
99
|
if (pConfig?.baseUrl) {
|
|
92
|
-
const customNormalized =
|
|
100
|
+
const customNormalized = normalizeRuntimeEndpoint(provider, pConfig.baseUrl)
|
|
93
101
|
if (customNormalized) return customNormalized
|
|
94
102
|
return pConfig.baseUrl.replace(/\/+$/, '')
|
|
95
103
|
}
|
|
96
104
|
|
|
97
|
-
const providerInfo = getProvider(provider)
|
|
98
105
|
if (!providerInfo?.defaultEndpoint) return null
|
|
99
|
-
return
|
|
106
|
+
return normalizeRuntimeEndpoint(provider, providerInfo.defaultEndpoint) || providerInfo.defaultEndpoint.replace(/\/+$/, '')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeRuntimeEndpoint(provider: string, endpoint: string | null | undefined): string | null {
|
|
110
|
+
if (provider === 'lmstudio') {
|
|
111
|
+
const value = typeof endpoint === 'string' ? endpoint.trim() : ''
|
|
112
|
+
return value ? normalizeLmStudioEndpoint(value) : null
|
|
113
|
+
}
|
|
114
|
+
if (provider === 'openai') {
|
|
115
|
+
const value = typeof endpoint === 'string' ? endpoint.trim() : ''
|
|
116
|
+
return value ? normalizeOpenAiCompatibleV1Endpoint(value, 'https://api.openai.com/v1') : null
|
|
117
|
+
}
|
|
118
|
+
return normalizeProviderEndpoint(provider, endpoint)
|
|
100
119
|
}
|
|
@@ -129,12 +129,13 @@ describe('provider-health', () => {
|
|
|
129
129
|
assert.ok(defaults.xai)
|
|
130
130
|
assert.ok(defaults.fireworks)
|
|
131
131
|
assert.ok(defaults.hermes)
|
|
132
|
+
assert.ok(defaults.lmstudio)
|
|
132
133
|
|
|
133
134
|
// Each entry has name and defaultEndpoint
|
|
134
135
|
for (const [key, val] of Object.entries(defaults)) {
|
|
135
136
|
assert.ok(typeof val.name === 'string' && val.name.length > 0)
|
|
136
137
|
assert.ok(typeof val.defaultEndpoint === 'string' && val.defaultEndpoint.length > 0)
|
|
137
|
-
if (key === 'hermes') {
|
|
138
|
+
if (key === 'hermes' || key === 'lmstudio') {
|
|
138
139
|
assert.ok(val.defaultEndpoint.startsWith('http://'))
|
|
139
140
|
} else {
|
|
140
141
|
assert.ok(val.defaultEndpoint.startsWith('https://'))
|
|
@@ -3,6 +3,7 @@ import { errorMessage, hmrSingleton, jitteredBackoff } from '@/lib/shared-utils'
|
|
|
3
3
|
import { upsertStoredItem, loadCollection } from './storage'
|
|
4
4
|
import { log } from './logger'
|
|
5
5
|
import { isCliProviderId } from '@/lib/providers/cli-provider-metadata'
|
|
6
|
+
import { normalizeLmStudioEndpoint, normalizeOpenAiCompatibleV1Endpoint } from '@/lib/providers/openai-compatible-endpoint'
|
|
6
7
|
|
|
7
8
|
const TAG = 'provider-health'
|
|
8
9
|
|
|
@@ -270,6 +271,7 @@ export const OPENAI_COMPATIBLE_DEFAULTS: Record<string, { name: string; defaultE
|
|
|
270
271
|
nebius: { name: 'Nebius', defaultEndpoint: 'https://api.tokenfactory.nebius.com/v1' },
|
|
271
272
|
deepinfra: { name: 'DeepInfra', defaultEndpoint: 'https://api.deepinfra.com/v1/openai' },
|
|
272
273
|
hermes: { name: 'Hermes Agent', defaultEndpoint: 'http://127.0.0.1:8642/v1' },
|
|
274
|
+
lmstudio: { name: 'LM Studio', defaultEndpoint: 'http://127.0.0.1:1234/v1' },
|
|
273
275
|
}
|
|
274
276
|
|
|
275
277
|
export async function pingOpenAiCompatible(
|
|
@@ -353,7 +355,7 @@ export async function pingProvider(
|
|
|
353
355
|
apiKey: string | undefined,
|
|
354
356
|
endpoint: string | undefined,
|
|
355
357
|
): Promise<{ ok: boolean; message: string }> {
|
|
356
|
-
const OPTIONAL_OPENAI_COMPATIBLE_KEY_PROVIDERS = new Set(['hermes'])
|
|
358
|
+
const OPTIONAL_OPENAI_COMPATIBLE_KEY_PROVIDERS = new Set(['hermes', 'lmstudio'])
|
|
357
359
|
if (isCliProviderId(provider)) return { ok: true, message: 'CLI provider - skipped.' }
|
|
358
360
|
|
|
359
361
|
try {
|
|
@@ -369,7 +371,11 @@ export async function pingProvider(
|
|
|
369
371
|
}
|
|
370
372
|
// OpenAI-compatible providers (openai, google, deepseek, groq, together, mistral, xai, fireworks, custom)
|
|
371
373
|
const defaults = OPENAI_COMPATIBLE_DEFAULTS[provider]
|
|
372
|
-
const resolvedEndpoint =
|
|
374
|
+
const resolvedEndpoint = provider === 'lmstudio'
|
|
375
|
+
? normalizeLmStudioEndpoint(endpoint || defaults?.defaultEndpoint)
|
|
376
|
+
: provider === 'openai'
|
|
377
|
+
? normalizeOpenAiCompatibleV1Endpoint(endpoint || defaults?.defaultEndpoint, defaults?.defaultEndpoint || 'https://api.openai.com/v1')
|
|
378
|
+
: endpoint || defaults?.defaultEndpoint
|
|
373
379
|
if (!resolvedEndpoint) return { ok: false, message: `No endpoint for provider "${provider}".` }
|
|
374
380
|
if (!apiKey && !OPTIONAL_OPENAI_COMPATIBLE_KEY_PROVIDERS.has(provider)) return { ok: false, message: 'No API key configured.' }
|
|
375
381
|
return await pingOpenAiCompatible(apiKey, resolvedEndpoint)
|
|
@@ -218,6 +218,16 @@ test('resolveDescriptor uses OpenRouter as an OpenAI-compatible provider', () =>
|
|
|
218
218
|
assert.equal(descriptor?.optionalApiKey, false)
|
|
219
219
|
})
|
|
220
220
|
|
|
221
|
+
test('resolveDescriptor normalizes OpenAI-compatible OpenAI endpoints to /v1', () => {
|
|
222
|
+
const descriptor = resolveDescriptor({
|
|
223
|
+
providerId: 'openai',
|
|
224
|
+
endpoint: 'http://10.2.0.2:1234',
|
|
225
|
+
})
|
|
226
|
+
assert.equal(descriptor?.strategy, 'openai-compatible')
|
|
227
|
+
assert.equal(descriptor?.endpoint, 'http://10.2.0.2:1234/v1')
|
|
228
|
+
assert.equal(descriptor?.requiresApiKey, true)
|
|
229
|
+
})
|
|
230
|
+
|
|
221
231
|
test('resolveDescriptor uses Hermes as an OpenAI-compatible provider with optional auth', () => {
|
|
222
232
|
const descriptor = resolveDescriptor({
|
|
223
233
|
providerId: 'hermes',
|
|
@@ -228,6 +238,17 @@ test('resolveDescriptor uses Hermes as an OpenAI-compatible provider with option
|
|
|
228
238
|
assert.equal(descriptor?.optionalApiKey, true)
|
|
229
239
|
})
|
|
230
240
|
|
|
241
|
+
test('resolveDescriptor normalizes LM Studio discovery endpoints to /v1 with optional auth', () => {
|
|
242
|
+
const descriptor = resolveDescriptor({
|
|
243
|
+
providerId: 'lmstudio',
|
|
244
|
+
endpoint: 'http://10.2.0.2:1234',
|
|
245
|
+
})
|
|
246
|
+
assert.equal(descriptor?.strategy, 'openai-compatible')
|
|
247
|
+
assert.equal(descriptor?.endpoint, 'http://10.2.0.2:1234/v1')
|
|
248
|
+
assert.equal(descriptor?.requiresApiKey, false)
|
|
249
|
+
assert.equal(descriptor?.optionalApiKey, true)
|
|
250
|
+
})
|
|
251
|
+
|
|
231
252
|
test('resolveDescriptor disables model discovery for local CLI-backed providers without live model catalogs', () => {
|
|
232
253
|
for (const providerId of ['copilot-cli', 'droid-cli', 'cursor-cli', 'qwen-code-cli', 'goose']) {
|
|
233
254
|
const descriptor = resolveDescriptor({ providerId })
|
|
@@ -4,6 +4,7 @@ import { getProviderList } from '@/lib/providers'
|
|
|
4
4
|
import { isOllamaCloudEndpoint, resolveStoredOllamaMode } from '@/lib/ollama-mode'
|
|
5
5
|
import { OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
|
|
6
6
|
import { resolveCredentialSecret } from '@/lib/server/credentials/credential-service'
|
|
7
|
+
import { normalizeLmStudioEndpoint, normalizeOpenAiCompatibleV1Endpoint } from '@/lib/providers/openai-compatible-endpoint'
|
|
7
8
|
import type { ProviderInfo, ProviderModelDiscoveryResult } from '@/types'
|
|
8
9
|
|
|
9
10
|
type DiscoveryStrategy = 'openai-compatible' | 'anthropic' | 'google' | 'ollama' | 'openclaw'
|
|
@@ -164,7 +165,11 @@ export function resolveDescriptor(input: DiscoverProviderModelsInput): Discovery
|
|
|
164
165
|
}
|
|
165
166
|
|
|
166
167
|
const openAiDefault = OPENAI_COMPATIBLE_DEFAULTS[providerId as keyof typeof OPENAI_COMPATIBLE_DEFAULTS]?.defaultEndpoint
|
|
167
|
-
const endpoint =
|
|
168
|
+
const endpoint = providerId === 'lmstudio'
|
|
169
|
+
? normalizeLmStudioEndpoint(input.endpoint || provider.defaultEndpoint || openAiDefault || '')
|
|
170
|
+
: providerId === 'openai'
|
|
171
|
+
? normalizeOpenAiCompatibleV1Endpoint(input.endpoint || provider.defaultEndpoint || openAiDefault || '', openAiDefault || 'https://api.openai.com/v1')
|
|
172
|
+
: normalizeEndpoint(input.endpoint, provider.defaultEndpoint || openAiDefault || '')
|
|
168
173
|
return {
|
|
169
174
|
providerId,
|
|
170
175
|
providerName: provider.name,
|
|
@@ -112,6 +112,20 @@ export const SETUP_PROVIDERS: SetupProviderOption[] = [
|
|
|
112
112
|
icon: 'H',
|
|
113
113
|
category: 'gateway',
|
|
114
114
|
},
|
|
115
|
+
{
|
|
116
|
+
id: 'lmstudio',
|
|
117
|
+
name: 'LM Studio',
|
|
118
|
+
description: 'Use locally served LM Studio models through the OpenAI-compatible API.',
|
|
119
|
+
requiresKey: false,
|
|
120
|
+
supportsEndpoint: true,
|
|
121
|
+
allowMultiple: true,
|
|
122
|
+
defaultEndpoint: 'http://127.0.0.1:1234/v1',
|
|
123
|
+
optionalKey: true,
|
|
124
|
+
badge: 'Local API',
|
|
125
|
+
icon: 'L',
|
|
126
|
+
modelLibraryUrl: 'https://lmstudio.ai/docs/developer/openai-compat',
|
|
127
|
+
category: 'local',
|
|
128
|
+
},
|
|
115
129
|
{
|
|
116
130
|
id: 'anthropic',
|
|
117
131
|
name: 'Anthropic',
|
|
@@ -851,6 +865,13 @@ export const DEFAULT_AGENTS = {
|
|
|
851
865
|
model: 'hermes-agent',
|
|
852
866
|
tools: STARTER_AGENT_TOOLS,
|
|
853
867
|
},
|
|
868
|
+
lmstudio: {
|
|
869
|
+
name: 'LM Studio',
|
|
870
|
+
description: 'A local assistant running through LM Studio.',
|
|
871
|
+
systemPrompt: SWARMCLAW_ASSISTANT_PROMPT,
|
|
872
|
+
model: 'local-model',
|
|
873
|
+
tools: STARTER_AGENT_TOOLS,
|
|
874
|
+
},
|
|
854
875
|
custom: {
|
|
855
876
|
name: 'Custom Agent',
|
|
856
877
|
description: 'An assistant powered by a custom OpenAI-compatible provider.',
|
package/src/types/provider.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type ProviderType = 'claude-cli' | 'codex-cli' | 'opencode-cli' | 'opencode-web' | 'gemini-cli' | 'copilot-cli' | 'droid-cli' | 'cursor-cli' | 'qwen-code-cli' | 'goose' | 'aider-cli' | 'amp-cli' | 'augment-cli' | 'adal-cli' | 'bob-cli' | 'cline-cli' | 'codebuddy-cli' | 'command-code-cli' | 'continue-cli' | 'cortex-cli' | 'crush-cli' | 'deepagents-cli' | 'firebender-cli' | 'iflow-cli' | 'junie-cli' | 'kilo-code-cli' | 'kimi-cli' | 'kode-cli' | 'mcpjam-cli' | 'mistral-vibe-cli' | 'mux-cli' | 'neovate-cli' | 'openhands-cli' | 'pochi-cli' | 'qoder-cli' | 'replit-cli' | 'roo-code-cli' | 'trae-cn-cli' | 'warp-cli' | 'windsurf-cli' | 'zencoder-cli' | 'openai' | 'openrouter' | 'ollama' | 'anthropic' | 'openclaw' | 'hermes' | 'google' | 'deepseek' | 'groq' | 'together' | 'mistral' | 'xai' | 'fireworks' | 'nebius' | 'deepinfra'
|
|
1
|
+
export type ProviderType = 'claude-cli' | 'codex-cli' | 'opencode-cli' | 'opencode-web' | 'gemini-cli' | 'copilot-cli' | 'droid-cli' | 'cursor-cli' | 'qwen-code-cli' | 'goose' | 'aider-cli' | 'amp-cli' | 'augment-cli' | 'adal-cli' | 'bob-cli' | 'cline-cli' | 'codebuddy-cli' | 'command-code-cli' | 'continue-cli' | 'cortex-cli' | 'crush-cli' | 'deepagents-cli' | 'firebender-cli' | 'iflow-cli' | 'junie-cli' | 'kilo-code-cli' | 'kimi-cli' | 'kode-cli' | 'mcpjam-cli' | 'mistral-vibe-cli' | 'mux-cli' | 'neovate-cli' | 'openhands-cli' | 'pochi-cli' | 'qoder-cli' | 'replit-cli' | 'roo-code-cli' | 'trae-cn-cli' | 'warp-cli' | 'windsurf-cli' | 'zencoder-cli' | 'openai' | 'openrouter' | 'ollama' | 'anthropic' | 'openclaw' | 'hermes' | 'lmstudio' | 'google' | 'deepseek' | 'groq' | 'together' | 'mistral' | 'xai' | 'fireworks' | 'nebius' | 'deepinfra'
|
|
2
2
|
export type ProviderId = ProviderType | (string & {})
|
|
3
3
|
|
|
4
4
|
export interface ProviderInfo {
|
|
@@ -28,6 +28,27 @@ export interface ProviderModelDiscoveryResult {
|
|
|
28
28
|
message?: string
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export type ProviderDiagnosticStatus = 'pass' | 'warn' | 'fail'
|
|
32
|
+
|
|
33
|
+
export interface ProviderDiagnosticStep {
|
|
34
|
+
id: string
|
|
35
|
+
label: string
|
|
36
|
+
status: ProviderDiagnosticStatus
|
|
37
|
+
detail?: string
|
|
38
|
+
target?: string
|
|
39
|
+
durationMs?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProviderCheckResult {
|
|
43
|
+
ok: boolean
|
|
44
|
+
message: string
|
|
45
|
+
normalizedEndpoint?: string
|
|
46
|
+
recommendedModel?: string
|
|
47
|
+
errorCode?: string
|
|
48
|
+
deviceId?: string
|
|
49
|
+
diagnostics?: ProviderDiagnosticStep[]
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
export interface Credential {
|
|
32
53
|
id: string
|
|
33
54
|
provider: string
|