Skip to content

Commit e6fb946

Browse files
committed
fix: stabilize local provider switching
1 parent 653fd13 commit e6fb946

22 files changed

Lines changed: 347 additions & 44 deletions

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ Full hosted deployment guides live at https://swarmclaw.ai/docs/deployment
182182

183183
## Core Capabilities
184184

185-
- **Providers**: 23 built-in — Claude Code CLI, Codex CLI, OpenCode CLI, Gemini CLI, Copilot CLI, Cursor Agent CLI, Qwen Code CLI, Goose, Anthropic, OpenAI, OpenRouter, Google Gemini, DeepSeek, Groq, Together, Mistral, xAI, Fireworks, Nebius, DeepInfra, Ollama, OpenClaw, and Hermes Agent, plus compatible custom endpoints.
185+
- **Providers**: 24+ built-in — Claude Code CLI, Codex CLI, OpenCode CLI, Gemini CLI, Copilot CLI, Cursor Agent CLI, Qwen Code CLI, Goose, Anthropic, OpenAI, OpenRouter, Google Gemini, DeepSeek, Groq, Together, Mistral, xAI, Fireworks, Nebius, DeepInfra, Ollama, LM Studio, OpenClaw, and Hermes Agent, plus compatible custom endpoints.
186186
- **OpenRouter**: <img src="public/provider-logos/openrouter.png" alt="OpenRouter logo" width="20" height="20" /> Use OpenRouter as a first-class built-in provider with its standard OpenAI-compatible endpoint and routed model IDs such as `openai/gpt-4.1-mini`.
187187
- **Hermes Agent**: <img src="public/provider-logos/hermes-agent.png" alt="Hermes Agent logo" width="20" height="20" /> Connect Hermes through its OpenAI-compatible API server, locally or through a reachable remote `/v1` endpoint.
188188
- **Delegation**: built-in delegation to Claude Code, Codex CLI, OpenCode CLI, Gemini CLI, Cursor Agent CLI, Qwen Code CLI, and native SwarmClaw subagents.
@@ -407,6 +407,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
407407

408408
## Releases
409409

410+
### v1.9.20 Highlights
411+
412+
Provider reliability release: local OpenAI-compatible runtimes now get safer endpoint handling, clearer setup, and first-class LM Studio support.
413+
414+
- **LM Studio provider.** LM Studio is available in setup, provider settings, agent editing, model discovery, and connection checks with an optional API key.
415+
- **Endpoint normalization.** LM Studio and OpenAI-compatible OpenAI overrides normalize bare hosts like `http://127.0.0.1:1234` to `/v1` before calling models or chat completions.
416+
- **Provider switch isolation.** Switching an agent from a local endpoint back to a fixed cloud provider clears stale per-agent endpoints and fallback keys.
417+
- **Manual model flow.** Provider model saves now preserve explicit empty endpoint resets and optional-key providers can be tested without creating a credential.
418+
410419
### v1.9.19 Highlights
411420

412421
Output hygiene release: final assistant responses now use the shared internal metadata scrubber before persistence, UI reset, connector delivery, and completion hooks.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@swarmclawai/swarmclaw",
3-
"version": "1.9.19",
3+
"version": "1.9.20",
44
"description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
55
"main": "electron-dist/main.js",
66
"license": "MIT",

