@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 CHANGED
@@ -81,8 +81,10 @@ Extension tutorial: https://swarmclaw.ai/docs/extension-tutorial
81
81
  Download the one-click installer from [swarmclaw.ai/downloads](https://swarmclaw.ai/downloads).
82
82
  Available for macOS (Apple Silicon & Intel), Windows, and Linux (AppImage + .deb).
83
83
 
84
- Current builds are ad-hoc signed but not notarized, so on first launch:
85
- - **macOS:** right-click the app in Finder → **Open** → **Open** to bypass Gatekeeper. If macOS instead reports *"SwarmClaw is damaged and can't be opened"* (common on Apple Silicon when the dmg was quarantined by Safari), strip the quarantine attribute and relaunch:
84
+ The release workflow supports Developer ID signing and notarization when Apple
85
+ credentials are configured. If a macOS build is still ad-hoc signed, first
86
+ launch may need one manual approval:
87
+ - **macOS:** right-click the app in Finder → **Open** → **Open** to bypass Gatekeeper. If macOS instead reports *"SwarmClaw is damaged and can't be opened"* (common when the dmg was quarantined by Safari), strip the quarantine attribute and relaunch:
86
88
  ```bash
87
89
  xattr -dr com.apple.quarantine /Applications/SwarmClaw.app
88
90
  ```
@@ -182,7 +184,7 @@ Full hosted deployment guides live at https://swarmclaw.ai/docs/deployment
182
184
 
183
185
  ## Core Capabilities
184
186
 
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.
187
+ - **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
188
  - **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
189
  - **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
190
  - **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 +409,24 @@ Operational docs: https://swarmclaw.ai/docs/observability
407
409
 
408
410
  ## Releases
409
411
 
412
+ ### v1.9.21 Highlights
413
+
414
+ Provider diagnostics release: connection checks now return a structured step timeline across setup, provider settings, and agent editing.
415
+
416
+ - **Connection timelines.** Provider checks show endpoint resolution, model discovery, fallback selection, and chat/gateway verification steps.
417
+ - **Safer error details.** Token-like values are redacted before check messages or diagnostics are returned to the UI.
418
+ - **Local runtime debugging.** LM Studio, Ollama, custom OpenAI-compatible endpoints, cloud providers, OpenClaw gateways, and CLI providers all report concise pass/fail diagnostics.
419
+ - **macOS signing path.** Desktop releases now forward Developer ID and Apple notarization credentials when configured, while ad-hoc fallback builds keep the quarantine workaround documented.
420
+
421
+ ### v1.9.20 Highlights
422
+
423
+ Provider reliability release: local OpenAI-compatible runtimes now get safer endpoint handling, clearer setup, and first-class LM Studio support.
424
+
425
+ - **LM Studio provider.** LM Studio is available in setup, provider settings, agent editing, model discovery, and connection checks with an optional API key.
426
+ - **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.
427
+ - **Provider switch isolation.** Switching an agent from a local endpoint back to a fixed cloud provider clears stale per-agent endpoints and fallback keys.
428
+ - **Manual model flow.** Provider model saves now preserve explicit empty endpoint resets and optional-key providers can be tested without creating a credential.
429
+
410
430
  ### v1.9.19 Highlights
411
431
 
412
432
  Output hygiene release: final assistant responses now use the shared internal metadata scrubber before persistence, UI reset, connector delivery, and completion hooks.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.19",
3
+ "version": "1.9.21",
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",
@@ -85,10 +85,10 @@
85
85
  "lint:baseline": "node ./scripts/lint-baseline.mjs check",
86
86
  "lint:baseline:update": "node ./scripts/lint-baseline.mjs update",
87
87
  "cli": "node ./bin/swarmclaw.js",
88
- "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
88
+ "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/electron-signing-config.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
89
89
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
90
90
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
91
- "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
91
+ "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/agent-planning-mode.test.ts src/lib/agent-config-history.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/provider-diagnostics.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/prompt-sections.planning-mode.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/chats/session-context-pack.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/runs/run-handoff.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/server/schedules/schedule-preview.test.ts src/lib/quality/release-readiness.test.ts src/lib/quality/architecture-health.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-execution-policy.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-pack-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/config-versions/config-versions-route.test.ts src/app/api/runs/run-handoff-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/preview/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
92
92
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
93
93
  "test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
94
94
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
3
 
4
4
  import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './helpers'
5
+ import { POST } from './route'
5
6
 
6
7
  test('normalizeOllamaSetupEndpoint strips local /v1 suffixes but preserves cloud endpoints', () => {
7
8
  assert.equal(
@@ -117,3 +118,46 @@ test('normalizeOpenClawUrl handles bare IP:port', () => {
117
118
  assert.equal(httpUrl, 'http://10.0.0.5:18789')
118
119
  assert.equal(wsUrl, 'ws://10.0.0.5:18789')
119
120
  })
121
+
122
+ test('POST returns provider diagnostics with normalized LM Studio targets and redacted errors', async () => {
123
+ const originalFetch = globalThis.fetch
124
+ const calls: string[] = []
125
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
126
+ const url = String(input)
127
+ calls.push(url)
128
+ if (url.endsWith('/models')) {
129
+ return new Response(JSON.stringify({ data: [{ id: 'google/gemma-4-e4b' }] }), { status: 200 })
130
+ }
131
+ return new Response(
132
+ JSON.stringify({ error: { message: 'Malformed token sk-local-secret provided.' } }),
133
+ { status: 400 },
134
+ )
135
+ }) as typeof fetch
136
+
137
+ try {
138
+ const res = await POST(new Request('http://localhost/api/setup/check-provider', {
139
+ method: 'POST',
140
+ body: JSON.stringify({
141
+ provider: 'lmstudio',
142
+ endpoint: 'http://10.2.0.2:1234',
143
+ }),
144
+ }))
145
+ const payload = await res.json()
146
+
147
+ assert.equal(payload.ok, false)
148
+ assert.equal(payload.normalizedEndpoint, 'http://10.2.0.2:1234/v1')
149
+ assert.deepEqual(calls, [
150
+ 'http://10.2.0.2:1234/v1/models',
151
+ 'http://10.2.0.2:1234/v1/chat/completions',
152
+ ])
153
+ assert.ok(Array.isArray(payload.diagnostics))
154
+ assert.equal(payload.diagnostics[0].target, 'http://10.2.0.2:1234/v1')
155
+ assert.equal(payload.message, 'Malformed token sk-... provided.')
156
+ assert.equal(
157
+ payload.diagnostics.some((step: { detail?: string }) => step.detail?.includes('sk-local-secret')),
158
+ false,
159
+ )
160
+ } finally {
161
+ globalThis.fetch = originalFetch
162
+ }
163
+ })
@@ -4,9 +4,12 @@ import { listCredentialIdsByProvider } from '@/lib/server/credentials/credential
4
4
  import { getDeviceId, wsConnect, rpcOnConnectedGateway } from '@/lib/providers/openclaw'
5
5
  import { isCliProviderId } from '@/lib/providers/cli-provider-metadata'
6
6
  import { checkCliProviderReady } from '@/lib/server/cli-provider-readiness'
7
+ import { createProviderDiagnostics, sanitizeProviderDiagnosticText } from '@/lib/server/provider-diagnostics'
7
8
  import { OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
9
+ import { normalizeLmStudioEndpoint, normalizeOpenAiCompatibleV1Endpoint } from '@/lib/providers/openai-compatible-endpoint'
8
10
  import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
9
11
  import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './helpers'
12
+ import type { ProviderCheckResult } from '@/types/provider'
10
13
 
11
14
  interface SetupCheckBody {
12
15
  provider?: string
@@ -32,15 +35,21 @@ async function checkOpenAiCompatible(
32
35
  endpointRaw: string,
33
36
  defaultEndpoint: string,
34
37
  modelHint?: string,
35
- ): Promise<{ ok: boolean; message: string; normalizedEndpoint: string }> {
38
+ ): Promise<ProviderCheckResult> {
39
+ const diagnostics = createProviderDiagnostics()
36
40
  const normalizedEndpoint = (endpointRaw || defaultEndpoint).replace(/\/+$/, '')
41
+ diagnostics.pass('Endpoint resolved', { target: normalizedEndpoint })
37
42
  const authHeaders = apiKey ? { authorization: `Bearer ${apiKey}` } : undefined
38
43
 
39
44
  // First, discover a model to test with (prefer the hint, fall back to the first available model)
40
45
  let testModel = modelHint || ''
41
- if (!testModel) {
46
+ if (testModel) {
47
+ diagnostics.pass('Test model selected', { detail: testModel })
48
+ } else {
49
+ const modelsTarget = `${normalizedEndpoint}/models`
50
+ const startedAt = Date.now()
42
51
  try {
43
- const modelsRes = await fetch(`${normalizedEndpoint}/models`, {
52
+ const modelsRes = await fetch(modelsTarget, {
44
53
  headers: authHeaders,
45
54
  signal: AbortSignal.timeout(8_000),
46
55
  cache: 'no-store',
@@ -48,10 +57,33 @@ async function checkOpenAiCompatible(
48
57
  if (modelsRes.ok) {
49
58
  const modelsPayload = await modelsRes.json().catch(() => ({} as Record<string, unknown>))
50
59
  const first = Array.isArray(modelsPayload?.data) ? modelsPayload.data[0] : null
51
- if (first?.id) testModel = String(first.id)
60
+ if (first?.id) {
61
+ testModel = String(first.id)
62
+ diagnostics.pass('Model discovery completed', {
63
+ target: modelsTarget,
64
+ detail: `Using ${testModel}`,
65
+ durationMs: Date.now() - startedAt,
66
+ })
67
+ } else {
68
+ diagnostics.warn('Model discovery returned no models', {
69
+ target: modelsTarget,
70
+ durationMs: Date.now() - startedAt,
71
+ })
72
+ }
73
+ } else {
74
+ const detail = sanitizeProviderDiagnosticText(await parseErrorMessage(modelsRes, `${providerName} models returned ${modelsRes.status}.`))
75
+ diagnostics.warn('Model discovery failed', {
76
+ target: modelsTarget,
77
+ detail: `HTTP ${modelsRes.status}: ${detail}`,
78
+ durationMs: Date.now() - startedAt,
79
+ })
52
80
  }
53
- } catch {
54
- // Model discovery failed — we'll still try the chat endpoint with the provider's default
81
+ } catch (err: unknown) {
82
+ diagnostics.warn('Model discovery request failed', {
83
+ target: modelsTarget,
84
+ detail: err instanceof Error && err.message ? err.message : 'Unable to query models.',
85
+ durationMs: Date.now() - startedAt,
86
+ })
55
87
  }
56
88
  }
57
89
 
@@ -70,57 +102,108 @@ async function checkOpenAiCompatible(
70
102
  DeepInfra: 'deepseek-ai/DeepSeek-R1-0528',
71
103
  OpenRouter: 'openai/gpt-4.1-mini',
72
104
  'Hermes Agent': 'hermes-agent',
105
+ 'LM Studio': 'local-model',
73
106
  }
74
107
  testModel = fallbacks[providerName] || 'gpt-4o-mini'
108
+ diagnostics.warn('Fallback test model selected', { detail: testModel })
75
109
  }
76
110
 
77
111
  // Test the chat completions endpoint with a minimal request
78
- const res = await fetch(`${normalizedEndpoint}/chat/completions`, {
79
- method: 'POST',
80
- headers: {
81
- 'content-type': 'application/json',
82
- ...(authHeaders || {}),
83
- },
84
- body: JSON.stringify({
85
- model: testModel,
86
- max_completion_tokens: 8,
87
- messages: [{ role: 'user', content: 'Reply OK' }],
88
- }),
89
- signal: AbortSignal.timeout(15_000),
90
- cache: 'no-store',
91
- })
112
+ const chatTarget = `${normalizedEndpoint}/chat/completions`
113
+ const chatStartedAt = Date.now()
114
+ let res: Response
115
+ try {
116
+ res = await fetch(chatTarget, {
117
+ method: 'POST',
118
+ headers: {
119
+ 'content-type': 'application/json',
120
+ ...(authHeaders || {}),
121
+ },
122
+ body: JSON.stringify({
123
+ model: testModel,
124
+ max_completion_tokens: 8,
125
+ messages: [{ role: 'user', content: 'Reply OK' }],
126
+ }),
127
+ signal: AbortSignal.timeout(15_000),
128
+ cache: 'no-store',
129
+ })
130
+ } catch (err: unknown) {
131
+ const message = err instanceof Error && err.name === 'TimeoutError'
132
+ ? 'Connection check timed out. Verify endpoint/network and try again.'
133
+ : (err instanceof Error && err.message ? err.message : 'Chat endpoint request failed.')
134
+ diagnostics.fail('Chat completion request failed', {
135
+ target: chatTarget,
136
+ detail: message,
137
+ durationMs: Date.now() - chatStartedAt,
138
+ })
139
+ return { ok: false, message: sanitizeProviderDiagnosticText(message), normalizedEndpoint, diagnostics: diagnostics.toJSON() }
140
+ }
92
141
  if (!res.ok) {
93
- const detail = await parseErrorMessage(res, `${providerName} returned ${res.status}.`)
94
- return { ok: false, message: detail, normalizedEndpoint }
142
+ const detail = sanitizeProviderDiagnosticText(await parseErrorMessage(res, `${providerName} returned ${res.status}.`))
143
+ diagnostics.fail('Chat completion check failed', {
144
+ target: chatTarget,
145
+ detail: `HTTP ${res.status}: ${detail}`,
146
+ durationMs: Date.now() - chatStartedAt,
147
+ })
148
+ return { ok: false, message: detail, normalizedEndpoint, diagnostics: diagnostics.toJSON() }
95
149
  }
150
+ diagnostics.pass('Chat completion check passed', {
151
+ target: chatTarget,
152
+ detail: `Verified with ${testModel}`,
153
+ durationMs: Date.now() - chatStartedAt,
154
+ })
96
155
  return {
97
156
  ok: true,
98
157
  message: `Connected to ${providerName}. Chat endpoint verified with ${testModel}.`,
99
158
  normalizedEndpoint,
159
+ diagnostics: diagnostics.toJSON(),
100
160
  }
101
161
  }
102
162
 
103
- async function checkAnthropic(apiKey: string, endpointRaw: string, modelRaw: string): Promise<{ ok: boolean; message: string }> {
163
+ async function checkAnthropic(apiKey: string, endpointRaw: string, modelRaw: string): Promise<ProviderCheckResult> {
164
+ const diagnostics = createProviderDiagnostics()
104
165
  const model = modelRaw || 'claude-sonnet-4-6'
105
166
  const baseUrl = (endpointRaw || 'https://api.anthropic.com').replace(/\/+$/, '')
106
- const res = await fetch(`${baseUrl}/v1/messages`, {
107
- method: 'POST',
108
- headers: {
109
- 'x-api-key': apiKey,
110
- 'anthropic-version': '2023-06-01',
111
- 'content-type': 'application/json',
112
- },
113
- body: JSON.stringify({
114
- model,
115
- max_tokens: 12,
116
- messages: [{ role: 'user', content: 'Reply with ANTHROPIC_SETUP_OK' }],
117
- }),
118
- signal: AbortSignal.timeout(15_000),
119
- cache: 'no-store',
120
- })
167
+ diagnostics.pass('Endpoint resolved', { target: baseUrl })
168
+ diagnostics.pass('Test model selected', { detail: model })
169
+ const target = `${baseUrl}/v1/messages`
170
+ const startedAt = Date.now()
171
+ let res: Response
172
+ try {
173
+ res = await fetch(target, {
174
+ method: 'POST',
175
+ headers: {
176
+ 'x-api-key': apiKey,
177
+ 'anthropic-version': '2023-06-01',
178
+ 'content-type': 'application/json',
179
+ },
180
+ body: JSON.stringify({
181
+ model,
182
+ max_tokens: 12,
183
+ messages: [{ role: 'user', content: 'Reply with ANTHROPIC_SETUP_OK' }],
184
+ }),
185
+ signal: AbortSignal.timeout(15_000),
186
+ cache: 'no-store',
187
+ })
188
+ } catch (err: unknown) {
189
+ const message = err instanceof Error && err.name === 'TimeoutError'
190
+ ? 'Connection check timed out. Verify endpoint/network and try again.'
191
+ : (err instanceof Error && err.message ? err.message : 'Anthropic request failed.')
192
+ diagnostics.fail('Message check request failed', {
193
+ target,
194
+ detail: message,
195
+ durationMs: Date.now() - startedAt,
196
+ })
197
+ return { ok: false, message: sanitizeProviderDiagnosticText(message), diagnostics: diagnostics.toJSON() }
198
+ }
121
199
  if (!res.ok) {
122
- const detail = await parseErrorMessage(res, `Anthropic returned ${res.status}.`)
123
- return { ok: false, message: detail }
200
+ const detail = sanitizeProviderDiagnosticText(await parseErrorMessage(res, `Anthropic returned ${res.status}.`))
201
+ diagnostics.fail('Message check failed', {
202
+ target,
203
+ detail: `HTTP ${res.status}: ${detail}`,
204
+ durationMs: Date.now() - startedAt,
205
+ })
206
+ return { ok: false, message: detail, diagnostics: diagnostics.toJSON() }
124
207
  }
125
208
  const payload = await res.json().catch(() => ({} as Record<string, unknown>))
126
209
  const content = Array.isArray(payload.content) ? payload.content : []
@@ -128,7 +211,12 @@ async function checkAnthropic(apiKey: string, endpointRaw: string, modelRaw: str
128
211
  const text = firstContent && typeof firstContent === 'object' && 'text' in firstContent && typeof firstContent.text === 'string'
129
212
  ? firstContent.text
130
213
  : ''
131
- return { ok: true, message: text ? `Connected to Anthropic. Sample: ${text.slice(0, 120)}` : 'Connected to Anthropic.' }
214
+ diagnostics.pass('Message check passed', {
215
+ target,
216
+ detail: text ? `Sample: ${text.slice(0, 80)}` : 'Provider returned a successful response.',
217
+ durationMs: Date.now() - startedAt,
218
+ })
219
+ return { ok: true, message: text ? `Connected to Anthropic. Sample: ${text.slice(0, 120)}` : 'Connected to Anthropic.', diagnostics: diagnostics.toJSON() }
132
220
  }
133
221
 
134
222
  async function checkOllama(params: {
@@ -136,7 +224,8 @@ async function checkOllama(params: {
136
224
  modelRaw: string
137
225
  ollamaMode?: string
138
226
  apiKey?: string
139
- }): Promise<{ ok: boolean; message: string; normalizedEndpoint: string; recommendedModel?: string }> {
227
+ }): Promise<ProviderCheckResult> {
228
+ const diagnostics = createProviderDiagnostics()
140
229
  const runtime = resolveOllamaRuntimeConfig({
141
230
  model: params.modelRaw,
142
231
  ollamaMode: params.ollamaMode ?? null,
@@ -144,22 +233,32 @@ async function checkOllama(params: {
144
233
  apiEndpoint: params.endpointRaw,
145
234
  })
146
235
  const normalizedEndpoint = normalizeOllamaSetupEndpoint(runtime.endpoint, runtime.useCloud)
236
+ diagnostics.pass('Endpoint resolved', {
237
+ target: normalizedEndpoint,
238
+ detail: runtime.useCloud ? 'Ollama Cloud mode' : 'Local Ollama mode',
239
+ })
147
240
  const headers: Record<string, string> = runtime.apiKey ? { authorization: `Bearer ${runtime.apiKey}` } : {}
148
241
  if (runtime.useCloud && !runtime.apiKey) {
242
+ diagnostics.fail('Credential required', { detail: 'Ollama Cloud requires an API key.' })
149
243
  return {
150
244
  ok: false,
151
245
  message: 'Ollama Cloud model requires an API key. Set OLLAMA_API_KEY or attach an Ollama credential.',
152
246
  normalizedEndpoint,
247
+ diagnostics: diagnostics.toJSON(),
153
248
  }
154
249
  }
155
250
 
156
251
  // Discover a model to test with
157
252
  let testModel = params.modelRaw || ''
158
253
  let recommendedModel: string | undefined
159
- if (!testModel) {
254
+ if (testModel) {
255
+ diagnostics.pass('Test model selected', { detail: testModel })
256
+ } else {
257
+ const tagsPath = runtime.useCloud ? '/v1/models' : '/api/tags'
258
+ const target = `${normalizedEndpoint}${tagsPath}`
259
+ const startedAt = Date.now()
160
260
  try {
161
- const tagsPath = runtime.useCloud ? '/v1/models' : '/api/tags'
162
- const res = await fetch(`${normalizedEndpoint}${tagsPath}`, {
261
+ const res = await fetch(target, {
163
262
  headers: headers.authorization ? headers : undefined,
164
263
  signal: AbortSignal.timeout(8_000),
165
264
  cache: 'no-store',
@@ -175,63 +274,126 @@ async function checkOllama(params: {
175
274
  if (firstModel) {
176
275
  testModel = firstModel
177
276
  recommendedModel = firstModel
277
+ diagnostics.pass('Model discovery completed', {
278
+ target,
279
+ detail: `Using ${firstModel}`,
280
+ durationMs: Date.now() - startedAt,
281
+ })
178
282
  }
179
283
  if (models.length === 0) {
284
+ diagnostics.warn('Model discovery returned no models', {
285
+ target,
286
+ durationMs: Date.now() - startedAt,
287
+ })
180
288
  return {
181
289
  ok: true,
182
290
  message: runtime.useCloud
183
291
  ? 'Connected to Ollama Cloud, but no models were returned.'
184
292
  : 'Connected to Ollama, but no models are installed yet. Run `ollama pull <model>` to add one.',
185
293
  normalizedEndpoint,
294
+ diagnostics: diagnostics.toJSON(),
186
295
  }
187
296
  }
297
+ } else {
298
+ const detail = sanitizeProviderDiagnosticText(await parseErrorMessage(res, `Ollama model discovery returned ${res.status}.`))
299
+ diagnostics.warn('Model discovery failed', {
300
+ target,
301
+ detail: `HTTP ${res.status}: ${detail}`,
302
+ durationMs: Date.now() - startedAt,
303
+ })
188
304
  }
189
- } catch {
190
- // Model discovery failed — try chat anyway
305
+ } catch (err: unknown) {
306
+ diagnostics.warn('Model discovery request failed', {
307
+ target,
308
+ detail: err instanceof Error && err.message ? err.message : 'Unable to query models.',
309
+ durationMs: Date.now() - startedAt,
310
+ })
191
311
  }
192
312
  }
193
313
 
194
- if (!testModel) testModel = 'llama3.2'
314
+ if (!testModel) {
315
+ testModel = 'llama3.2'
316
+ diagnostics.warn('Fallback test model selected', { detail: testModel })
317
+ }
195
318
 
196
319
  // Test the chat endpoint
197
320
  const label = runtime.useCloud ? 'Ollama Cloud' : 'Ollama'
198
321
  const chatEndpoint = `${normalizedEndpoint}/v1/chat/completions`
199
322
  const chatBody = JSON.stringify({ model: testModel, max_completion_tokens: 8, messages: [{ role: 'user', content: 'Reply OK' }] })
200
323
 
201
- const chatRes = await fetch(chatEndpoint, {
202
- method: 'POST',
203
- headers: { ...headers, 'content-type': 'application/json' },
204
- body: chatBody,
205
- signal: AbortSignal.timeout(30_000),
206
- cache: 'no-store',
207
- })
324
+ const chatStartedAt = Date.now()
325
+ let chatRes: Response
326
+ try {
327
+ chatRes = await fetch(chatEndpoint, {
328
+ method: 'POST',
329
+ headers: { ...headers, 'content-type': 'application/json' },
330
+ body: chatBody,
331
+ signal: AbortSignal.timeout(30_000),
332
+ cache: 'no-store',
333
+ })
334
+ } catch (err: unknown) {
335
+ const message = err instanceof Error && err.name === 'TimeoutError'
336
+ ? 'Connection check timed out. Verify endpoint/network and try again.'
337
+ : (err instanceof Error && err.message ? err.message : 'Ollama chat request failed.')
338
+ diagnostics.fail('Chat completion request failed', {
339
+ target: chatEndpoint,
340
+ detail: message,
341
+ durationMs: Date.now() - chatStartedAt,
342
+ })
343
+ return { ok: false, message: sanitizeProviderDiagnosticText(message), normalizedEndpoint, recommendedModel, diagnostics: diagnostics.toJSON() }
344
+ }
208
345
  if (!chatRes.ok) {
209
- const detail = await parseErrorMessage(chatRes, `${label} chat returned ${chatRes.status}.`)
210
- return { ok: false, message: detail, normalizedEndpoint, recommendedModel }
346
+ const detail = sanitizeProviderDiagnosticText(await parseErrorMessage(chatRes, `${label} chat returned ${chatRes.status}.`))
347
+ diagnostics.fail('Chat completion check failed', {
348
+ target: chatEndpoint,
349
+ detail: `HTTP ${chatRes.status}: ${detail}`,
350
+ durationMs: Date.now() - chatStartedAt,
351
+ })
352
+ return { ok: false, message: detail, normalizedEndpoint, recommendedModel, diagnostics: diagnostics.toJSON() }
211
353
  }
354
+ diagnostics.pass('Chat completion check passed', {
355
+ target: chatEndpoint,
356
+ detail: `Verified with ${testModel}`,
357
+ durationMs: Date.now() - chatStartedAt,
358
+ })
212
359
  return {
213
360
  ok: true,
214
361
  message: `Connected to ${label}. Chat verified with ${testModel}.`,
215
362
  normalizedEndpoint,
216
363
  recommendedModel: recommendedModel || testModel,
364
+ diagnostics: diagnostics.toJSON(),
217
365
  }
218
366
  }
219
367
 
220
- async function checkOpenClaw(apiKey: string, endpointRaw: string): Promise<{ ok: boolean; message: string; normalizedEndpoint: string; deviceId?: string; errorCode?: string; recommendedModel?: string }> {
368
+ async function checkOpenClaw(apiKey: string, endpointRaw: string): Promise<ProviderCheckResult> {
369
+ const diagnostics = createProviderDiagnostics()
221
370
  const { httpUrl: normalizedEndpoint, wsUrl } = normalizeOpenClawUrl(endpointRaw)
222
371
  const token = apiKey || undefined
223
372
  const deviceId = getDeviceId()
373
+ diagnostics.pass('Endpoint resolved', { target: normalizedEndpoint })
224
374
 
375
+ const wsStartedAt = Date.now()
225
376
  const result = await wsConnect(wsUrl, token, true, 10_000)
226
377
 
227
378
  if (!result.ok) {
228
379
  if (result.ws) try { result.ws.close() } catch {}
229
- return { ok: false, message: result.message, normalizedEndpoint, deviceId, errorCode: result.errorCode }
380
+ diagnostics.fail('Gateway websocket check failed', {
381
+ target: wsUrl,
382
+ detail: result.message,
383
+ durationMs: Date.now() - wsStartedAt,
384
+ })
385
+ return { ok: false, message: sanitizeProviderDiagnosticText(result.message), normalizedEndpoint, deviceId, errorCode: result.errorCode, diagnostics: diagnostics.toJSON() }
230
386
  }
387
+ diagnostics.pass('Gateway websocket check passed', {
388
+ target: wsUrl,
389
+ detail: deviceId ? `Device ${deviceId}` : undefined,
390
+ durationMs: Date.now() - wsStartedAt,
391
+ })
231
392
 
232
393
  // Attempt model discovery via RPC before closing the connection
233
394
  let recommendedModel: string | undefined
234
395
  if (result.ws) {
396
+ const modelStartedAt = Date.now()
235
397
  try {
236
398
  const payload = await rpcOnConnectedGateway(result.ws, 'models.list', {}, 8_000) as Record<string, unknown> | unknown[] | undefined
237
399
  const p = payload as Record<string, unknown> | undefined
@@ -244,13 +406,20 @@ async function checkOpenClaw(apiKey: string, endpointRaw: string): Promise<{ ok:
244
406
  } else if (typeof first?.name === 'string') {
245
407
  recommendedModel = first.name
246
408
  }
247
- } catch {
248
- // Model discovery is non-fatal connection still counts as successful
409
+ diagnostics.pass('Gateway model discovery completed', {
410
+ detail: recommendedModel ? `Using ${recommendedModel}` : 'No model recommendation returned.',
411
+ durationMs: Date.now() - modelStartedAt,
412
+ })
413
+ } catch (err: unknown) {
414
+ diagnostics.warn('Gateway model discovery failed', {
415
+ detail: err instanceof Error && err.message ? err.message : 'Model discovery is unavailable.',
416
+ durationMs: Date.now() - modelStartedAt,
417
+ })
249
418
  }
250
419
  try { result.ws.close() } catch {}
251
420
  }
252
421
 
253
- return { ok: true, message: 'Connected to OpenClaw gateway.', normalizedEndpoint, deviceId, recommendedModel }
422
+ return { ok: true, message: 'Connected to OpenClaw gateway.', normalizedEndpoint, deviceId, recommendedModel, diagnostics: diagnostics.toJSON() }
254
423
  }
255
424
 
256
425
  export async function POST(req: Request) {
@@ -300,7 +469,12 @@ export async function POST(req: Request) {
300
469
 
301
470
  if (isCliProviderId(provider)) {
302
471
  const result = checkCliProviderReady(provider)
303
- return NextResponse.json(result)
472
+ const diagnostics = createProviderDiagnostics()
473
+ diagnostics.add('CLI readiness check', result.ok ? 'pass' : 'fail', {
474
+ detail: result.message,
475
+ target: result.binaryPath || result.binaryName || provider,
476
+ })
477
+ return NextResponse.json({ ...result, diagnostics: diagnostics.toJSON() })
304
478
  }
305
479
 
306
480
  if (!provider) {
@@ -312,7 +486,13 @@ export async function POST(req: Request) {
312
486
  case 'openai': {
313
487
  if (!apiKey) return NextResponse.json({ ok: false, message: 'OpenAI API key is required.' })
314
488
  const info = OPENAI_COMPATIBLE_DEFAULTS.openai
315
- const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
489
+ const result = await checkOpenAiCompatible(
490
+ info.name,
491
+ apiKey,
492
+ normalizeOpenAiCompatibleV1Endpoint(endpoint || info.defaultEndpoint, info.defaultEndpoint),
493
+ info.defaultEndpoint,
494
+ model,
495
+ )
316
496
  return NextResponse.json(result)
317
497
  }
318
498
  case 'openrouter': {
@@ -345,6 +525,17 @@ export async function POST(req: Request) {
345
525
  const result = await checkOpenAiCompatible(info.name, apiKey, endpoint, info.defaultEndpoint, model)
346
526
  return NextResponse.json(result)
347
527
  }
528
+ case 'lmstudio': {
529
+ const info = OPENAI_COMPATIBLE_DEFAULTS.lmstudio
530
+ const result = await checkOpenAiCompatible(
531
+ info.name,
532
+ apiKey,
533
+ normalizeLmStudioEndpoint(endpoint || info.defaultEndpoint),
534
+ info.defaultEndpoint,
535
+ model,
536
+ )
537
+ return NextResponse.json(result)
538
+ }
348
539
  case 'ollama': {
349
540
  const result = await checkOllama({
350
541
  endpointRaw: endpoint,