@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 +13 -2
- package/package.json +3 -3
- package/src/app/api/setup/check-provider/route.test.ts +44 -0
- package/src/app/api/setup/check-provider/route.ts +235 -63
- package/src/components/agents/agent-sheet.tsx +18 -5
- package/src/components/auth/setup-wizard/step-connect.tsx +13 -3
- package/src/components/auth/setup-wizard/types.ts +2 -9
- package/src/components/providers/provider-diagnostics-list.tsx +58 -0
- package/src/components/providers/provider-sheet.tsx +11 -1
- package/src/features/providers/queries.ts +2 -1
- package/src/lib/server/provider-diagnostics.test.ts +39 -0
- package/src/lib/server/provider-diagnostics.ts +114 -0
- package/src/types/provider.ts +21 -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
|
-
|
|
85
|
-
|
|
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.
|
|
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<
|
|
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 (
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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<
|
|
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
|
+
}
|
package/src/types/provider.ts
CHANGED
|
@@ -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
|