src/app/api/setup/check-provider/route.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getDeviceId, wsConnect, rpcOnConnectedGateway } from '@/lib/providers/o
55
import { isCliProviderId } from '@/lib/providers/cli-provider-metadata'
66
import { checkCliProviderReady } from '@/lib/server/cli-provider-readiness'
77
import { OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
8+
import { normalizeLmStudioEndpoint, normalizeOpenAiCompatibleV1Endpoint } from '@/lib/providers/openai-compatible-endpoint'
89
import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
910
import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './helpers'
1011

@@ -70,6 +71,7 @@ async function checkOpenAiCompatible(
7071
DeepInfra: 'deepseek-ai/DeepSeek-R1-0528',
7172
OpenRouter: 'openai/gpt-4.1-mini',
7273
'Hermes Agent': 'hermes-agent',
74+
'LM Studio': 'local-model',
7375
}
7476
testModel = fallbacks[providerName] || 'gpt-4o-mini'
7577
}
@@ -312,7 +314,13 @@ export async function POST(req: Request) {
312314
case 'openai': {
313315
if (!apiKey) return NextResponse.json({ ok: false, message: 'OpenAI API key is required.' })
314316
const info = OPENAI_COMPATIBLE_DEFAULTS.openai
315-
const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
317+
const result = await checkOpenAiCompatible(
318+
info.name,
319+
apiKey,
320+
normalizeOpenAiCompatibleV1Endpoint(endpoint || info.defaultEndpoint, info.defaultEndpoint),
321+
info.defaultEndpoint,
322+
model,
323+
)
316324
return NextResponse.json(result)
317325
}
318326
case 'openrouter': {
@@ -345,6 +353,17 @@ export async function POST(req: Request) {
345353
const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
346354
return NextResponse.json(result)
347355
}
356+
case 'lmstudio': {
357+
const info = OPENAI_COMPATIBLE_DEFAULTS.lmstudio
358+
const result = await checkOpenAiCompatible(
359+
info.name,
360+
apiKey,
361+
normalizeLmStudioEndpoint(endpoint || info.defaultEndpoint),
362+
info.defaultEndpoint,
363+
model,
364+
)
365+
return NextResponse.json(result)
366+
}
348367
case 'ollama': {
349368
const result = await checkOllama({
350369
endpointRaw: endpoint,

src/components/agents/agent-sheet.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const AUTO_SYNC_MODEL_PROVIDER_IDS = new Set<ProviderType>([
5050
'nebius',
5151
'deepinfra',
5252
'hermes',
53+
'lmstudio',
5354
'ollama',
5455
])
5556
const CONNECTION_TEST_TIMEOUT_MS = 40_000
@@ -745,6 +746,23 @@ export function AgentSheet() {
745746
if (!model) setModel('default')
746747
}
747748

749+
const applyDirectProviderSelection = (nextProviderId: string) => {
750+
const nextProvider = agentSelectableProviders.find((item) => item.id === nextProviderId)
751+
const nextCredentials = resolveAgentSelectableProviderCredentials(nextProviderId, credentials, providerConfigs)
752+
setProvider(nextProviderId)
753+
setModel(nextProvider?.models[0] || '')
754+
setCredentialId(nextCredentials[0]?.id || null)
755+
setFallbackCredentialIds([])
756+
setGatewayProfileId(null)
757+
setApiEndpoint(nextProvider?.requiresEndpoint ? nextProvider.defaultEndpoint || null : null)
758+
setTestStatus('idle')
759+
setTestMessage('')
760+
setTestErrorCode(null)
761+
setAddingKey(false)
762+
setNewKeyName('')
763+
setNewKeyValue('')
764+
}
765+
748766
const updateRoutingTarget = (targetId: string, patch: Partial<AgentRoutingTarget>) => {
749767
setRoutingTargets((current) => current.map((target) => (
750768
target.id === targetId
@@ -778,7 +796,8 @@ export function AgentSheet() {
778796

779797
const handleSave = async () => {
780798
// For any endpoint, just ensure bare host:port gets a protocol prepended
781-
let normalizedEndpoint = apiEndpoint
799+
const providerAllowsAgentEndpoint = Boolean(openclawEnabled || currentProvider?.requiresEndpoint || currentProvider?.optionalEndpoint)
800+
let normalizedEndpoint = providerAllowsAgentEndpoint ? apiEndpoint : null
782801
if (normalizedEndpoint) {
783802
const url = normalizedEndpoint.trim().replace(/\/+$/, '')
784803
normalizedEndpoint = /^(https?|wss?):\/\//i.test(url) ? url : `http://${url}`
@@ -1543,13 +1562,7 @@ export function AgentSheet() {
15431562
return (
15441563
<button
15451564
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-
}}
1565+
onClick={() => applyDirectProviderSelection(p.id)}
15531566
className={`relative py-3.5 px-4 rounded-[14px] text-center cursor-pointer transition-all duration-200
15541567
active:scale-[0.97] text-[14px] font-600 border
15551568
${provider === p.id
@@ -1731,14 +1744,17 @@ export function AgentSheet() {
17311744

17321745
{(currentProvider?.requiresEndpoint || currentProvider?.optionalEndpoint) && (provider !== 'ollama' || ollamaMode === 'local') && (
17331746
<div className="mb-8">
1734-
<SectionLabel>{provider === 'openclaw' ? 'OpenClaw Endpoint' : provider === 'hermes' ? 'Hermes API Endpoint' : 'Endpoint'}</SectionLabel>
1747+
<SectionLabel>{provider === 'openclaw' ? 'OpenClaw Endpoint' : provider === 'hermes' ? 'Hermes API Endpoint' : provider === 'lmstudio' ? 'LM Studio Endpoint' : 'Endpoint'}</SectionLabel>
17351748
<input type="text" value={apiEndpoint || ''} onChange={(e) => setApiEndpoint(e.target.value || null)} placeholder={currentProvider.defaultEndpoint || 'http://localhost:11434'} className={`${inputClass} font-mono text-[14px]`} />
17361749
{provider === 'openclaw' && (
17371750
<p className="text-[13px] text-text-3/70 mt-2">The URL of your OpenClaw gateway</p>
17381751
)}
17391752
{provider === 'hermes' && (
17401753
<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>
17411754
)}
1755+
{provider === 'lmstudio' && (
1756+
<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>
1757+
)}
17421758
</div>
17431759
)}
17441760

src/components/auth/setup-wizard/step-agents.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const SETUP_PROVIDERS_WITH_MODEL_DISCOVERY = new Set([
2828
'ollama',
2929
'openclaw',
3030
'hermes',
31+
'lmstudio',
3132
])
3233

3334
/* ── Model combobox: search discovered models or type a custom one ── */

src/components/auth/setup-wizard/step-connect.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,12 @@ export function StepConnect({
335335
<p className="text-[12px] text-text-3">Use any reachable local or remote API-server endpoint exposed by Hermes.</p>
336336
</div>
337337
)}
338+
{provider === 'lmstudio' && (
339+
<div className="mt-2 space-y-0.5">
340+
<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>
341+
<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>
342+
</div>
343+
)}
338344
</div>
339345
)}
340346

src/components/providers/provider-sheet.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,10 @@ export function ProviderSheet() {
271271
const modelList = models.split(',').map((m) => m.trim()).filter(Boolean)
272272
const showApiKey = isBuiltin ? editingBuiltin?.requiresApiKey || editingBuiltin?.optionalApiKey : requiresApiKey
273273
const canDiscoverModels = Boolean(isBuiltin && editingBuiltin?.supportsModelDiscovery)
274-
const showTestButton = Boolean(isBuiltin && showApiKey && credentialId)
274+
const showTestButton = Boolean(
275+
isBuiltin
276+
&& (editingBuiltin?.requiresApiKey ? credentialId : (showApiKey || editingBuiltin?.requiresEndpoint || editingBuiltin?.optionalEndpoint)),
277+
)
275278

276279
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"
277280

src/features/providers/queries.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export function useSaveBuiltinProviderMutation() {
8686
await api('PUT', `/providers/${id}/models`, { models })
8787
return api('PUT', `/providers/${id}`, {
8888
isEnabled,
89-
...(baseUrl ? { baseUrl } : {}),
89+
...(typeof baseUrl === 'string' ? { baseUrl } : {}),
9090
})
9191
},
9292
onSuccess: async () => {

src/lib/providers/index.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,34 @@ test('builtin provider override records do not surface as custom providers', ()
7777
assert.equal(output.openAiCount, 1)
7878
})
7979

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+
80108
test('custom provider resolution includes defaultEndpoint and optionalApiKey', () => {
81109
const output = runWithTempDataDir<{
82110
defaultEndpoint: string | null

0 commit comments

Comments
 (0)