@swarmclawai/swarmclaw 1.9.18 → 1.9.20

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 CHANGED
@@ -182,7 +182,7 @@ Full hosted deployment guides live at https://swarmclaw.ai/docs/deployment
182
182
 
183
183
  ## Core Capabilities
184
184
 
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.
186
186
  - **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`.
187
187
  - **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.
188
188
  - **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,24 @@ Operational docs: https://swarmclaw.ai/docs/observability
407
407
 
408
408
  ## Releases
409
409
 
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
+
419
+ ### v1.9.19 Highlights
420
+
421
+ Output hygiene release: final assistant responses now use the shared internal metadata scrubber before persistence, UI reset, connector delivery, and completion hooks.
422
+
423
+ - **Multi-block scrubbing.** Repeated internal metadata payloads are stripped in one pass instead of stopping after the first block.
424
+ - **Malformed prelude cleanup.** When a validated internal block is followed by a malformed internal fragment, the leftover prelude is removed before user-facing text is delivered.
425
+ - **Shared finalizer path.** Post-stream finalization now uses the same metadata scrubber as the chat UI, keeping stored, streamed, and connector-visible output aligned.
426
+ - **Regression coverage.** Tests cover repeated classifier-shape blocks, malformed follow-on fragments, and false-positive protection for malformed text without a prior validated strip.
427
+
410
428
  ### v1.9.18 Highlights
411
429
 
412
430
  Schedule preflight release: schedules now show server-backed timing forecasts before save, with timezone-aware cron previews and warnings for risky drafts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.18",
3
+ "version": "1.9.20",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -5,6 +5,7 @@ import { getDeviceId, wsConnect, rpcOnConnectedGateway } from '@/lib/providers/o
5
5
  import { isCliProviderId } from '@/lib/providers/cli-provider-metadata'
6
6
  import { checkCliProviderReady } from '@/lib/server/cli-provider-readiness'
7
7
  import { OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
8
+ import { normalizeLmStudioEndpoint, normalizeOpenAiCompatibleV1Endpoint } from '@/lib/providers/openai-compatible-endpoint'
8
9
  import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
9
10
  import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './helpers'
10
11
 
@@ -70,6 +71,7 @@ async function checkOpenAiCompatible(
70
71
  DeepInfra: 'deepseek-ai/DeepSeek-R1-0528',
71
72
  OpenRouter: 'openai/gpt-4.1-mini',
72
73
  'Hermes Agent': 'hermes-agent',
74
+ 'LM Studio': 'local-model',
73
75
  }
74
76
  testModel = fallbacks[providerName] || 'gpt-4o-mini'
75
77
  }
@@ -312,7 +314,13 @@ export async function POST(req: Request) {
312
314
  case 'openai': {
313
315
  if (!apiKey) return NextResponse.json({ ok: false, message: 'OpenAI API key is required.' })
314
316
  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
+ )
316
324
  return NextResponse.json(result)
317
325
  }
318
326
  case 'openrouter': {
@@ -345,6 +353,17 @@ export async function POST(req: Request) {
345
353
  const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
346
354
  return NextResponse.json(result)
347
355
  }
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
+ }
348
367
  case 'ollama': {
349
368
  const result = await checkOllama({
350
369
  endpointRaw: endpoint,
@@ -50,6 +50,7 @@ const AUTO_SYNC_MODEL_PROVIDER_IDS = new Set<ProviderType>([
50
50
  'nebius',
51
51
  'deepinfra',
52
52
  'hermes',
53
+ 'lmstudio',
53
54
  'ollama',
54
55
  ])
55
56
  const CONNECTION_TEST_TIMEOUT_MS = 40_000
@@ -745,6 +746,23 @@ export function AgentSheet() {
745
746
  if (!model) setModel('default')
746
747
  }
747
748
 
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
+
748
766
  const updateRoutingTarget = (targetId: string, patch: Partial<AgentRoutingTarget>) => {
749
767
  setRoutingTargets((current) => current.map((target) => (
750
768
  target.id === targetId
@@ -778,7 +796,8 @@ export function AgentSheet() {
778
796
 
779
797
  const handleSave = async () => {
780
798
  // 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
782
801
  if (normalizedEndpoint) {
783
802
  const url = normalizedEndpoint.trim().replace(/\/+$/, '')
784
803
  normalizedEndpoint = /^(https?|wss?):\/\//i.test(url) ? url : `http://${url}`
