@swarmclawai/swarmclaw 1.9.20 → 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
  ```
@@ -407,6 +409,15 @@ 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
+
410
421
  ### v1.9.20 Highlights
411
422
 
412
423
  Provider reliability release: local OpenAI-compatible runtimes now get safer endpoint handling, clearer setup, and first-class LM Studio support.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.20",
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,10 +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'
8
9
  import { normalizeLmStudioEndpoint, normalizeOpenAiCompatibleV1Endpoint } from '@/lib/providers/openai-compatible-endpoint'
9
10
  import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
10
11
  import { normalizeOllamaSetupEndpoint, normalizeOpenClawUrl, parseErrorMessage } from './helpers'
12
+ import type { ProviderCheckResult } from '@/types/provider'
11
13
 
12
14
  interface SetupCheckBody {
13
15
  provider?: string
@@ -33,15 +35,21 @@ async function checkOpenAiCompatible(
33
35
  endpointRaw: string,
34
36
  defaultEndpoint: string,
35
37
  modelHint?: string,
36
- ): Promise<{ ok: boolean; message: string; normalizedEndpoint: string }> {
38
+ ): Promise<ProviderCheckResult> {
39
+ const diagnostics = createProviderDiagnostics()
37
40
  const normalizedEndpoint = (endpointRaw || defaultEndpoint).replace(/\/+$/, '')
41
+ diagnostics.pass('Endpoint resolved', { target: normalizedEndpoint })
38
42
  const authHeaders = apiKey ? { authorization: `Bearer ${apiKey}` } : undefined
39
43
 
40
44
  // First, discover a model to test with (prefer the hint, fall back to the first available model)
41
45
  let testModel = modelHint || ''
42
- 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()
43
51
  try {
44
- const modelsRes = await fetch(`${normalizedEndpoint}/models`, {
52
+ const modelsRes = await fetch(modelsTarget, {
45
53
  headers: authHeaders,
46
54
  signal: AbortSignal.timeout(8_000),
47
55
  cache: 'no-store',
@@ -49,10 +57,33 @@ async function checkOpenAiCompatible(
49
57
  if (modelsRes.ok) {
50
58
  const modelsPayload = await modelsRes.json().catch(() => ({} as Record<string, unknown>))
51
59
  const first = Array.isArray(modelsPayload?.data) ? modelsPayload.data[0] : null
52
- 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
+ })
53
80
  }
54
- } catch {
55
- // 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
+ })
56
87
  }
57
88
  }
58
89
 
@@ -74,55 +105,105 @@ async function checkOpenAiCompatible(
74
105
  'LM Studio': 'local-model',
75
106
  }
76
107
  testModel = fallbacks[providerName] || 'gpt-4o-mini'
108
+ diagnostics.warn('Fallback test model selected', { detail: testModel })
77
109
  }
78
110
 
79
111
  // Test the chat completions endpoint with a minimal request
80
- const res = await fetch(`${normalizedEndpoint}/chat/completions`, {
81
- method: 'POST',
82
- headers: {
83
- 'content-type': 'application/json',
84
- ...(authHeaders || {}),
85
- },
86
- body: JSON.stringify({
87
- model: testModel,
88
- max_completion_tokens: 8,
89
- messages: [{ role: 'user', content: 'Reply OK' }],
90
- }),
91
- signal: AbortSignal.timeout(15_000),
92
- cache: 'no-store',
93
- })
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
+ }
94
141
  if (!res.ok) {
95
- const detail = await parseErrorMessage(res, `${providerName} returned ${res.status}.`)
96
- 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() }
97
149
  }
150
+ diagnostics.pass('Chat completion check passed', {
151
+ target: chatTarget,
152
+ detail: `Verified with ${testModel}`,
153
+ durationMs: Date.now() - chatStartedAt,
154
+ })
98
155
  return {
99
156
  ok: true,
100
157
  message: `Connected to ${providerName}. Chat endpoint verified with ${testModel}.`,
101
158
  normalizedEndpoint,
159
+ diagnostics: diagnostics.toJSON(),
102
160
  }
103
161
  }
104
162
 
105
- 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()
106
165
  const model = modelRaw || 'claude-sonnet-4-6'
107
166
  const baseUrl = (endpointRaw || 'https://api.anthropic.com').replace(/\/+$/, '')
108
- const res = await fetch(`${baseUrl}/v1/messages`, {
109
- method: 'POST',
110
- headers: {
111
- 'x-api-key': apiKey,
112
- 'anthropic-version': '2023-06-01',
113
- 'content-type': 'application/json',
114
- },
115
- body: JSON.stringify({
116
- model,
117
- max_tokens: 12,
118
- messages: [{ role: 'user', content: 'Reply with ANTHROPIC_SETUP_OK' }],
119
- }),
120
- signal: AbortSignal.timeout(15_000),
121
- cache: 'no-store',
122
- })
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
+ }
123
199
  if (!res.ok) {
124
- const detail = await parseErrorMessage(res, `Anthropic returned ${res.status}.`)
125
- 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() }
126
207
  }
127
208
  const payload = await res.json().catch(() => ({} as Record<string, unknown>))
128
209
  const content = Array.isArray(payload.content) ? payload.content : []
@@ -130,7 +211,12 @@ async function checkAnthropic(apiKey: string, endpointRaw: string, modelRaw: str
130
211
  const text = firstContent && typeof firstContent === 'object' && 'text' in firstContent && typeof firstContent.text === 'string'
131
212
  ? firstContent.text
132
213
  : ''
133
- 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() }
134
220
  }
135
221
 
136
222
  async function checkOllama(params: {
@@ -138,7 +224,8 @@ async function checkOllama(params: {
138
224
  modelRaw: string
139
225
  ollamaMode?: string
140
226
  apiKey?: string
141
- }): Promise<{ ok: boolean; message: string; normalizedEndpoint: string; recommendedModel?: string }> {
227
+ }): Promise<ProviderCheckResult> {
228
+ const diagnostics = createProviderDiagnostics()
142
229
  const runtime = resolveOllamaRuntimeConfig({
143
230
  model: params.modelRaw,
144
231
  ollamaMode: params.ollamaMode ?? null,
@@ -146,22 +233,32 @@ async function checkOllama(params: {
146
233
  apiEndpoint: params.endpointRaw,
147
234
  })
148
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
+ })
149
240
  const headers: Record<string, string> = runtime.apiKey ? { authorization: `Bearer ${runtime.apiKey}` } : {}
150
241
  if (runtime.useCloud && !runtime.apiKey) {
242
+ diagnostics.fail('Credential required', { detail: 'Ollama Cloud requires an API key.' })
151
243
  return {
152
244
  ok: false,
153
245
  message: 'Ollama Cloud model requires an API key. Set OLLAMA_API_KEY or attach an Ollama credential.',
154
246
  normalizedEndpoint,
247
+ diagnostics: diagnostics.toJSON(),
155
248
  }
156
249
  }
157
250
 
158
251
  // Discover a model to test with
159
252
  let testModel = params.modelRaw || ''
160
253
  let recommendedModel: string | undefined
161
- 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()
162
260
  try {
163
- const tagsPath = runtime.useCloud ? '/v1/models' : '/api/tags'
164
- const res = await fetch(`${normalizedEndpoint}${tagsPath}`, {
261
+ const res = await fetch(target, {
165
262
  headers: headers.authorization ? headers : undefined,
166
263
  signal: AbortSignal.timeout(8_000),
167
264
  cache: 'no-store',
@@ -177,63 +274,126 @@ async function checkOllama(params: {
177
274
  if (firstModel) {
178
275
  testModel = firstModel
179
276
  recommendedModel = firstModel
277
+ diagnostics.pass('Model discovery completed', {
278
+ target,
279
+ detail: `Using ${firstModel}`,
280
+ durationMs: Date.now() - startedAt,
281
+ })
180
282
  }
181
283
  if (models.length === 0) {
284
+ diagnostics.warn('Model discovery returned no models', {
285
+ target,
286
+ durationMs: Date.now() - startedAt,
287
+ })
182
288
  return {
183
289
  ok: true,
184
290
  message: runtime.useCloud
185
291
  ? 'Connected to Ollama Cloud, but no models were returned.'
186
292
  : 'Connected to Ollama, but no models are installed yet. Run `ollama pull <model>` to add one.',
187
293
  normalizedEndpoint,
294
+ diagnostics: diagnostics.toJSON(),
188
295
  }
189
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
+ })
190
304
  }
191
- } catch {
192
- // 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
+ })
193
311
  }
194
312
  }
195
313
 
196
- if (!testModel) testModel = 'llama3.2'
314
+ if (!testModel) {
315
+ testModel = 'llama3.2'
316
+ diagnostics.warn('Fallback test model selected', { detail: testModel })
317
+ }
197
318
 
198
319
  // Test the chat endpoint
199
320
  const label = runtime.useCloud ? 'Ollama Cloud' : 'Ollama'
200
321
  const chatEndpoint = `${normalizedEndpoint}/v1/chat/completions`
201
322
  const chatBody = JSON.stringify({ model: testModel, max_completion_tokens: 8, messages: [{ role: 'user', content: 'Reply OK' }] })
202
323
 
203
- const chatRes = await fetch(chatEndpoint, {
204
- method: 'POST',
205
- headers: { ...headers, 'content-type': 'application/json' },
206
- body: chatBody,
207
- signal: AbortSignal.timeout(30_000),
208
- cache: 'no-store',
209
- })
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
+ }
210
345
  if (!chatRes.ok) {
211
- const detail = await parseErrorMessage(chatRes, `${label} chat returned ${chatRes.status}.`)
212
- 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() }
213
353
  }
354
+ diagnostics.pass('Chat completion check passed', {
355
+ target: chatEndpoint,
356
+ detail: `Verified with ${testModel}`,
357
+ durationMs: Date.now() - chatStartedAt,
358
+ })
214
359
  return {
215
360
  ok: true,
216
361
  message: `Connected to ${label}. Chat verified with ${testModel}.`,
217
362
  normalizedEndpoint,
218
363
  recommendedModel: recommendedModel || testModel,
364
+ diagnostics: diagnostics.toJSON(),
219
365
  }
220
366
  }
221
367
 
222
- 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()
223
370
  const { httpUrl: normalizedEndpoint, wsUrl } = normalizeOpenClawUrl(endpointRaw)
224
371
  const token = apiKey || undefined
225
372
  const deviceId = getDeviceId()
373
+ diagnostics.pass('Endpoint resolved', { target: normalizedEndpoint })
226
374
 
375
+ const wsStartedAt = Date.now()
227
376
  const result = await wsConnect(wsUrl, token, true, 10_000)
228
377
 
229
378
  if (!result.ok) {
230
379
  if (result.ws) try { result.ws.close() } catch {}
231
- 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() }
232
386
  }
387
+ diagnostics.pass('Gateway websocket check passed', {
388
+ target: wsUrl,
389
+ detail: deviceId ? `Device ${deviceId}` : undefined,
390
+ durationMs: Date.now() - wsStartedAt,
391
+ })
233
392
 
234
393
  // Attempt model discovery via RPC before closing the connection
235
394
  let recommendedModel: string | undefined
236
395
  if (result.ws) {
396
+ const modelStartedAt = Date.now()
237
397
  try {
238
398
  const payload = await rpcOnConnectedGateway(result.ws, 'models.list', {}, 8_000) as Record<string, unknown> | unknown[] | undefined
239
399
  const p = payload as Record<string, unknown> | undefined
@@ -246,13 +406,20 @@ async function checkOpenClaw(apiKey: string, endpointRaw: string): Promise<{ ok:
246
406
  } else if (typeof first?.name === 'string') {
247
407
  recommendedModel = first.name
248
408
  }
249
- } catch {
250
- // 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
+ })
251
418
  }
252
419
  try { result.ws.close() } catch {}
253
420
  }
254
421
 
255
- 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() }
256
423
  }
257
424
 
258
425
  export async function POST(req: Request) {
@@ -302,7 +469,12 @@ export async function POST(req: Request) {
302
469
 
303
470
  if (isCliProviderId(provider)) {
304
471
  const result = checkCliProviderReady(provider)
305
- 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() })
306
478
  }
307
479
 
308
480
  if (!provider) {
@@ -10,7 +10,7 @@ import { sleep } from '@/lib/shared-utils'
10
10
  import { BottomSheet } from '@/components/shared/bottom-sheet'
11
11
  import { toast } from 'sonner'
12
12
  import { ModelCombobox } from '@/components/shared/model-combobox'
13
- import type { ProviderType, ClaudeSkill, AgentPackManifest, AgentRoutingStrategy, AgentRoutingTarget } from '@/types'
13
+ import type { ProviderType, ProviderDiagnosticStep, ClaudeSkill, AgentPackManifest, AgentRoutingStrategy, AgentRoutingTarget } from '@/types'
14
14
  import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
15
15
  import { MCP_INJECTION_PROVIDER_IDS, NATIVE_CAPABILITY_PROVIDER_IDS, NON_LANGGRAPH_PROVIDER_IDS, WORKER_ONLY_PROVIDER_IDS } from '@/lib/provider-sets'
16
16
  import { isOrchestratorProviderEligible } from '@/lib/orchestrator-config'
@@ -33,6 +33,7 @@ import { buildAgentSelectableProviders, resolveAgentSelectableProviderCredential
33
33
  import { AgentSocialSettings } from '@/features/swarmfeed/agent-social-settings'
34
34
  import { AgentMarketplaceSettings } from '@/features/swarmdock/agent-marketplace-settings'
35
35
  import type { ConfigVersion } from '@/types/config-version'
36
+ import { ProviderDiagnosticsList } from '@/components/providers/provider-diagnostics-list'
36
37
 
37
38
  const HB_PRESETS = [1800, 3600, 7200, 21600, 43200] as const
38
39
  const FALLBACK_ELEVENLABS_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb'
@@ -284,6 +285,7 @@ export function AgentSheet() {
284
285
  const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'pass' | 'fail'>('idle')
285
286
  const [testMessage, setTestMessage] = useState('')
286
287
  const [testErrorCode, setTestErrorCode] = useState<string | null>(null)
288
+ const [testDiagnostics, setTestDiagnostics] = useState<ProviderDiagnosticStep[]>([])
287
289
  const [testDeviceId, setTestDeviceId] = useState<string | null>(null)
288
290
  const [openclawDeviceId, setOpenclawDeviceId] = useState<string | null>(null)
289
291
  const [configCopied, setConfigCopied] = useState(false)
@@ -426,6 +428,7 @@ export function AgentSheet() {
426
428
  .catch(() => {})
427
429
  setTestStatus('idle')
428
430
  setTestMessage('')
431
+ setTestDiagnostics([])
429
432
  setShowAdvancedSettings(false)
430
433
  if (editing) {
431
434
  setName(editing.name)
@@ -690,6 +693,7 @@ export function AgentSheet() {
690
693
  useEffect(() => {
691
694
  setTestStatus('idle')
692
695
  setTestMessage('')
696
+ setTestDiagnostics([])
693
697
  }, [provider, credentialId, apiEndpoint])
694
698
 
695
699
  // Fetch MCP tools when selected servers change
@@ -758,6 +762,7 @@ export function AgentSheet() {
758
762
  setTestStatus('idle')
759
763
  setTestMessage('')
760
764
  setTestErrorCode(null)
765
+ setTestDiagnostics([])
761
766
  setAddingKey(false)
762
767
  setNewKeyName('')
763
768
  setNewKeyValue('')
@@ -1047,8 +1052,9 @@ export function AgentSheet() {
1047
1052
  setTestStatus('testing')
1048
1053
  setTestMessage('')
1049
1054
  setTestErrorCode(null)
1055
+ setTestDiagnostics([])
1050
1056
  try {
1051
- const result = await api<{ ok: boolean; message: string; errorCode?: string; deviceId?: string }>('POST', '/setup/check-provider', {
1057
+ const result = await api<{ ok: boolean; message: string; errorCode?: string; deviceId?: string; diagnostics?: ProviderDiagnosticStep[] }>('POST', '/setup/check-provider', {
1052
1058
  provider,
1053
1059
  credentialId,
1054
1060
  endpoint: apiEndpoint,
@@ -1057,6 +1063,7 @@ export function AgentSheet() {
1057
1063
  }, {
1058
1064
  timeoutMs: CONNECTION_TEST_TIMEOUT_MS,
1059
1065
  })
1066
+ setTestDiagnostics(result.diagnostics ?? [])
1060
1067
  if (result.deviceId) setTestDeviceId(result.deviceId)
1061
1068
  if (result.ok) {
1062
1069
  let syncedModels: string[] = []
@@ -1084,6 +1091,7 @@ export function AgentSheet() {
1084
1091
  const msg = err instanceof Error ? err.message : 'Connection test failed'
1085
1092
  setTestStatus('fail')
1086
1093
  setTestMessage(msg)
1094
+ setTestDiagnostics([])
1087
1095
  toast.error(msg)
1088
1096
  return false
1089
1097
  }
@@ -1317,6 +1325,10 @@ export function AgentSheet() {
1317
1325
  <button
1318
1326
  type="button"
1319
1327
  onClick={() => {
1328
+ setTestStatus('idle')
1329
+ setTestMessage('')
1330
+ setTestErrorCode(null)
1331
+ setTestDiagnostics([])
1320
1332
  if (!openclawEnabled) {
1321
1333
  setOpenclawEnabled(true)
1322
1334
  setProvider('openclaw')
@@ -1330,9 +1342,6 @@ export function AgentSheet() {
1330
1342
  setApiEndpoint(null)
1331
1343
  setCredentialId(null)
1332
1344
  setGatewayProfileId(null)
1333
- setTestStatus('idle')
1334
- setTestMessage('')
1335
- setTestErrorCode(null)
1336
1345
  }
1337
1346
  }}
1338
1347
  className={`relative h-6 w-11 rounded-full border-none transition-colors duration-200 ${openclawEnabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`}
@@ -1473,6 +1482,7 @@ export function AgentSheet() {
1473
1482
  <p className="text-[14px] text-emerald-400 font-600">Connected</p>
1474
1483
  </div>
1475
1484
  <p className="text-[13px] text-text-2/80 leading-[1.6]">Gateway is reachable and this device is paired. Tools and models are managed by the OpenClaw instance.</p>
1485
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
1476
1486
  </div>
1477
1487
  )}
1478
1488
  {testStatus === 'fail' && (
@@ -1548,6 +1558,7 @@ export function AgentSheet() {
1548
1558
  </p>
1549
1559
  </div>
1550
1560
  )}
1561
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
1551
1562
  </div>
1552
1563
  )}
1553
1564
  </div>
@@ -2965,11 +2976,13 @@ export function AgentSheet() {
2965
2976
  {!openclawEnabled && testStatus === 'fail' && (
2966
2977
  <div className="mb-4 p-3 rounded-[12px] bg-red-500/[0.08] border border-red-500/20">
2967
2978
  <p className="text-[13px] text-red-400">{testMessage || 'Connection test failed'}</p>
2979
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
2968
2980
  </div>
2969
2981
  )}
2970
2982
  {!openclawEnabled && testStatus === 'pass' && (
2971
2983
  <div className="mb-4 p-3 rounded-[12px] bg-emerald-500/[0.08] border border-emerald-500/20">
2972
2984
  <p className="text-[13px] text-emerald-400">{testMessage || 'Connected successfully'}</p>
2985
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
2973
2986
  </div>
2974
2987
  )}
2975
2988
 
@@ -5,7 +5,8 @@ import { api } from '@/lib/app/api-client'
5
5
  import { dedup, errorMessage } from '@/lib/shared-utils'
6
6
  import { getDefaultModelForProvider } from '@/lib/setup-defaults'
7
7
  import { OpenClawDeployPanel } from '@/components/openclaw/openclaw-deploy-panel'
8
- import type { Credential, Credentials, GatewayProfile, ProviderId, ProviderConfig } from '@/types'
8
+ import { ProviderDiagnosticsList } from '@/components/providers/provider-diagnostics-list'
9
+ import type { Credential, Credentials, GatewayProfile, ProviderDiagnosticStep, ProviderId, ProviderConfig } from '@/types'
9
10
  import type { StepConnectProps, CheckState, ProviderCheckResponse, ConfiguredProvider } from './types'
10
11
  import {
11
12
  formatEndpointHost,
@@ -34,6 +35,7 @@ export function StepConnect({
34
35
  const [checkState, setCheckState] = useState<CheckState>(editingProvider?.verified ? 'ok' : 'idle')
35
36
  const [checkMessage, setCheckMessage] = useState('')
36
37
  const [checkErrorCode, setCheckErrorCode] = useState<string | null>(null)
38
+ const [checkDiagnostics, setCheckDiagnostics] = useState<ProviderDiagnosticStep[]>([])
37
39
  const [openclawDeviceId, setOpenclawDeviceId] = useState<string | null>(null)
38
40
  const [providerSuggestedModel, setProviderSuggestedModel] = useState(
39
41
  editingProvider?.defaultModel || (provider === 'custom' ? '' : getDefaultModelForProvider(provider)),
@@ -111,6 +113,7 @@ export function StepConnect({
111
113
  setCheckState('idle')
112
114
  setCheckMessage('')
113
115
  setCheckErrorCode(null)
116
+ setCheckDiagnostics([])
114
117
  setError('')
115
118
  }
116
119
 
@@ -118,12 +121,14 @@ export function StepConnect({
118
121
  if (requiresKey && !hasKeyOrCredential) {
119
122
  setCheckState('error')
120
123
  setCheckMessage('Please paste your API key or select a saved key first.')
124
+ setCheckDiagnostics([])
121
125
  return false
122
126
  }
123
127
 
124
128
  setCheckState('checking')
125
129
  setCheckMessage('')
126
130
  setCheckErrorCode(null)
131
+ setCheckDiagnostics([])
127
132
  setError('')
128
133
  try {
129
134
  const result = await api<ProviderCheckResponse>('POST', '/setup/check-provider', {
@@ -140,6 +145,7 @@ export function StepConnect({
140
145
  setProviderSuggestedModel(result.recommendedModel)
141
146
  }
142
147
  setCheckErrorCode(result.errorCode || null)
148
+ setCheckDiagnostics(result.diagnostics ?? [])
143
149
  setOpenclawDeviceId(result.deviceId || null)
144
150
  setCheckState(result.ok ? 'ok' : 'error')
145
151
  setCheckMessage(result.message || (result.ok ? 'Connected successfully.' : 'Connection failed.'))
@@ -148,6 +154,7 @@ export function StepConnect({
148
154
  setCheckState('error')
149
155
  setCheckMessage(errorMessage(err))
150
156
  setCheckErrorCode(null)
157
+ setCheckDiagnostics([])
151
158
  return false
152
159
  }
153
160
  }
@@ -280,7 +287,7 @@ export function StepConnect({
280
287
  <input
281
288
  type="text"
282
289
  value={endpoint}
283
- onChange={(e) => { setEndpoint(e.target.value); setCheckState('idle'); setCheckMessage('') }}
290
+ onChange={(e) => { setEndpoint(e.target.value); setCheckState('idle'); setCheckMessage(''); setCheckDiagnostics([]) }}
284
291
  placeholder={selectedProvider.defaultEndpoint || ''}
285
292
  className="w-full px-4 py-3 rounded-[12px] border border-white/[0.08] bg-surface
286
293
  text-text text-[14px] font-mono outline-none transition-all duration-200
@@ -295,6 +302,7 @@ export function StepConnect({
295
302
  setEndpoint(isCloud ? (selectedProvider.defaultEndpoint || '') : selectedProvider.cloudEndpoint!)
296
303
  setCheckState('idle')
297
304
  setCheckMessage('')
305
+ setCheckDiagnostics([])
298
306
  }}
299
307
  className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] border text-[12px] font-500 cursor-pointer transition-all duration-200 bg-transparent
300
308
  border-white/[0.08] text-text-2 hover:bg-white/[0.04]"
@@ -440,6 +448,7 @@ export function StepConnect({
440
448
  setApiKey('')
441
449
  setCheckState('idle')
442
450
  setCheckMessage('')
451
+ setCheckDiagnostics([])
443
452
  }
444
453
  }}
445
454
  className="w-full px-4 py-3 rounded-[12px] border border-white/[0.08] bg-surface
@@ -466,7 +475,7 @@ export function StepConnect({
466
475
  <input
467
476
  type="password"
468
477
  value={apiKey}
469
- onChange={(e) => { setApiKey(e.target.value); setCredentialId(null); setCheckState('idle'); setCheckMessage(''); setError('') }}
478
+ onChange={(e) => { setApiKey(e.target.value); setCredentialId(null); setCheckState('idle'); setCheckMessage(''); setCheckDiagnostics([]); setError('') }}
470
479
  placeholder={selectedProvider.keyPlaceholder || (provider === 'openclaw' ? 'Paste OpenClaw bearer token' : 'sk-...')}
471
480
  className="w-full px-4 py-3 rounded-[12px] border border-white/[0.08] bg-surface
472
481
  text-text text-[14px] font-mono outline-none transition-all duration-200
@@ -530,6 +539,7 @@ export function StepConnect({
530
539
  Device paired as <code className="text-text-2">{openclawDeviceId.slice(0, 12)}...</code>.
531
540
  </p>
532
541
  )}
542
+ <ProviderDiagnosticsList diagnostics={checkDiagnostics} />
533
543
  </div>
534
544
  )}
535
545
 
@@ -1,17 +1,10 @@
1
- import type { GatewayProfile, ProviderId } from '@/types'
1
+ import type { GatewayProfile, ProviderCheckResult, ProviderId } from '@/types'
2
2
  import type { SetupProvider } from '@/lib/setup-defaults'
3
3
 
4
4
  export type SetupStep = 'profile' | 'path' | 'providers' | 'connect' | 'agents' | 'next' | 'done'
5
5
  export type CheckState = 'idle' | 'checking' | 'ok' | 'error'
6
6
 
7
- export interface ProviderCheckResponse {
8
- ok: boolean
9
- message: string
10
- normalizedEndpoint?: string
11
- recommendedModel?: string
12
- errorCode?: string
13
- deviceId?: string
14
- }
7
+ export type ProviderCheckResponse = ProviderCheckResult
15
8
 
16
9
  export interface SetupDoctorCheck {
17
10
  id: string
@@ -0,0 +1,58 @@
1
+ 'use client'
2
+
3
+ import type { ProviderDiagnosticStep } from '@/types'
4
+
5
+ const STATUS_CLASSES: Record<ProviderDiagnosticStep['status'], string> = {
6
+ pass: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-300',
7
+ warn: 'border-amber-500/25 bg-amber-500/10 text-amber-300',
8
+ fail: 'border-red-500/25 bg-red-500/10 text-red-300',
9
+ }
10
+
11
+ const STATUS_LABELS: Record<ProviderDiagnosticStep['status'], string> = {
12
+ pass: 'Pass',
13
+ warn: 'Warn',
14
+ fail: 'Fail',
15
+ }
16
+
17
+ export function ProviderDiagnosticsList({
18
+ diagnostics,
19
+ className = '',
20
+ }: {
21
+ diagnostics?: ProviderDiagnosticStep[] | null
22
+ className?: string
23
+ }) {
24
+ if (!diagnostics?.length) return null
25
+
26
+ return (
27
+ <div className={`mt-3 border-t border-white/[0.06] pt-3 ${className}`}>
28
+ <div className="mb-2 text-[11px] font-700 uppercase tracking-[0.1em] text-text-3/70">
29
+ Diagnostics
30
+ </div>
31
+ <ol className="space-y-2">
32
+ {diagnostics.map((step) => (
33
+ <li key={step.id} className="grid gap-1 text-left sm:grid-cols-[64px_minmax(0,1fr)] sm:gap-3">
34
+ <div>
35
+ <span className={`inline-flex min-w-[54px] items-center justify-center rounded-[999px] border px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.08em] ${STATUS_CLASSES[step.status]}`}>
36
+ {STATUS_LABELS[step.status]}
37
+ </span>
38
+ </div>
39
+ <div className="min-w-0">
40
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
41
+ <span className="text-[12px] font-650 text-text-2">{step.label}</span>
42
+ {typeof step.durationMs === 'number' && (
43
+ <span className="text-[11px] text-text-3">{step.durationMs} ms</span>
44
+ )}
45
+ </div>
46
+ {step.target && (
47
+ <div className="mt-0.5 break-all font-mono text-[11px] text-text-3">{step.target}</div>
48
+ )}
49
+ {step.detail && (
50
+ <div className="mt-0.5 text-[11px] leading-relaxed text-text-3">{step.detail}</div>
51
+ )}
52
+ </div>
53
+ </li>
54
+ ))}
55
+ </ol>
56
+ </div>
57
+ )
58
+ }
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { BottomSheet } from '@/components/shared/bottom-sheet'
6
6
  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
7
+ import { ProviderDiagnosticsList } from '@/components/providers/provider-diagnostics-list'
7
8
  import { toast } from 'sonner'
8
9
  import { errorMessage } from '@/lib/shared-utils'
9
10
  import {
@@ -17,6 +18,7 @@ import {
17
18
  useSaveCustomProviderMutation,
18
19
  } from '@/features/providers/queries'
19
20
  import { useCreateCredentialMutation, useCredentialsQuery } from '@/features/credentials/queries'
21
+ import type { ProviderDiagnosticStep } from '@/types'
20
22
 
21
23
  export function ProviderSheet() {
22
24
  const open = useAppStore((s) => s.providerSheetOpen)
@@ -54,6 +56,7 @@ export function ProviderSheet() {
54
56
  const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'pass' | 'fail'>('idle')
55
57
  const [testMessage, setTestMessage] = useState('')
56
58
  const [testModel, setTestModel] = useState('')
59
+ const [testDiagnostics, setTestDiagnostics] = useState<ProviderDiagnosticStep[]>([])
57
60
 
58
61
  const [liveModels, setLiveModels] = useState<string[]>([])
59
62
  const [liveLoading, setLiveLoading] = useState(false)
@@ -77,6 +80,7 @@ export function ProviderSheet() {
77
80
  setLiveCached(false)
78
81
  setTestStatus('idle')
79
82
  setTestMessage('')
83
+ setTestDiagnostics([])
80
84
  if (editingCustom) {
81
85
  setName(editingCustom.name)
82
86
  setBaseUrl(editingCustom.baseUrl || '')
@@ -108,6 +112,7 @@ export function ProviderSheet() {
108
112
  useEffect(() => {
109
113
  setTestStatus('idle')
110
114
  setTestMessage('')
115
+ setTestDiagnostics([])
111
116
  }, [credentialId, baseUrl])
112
117
 
113
118
  useEffect(() => {
@@ -121,6 +126,7 @@ export function ProviderSheet() {
121
126
  if (!isBuiltin) return
122
127
  setTestStatus('testing')
123
128
  setTestMessage('')
129
+ setTestDiagnostics([])
124
130
  try {
125
131
  const result = await checkProviderConnectionMutation.mutateAsync({
126
132
  provider: editingId || 'custom',
@@ -128,6 +134,7 @@ export function ProviderSheet() {
128
134
  endpoint: baseUrl,
129
135
  model: testModel || undefined,
130
136
  })
137
+ setTestDiagnostics(result.diagnostics ?? [])
131
138
  if (result.ok) {
132
139
  setTestStatus('pass')
133
140
  setTestMessage(result.message)
@@ -141,6 +148,7 @@ export function ProviderSheet() {
141
148
  const msg = err instanceof Error ? err.message : 'Connection test failed'
142
149
  setTestStatus('fail')
143
150
  setTestMessage(msg)
151
+ setTestDiagnostics([])
144
152
  toast.error(msg)
145
153
  }
146
154
  }
@@ -530,7 +538,7 @@ export function ProviderSheet() {
530
538
  </label>
531
539
  <select
532
540
  value={testModel}
533
- onChange={(e) => { setTestModel(e.target.value); setTestStatus('idle'); setTestMessage('') }}
541
+ onChange={(e) => { setTestModel(e.target.value); setTestStatus('idle'); setTestMessage(''); setTestDiagnostics([]) }}
534
542
  className={`${inputClass} appearance-none cursor-pointer`}
535
543
  style={{ fontFamily: 'inherit' }}
536
544
  >
@@ -546,11 +554,13 @@ export function ProviderSheet() {
546
554
  {isBuiltin && testStatus === 'fail' && (
547
555
  <div className="mb-4 p-3 rounded-[12px] bg-red-500/[0.08] border border-red-500/20">
548
556
  <p className="text-[13px] text-red-400">{testMessage || 'Connection test failed'}</p>
557
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
549
558
  </div>
550
559
  )}
551
560
  {isBuiltin && testStatus === 'pass' && (
552
561
  <div className="mb-4 p-3 rounded-[12px] bg-emerald-500/[0.08] border border-emerald-500/20">
553
562
  <p className="text-[13px] text-emerald-400">{testMessage || 'Connected successfully'}</p>
563
+ <ProviderDiagnosticsList diagnostics={testDiagnostics} />
554
564
  </div>
555
565
  )}
556
566
 
@@ -13,6 +13,7 @@ import {
13
13
  import { fetchProviders } from '@/lib/chat/chats'
14
14
  import type {
15
15
  ProviderConfig,
16
+ ProviderCheckResult,
16
17
  ProviderInfo,
17
18
  ProviderModelDiscoveryResult,
18
19
  } from '@/types'
@@ -129,7 +130,7 @@ export function useResetProviderModelsMutation() {
129
130
  export function useCheckProviderConnectionMutation() {
130
131
  return useMutation({
131
132
  mutationFn: ({ provider, credentialId, endpoint, model }: CheckProviderConnectionInput) =>
132
- api<{ ok: boolean; message: string }>('POST', '/setup/check-provider', {
133
+ api<ProviderCheckResult>('POST', '/setup/check-provider', {
133
134
  provider,
134
135
  credentialId,
135
136
  endpoint,
@@ -0,0 +1,39 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import {
5
+ createProviderDiagnostics,
6
+ sanitizeProviderDiagnosticTarget,
7
+ sanitizeProviderDiagnosticText,
8
+ } from './provider-diagnostics'
9
+
10
+ test('provider diagnostics records sanitized ordered steps', () => {
11
+ const diagnostics = createProviderDiagnostics()
12
+ diagnostics.pass('Endpoint resolved', { target: 'http://user:pass@127.0.0.1:1234/v1/models?token=sk-secret' })
13
+ diagnostics.fail('Chat failed', { detail: 'Malformed token sk-abc123456789 provided', durationMs: 12.4 })
14
+
15
+ const steps = diagnostics.toJSON()
16
+ assert.equal(steps.length, 2)
17
+ assert.deepEqual(
18
+ steps.map((step) => step.id),
19
+ ['diag-1', 'diag-2'],
20
+ )
21
+ assert.equal(steps[0].status, 'pass')
22
+ assert.equal(steps[0].target, 'http://127.0.0.1:1234/v1/models')
23
+ assert.equal(steps[1].detail, 'Malformed token sk-... provided')
24
+ assert.equal(steps[1].durationMs, 12)
25
+ })
26
+
27
+ test('sanitizeProviderDiagnosticText redacts common provider token prefixes', () => {
28
+ assert.equal(
29
+ sanitizeProviderDiagnosticText('bad keys: sk-test123 gsk_live456 hf_token789 AIzaSySecret'),
30
+ 'bad keys: sk-... gsk_... hf_... AIza...',
31
+ )
32
+ })
33
+
34
+ test('sanitizeProviderDiagnosticTarget removes credentials, query, and hash from URLs', () => {
35
+ assert.equal(
36
+ sanitizeProviderDiagnosticTarget('https://user:secret@example.com/v1/models?api_key=sk-secret#frag'),
37
+ 'https://example.com/v1/models',
38
+ )
39
+ })
@@ -0,0 +1,114 @@
1
+ import type { ProviderDiagnosticStatus, ProviderDiagnosticStep } from '@/types/provider'
2
+
3
+ const SECRET_PREFIXES = [
4
+ 'sk-',
5
+ 'sk_',
6
+ 'gsk_',
7
+ 'hf_',
8
+ 'xai-',
9
+ 'or-',
10
+ 'pat_',
11
+ 'ghp_',
12
+ 'gho_',
13
+ 'AIza',
14
+ ]
15
+
16
+ const MAX_DETAIL_LENGTH = 300
17
+ const MAX_TARGET_LENGTH = 220
18
+
19
+ function isSecretBoundary(char: string | undefined): boolean {
20
+ if (!char) return true
21
+ return char === ' ' || char === '\n' || char === '\r' || char === '\t'
22
+ || char === '"' || char === "'" || char === '`'
23
+ || char === ',' || char === ';' || char === ')' || char === ']'
24
+ || char === '}' || char === '<' || char === '>'
25
+ }
26
+
27
+ export function sanitizeProviderDiagnosticText(value: unknown, maxLength = MAX_DETAIL_LENGTH): string {
28
+ const input = typeof value === 'string' ? value : String(value ?? '')
29
+ let out = ''
30
+ for (let i = 0; i < input.length;) {
31
+ const prefix = SECRET_PREFIXES.find((candidate) => input.startsWith(candidate, i))
32
+ if (!prefix) {
33
+ out += input[i]
34
+ i++
35
+ continue
36
+ }
37
+
38
+ let end = i + prefix.length
39
+ while (end < input.length && !isSecretBoundary(input[end]) && end - i < 160) end++
40
+ out += `${prefix}...`
41
+ i = end
42
+ }
43
+ const collapsed = out.split(/\s+/).join(' ').trim()
44
+ return collapsed.length > maxLength ? `${collapsed.slice(0, Math.max(0, maxLength - 1))}...` : collapsed
45
+ }
46
+
47
+ export function sanitizeProviderDiagnosticTarget(value: unknown): string {
48
+ const raw = sanitizeProviderDiagnosticText(value, MAX_TARGET_LENGTH)
49
+ try {
50
+ const url = new URL(raw)
51
+ url.username = ''
52
+ url.password = ''
53
+ url.search = ''
54
+ url.hash = ''
55
+ return sanitizeProviderDiagnosticText(url.toString(), MAX_TARGET_LENGTH)
56
+ } catch {
57
+ return raw
58
+ }
59
+ }
60
+
61
+ export interface ProviderDiagnostics {
62
+ add: (
63
+ label: string,
64
+ status: ProviderDiagnosticStatus,
65
+ options?: {
66
+ detail?: unknown
67
+ target?: unknown
68
+ durationMs?: number
69
+ },
70
+ ) => ProviderDiagnosticStep
71
+ pass: (label: string, options?: { detail?: unknown; target?: unknown; durationMs?: number }) => ProviderDiagnosticStep
72
+ warn: (label: string, options?: { detail?: unknown; target?: unknown; durationMs?: number }) => ProviderDiagnosticStep
73
+ fail: (label: string, options?: { detail?: unknown; target?: unknown; durationMs?: number }) => ProviderDiagnosticStep
74
+ toJSON: () => ProviderDiagnosticStep[]
75
+ }
76
+
77
+ export function createProviderDiagnostics(): ProviderDiagnostics {
78
+ const steps: ProviderDiagnosticStep[] = []
79
+ let sequence = 0
80
+
81
+ function add(
82
+ label: string,
83
+ status: ProviderDiagnosticStatus,
84
+ options: {
85
+ detail?: unknown
86
+ target?: unknown
87
+ durationMs?: number
88
+ } = {},
89
+ ): ProviderDiagnosticStep {
90
+ sequence++
91
+ const step: ProviderDiagnosticStep = {
92
+ id: `diag-${sequence}`,
93
+ label: sanitizeProviderDiagnosticText(label, 80),
94
+ status,
95
+ }
96
+ const detail = sanitizeProviderDiagnosticText(options.detail, MAX_DETAIL_LENGTH)
97
+ const target = sanitizeProviderDiagnosticTarget(options.target)
98
+ if (detail) step.detail = detail
99
+ if (target) step.target = target
100
+ if (typeof options.durationMs === 'number' && Number.isFinite(options.durationMs)) {
101
+ step.durationMs = Math.max(0, Math.round(options.durationMs))
102
+ }
103
+ steps.push(step)
104
+ return step
105
+ }
106
+
107
+ return {
108
+ add,
109
+ pass: (label, options) => add(label, 'pass', options),
110
+ warn: (label, options) => add(label, 'warn', options),
111
+ fail: (label, options) => add(label, 'fail', options),
112
+ toJSON: () => steps.map((step) => ({ ...step })),
113
+ }
114
+ }
@@ -28,6 +28,27 @@ export interface ProviderModelDiscoveryResult {
28
28
  message?: string
29
29
  }
30
30
 
31
+ export type ProviderDiagnosticStatus = 'pass' | 'warn' | 'fail'
32
+
33
+ export interface ProviderDiagnosticStep {
34
+ id: string
35
+ label: string
36
+ status: ProviderDiagnosticStatus
37
+ detail?: string
38
+ target?: string
39
+ durationMs?: number
40
+ }
41
+
42
+ export interface ProviderCheckResult {
43
+ ok: boolean
44
+ message: string
45
+ normalizedEndpoint?: string
46
+ recommendedModel?: string
47
+ errorCode?: string
48
+ deviceId?: string
49
+ diagnostics?: ProviderDiagnosticStep[]
50
+ }
51
+
31
52
  export interface Credential {
32
53
  id: string
33
54
  provider: string