@swarmclawai/swarmclaw 1.9.20 → 1.9.22

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.
Files changed (34) hide show
  1. package/README.md +22 -2
  2. package/package.json +3 -3
  3. package/src/app/api/setup/check-provider/route.test.ts +44 -0
  4. package/src/app/api/setup/check-provider/route.ts +235 -63
  5. package/src/components/agents/agent-sheet.tsx +18 -5
  6. package/src/components/auth/setup-wizard/step-connect.tsx +13 -3
  7. package/src/components/auth/setup-wizard/types.ts +2 -9
  8. package/src/components/chat/activity-moment.tsx +4 -0
  9. package/src/components/chat/tool-call-bubble.tsx +6 -0
  10. package/src/components/providers/provider-diagnostics-list.tsx +58 -0
  11. package/src/components/providers/provider-sheet.tsx +11 -1
  12. package/src/features/providers/queries.ts +2 -1
  13. package/src/lib/server/capability-router.test.ts +4 -4
  14. package/src/lib/server/capability-router.ts +1 -0
  15. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +27 -0
  16. package/src/lib/server/chat-execution/chat-execution-utils.ts +21 -0
  17. package/src/lib/server/chat-execution/iteration-event-handler.ts +1 -1
  18. package/src/lib/server/chat-execution/stream-continuation.ts +6 -2
  19. package/src/lib/server/plugins-advanced.test.ts +7 -3
  20. package/src/lib/server/provider-diagnostics.test.ts +39 -0
  21. package/src/lib/server/provider-diagnostics.ts +114 -0
  22. package/src/lib/server/session-tools/web-crawl.test.ts +106 -0
  23. package/src/lib/server/session-tools/web-inputs.test.ts +5 -0
  24. package/src/lib/server/session-tools/web-utils.ts +8 -2
  25. package/src/lib/server/session-tools/web.ts +256 -29
  26. package/src/lib/server/storage.ts +2 -0
  27. package/src/lib/server/tool-aliases.ts +1 -1
  28. package/src/lib/server/tool-capability-policy-advanced.test.ts +3 -3
  29. package/src/lib/server/tool-capability-policy.ts +4 -1
  30. package/src/lib/server/tool-planning.test.ts +2 -1
  31. package/src/lib/server/tool-planning.ts +31 -0
  32. package/src/lib/server/untrusted-content.ts +2 -2
  33. package/src/types/provider.ts +21 -0
  34. package/src/types/session.ts +2 -0
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,24 @@ Operational docs: https://swarmclaw.ai/docs/observability
407
409
 
408
410
  ## Releases
409
411
 
412
+ ### v1.9.22 Highlights
413
+
414
+ Research tools release: agents now get direct `web_extract` and `web_crawl` tools alongside `web_search`, `web_fetch`, and the unified `web` tool.
415
+
416
+ - **Source-grounded extraction.** `web_extract` returns a page title, canonical URL, and readable content for known source URLs.
417
+ - **Bounded crawls.** `web_crawl` walks same-origin links by default with conservative page and depth caps, plus an explicit external-link opt-in.
418
+ - **Better routing.** Tool aliases, capability policy, planning hints, continuation recovery, and the chat UI all recognize the granular research tools.
419
+ - **Regression coverage.** New tests cover action inference, tool-call translation, direct tool registration, extraction cleanup, and same-origin crawl bounds.
420
+
421
+ ### v1.9.21 Highlights
422
+
423
+ Provider diagnostics release: connection checks now return a structured step timeline across setup, provider settings, and agent editing.
424
+
425
+ - **Connection timelines.** Provider checks show endpoint resolution, model discovery, fallback selection, and chat/gateway verification steps.
426
+ - **Safer error details.** Token-like values are redacted before check messages or diagnostics are returned to the UI.
427
+ - **Local runtime debugging.** LM Studio, Ollama, custom OpenAI-compatible endpoints, cloud providers, OpenClaw gateways, and CLI providers all report concise pass/fail diagnostics.
428
+ - **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.
429
+
410
430
  ### v1.9.20 Highlights
411
431
 
412
432
  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.22",
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/server/session-tools/web-crawl.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) {