@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.
@@ -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.apiEndpoint || PROVIDER_DEFAULTS.openai
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 = apiEndpoint || providerInfo?.defaultEndpoint || null
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 explicitEndpoint = normalizeProviderEndpoint(provider, input.apiEndpoint ?? null)
75
- if (explicitEndpoint) return explicitEndpoint
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 = normalizeProviderEndpoint(provider, pConfig.baseUrl)
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 normalizeProviderEndpoint(provider, providerInfo.defaultEndpoint) || providerInfo.defaultEndpoint.replace(/\/+$/, '')
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 = endpoint || defaults?.defaultEndpoint
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 = normalizeEndpoint(input.endpoint, provider.defaultEndpoint || openAiDefault || '')
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.',
@@ -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