@@ -1543,13 +1562,7 @@ export function AgentSheet() {
1543
1562
  return (
1544
1563
  <button
1545
1564
  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)}
1553
1566
  className={`relative py-3.5 px-4 rounded-[14px] text-center cursor-pointer transition-all duration-200
1554
1567
  active:scale-[0.97] text-[14px] font-600 border
1555
1568
  ${provider === p.id
@@ -1731,7 +1744,7 @@ export function AgentSheet() {
1731
1744
 
1732
1745
  {(currentProvider?.requiresEndpoint || currentProvider?.optionalEndpoint) && (provider !== 'ollama' || ollamaMode === 'local') && (
1733
1746
  <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>
1735
1748
  <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
1749
  {provider === 'openclaw' && (
1737
1750
  <p className="text-[13px] text-text-3/70 mt-2">The URL of your OpenClaw gateway</p>
@@ -1739,6 +1752,9 @@ export function AgentSheet() {
1739
1752
  {provider === 'hermes' && (
1740
1753
  <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
1754
  )}
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
+ )}
1742
1758
  </div>
1743
1759
  )}
1744
1760
 
@@ -28,6 +28,7 @@ const SETUP_PROVIDERS_WITH_MODEL_DISCOVERY = new Set([
28
28
  'ollama',
29
29
  'openclaw',
30
30
  'hermes',
31
+ 'lmstudio',
31
32
  ])
32
33
 
33
34
  /* ── Model combobox: search discovered models or type a custom one ── */
@@ -335,6 +335,12 @@ export function StepConnect({
335
335
  <p className="text-[12px] text-text-3">Use any reachable local or remote API-server endpoint exposed by Hermes.</p>
336
336
  </div>
337
337
  )}
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
+ )}
338
344
  </div>
339
345
  )}
340
346
 
@@ -271,7 +271,10 @@ export function ProviderSheet() {
271
271
  const modelList = models.split(',').map((m) => m.trim()).filter(Boolean)
272
272
  const showApiKey = isBuiltin ? editingBuiltin?.requiresApiKey || editingBuiltin?.optionalApiKey : requiresApiKey
273
273
  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
+ )
275
278
 
276
279
  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
280
 
@@ -86,7 +86,7 @@ export function useSaveBuiltinProviderMutation() {
86
86
  await api('PUT', `/providers/${id}/models`, { models })
87
87
  return api('PUT', `/providers/${id}`, {
88
88
  isEnabled,
89
- ...(baseUrl ? { baseUrl } : {}),
89
+ ...(typeof baseUrl === 'string' ? { baseUrl } : {}),
90
90
  })
91
91
  },
92
92
  onSuccess: async () => {
@@ -77,6 +77,34 @@ test('builtin provider override records do not surface as custom providers', ()
77
77
  assert.equal(output.openAiCount, 1)
78
78
  })
79
79
 
80
+ test('LM Studio is available as a first-class local OpenAI-compatible provider', () => {
81
+ const output = runWithTempDataDir<{
82
+ providerName: string | null
83
+ defaultEndpoint: string | null
84
+ requiresApiKey: boolean | null
85
+ optionalApiKey: boolean | null
86
+ supportsModelDiscovery: boolean | null
87
+ }>(`
88
+ const providersModule = await import('@/lib/providers/index')
89
+ const providers = providersModule.default || providersModule
90
+ const provider = providers.getProviderList().find((entry) => entry.id === 'lmstudio')
91
+
92
+ console.log(JSON.stringify({
93
+ providerName: provider?.name ?? null,
94
+ defaultEndpoint: provider?.defaultEndpoint ?? null,
95
+ requiresApiKey: provider?.requiresApiKey ?? null,
96
+ optionalApiKey: provider?.optionalApiKey ?? null,
97
+ supportsModelDiscovery: provider?.supportsModelDiscovery ?? null,
98
+ }))
99
+ `)
100
+
101
+ assert.equal(output.providerName, 'LM Studio')
102
+ assert.equal(output.defaultEndpoint, 'http://127.0.0.1:1234/v1')
103
+ assert.equal(output.requiresApiKey, false)
104
+ assert.equal(output.optionalApiKey, true)
105
+ assert.equal(output.supportsModelDiscovery, true)
106
+ })
107
+
80
108
  test('custom provider resolution includes defaultEndpoint and optionalApiKey', () => {
81
109
  const output = runWithTempDataDir<{
82
110
  defaultEndpoint: string | null
@@ -14,6 +14,7 @@ import { streamOpenAiChat } from './openai'
14
14
  import { streamOllamaChat } from './ollama'
15
15
  import { streamAnthropicChat } from './anthropic'
16
16
  import { streamOpenClawChat } from './openclaw'
17
+ import { normalizeLmStudioEndpoint, normalizeOpenAiCompatibleV1Endpoint } from './openai-compatible-endpoint'
17
18
  import { errorMessage, sleep, jitteredBackoff } from '@/lib/shared-utils'
18
19
  import { classifyProviderError } from './error-classification'
19
20
  import { log } from '@/lib/server/logger'
@@ -173,6 +174,24 @@ export const PROVIDERS: Record<string, BuiltinProviderConfig> = {
173
174
  },
174
175
  },
175
176
  },
177
+ lmstudio: {
178
+ id: 'lmstudio',
179
+ name: 'LM Studio',
180
+ models: ['local-model', 'google/gemma-4-e4b'],
181
+ requiresApiKey: false,
182
+ optionalApiKey: true,
183
+ requiresEndpoint: true,
184
+ defaultEndpoint: 'http://127.0.0.1:1234/v1',
185
+ handler: {
186
+ streamChat: (opts) => {
187
+ const patchedSession = {
188
+ ...opts.session,
189
+ apiEndpoint: normalizeLmStudioEndpoint(opts.session.apiEndpoint),
190
+ }
191
+ return streamOpenAiChat({ ...opts, session: patchedSession })
192
+ },
193
+ },
194
+ },
176
195
  'opencode-cli': {
177
196
  id: 'opencode-cli',
178
197
  name: 'OpenCode CLI',
@@ -560,22 +579,29 @@ export function getProvider(id: string): BuiltinProviderConfig | null {
560
579
  if (builtin) {
561
580
  const pConfigs = loadProviderConfigs()
562
581
  const pConfig = pConfigs[id]
563
- if (pConfig?.baseUrl && pConfig.baseUrl !== builtin.defaultEndpoint) {
564
- const originalHandler = builtin.handler
565
- return {
566
- ...builtin,
567
- handler: {
568
- streamChat: (opts) => {
569
- const patchedSession = {
570
- ...opts.session,
571
- apiEndpoint: opts.session.apiEndpoint || pConfig.baseUrl,
572
- }
573
- return originalHandler.streamChat({ ...opts, session: patchedSession })
574
- },
582
+ const originalHandler = builtin.handler
583
+ return {
584
+ ...builtin,
585
+ handler: {
586
+ streamChat: (opts) => {
587
+ const configuredEndpoint = typeof pConfig?.baseUrl === 'string' && pConfig.baseUrl.trim()
588
+ ? normalizeProviderRuntimeEndpoint(id, pConfig.baseUrl)
589
+ : null
590
+ const sessionEndpoint = typeof opts.session.apiEndpoint === 'string' && opts.session.apiEndpoint.trim()
591
+ ? normalizeProviderRuntimeEndpoint(id, opts.session.apiEndpoint)
592
+ : null
593
+ const honorsAgentEndpoint = Boolean(builtin.requiresEndpoint || builtin.optionalEndpoint)
594
+ const apiEndpoint = honorsAgentEndpoint
595
+ ? sessionEndpoint || configuredEndpoint
596
+ : configuredEndpoint
597
+ const patchedSession = {
598
+ ...opts.session,
599
+ apiEndpoint: apiEndpoint || undefined,
600
+ }
601
+ return originalHandler.streamChat({ ...opts, session: patchedSession })
575
602
  },
576
- }
603
+ },
577
604
  }
578
- return builtin
579
605
  }
580
606
 
581
607
  // Check custom providers
@@ -619,6 +645,12 @@ export function getProvider(id: string): BuiltinProviderConfig | null {
619
645
  return null
620
646
  }
621
647
 
648
+ function normalizeProviderRuntimeEndpoint(providerId: string, endpoint: string): string {
649
+ if (providerId === 'lmstudio') return normalizeLmStudioEndpoint(endpoint)
650
+ if (providerId === 'openai') return normalizeOpenAiCompatibleV1Endpoint(endpoint, 'https://api.openai.com/v1')
651
+ return endpoint.replace(/\/+$/, '')
652
+ }
653
+
622
654
  /**
623
655
  * Stream chat with automatic failover to fallback credentials on retryable errors.
624
656
  * Falls back through fallbackCredentialIds on 401/429/500/502/503 errors.
@@ -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
@@ -33,6 +33,28 @@ describe('stripLeakedClassificationJson', () => {
33
33
  assert.equal(cleaned.includes('taskIntent'), false)
34
34
  })
35
35
 
36
+ it('strips multiple leaked classification JSON blocks', () => {
37
+ const input = `${VALID_LEAK}\n${VALID_LEAK}\nTask created and delegated.`
38
+ const { cleaned, stripped } = stripLeakedClassificationJson(input)
39
+ assert.equal(stripped, true)
40
+ assert.equal(cleaned, 'Task created and delegated.')
41
+ })
42
+
43
+ it('strips a malformed internal prelude after a validated leaked block', () => {
44
+ const malformedPrelude = [
45
+ '{',
46
+ ' "taskIntent": "research",',
47
+ ' "isBroadGoal":{',
48
+ ' false,',
49
+ ' "isLightweightDirectChat": false,',
50
+ '}',
51
+ ].join('\n')
52
+ const input = `${VALID_LEAK}\n${malformedPrelude}\nAll five research bundles reviewed.`
53
+ const { cleaned, stripped } = stripLeakedClassificationJson(input)
54
+ assert.equal(stripped, true)
55
+ assert.equal(cleaned, 'All five research bundles reviewed.')
56
+ })
57
+
36
58
  it('leaves normal assistant text untouched', () => {
37
59
  const input = 'Your favorite color is blue.'
38
60
  const { cleaned, stripped } = stripLeakedClassificationJson(input)
@@ -8,6 +8,7 @@
8
8
  import type { KnowledgeRetrievalTrace, Session, UsageRecord } from '@/types'
9
9
  import { log } from '@/lib/server/logger'
10
10
  import type { ChatTurnState } from '@/lib/server/chat-execution/chat-turn-state'
11
+ import { stripAllInternalMetadata } from '@/lib/strip-internal-metadata'
11
12
 
12
13
  const TAG = 'post-stream'
13
14
  import { extractSuggestions } from '@/lib/server/suggestions'
@@ -20,7 +21,6 @@ import {
20
21
  shouldForceExternalServiceSummary,
21
22
  } from '@/lib/server/chat-execution/chat-streaming-utils'
22
23
  import {
23
- MessageClassificationSchema,
24
24
  type MessageClassification,
25
25
  } from '@/lib/server/chat-execution/message-classifier'
26
26
  import {
@@ -28,48 +28,9 @@ import {
28
28
  } from '@/lib/server/chat-execution/stream-continuation'
29
29
  import { buildForcedExternalServiceSummary } from '@/lib/server/chat-execution/prompt-builder'
30
30
 
31
- // ---------------------------------------------------------------------------
32
- // Classification JSON leak detection — strips MessageClassification objects
33
- // that some models echo verbatim into their response text. Candidate JSON
34
- // substrings are found by brace-matching, then validated against the actual
35
- // MessageClassificationSchema — the single source of truth for what a
36
- // classifier object looks like.
37
- // ---------------------------------------------------------------------------
38
-
39
- /** Returns the index just past the balanced `}` for the `{` at `start`, or -1. */
40
- function findBalancedObjectEnd(text: string, start: number): number {
41
- let depth = 0
42
- let inString = false
43
- let escape = false
44
- for (let i = start; i < text.length; i++) {
45
- const ch = text[i]
46
- if (escape) { escape = false; continue }
47
- if (inString) {
48
- if (ch === '\\') escape = true
49
- else if (ch === '"') inString = false
50
- continue
51
- }
52
- if (ch === '"') inString = true
53
- else if (ch === '{') depth += 1
54
- else if (ch === '}') {
55
- depth -= 1
56
- if (depth === 0) return i + 1
57
- }
58
- }
59
- return -1
60
- }
61
-
62
31
  export function stripLeakedClassificationJson(text: string): { cleaned: string; stripped: boolean } {
63
- for (let i = text.indexOf('{'); i !== -1; i = text.indexOf('{', i + 1)) {
64
- const end = findBalancedObjectEnd(text, i)
65
- if (end === -1) break
66
- let parsed: unknown
67
- try { parsed = JSON.parse(text.slice(i, end)) } catch { continue }
68
- if (!MessageClassificationSchema.safeParse(parsed).success) continue
69
- log.warn(TAG, 'Stripped leaked classification JSON from model output')
70
- return { cleaned: (text.slice(0, i) + text.slice(end)).trimStart(), stripped: true }
71
- }
72
- return { cleaned: text, stripped: false }
32
+ const cleaned = stripAllInternalMetadata(text)
33
+ return { cleaned, stripped: cleaned !== text }
73
34
  }
74
35
 
75
36
  // StreamAgentChatResult is defined inline to avoid circular dependency with stream-agent-chat.ts
@@ -174,9 +135,10 @@ export async function finalizeStreamResult(opts: FinalizeStreamResultOpts): Prom
174
135
  }
175
136
  }
176
137
 
177
- // Strip leaked classification JSON from model output (e.g. `{ "isDeliverableTask": true, ... }`)
138
+ // Strip leaked internal metadata from model output (e.g. `{ "isDeliverableTask": true, ... }`)
178
139
  const leakResult = stripLeakedClassificationJson(state.fullText)
179
140
  if (leakResult.stripped) {
141
+ log.warn(TAG, 'Stripped leaked internal metadata from model output')
180
142
  state.fullText = leakResult.cleaned
181
143
  // Emit a reset so the frontend re-renders with the cleaned text
182
144
  write(`data: ${JSON.stringify({ t: 'reset', text: leakResult.cleaned })}\n\n`)
@@ -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.',
@@ -64,6 +64,41 @@ describe('stripInternalJson', () => {
64
64
  assert.doesNotMatch(result, /isDeliverableTask/)
65
65
  assert.match(result, /\{ "foo": "bar" \}/)
66
66
  })
67
+
68
+ it('removes multiple leading internal JSON blocks', () => {
69
+ const input = [
70
+ '{ "isDeliverableTask": true, "confidence": 0.9 }',
71
+ '{ "factsUpsert": [], "questionsUpsert": [] }',
72
+ 'All queued work is complete.',
73
+ ].join('\n')
74
+ assert.equal(stripInternalJson(input), 'All queued work is complete.')
75
+ })
76
+
77
+ it('removes a malformed internal prelude only after a strict leading strip', () => {
78
+ const input = [
79
+ '{ "isDeliverableTask": true, "confidence": 0.9 }',
80
+ '{',
81
+ ' "taskIntent": "research",',
82
+ ' "isBroadGoal":{',
83
+ ' false,',
84
+ ' "isLightweightDirectChat": false,',
85
+ '}',
86
+ 'All queued work is complete.',
87
+ ].join('\n')
88
+ assert.equal(stripInternalJson(input), 'All queued work is complete.')
89
+ })
90
+
91
+ it('preserves malformed internal-looking text without a strict leading strip', () => {
92
+ const input = [
93
+ '{',
94
+ ' "taskIntent": "research",',
95
+ ' "isBroadGoal":{',
96
+ ' false,',
97
+ '}',
98
+ 'Visible answer.',
99
+ ].join('\n')
100
+ assert.equal(stripInternalJson(input), input)
101
+ })
67
102
  })
68
103
 
69
104
  // ---------------------------------------------------------------------------
@@ -25,6 +25,9 @@ const INTERNAL_JSON_KEYS = [
25
25
 
26
26
  export const INTERNAL_KEY_RE = new RegExp(`"(?:${INTERNAL_JSON_KEYS.join('|')})"`)
27
27
 
28
+ const TaskIntentLikeSchema = z.enum(['coding', 'research', 'browsing', 'outreach', 'scheduling', 'general']).optional()
29
+ const WorkTypeLikeSchema = z.enum(['coding', 'research', 'writing', 'review', 'operations', 'general']).optional()
30
+
28
31
  const WorkingStatePatchLikeSchema = z.object({
29
32
  factsUpsert: z.array(z.unknown()).optional(),
30
33
  artifactsUpsert: z.array(z.unknown()).optional(),
@@ -37,13 +40,15 @@ const WorkingStatePatchLikeSchema = z.object({
37
40
  }).passthrough()
38
41
 
39
42
  const MessageClassificationLikeSchema = z.object({
40
- taskIntent: z.string().optional(),
43
+ taskIntent: TaskIntentLikeSchema,
41
44
  isLightweightDirectChat: z.boolean().optional(),
42
45
  isDeliverableTask: z.boolean().optional(),
43
46
  isBroadGoal: z.boolean().optional(),
44
47
  hasHumanSignals: z.boolean().optional(),
48
+ hasSignificantEvent: z.boolean().optional(),
45
49
  explicitToolRequests: z.array(z.unknown()).optional(),
46
50
  isResearchSynthesis: z.boolean().optional(),
51
+ workType: WorkTypeLikeSchema,
47
52
  confidence: z.number().optional(),
48
53
  }).passthrough()
49
54
 
@@ -104,6 +109,13 @@ function objectIsInternalMetadata(obj: Record<string, unknown>): boolean {
104
109
  return false
105
110
  }
106
111
 
112
+ function isDistinctiveInternalKey(key: string): boolean {
113
+ for (const { distinctiveKeys } of INTERNAL_PAYLOAD_RULES) {
114
+ if (distinctiveKeys.includes(key)) return true
115
+ }
116
+ return false
117
+ }
118
+
107
119
  function findBalancedJsonObjectEnd(text: string, start: number): number {
108
120
  if (text.charAt(start) !== '{') return -1
109
121
  let depth = 0
@@ -130,6 +142,109 @@ function findBalancedJsonObjectEnd(text: string, start: number): number {
130
142
  return -1
131
143
  }
132
144
 
145
+ function parseQuotedKeyAt(text: string, start: number): { key: string; end: number } | null {
146
+ if (text.charAt(start) !== '"') return null
147
+ let key = ''
148
+ let escaped = false
149
+ for (let i = start + 1; i < text.length; i += 1) {
150
+ const c = text.charAt(i)
151
+ if (escaped) {
152
+ key += c
153
+ escaped = false
154
+ continue
155
+ }
156
+ if (c === '\\') {
157
+ escaped = true
158
+ continue
159
+ }
160
+ if (c !== '"') {
161
+ key += c
162
+ continue
163
+ }
164
+ let cursor = i + 1
165
+ while (cursor < text.length && /\s/.test(text.charAt(cursor))) cursor += 1
166
+ if (text.charAt(cursor) !== ':') return null
167
+ return { key, end: cursor + 1 }
168
+ }
169
+ return null
170
+ }
171
+
172
+ function lineHasDistinctiveInternalKey(line: string): boolean {
173
+ for (let i = 0; i < line.length; i += 1) {
174
+ const parsed = parseQuotedKeyAt(line, i)
175
+ if (!parsed) continue
176
+ if (isDistinctiveInternalKey(parsed.key)) return true
177
+ i = parsed.end - 1
178
+ }
179
+ return false
180
+ }
181
+
182
+ function startsWithJsonLiteral(text: string, value: string): boolean {
183
+ if (!text.startsWith(value)) return false
184
+ const next = text.charAt(value.length)
185
+ return next === '' || next === ',' || next === '}' || next === ']' || /\s/.test(next)
186
+ }
187
+
188
+ function isMalformedJsonFragmentLine(line: string): boolean {
189
+ const trimmed = line.trim()
190
+ if (!trimmed) return true
191
+ const first = trimmed.charAt(0)
192
+ if (first === '{' || first === '}' || first === '[' || first === ']' || first === '"' || first === ',' || first === ':') {
193
+ return true
194
+ }
195
+ if (startsWithJsonLiteral(trimmed, 'true')) return true
196
+ if (startsWithJsonLiteral(trimmed, 'false')) return true
197
+ if (startsWithJsonLiteral(trimmed, 'null')) return true
198
+ if (trimmed.startsWith('...')) return true
199
+ return false
200
+ }
201
+
202
+ function findInlineVisibleTextAfterClosingBrace(line: string): number {
203
+ for (let i = 0; i < line.length; i += 1) {
204
+ if (line.charAt(i) !== '}') continue
205
+ let cursor = i + 1
206
+ while (cursor < line.length && /\s/.test(line.charAt(cursor))) cursor += 1
207
+ const next = line.charAt(cursor)
208
+ if (!next || next === ',' || next === '}' || next === ']') continue
209
+ return i + 1
210
+ }
211
+ return -1
212
+ }
213
+
214
+ function findMalformedInternalPreludeEnd(text: string): number {
215
+ let leading = 0
216
+ while (leading < text.length && /\s/.test(text.charAt(leading))) leading += 1
217
+ if (text.charAt(leading) !== '{') return -1
218
+
219
+ let cursor = leading
220
+ let sawDistinctiveKey = false
221
+ let consumedEnd = -1
222
+ while (cursor < text.length) {
223
+ const newlineAt = text.indexOf('\n', cursor)
224
+ const lineEnd = newlineAt === -1 ? text.length : newlineAt
225
+ const line = text.slice(cursor, lineEnd)
226
+ if (lineHasDistinctiveInternalKey(line)) sawDistinctiveKey = true
227
+
228
+ if (!isMalformedJsonFragmentLine(line)) {
229
+ return sawDistinctiveKey && consumedEnd > leading ? consumedEnd : -1
230
+ }
231
+
232
+ const inlineEnd = sawDistinctiveKey ? findInlineVisibleTextAfterClosingBrace(line) : -1
233
+ if (inlineEnd >= 0) return cursor + inlineEnd
234
+
235
+ consumedEnd = newlineAt === -1 ? lineEnd : lineEnd + 1
236
+ cursor = consumedEnd
237
+ }
238
+
239
+ return -1
240
+ }
241
+
242
+ function stripMalformedInternalPreludeAfterStrictStrip(text: string): string {
243
+ const end = findMalformedInternalPreludeEnd(text)
244
+ if (end < 0) return text
245
+ return text.slice(end).trimStart()
246
+ }
247
+
133
248
  /**
134
249
  * Remove top-level `{ ... }` blocks that contain known internal classification keys.
135
250
  * Handles nested and multi-line JSON. Only strips blocks where at least one
@@ -137,6 +252,7 @@ function findBalancedJsonObjectEnd(text: string, start: number): number {
137
252
  */
138
253
  export function stripInternalJson(text: string): string {
139
254
  let out = text || ''
255
+ let removedLeadingInternalJson = false
140
256
  for (let guard = 0; guard < 32; guard += 1) {
141
257
  let removed = false
142
258
  for (let i = 0; i < out.length; i += 1) {
@@ -152,12 +268,16 @@ export function stripInternalJson(text: string): string {
152
268
  }
153
269
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue
154
270
  if (!objectIsInternalMetadata(parsed as Record<string, unknown>)) continue
271
+ if (!out.slice(0, i).trim()) removedLeadingInternalJson = true
155
272
  out = (out.slice(0, i).replace(/\s+$/, '') + ' ' + out.slice(end).replace(/^\s+/, '')).trim()
156
273
  removed = true
157
274
  break
158
275
  }
159
276
  if (!removed) break
160
277
  }
278
+ if (removedLeadingInternalJson) {
279
+ out = stripMalformedInternalPreludeAfterStrictStrip(out)
280
+ }
161
281
  return out
162
282
  }
163
283
 
@@ -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 {