@swarmclawai/swarmclaw 1.5.44 → 1.5.46
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 +23 -0
- package/package.json +1 -1
- package/skills/swarmvault/SKILL.md +83 -0
- package/src/app/api/mcp-servers/route.test.ts +49 -3
- package/src/app/api/mcp-servers/route.ts +1 -0
- package/src/app/api/providers/[id]/route.ts +1 -1
- package/src/app/api/setup/check-provider/route.ts +33 -7
- package/src/components/agents/agent-sheet.tsx +1 -1
- package/src/components/mcp-servers/mcp-server-sheet.tsx +96 -0
- package/src/components/providers/provider-sheet.tsx +27 -2
- package/src/features/providers/queries.ts +6 -2
- package/src/lib/agent-provider-options.ts +1 -0
- package/src/lib/providers/anthropic.ts +64 -66
- package/src/lib/providers/index.ts +35 -2
- package/src/lib/providers/provider-defaults.ts +1 -1
- package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +7 -0
- package/src/lib/server/build-llm.ts +1 -0
- package/src/lib/server/mcp-client.ts +1 -0
- package/src/lib/server/provider-endpoint.ts +27 -1
- package/src/types/misc.ts +1 -0
- package/src/types/provider.ts +2 -0
package/README.md
CHANGED
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
<img src="https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/public/branding/swarmclaw-org-avatar.png" alt="SwarmClaw lobster logo" width="120" />
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
+
<p align="center"><strong>Self-hosted runtime for autonomous AI agents.</strong> Multi-provider, MCP-native, with memory, skills, delegation, and schedules.</p>
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
14
|
+
<img src="doc/assets/screenshots/org-chart.png" alt="SwarmClaw org chart with delegation and live agent activity" width="900" />
|
|
15
|
+
</p>
|
|
16
|
+
|
|
11
17
|
SwarmClaw is a self-hosted AI runtime for OpenClaw and multi-agent work. It helps you run autonomous agents and orchestrators with heartbeats, schedules, delegation, memory, runtime skills, and reviewed conversation-to-skill learning across OpenClaw gateways and other providers.
|
|
12
18
|
|
|
13
19
|
GitHub: https://github.com/swarmclawai/swarmclaw
|
|
@@ -175,6 +181,7 @@ Full hosted deployment guides live at https://swarmclaw.ai/docs/deployment
|
|
|
175
181
|
- **Memory**: hybrid recall, graph traversal, journaling, durable documents, project-scoped context, automatic reflection memory, communication preferences, profile and boundary memory, significant events, and open follow-up loops.
|
|
176
182
|
- **Wallets**: linked Base wallet generation, address management, approval-oriented limits, and agent payout identity.
|
|
177
183
|
- **Connectors**: Discord, Slack, Telegram, WhatsApp, Teams, Matrix, OpenClaw, SwarmDock, SwarmFeed, and more.
|
|
184
|
+
- **MCP Servers**: connect any Model Context Protocol server (stdio, SSE, or streamable HTTP) and inject its tools into agents alongside built-ins. Configure, test, and assign per-agent from the MCP Servers panel.
|
|
178
185
|
- **Extensions**: external tool extensions, UI modules, hooks, and install/update flows.
|
|
179
186
|
|
|
180
187
|
## What SwarmClaw Focuses On
|
|
@@ -389,6 +396,22 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
389
396
|
|
|
390
397
|
## Releases
|
|
391
398
|
|
|
399
|
+
### v1.5.46 Highlights
|
|
400
|
+
|
|
401
|
+
- **Custom base URL for built-in OpenAI and Anthropic providers**: the Endpoint field in provider settings now works for the built-in OpenAI and Anthropic providers (marked as `optionalEndpoint`). Point them at a proxy, gateway, or self-hosted endpoint and the URL persists, auto-resolves on connection test, and flows through both the live chat path and the LangGraph agent path (`ChatAnthropic` now receives `anthropicApiUrl`). Existing installs with no custom URL keep using the defaults.
|
|
402
|
+
- **Test-model selector in provider settings**: when you hit "Test Connection", a new dropdown lets you pick a specific model (for example `gpt-4.1-mini` or `claude-haiku-4-5`) or leave it on Auto-detect. Useful for verifying a specific model is reachable on a given endpoint.
|
|
403
|
+
- **Auto-resolution of credentials and endpoints in the connection test**: the test route now looks up the saved credential and base URL for the provider when they are not explicitly supplied, so the provider sheet's "Test" button works without needing to replay config.
|
|
404
|
+
- **Anthropic streaming refactor**: the streaming handler moved from Node's `https.request()` to `fetch()`. Same behavior, cleaner cancellation, and it now respects `session.apiEndpoint` as a full base URL instead of a hostname.
|
|
405
|
+
- **Connection test body**: Ollama and OpenAI-compatible test requests now send `max_completion_tokens` instead of the legacy `max_tokens`, matching current OpenAI conventions and working correctly with reasoning models that reject `max_tokens`.
|
|
406
|
+
|
|
407
|
+
Thanks to @Llugaes for the contribution.
|
|
408
|
+
|
|
409
|
+
### v1.5.45 Highlights
|
|
410
|
+
|
|
411
|
+
- **SwarmVault MCP preset**: a new "SwarmVault" Quick Setup chip in the MCP server sheet pre-fills `npx -y @swarmvaultai/cli mcp` over `stdio` and prompts for the vault directory. One click registers a SwarmVault knowledge vault as an MCP server; agents pick it up via the existing per-agent MCP server selector. SwarmVault docs: https://swarmvault.ai
|
|
412
|
+
- **`cwd` on stdio MCP servers**: `McpServerConfig` now has an optional `cwd` field. The MCP client passes it through to `StdioClientTransport` so servers that discover config from the working directory (SwarmVault, anything that reads from `cwd`-relative files) work correctly. Existing MCP servers are untouched (the field is optional and defaults to the SwarmClaw process cwd, which was the prior behaviour).
|
|
413
|
+
- **Bundled `swarmvault` skill**: ships at `skills/swarmvault/SKILL.md` and is auto-discovered alongside the other bundled skills. Captures the schema-first / graph-query-first conventions (read `swarmvault.schema.md` before compile or query work, treat `raw/` as immutable, prefer `graph query|path|explain` over grep, preserve `page_id` / `source_ids` / `node_ids` / `freshness` / `source_hashes` frontmatter, save high-value answers to `wiki/outputs/`). Pin it on any agent that talks to a SwarmVault vault. Optional and decoupled from the MCP integration.
|
|
414
|
+
|
|
392
415
|
### v1.5.44 Highlights
|
|
393
416
|
|
|
394
417
|
- **Model lists refreshed across every provider**: dropdowns now lead with the April-2026 flagship models instead of mid-2025 names. OpenAI goes to GPT-5.4 / 5.4-mini / 5.4-nano / 5.3 / o3-mini. Google and Gemini CLI lead with Gemini 3.1 Pro, Gemini 3 Flash, and 3.1 Flash-Lite, keeping 2.5 as a legacy fallback. xAI jumps from Grok 3 to Grok 4 plus the Grok 4 / 4.1 Fast reasoning and non-reasoning variants. Groq drops the deprecated `deepseek-r1-distill-llama-70b` and leads with Llama 4 Maverick, Llama 4 Scout, Kimi K2, and gpt-oss 120b/20b. Mistral moves to Magistral 1.2, Devstral 2, Codestral, and Mistral Small 4. Fireworks / Nebius / DeepInfra now lead with DeepSeek V3.2, Kimi K2.5, and Qwen 3 235B instead of the older R1-0528 checkpoint. Anthropic and Claude CLI reorder Opus 4.6 / Sonnet 4.6 / Haiku 4.5 newest-first. OpenCode Web refreshes its `providerID/modelID` seed list.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.46",
|
|
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",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: swarmvault
|
|
3
|
+
description: Use when working with a SwarmVault knowledge vault (raw/, wiki/, swarmvault.schema.md). Establishes schema-first conventions and prefers graph queries over broad search.
|
|
4
|
+
homepage: https://swarmvault.ai
|
|
5
|
+
metadata:
|
|
6
|
+
openclaw:
|
|
7
|
+
capabilities: [knowledge-base, knowledge-graph, retrieval, vault]
|
|
8
|
+
requires:
|
|
9
|
+
bins: [npx]
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# SwarmVault
|
|
13
|
+
|
|
14
|
+
Use when the agent has a SwarmVault MCP server enabled (transport `stdio`, command `npx -y @swarmvaultai/cli mcp`) pointed at a vault directory.
|
|
15
|
+
|
|
16
|
+
A SwarmVault workspace is a three-layer knowledge system:
|
|
17
|
+
|
|
18
|
+
- `raw/` — immutable source inputs (PDFs, transcripts, code, emails, URLs, sheets). Never edit.
|
|
19
|
+
- `wiki/` — generated markdown owned by the agent and the SwarmVault compiler. Pages carry frontmatter (`page_id`, `source_ids`, `node_ids`, `freshness`, `source_hashes`).
|
|
20
|
+
- `state/` — generated indexes, graphs, and approvals. Treat as opaque output of `compile`.
|
|
21
|
+
|
|
22
|
+
The vault contract lives in `swarmvault.schema.md` at the workspace root. The vault config lives in `swarmvault.config.json`.
|
|
23
|
+
|
|
24
|
+
## Rules
|
|
25
|
+
|
|
26
|
+
1. **Read `swarmvault.schema.md` first** before any compile or query work. It defines categories, naming, freshness rules, and grounding conventions for this specific vault.
|
|
27
|
+
2. **Read `wiki/graph/report.md` before broad file searching** when it exists; otherwise start with `wiki/index.md`. Both summarize the vault structure so you don't re-scan everything.
|
|
28
|
+
3. **Treat `raw/` as immutable.** Never edit, rename, or delete files there. New sources go through `ingest`.
|
|
29
|
+
4. **Treat `wiki/` as compiler-owned.** Edits should preserve frontmatter fields exactly: `page_id`, `source_ids`, `node_ids`, `freshness`, `source_hashes`. If those drift, the next `compile` will overwrite or flag the page.
|
|
30
|
+
5. **Prefer graph queries over grep/glob** for "how does X relate to Y" or "what depends on Z" questions. The vault's typed graph is more reliable than text search.
|
|
31
|
+
6. **Save high-value answers** to `wiki/outputs/` (use the `query` or `explore` tools) instead of leaving them only in chat. That way they become first-class vault content for next time.
|
|
32
|
+
|
|
33
|
+
## Tool Palette
|
|
34
|
+
|
|
35
|
+
The SwarmVault MCP server exposes the following tools (names are prefixed by SwarmClaw with `mcp_<sanitized server name>_`, e.g. `mcp_SwarmVault_query_vault`). Match the user's intent to the closest tool:
|
|
36
|
+
|
|
37
|
+
Vault inspection:
|
|
38
|
+
- `workspace_info` — return current vault paths and high-level counts. Use this first when you've never seen this vault.
|
|
39
|
+
- `list_sources` — list source manifests under `raw/`.
|
|
40
|
+
- `search_pages` — full-text search across compiled wiki pages.
|
|
41
|
+
- `read_page` — read a specific wiki page by its `wiki/`-relative path.
|
|
42
|
+
|
|
43
|
+
Graph (prefer over grep for relational questions):
|
|
44
|
+
- `graph_report` — machine-readable graph report and trust artifact. Read this before broad searching.
|
|
45
|
+
- `query_graph` — traverse the graph from search seeds without calling an LLM provider.
|
|
46
|
+
- `get_node` — explain a graph node, its page, community, neighbors, and group patterns.
|
|
47
|
+
- `get_neighbors` — neighbors of a node or page target.
|
|
48
|
+
- `get_hyperedges` — list graph hyperedges, optionally filtered.
|
|
49
|
+
- `shortest_path` — shortest path between two graph targets.
|
|
50
|
+
- `god_nodes` — highest-connectivity nodes (the vault's hubs).
|
|
51
|
+
- `blast_radius` — impact analysis: what depends on this file or module?
|
|
52
|
+
|
|
53
|
+
Question answering:
|
|
54
|
+
- `query_vault` — natural-language question against the vault. Returns grounded citations. Pass `save: true` to persist the answer to `wiki/outputs/`.
|
|
55
|
+
|
|
56
|
+
Ingest and maintenance:
|
|
57
|
+
- `ingest_input` — add a file path or URL to `raw/` and register it as a managed source.
|
|
58
|
+
- `compile_vault` — re-derive `wiki/` pages, graph, and search index. Run after ingest, after schema changes, or when freshness is stale.
|
|
59
|
+
- `lint_vault` — anti-drift and vault health checks.
|
|
60
|
+
|
|
61
|
+
If the MCP server is unavailable but the agent has a `shell` or `execute` tool, the same operations are available via `swarmvault <subcommand>` (or `npx -y @swarmvaultai/cli <subcommand>`) with the working directory set to the vault root.
|
|
62
|
+
|
|
63
|
+
## Workflow
|
|
64
|
+
|
|
65
|
+
For a fresh question against the vault:
|
|
66
|
+
|
|
67
|
+
1. Call `workspace_info` if you haven't already, then read `swarmvault.schema.md`. If `wiki/graph/report.md` or `wiki/index.md` exists, skim it.
|
|
68
|
+
2. Use `query_vault` (or `query_graph` / `get_node` / `shortest_path` for relational questions). Cite returned `source_ids` and `node_ids`.
|
|
69
|
+
3. If the answer reveals a gap, propose `ingest_input` for the missing source, then `compile_vault`.
|
|
70
|
+
4. Save the final answer with `query_vault` `save: true` so it becomes vault content under `wiki/outputs/`.
|
|
71
|
+
|
|
72
|
+
For a new source the user mentions:
|
|
73
|
+
|
|
74
|
+
1. `ingest_input` the file/URL.
|
|
75
|
+
2. `compile_vault` to derive new wiki pages, graph, and search index.
|
|
76
|
+
3. `lint_vault` to check frontmatter and links.
|
|
77
|
+
4. Skim the new pages in `wiki/sources/` and confirm provenance.
|
|
78
|
+
|
|
79
|
+
## Boundaries
|
|
80
|
+
|
|
81
|
+
- Don't run `compile` against an unreviewed change to `swarmvault.schema.md` — `lint` first.
|
|
82
|
+
- Don't promote candidate pages (`wiki/candidates/`) to `wiki/concepts/` or `wiki/entities/` without the user's confirmation; the approval flow exists for a reason.
|
|
83
|
+
- Don't push the vault graph to Neo4j or export to Obsidian without an explicit ask.
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
2
4
|
import path from 'node:path'
|
|
3
5
|
import { fileURLToPath } from 'node:url'
|
|
4
6
|
import test, { afterEach } from 'node:test'
|
|
@@ -57,12 +59,12 @@ test('MCP server routes exercise a live stdio server end to end', async () => {
|
|
|
57
59
|
assert.equal(healthResponse.status, 200)
|
|
58
60
|
const health = await healthResponse.json() as Record<string, unknown>
|
|
59
61
|
assert.equal(health.ok, true)
|
|
60
|
-
assert.deepEqual(health.tools, ['mcp_smoke_ping', 'mcp_smoke_echo'])
|
|
62
|
+
assert.deepEqual(health.tools, ['mcp_smoke_ping', 'mcp_smoke_echo', 'mcp_smoke_cwd_check'])
|
|
61
63
|
|
|
62
64
|
const toolsResponse = await listMcpTools(new Request(`http://local/api/mcp-servers/${serverId}/tools`), routeParams(serverId))
|
|
63
65
|
assert.equal(toolsResponse.status, 200)
|
|
64
66
|
const tools = await toolsResponse.json() as Array<Record<string, unknown>>
|
|
65
|
-
assert.deepEqual(tools.map((tool) => tool.name), ['ping', 'echo'])
|
|
67
|
+
assert.deepEqual(tools.map((tool) => tool.name), ['ping', 'echo', 'cwd_check'])
|
|
66
68
|
|
|
67
69
|
const invokeResponse = await invokeMcpTool(new Request(`http://local/api/mcp-servers/${serverId}/invoke`, {
|
|
68
70
|
method: 'POST',
|
|
@@ -85,7 +87,7 @@ test('MCP server routes exercise a live stdio server end to end', async () => {
|
|
|
85
87
|
assert.equal(conformanceResponse.status, 200)
|
|
86
88
|
const conformance = await conformanceResponse.json() as Record<string, unknown>
|
|
87
89
|
assert.equal(conformance.ok, true)
|
|
88
|
-
assert.equal(conformance.toolsCount,
|
|
90
|
+
assert.equal(conformance.toolsCount, 3)
|
|
89
91
|
assert.equal(conformance.smokeToolName, 'ping')
|
|
90
92
|
|
|
91
93
|
const updateResponse = await updateMcpServer(new Request(`http://local/api/mcp-servers/${serverId}`, {
|
|
@@ -104,6 +106,50 @@ test('MCP server routes exercise a live stdio server end to end', async () => {
|
|
|
104
106
|
assert.equal(loadMcpServers()[serverId], undefined)
|
|
105
107
|
})
|
|
106
108
|
|
|
109
|
+
test('cwd is persisted and forwarded to the spawned MCP server', async () => {
|
|
110
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-cwd-'))
|
|
111
|
+
// Resolve symlinks (macOS /var → /private/var) so the comparison matches what the child process reports.
|
|
112
|
+
const resolvedTmpDir = fs.realpathSync(tmpDir)
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const createResponse = await createMcpServer(new Request('http://local/api/mcp-servers', {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'content-type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
name: 'cwd-smoke',
|
|
120
|
+
transport: 'stdio',
|
|
121
|
+
command: process.execPath,
|
|
122
|
+
args: [fixturePath],
|
|
123
|
+
cwd: resolvedTmpDir,
|
|
124
|
+
}),
|
|
125
|
+
}))
|
|
126
|
+
assert.equal(createResponse.status, 200)
|
|
127
|
+
const created = await createResponse.json() as Record<string, unknown>
|
|
128
|
+
const serverId = String(created.id)
|
|
129
|
+
assert.equal(created.cwd, resolvedTmpDir, 'POST should persist cwd on the saved record')
|
|
130
|
+
|
|
131
|
+
const detailResponse = await getMcpServer(new Request(`http://local/api/mcp-servers/${serverId}`), routeParams(serverId))
|
|
132
|
+
const detail = await detailResponse.json() as Record<string, unknown>
|
|
133
|
+
assert.equal(detail.cwd, resolvedTmpDir, 'GET should return the persisted cwd')
|
|
134
|
+
|
|
135
|
+
const invokeResponse = await invokeMcpTool(new Request(`http://local/api/mcp-servers/${serverId}/invoke`, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'content-type': 'application/json' },
|
|
138
|
+
body: JSON.stringify({ toolName: 'cwd_check', args: '{}' }),
|
|
139
|
+
}), routeParams(serverId))
|
|
140
|
+
assert.equal(invokeResponse.status, 200)
|
|
141
|
+
const invokePayload = await invokeResponse.json() as Record<string, unknown>
|
|
142
|
+
assert.equal(invokePayload.ok, true)
|
|
143
|
+
assert.equal(
|
|
144
|
+
invokePayload.text,
|
|
145
|
+
`cwd: ${resolvedTmpDir}`,
|
|
146
|
+
'Spawned MCP server should report the cwd we configured, not SwarmClaw\'s process cwd',
|
|
147
|
+
)
|
|
148
|
+
} finally {
|
|
149
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
107
153
|
test('MCP invoke route validates required fields before connecting', async () => {
|
|
108
154
|
const serverId = 'mcp-validate-smoke'
|
|
109
155
|
const servers = loadMcpServers()
|
|
@@ -31,7 +31,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
|
|
|
31
31
|
id,
|
|
32
32
|
name: builtin.name,
|
|
33
33
|
type: 'builtin',
|
|
34
|
-
baseUrl: builtin.defaultEndpoint || '',
|
|
34
|
+
baseUrl: (typeof body.baseUrl === 'string' ? body.baseUrl : builtin.defaultEndpoint) || '',
|
|
35
35
|
models: [...builtin.models],
|
|
36
36
|
requiresApiKey: builtin.requiresApiKey,
|
|
37
37
|
credentialId: null,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server'
|
|
2
|
-
import { loadCredentials, decryptKey } from '@/lib/server/storage'
|
|
2
|
+
import { loadCredentials, decryptKey, loadProviderConfigs } from '@/lib/server/storage'
|
|
3
|
+
import { listCredentialIdsByProvider } from '@/lib/server/credentials/credential-service'
|
|
3
4
|
import { getDeviceId, wsConnect, rpcOnConnectedGateway } from '@/lib/providers/openclaw'
|
|
4
5
|
import { buildCliEnv, probeCliAuth, resolveCliBinary } from '@/lib/providers/cli-utils'
|
|
5
6
|
import { OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
|
|
@@ -109,7 +110,7 @@ async function checkOpenAiCompatible(
|
|
|
109
110
|
},
|
|
110
111
|
body: JSON.stringify({
|
|
111
112
|
model: testModel,
|
|
112
|
-
|
|
113
|
+
max_completion_tokens: 8,
|
|
113
114
|
messages: [{ role: 'user', content: 'Reply OK' }],
|
|
114
115
|
}),
|
|
115
116
|
signal: AbortSignal.timeout(15_000),
|
|
@@ -126,9 +127,10 @@ async function checkOpenAiCompatible(
|
|
|
126
127
|
}
|
|
127
128
|
}
|
|
128
129
|
|
|
129
|
-
async function checkAnthropic(apiKey: string, modelRaw: string): Promise<{ ok: boolean; message: string }> {
|
|
130
|
+
async function checkAnthropic(apiKey: string, endpointRaw: string, modelRaw: string): Promise<{ ok: boolean; message: string }> {
|
|
130
131
|
const model = modelRaw || 'claude-sonnet-4-6'
|
|
131
|
-
const
|
|
132
|
+
const baseUrl = (endpointRaw || 'https://api.anthropic.com').replace(/\/+$/, '')
|
|
133
|
+
const res = await fetch(`${baseUrl}/v1/messages`, {
|
|
132
134
|
method: 'POST',
|
|
133
135
|
headers: {
|
|
134
136
|
'x-api-key': apiKey,
|
|
@@ -221,7 +223,7 @@ async function checkOllama(params: {
|
|
|
221
223
|
// Test the chat endpoint
|
|
222
224
|
const label = runtime.useCloud ? 'Ollama Cloud' : 'Ollama'
|
|
223
225
|
const chatEndpoint = `${normalizedEndpoint}/v1/chat/completions`
|
|
224
|
-
const chatBody = JSON.stringify({ model: testModel,
|
|
226
|
+
const chatBody = JSON.stringify({ model: testModel, max_completion_tokens: 8, messages: [{ role: 'user', content: 'Reply OK' }] })
|
|
225
227
|
|
|
226
228
|
const chatRes = await fetch(chatEndpoint, {
|
|
227
229
|
method: 'POST',
|
|
@@ -312,7 +314,7 @@ export async function POST(req: Request) {
|
|
|
312
314
|
const provider = clean(body.provider) as SetupProvider
|
|
313
315
|
let apiKey = clean(body.apiKey)
|
|
314
316
|
const credentialId = clean(body.credentialId)
|
|
315
|
-
|
|
317
|
+
let endpoint = clean(body.endpoint)
|
|
316
318
|
const model = clean(body.model)
|
|
317
319
|
const CLI_PROVIDERS = new Set<CliSetupProvider>(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'droid-cli', 'cursor-cli', 'qwen-code-cli', 'goose'])
|
|
318
320
|
|
|
@@ -329,6 +331,30 @@ export async function POST(req: Request) {
|
|
|
329
331
|
}
|
|
330
332
|
}
|
|
331
333
|
|
|
334
|
+
// Auto-resolve credential by provider when no explicit credentialId
|
|
335
|
+
if (!apiKey && !credentialId && provider) {
|
|
336
|
+
try {
|
|
337
|
+
const credIds = listCredentialIdsByProvider(provider)
|
|
338
|
+
if (credIds.length > 0) {
|
|
339
|
+
const creds = loadCredentials()
|
|
340
|
+
for (const cid of credIds) {
|
|
341
|
+
if (creds[cid]?.encryptedKey) {
|
|
342
|
+
try { apiKey = decryptKey(creds[cid].encryptedKey); break } catch { /* skip */ }
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} catch { /* best effort */ }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Auto-resolve endpoint from provider config when not explicitly provided
|
|
350
|
+
if (!endpoint && provider) {
|
|
351
|
+
try {
|
|
352
|
+
const pConfigs = loadProviderConfigs()
|
|
353
|
+
const pConfig = pConfigs[provider]
|
|
354
|
+
if (pConfig?.baseUrl) endpoint = pConfig.baseUrl
|
|
355
|
+
} catch { /* best effort */ }
|
|
356
|
+
}
|
|
357
|
+
|
|
332
358
|
if (CLI_PROVIDERS.has(provider as CliSetupProvider)) {
|
|
333
359
|
const result = checkCliProvider(provider as CliSetupProvider)
|
|
334
360
|
return NextResponse.json(result)
|
|
@@ -354,7 +380,7 @@ export async function POST(req: Request) {
|
|
|
354
380
|
}
|
|
355
381
|
case 'anthropic': {
|
|
356
382
|
if (!apiKey) return NextResponse.json({ ok: false, message: 'Anthropic API key is required.' })
|
|
357
|
-
const result = await checkAnthropic(apiKey, model)
|
|
383
|
+
const result = await checkAnthropic(apiKey, endpoint, model)
|
|
358
384
|
return NextResponse.json(result)
|
|
359
385
|
}
|
|
360
386
|
case 'google':
|
|
@@ -1656,7 +1656,7 @@ export function AgentSheet() {
|
|
|
1656
1656
|
</div>
|
|
1657
1657
|
)}
|
|
1658
1658
|
|
|
1659
|
-
{currentProvider?.requiresEndpoint && (provider !== 'ollama' || ollamaMode === 'local') && (
|
|
1659
|
+
{(currentProvider?.requiresEndpoint || currentProvider?.optionalEndpoint) && (provider !== 'ollama' || ollamaMode === 'local') && (
|
|
1660
1660
|
<div className="mb-8">
|
|
1661
1661
|
<SectionLabel>{provider === 'openclaw' ? 'OpenClaw Endpoint' : provider === 'hermes' ? 'Hermes API Endpoint' : 'Endpoint'}</SectionLabel>
|
|
1662
1662
|
<input type="text" value={apiEndpoint || ''} onChange={(e) => setApiEndpoint(e.target.value || null)} placeholder={currentProvider.defaultEndpoint || 'http://localhost:11434'} className={`${inputClass} font-mono text-[14px]`} />
|
|
@@ -4,11 +4,40 @@ import { 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 { HintTip } from '@/components/shared/hint-tip'
|
|
7
8
|
import { api } from '@/lib/app/api-client'
|
|
8
9
|
import { toast } from 'sonner'
|
|
9
10
|
import type { McpServerConfig, McpTransport } from '@/types'
|
|
10
11
|
import { useMountedRef } from '@/hooks/use-mounted-ref'
|
|
11
12
|
|
|
13
|
+
interface McpPreset {
|
|
14
|
+
id: string
|
|
15
|
+
label: string
|
|
16
|
+
description: string
|
|
17
|
+
helpUrl?: string
|
|
18
|
+
transport: McpTransport
|
|
19
|
+
command?: string
|
|
20
|
+
args?: string[]
|
|
21
|
+
needsCwd?: boolean
|
|
22
|
+
cwdHint?: string
|
|
23
|
+
defaultName: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MCP_PRESETS: McpPreset[] = [
|
|
27
|
+
{
|
|
28
|
+
id: 'swarmvault',
|
|
29
|
+
label: 'SwarmVault',
|
|
30
|
+
description: 'Local-first knowledge vault. Point this at a directory containing swarmvault.config.json.',
|
|
31
|
+
helpUrl: 'https://swarmvault.ai',
|
|
32
|
+
transport: 'stdio',
|
|
33
|
+
command: 'npx',
|
|
34
|
+
args: ['-y', '@swarmvaultai/cli', 'mcp'],
|
|
35
|
+
needsCwd: true,
|
|
36
|
+
cwdHint: 'Absolute path to a SwarmVault workspace (the directory containing swarmvault.config.json). Run `npx @swarmvaultai/cli init` there first if you haven\'t.',
|
|
37
|
+
defaultName: 'SwarmVault',
|
|
38
|
+
},
|
|
39
|
+
]
|
|
40
|
+
|
|
12
41
|
function McpServerForm({ editing, onClose, loadMcpServers }: {
|
|
13
42
|
editing: McpServerConfig | null
|
|
14
43
|
onClose: () => void
|
|
@@ -19,6 +48,8 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
|
|
|
19
48
|
const [transport, setTransport] = useState<McpTransport>(editing?.transport || 'stdio')
|
|
20
49
|
const [command, setCommand] = useState(editing?.command || '')
|
|
21
50
|
const [args, setArgs] = useState(editing?.args?.join(', ') || '')
|
|
51
|
+
const [cwd, setCwd] = useState(editing?.cwd || '')
|
|
52
|
+
const [activePresetId, setActivePresetId] = useState<string | null>(null)
|
|
22
53
|
const [url, setUrl] = useState(editing?.url || '')
|
|
23
54
|
const [envText, setEnvText] = useState(
|
|
24
55
|
editing?.env ? Object.entries(editing.env).map(([k, v]) => `${k}=${v}`).join('\n') : '',
|
|
@@ -61,6 +92,7 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
|
|
|
61
92
|
if (transport === 'stdio') {
|
|
62
93
|
data.command = command.trim()
|
|
63
94
|
data.args = args.trim() ? args.split(',').map((a) => a.trim()).filter(Boolean) : []
|
|
95
|
+
data.cwd = cwd.trim() || undefined
|
|
64
96
|
} else {
|
|
65
97
|
data.url = url.trim()
|
|
66
98
|
}
|
|
@@ -122,6 +154,16 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
|
|
|
122
154
|
|
|
123
155
|
const canSave = name.trim() && (transport === 'stdio' ? command.trim() : url.trim())
|
|
124
156
|
|
|
157
|
+
const applyPreset = (preset: McpPreset) => {
|
|
158
|
+
setActivePresetId(preset.id)
|
|
159
|
+
setTransport(preset.transport)
|
|
160
|
+
if (preset.command !== undefined) setCommand(preset.command)
|
|
161
|
+
if (preset.args !== undefined) setArgs(preset.args.join(', '))
|
|
162
|
+
if (!name.trim()) setName(preset.defaultName)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const activePreset = activePresetId ? MCP_PRESETS.find((p) => p.id === activePresetId) ?? null : null
|
|
166
|
+
|
|
125
167
|
const inputClass = "w-full px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow"
|
|
126
168
|
const labelClass = "block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3"
|
|
127
169
|
|
|
@@ -134,6 +176,44 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
|
|
|
134
176
|
<p className="text-[14px] text-text-3">Configure an MCP server to provide tools to agents</p>
|
|
135
177
|
</div>
|
|
136
178
|
|
|
179
|
+
{!editing && MCP_PRESETS.length > 0 && (
|
|
180
|
+
<div className="mb-8">
|
|
181
|
+
<label className={labelClass}>Quick Setup</label>
|
|
182
|
+
<div className="flex flex-wrap gap-2">
|
|
183
|
+
{MCP_PRESETS.map((preset) => {
|
|
184
|
+
const isActive = activePresetId === preset.id
|
|
185
|
+
return (
|
|
186
|
+
<button
|
|
187
|
+
key={preset.id}
|
|
188
|
+
type="button"
|
|
189
|
+
onClick={() => applyPreset(preset)}
|
|
190
|
+
className={`py-2 px-4 rounded-[12px] border text-[13px] font-600 cursor-pointer transition-all ${
|
|
191
|
+
isActive
|
|
192
|
+
? 'border-accent-bright bg-accent-bright/10 text-accent-bright'
|
|
193
|
+
: 'border-white/[0.08] bg-transparent text-text-2 hover:bg-surface-2'
|
|
194
|
+
}`}
|
|
195
|
+
style={{ fontFamily: 'inherit' }}
|
|
196
|
+
title={preset.description}
|
|
197
|
+
>
|
|
198
|
+
{preset.label}
|
|
199
|
+
</button>
|
|
200
|
+
)
|
|
201
|
+
})}
|
|
202
|
+
</div>
|
|
203
|
+
{activePreset && (
|
|
204
|
+
<p className="mt-3 text-[12px] text-text-3">
|
|
205
|
+
{activePreset.description}
|
|
206
|
+
{activePreset.helpUrl && (
|
|
207
|
+
<>
|
|
208
|
+
{' '}
|
|
209
|
+
<a href={activePreset.helpUrl} target="_blank" rel="noopener noreferrer" className="text-accent-bright hover:underline">Learn more</a>
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
</p>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
|
|
137
217
|
<div className="mb-8">
|
|
138
218
|
<label className={labelClass}>Name</label>
|
|
139
219
|
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Filesystem Server" className={inputClass} style={{ fontFamily: 'inherit' }} />
|
|
@@ -165,6 +245,22 @@ function McpServerForm({ editing, onClose, loadMcpServers }: {
|
|
|
165
245
|
</label>
|
|
166
246
|
<input type="text" value={args} onChange={(e) => setArgs(e.target.value)} placeholder="e.g. /path/to/dir, --verbose" className={inputClass} style={{ fontFamily: 'inherit' }} />
|
|
167
247
|
</div>
|
|
248
|
+
<div className="mb-8">
|
|
249
|
+
<label className={labelClass}>
|
|
250
|
+
<span className="inline-flex items-center gap-2">
|
|
251
|
+
Working Directory <span className="normal-case tracking-normal font-normal text-text-3">(optional)</span>
|
|
252
|
+
<HintTip text={activePreset?.cwdHint || 'Working directory for the spawned process. Useful when the MCP server discovers config from cwd (e.g. SwarmVault).'} />
|
|
253
|
+
</span>
|
|
254
|
+
</label>
|
|
255
|
+
<input
|
|
256
|
+
type="text"
|
|
257
|
+
value={cwd}
|
|
258
|
+
onChange={(e) => setCwd(e.target.value)}
|
|
259
|
+
placeholder={activePreset?.needsCwd ? 'e.g. /Users/you/my-vault' : 'e.g. /path/to/working/dir'}
|
|
260
|
+
className={inputClass}
|
|
261
|
+
style={{ fontFamily: 'inherit' }}
|
|
262
|
+
/>
|
|
263
|
+
</div>
|
|
168
264
|
</>
|
|
169
265
|
) : (
|
|
170
266
|
<div className="mb-8">
|
|
@@ -53,6 +53,7 @@ export function ProviderSheet() {
|
|
|
53
53
|
// Test connection state
|
|
54
54
|
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'pass' | 'fail'>('idle')
|
|
55
55
|
const [testMessage, setTestMessage] = useState('')
|
|
56
|
+
const [testModel, setTestModel] = useState('')
|
|
56
57
|
|
|
57
58
|
const [liveModels, setLiveModels] = useState<string[]>([])
|
|
58
59
|
const [liveLoading, setLiveLoading] = useState(false)
|
|
@@ -85,7 +86,7 @@ export function ProviderSheet() {
|
|
|
85
86
|
setIsEnabled(editingCustom.isEnabled)
|
|
86
87
|
} else if (editingBuiltin) {
|
|
87
88
|
setName(editingBuiltin.name)
|
|
88
|
-
setBaseUrl(editingBuiltin.defaultEndpoint || '')
|
|
89
|
+
setBaseUrl(editingBuiltinOverride?.baseUrl || editingBuiltin.defaultEndpoint || '')
|
|
89
90
|
setModels(editingBuiltin.models.join(', '))
|
|
90
91
|
setRequiresApiKey(editingBuiltin.requiresApiKey)
|
|
91
92
|
// Default to existing credential for this provider
|
|
@@ -113,6 +114,7 @@ export function ProviderSheet() {
|
|
|
113
114
|
setLiveModels([])
|
|
114
115
|
setLiveMessage('')
|
|
115
116
|
setLiveCached(false)
|
|
117
|
+
setTestModel('')
|
|
116
118
|
}, [editingId, credentialId, baseUrl, requiresApiKey])
|
|
117
119
|
|
|
118
120
|
const handleTestConnection = async () => {
|
|
@@ -124,6 +126,7 @@ export function ProviderSheet() {
|
|
|
124
126
|
provider: editingId || 'custom',
|
|
125
127
|
credentialId,
|
|
126
128
|
endpoint: baseUrl,
|
|
129
|
+
model: testModel || undefined,
|
|
127
130
|
})
|
|
128
131
|
if (result.ok) {
|
|
129
132
|
setTestStatus('pass')
|
|
@@ -157,6 +160,7 @@ export function ProviderSheet() {
|
|
|
157
160
|
id: editingId || '',
|
|
158
161
|
models: modelList,
|
|
159
162
|
isEnabled,
|
|
163
|
+
baseUrl: baseUrl.trim() || undefined,
|
|
160
164
|
})
|
|
161
165
|
toast.success('Built-in provider updated')
|
|
162
166
|
onClose()
|
|
@@ -290,7 +294,7 @@ export function ProviderSheet() {
|
|
|
290
294
|
</div>
|
|
291
295
|
|
|
292
296
|
{/* Base URL — for custom providers and built-ins with endpoints (Ollama, OpenClaw) */}
|
|
293
|
-
{(!isBuiltin || editingBuiltin?.requiresEndpoint) && (
|
|
297
|
+
{(!isBuiltin || editingBuiltin?.requiresEndpoint || editingBuiltin?.optionalEndpoint) && (
|
|
294
298
|
<div className="mb-8">
|
|
295
299
|
<label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
|
|
296
300
|
{isBuiltin ? 'Endpoint' : 'Base URL'}
|
|
@@ -514,6 +518,27 @@ export function ProviderSheet() {
|
|
|
514
518
|
</div>
|
|
515
519
|
)}
|
|
516
520
|
|
|
521
|
+
{/* Test model selector */}
|
|
522
|
+
{showTestButton && (
|
|
523
|
+
<div className="mb-4">
|
|
524
|
+
<label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
|
|
525
|
+
Test Model
|
|
526
|
+
<span className="normal-case tracking-normal font-normal text-text-3 ml-1">(optional)</span>
|
|
527
|
+
</label>
|
|
528
|
+
<select
|
|
529
|
+
value={testModel}
|
|
530
|
+
onChange={(e) => { setTestModel(e.target.value); setTestStatus('idle'); setTestMessage('') }}
|
|
531
|
+
className={`${inputClass} appearance-none cursor-pointer`}
|
|
532
|
+
style={{ fontFamily: 'inherit' }}
|
|
533
|
+
>
|
|
534
|
+
<option value="">Auto-detect</option>
|
|
535
|
+
{modelList.map((m) => (
|
|
536
|
+
<option key={m} value={m}>{m}</option>
|
|
537
|
+
))}
|
|
538
|
+
</select>
|
|
539
|
+
</div>
|
|
540
|
+
)}
|
|
541
|
+
|
|
517
542
|
{/* Test connection result */}
|
|
518
543
|
{isBuiltin && testStatus === 'fail' && (
|
|
519
544
|
<div className="mb-4 p-3 rounded-[12px] bg-red-500/[0.08] border border-red-500/20">
|
|
@@ -25,6 +25,7 @@ interface SaveBuiltinProviderInput {
|
|
|
25
25
|
id: string
|
|
26
26
|
models: string[]
|
|
27
27
|
isEnabled: boolean
|
|
28
|
+
baseUrl?: string
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
interface SaveCustomProviderInput {
|
|
@@ -36,6 +37,7 @@ interface CheckProviderConnectionInput {
|
|
|
36
37
|
provider: string
|
|
37
38
|
credentialId?: string | null
|
|
38
39
|
endpoint?: string | null
|
|
40
|
+
model?: string | null
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
async function invalidateProviderQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
|
@@ -80,11 +82,12 @@ export function useToggleProviderMutation() {
|
|
|
80
82
|
export function useSaveBuiltinProviderMutation() {
|
|
81
83
|
const queryClient = useQueryClient()
|
|
82
84
|
return useMutation({
|
|
83
|
-
mutationFn: async ({ id, models, isEnabled }: SaveBuiltinProviderInput) => {
|
|
85
|
+
mutationFn: async ({ id, models, isEnabled, baseUrl }: SaveBuiltinProviderInput) => {
|
|
84
86
|
await api('PUT', `/providers/${id}/models`, { models })
|
|
85
87
|
return api('PUT', `/providers/${id}`, {
|
|
86
88
|
type: 'builtin',
|
|
87
89
|
isEnabled,
|
|
90
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
88
91
|
})
|
|
89
92
|
},
|
|
90
93
|
onSuccess: async () => {
|
|
@@ -126,11 +129,12 @@ export function useResetProviderModelsMutation() {
|
|
|
126
129
|
|
|
127
130
|
export function useCheckProviderConnectionMutation() {
|
|
128
131
|
return useMutation({
|
|
129
|
-
mutationFn: ({ provider, credentialId, endpoint }: CheckProviderConnectionInput) =>
|
|
132
|
+
mutationFn: ({ provider, credentialId, endpoint, model }: CheckProviderConnectionInput) =>
|
|
130
133
|
api<{ ok: boolean; message: string }>('POST', '/setup/check-provider', {
|
|
131
134
|
provider,
|
|
132
135
|
credentialId,
|
|
133
136
|
endpoint,
|
|
137
|
+
model,
|
|
134
138
|
}),
|
|
135
139
|
})
|
|
136
140
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import fs from 'fs'
|
|
2
|
-
import https from 'https'
|
|
3
2
|
import type { StreamChatOptions } from './index'
|
|
4
3
|
import { PROVIDER_DEFAULTS, IMAGE_EXTS, TEXT_EXTS, ANTHROPIC_MAX_TOKENS, MAX_HISTORY_MESSAGES, writeSSE } from './provider-defaults'
|
|
5
4
|
import { log } from '@/lib/server/logger'
|
|
@@ -45,55 +44,66 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
|
|
|
45
44
|
}
|
|
46
45
|
|
|
47
46
|
const payload = JSON.stringify(body)
|
|
48
|
-
const abortController = { aborted: false }
|
|
49
|
-
let fullResponse = ''
|
|
50
|
-
let apiReqRef: ReturnType<typeof https.request> | null = null
|
|
51
47
|
|
|
48
|
+
// Support custom base URL (e.g. proxy / gateway)
|
|
49
|
+
const baseUrl = (session.apiEndpoint || PROVIDER_DEFAULTS.anthropic).replace(/\/+$/, '')
|
|
50
|
+
const url = `${baseUrl}/v1/messages`
|
|
51
|
+
|
|
52
|
+
const abortController = new AbortController()
|
|
52
53
|
if (signal) {
|
|
53
|
-
if (signal.aborted)
|
|
54
|
-
|
|
55
|
-
} else {
|
|
56
|
-
signal.addEventListener('abort', () => {
|
|
57
|
-
abortController.aborted = true
|
|
58
|
-
apiReqRef?.destroy()
|
|
59
|
-
}, { once: true })
|
|
60
|
-
}
|
|
54
|
+
if (signal.aborted) abortController.abort()
|
|
55
|
+
else signal.addEventListener('abort', () => abortController.abort(), { once: true })
|
|
61
56
|
}
|
|
57
|
+
active.set(session.id, { kill: () => abortController.abort() })
|
|
62
58
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
59
|
+
let fullResponse = ''
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(url, {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: {
|
|
65
|
+
'x-api-key': apiKey || '',
|
|
66
|
+
'anthropic-version': '2023-06-01',
|
|
67
|
+
'Content-Type': 'application/json',
|
|
68
|
+
},
|
|
69
|
+
body: payload,
|
|
70
|
+
signal: abortController.signal,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
if (!res.ok) {
|
|
74
|
+
const errBody = await res.text().catch(() => '')
|
|
75
|
+
const msg = `Anthropic error ${res.status}: ${errBody.slice(0, 200)}`
|
|
76
|
+
log.error(TAG, `[${session.id}] ${msg}`)
|
|
77
|
+
let errMsg = `Anthropic API error (${res.status})`
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(errBody)
|
|
80
|
+
if (parsed.error?.message) errMsg = parsed.error.message
|
|
81
|
+
} catch {}
|
|
82
|
+
writeSSE(write, 'err', errMsg)
|
|
83
|
+
active.delete(session.id)
|
|
84
|
+
reject(new Error(msg))
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!res.body) {
|
|
89
|
+
const msg = `No response body from ${baseUrl}`
|
|
90
|
+
log.error(TAG, `[${session.id}] ${msg}`)
|
|
91
|
+
active.delete(session.id)
|
|
92
|
+
reject(new Error(msg))
|
|
89
93
|
return
|
|
90
94
|
}
|
|
91
95
|
|
|
96
|
+
const reader = res.body.getReader()
|
|
97
|
+
const decoder = new TextDecoder()
|
|
92
98
|
let buf = ''
|
|
93
99
|
let malformedChunkLogged = false
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
|
|
101
|
+
while (true) {
|
|
102
|
+
const { done, value } = await reader.read()
|
|
103
|
+
if (done) break
|
|
104
|
+
if (abortController.signal.aborted) break
|
|
105
|
+
|
|
106
|
+
buf += decoder.decode(value, { stream: true })
|
|
97
107
|
const lines = buf.split('\n')
|
|
98
108
|
buf = lines.pop()!
|
|
99
109
|
|
|
@@ -122,33 +132,21 @@ export function streamAnthropicChat({ session, message, imagePath, apiKey, syste
|
|
|
122
132
|
}
|
|
123
133
|
}
|
|
124
134
|
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
apiRes.on('end', () => {
|
|
128
|
-
if (onUsage && (usageInput > 0 || usageOutput > 0)) {
|
|
129
|
-
onUsage({ inputTokens: usageInput, outputTokens: usageOutput })
|
|
130
|
-
}
|
|
131
|
-
active.delete(session.id)
|
|
132
|
-
resolve(fullResponse)
|
|
133
|
-
})
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
apiReqRef = apiReq
|
|
137
|
-
active.set(session.id, { kill: () => { abortController.aborted = true; apiReq.destroy() } })
|
|
138
|
-
|
|
139
|
-
apiReq.on('timeout', () => {
|
|
140
|
-
log.error(TAG, `[${session.id}] anthropic request timed out after 60s`)
|
|
141
|
-
apiReq.destroy(new Error('Request timed out after 60s'))
|
|
142
|
-
})
|
|
135
|
+
}
|
|
143
136
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
137
|
+
if (onUsage && (usageInput > 0 || usageOutput > 0)) {
|
|
138
|
+
onUsage({ inputTokens: usageInput, outputTokens: usageOutput })
|
|
139
|
+
}
|
|
140
|
+
} catch (err: unknown) {
|
|
141
|
+
const errObj = err as { name?: string; message?: string }
|
|
142
|
+
if (errObj.name !== 'AbortError') {
|
|
143
|
+
log.error(TAG, `[${session.id}] anthropic fetch error:`, errObj.message || '')
|
|
144
|
+
writeSSE(write, 'err', errObj.message || 'Anthropic request failed')
|
|
145
|
+
}
|
|
146
|
+
}
|
|
150
147
|
|
|
151
|
-
|
|
148
|
+
active.delete(session.id)
|
|
149
|
+
resolve(fullResponse)
|
|
152
150
|
} catch (err) { reject(err) }
|
|
153
151
|
})()
|
|
154
152
|
})
|
|
@@ -74,6 +74,8 @@ export const PROVIDERS: Record<string, BuiltinProviderConfig> = {
|
|
|
74
74
|
models: ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.4-nano', 'gpt-5.3', 'o3-mini', 'gpt-4.1', 'gpt-4.1-mini'],
|
|
75
75
|
requiresApiKey: true,
|
|
76
76
|
requiresEndpoint: false,
|
|
77
|
+
optionalEndpoint: true,
|
|
78
|
+
defaultEndpoint: 'https://api.openai.com/v1',
|
|
77
79
|
handler: { streamChat: streamOpenAiChat },
|
|
78
80
|
},
|
|
79
81
|
openrouter: {
|
|
@@ -107,7 +109,17 @@ export const PROVIDERS: Record<string, BuiltinProviderConfig> = {
|
|
|
107
109
|
models: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'],
|
|
108
110
|
requiresApiKey: true,
|
|
109
111
|
requiresEndpoint: false,
|
|
110
|
-
|
|
112
|
+
optionalEndpoint: true,
|
|
113
|
+
defaultEndpoint: 'https://api.anthropic.com',
|
|
114
|
+
handler: {
|
|
115
|
+
streamChat: (opts) => {
|
|
116
|
+
const patchedSession = {
|
|
117
|
+
...opts.session,
|
|
118
|
+
apiEndpoint: opts.session.apiEndpoint || 'https://api.anthropic.com',
|
|
119
|
+
}
|
|
120
|
+
return streamAnthropicChat({ ...opts, session: patchedSession })
|
|
121
|
+
},
|
|
122
|
+
},
|
|
111
123
|
},
|
|
112
124
|
openclaw: {
|
|
113
125
|
id: 'openclaw',
|
|
@@ -518,7 +530,28 @@ function buildCustomProviderConfig(custom: CustomProviderConfig): BuiltinProvide
|
|
|
518
530
|
}
|
|
519
531
|
|
|
520
532
|
export function getProvider(id: string): BuiltinProviderConfig | null {
|
|
521
|
-
|
|
533
|
+
// Check builtin providers — inject custom baseUrl from provider config if set
|
|
534
|
+
const builtin = PROVIDERS[id]
|
|
535
|
+
if (builtin) {
|
|
536
|
+
const pConfigs = loadProviderConfigs()
|
|
537
|
+
const pConfig = pConfigs[id]
|
|
538
|
+
if (pConfig?.baseUrl && pConfig.baseUrl !== builtin.defaultEndpoint) {
|
|
539
|
+
const originalHandler = builtin.handler
|
|
540
|
+
return {
|
|
541
|
+
...builtin,
|
|
542
|
+
handler: {
|
|
543
|
+
streamChat: (opts) => {
|
|
544
|
+
const patchedSession = {
|
|
545
|
+
...opts.session,
|
|
546
|
+
apiEndpoint: opts.session.apiEndpoint || pConfig.baseUrl,
|
|
547
|
+
}
|
|
548
|
+
return originalHandler.streamChat({ ...opts, session: patchedSession })
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return builtin
|
|
554
|
+
}
|
|
522
555
|
|
|
523
556
|
// Check custom providers
|
|
524
557
|
const customs = getCustomProviders()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Default base URLs for built-in LLM providers */
|
|
2
2
|
export const PROVIDER_DEFAULTS = {
|
|
3
3
|
openai: 'https://api.openai.com/v1',
|
|
4
|
-
anthropic: 'api.anthropic.com',
|
|
4
|
+
anthropic: 'https://api.anthropic.com',
|
|
5
5
|
ollama: 'http://localhost:11434',
|
|
6
6
|
ollamaCloud: 'https://ollama.com',
|
|
7
7
|
} as const
|
|
@@ -23,5 +23,12 @@ server.registerTool('echo', {
|
|
|
23
23
|
content: [{ type: 'text', text: `echo: ${message}` }],
|
|
24
24
|
}))
|
|
25
25
|
|
|
26
|
+
server.registerTool('cwd_check', {
|
|
27
|
+
description: 'Returns the working directory the server was launched in',
|
|
28
|
+
inputSchema: {},
|
|
29
|
+
}, async () => ({
|
|
30
|
+
content: [{ type: 'text', text: `cwd: ${process.cwd()}` }],
|
|
31
|
+
}))
|
|
32
|
+
|
|
26
33
|
const transport = new StdioServerTransport()
|
|
27
34
|
await server.connect(transport)
|
|
@@ -79,6 +79,7 @@ export function buildChatModel(opts: {
|
|
|
79
79
|
const anthropicOpts: Record<string, unknown> = {
|
|
80
80
|
model: model || 'claude-sonnet-4-6',
|
|
81
81
|
anthropicApiKey: resolvedApiKey || undefined,
|
|
82
|
+
...(endpoint ? { anthropicApiUrl: endpoint } : {}),
|
|
82
83
|
maxTokens: 8192,
|
|
83
84
|
maxRetries: OPENAI_COMPAT_MODEL_MAX_RETRIES,
|
|
84
85
|
}
|
|
@@ -77,6 +77,7 @@ export async function connectMcpServer(
|
|
|
77
77
|
transport = new StdioClientTransport({
|
|
78
78
|
command: config.command!,
|
|
79
79
|
args: config.args ?? [],
|
|
80
|
+
cwd: config.cwd || undefined,
|
|
80
81
|
env: { ...process.env, ...config.env } as Record<string, string>,
|
|
81
82
|
})
|
|
82
83
|
} else if (config.transport === 'sse') {
|
|
@@ -3,6 +3,7 @@ import { getProvider } from '@/lib/providers'
|
|
|
3
3
|
import { loadCredential } from '@/lib/server/credentials/credential-repository'
|
|
4
4
|
import { listCredentialIdsByProvider, resolveCredentialSecret } from '@/lib/server/credentials/credential-service'
|
|
5
5
|
import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
|
|
6
|
+
import { loadProviderConfigs } from '@/lib/server/storage'
|
|
6
7
|
|
|
7
8
|
function clean(value: string | null | undefined): string | null {
|
|
8
9
|
if (typeof value !== 'string') return null
|
|
@@ -16,7 +17,23 @@ export function resolveProviderCredentialId(input: {
|
|
|
16
17
|
credentialId?: string | null
|
|
17
18
|
}): string | null {
|
|
18
19
|
const normalizedId = clean(input.credentialId)
|
|
19
|
-
|
|
20
|
+
|
|
21
|
+
// When no credentialId provided, auto-match by provider
|
|
22
|
+
if (!normalizedId) {
|
|
23
|
+
const provider = clean(input.provider)
|
|
24
|
+
if (!provider) return null
|
|
25
|
+
const byProvider = listCredentialIdsByProvider(provider)
|
|
26
|
+
.map((id) => [id, loadCredential(id)] as const)
|
|
27
|
+
.filter(([, cred]) => Boolean(cred))
|
|
28
|
+
if (byProvider.length === 1) return byProvider[0][0]
|
|
29
|
+
if (byProvider.length > 1) {
|
|
30
|
+
// Pick the most recently created credential
|
|
31
|
+
return [...byProvider]
|
|
32
|
+
.sort((a, b) => ((b[1]?.createdAt as number) || 0) - ((a[1]?.createdAt as number) || 0))[0]?.[0] || null
|
|
33
|
+
}
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
20
37
|
if (loadCredential(normalizedId)) return normalizedId
|
|
21
38
|
|
|
22
39
|
const provider = clean(input.provider)
|
|
@@ -71,6 +88,15 @@ export function resolveProviderApiEndpoint(input: {
|
|
|
71
88
|
return normalizeProviderEndpoint(provider, runtime.endpoint) || runtime.endpoint.replace(/\/+$/, '')
|
|
72
89
|
}
|
|
73
90
|
|
|
91
|
+
// Prefer provider config's custom baseUrl over the hardcoded defaultEndpoint
|
|
92
|
+
const pConfigs = loadProviderConfigs()
|
|
93
|
+
const pConfig = pConfigs[provider]
|
|
94
|
+
if (pConfig?.baseUrl) {
|
|
95
|
+
const customNormalized = normalizeProviderEndpoint(provider, pConfig.baseUrl)
|
|
96
|
+
if (customNormalized) return customNormalized
|
|
97
|
+
return pConfig.baseUrl.replace(/\/+$/, '')
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
const providerInfo = getProvider(provider)
|
|
75
101
|
if (!providerInfo?.defaultEndpoint) return null
|
|
76
102
|
return normalizeProviderEndpoint(provider, providerInfo.defaultEndpoint) || providerInfo.defaultEndpoint.replace(/\/+$/, '')
|
package/src/types/misc.ts
CHANGED
|
@@ -613,6 +613,7 @@ export interface McpServerConfig {
|
|
|
613
613
|
transport: McpTransport
|
|
614
614
|
command?: string // for stdio transport
|
|
615
615
|
args?: string[] // for stdio transport
|
|
616
|
+
cwd?: string // working directory for stdio transport (e.g. SwarmVault vault dir)
|
|
616
617
|
url?: string // for sse/streamable-http transport
|
|
617
618
|
env?: Record<string, string> // environment variables
|
|
618
619
|
headers?: Record<string, string> // HTTP headers for sse/streamable-http
|
package/src/types/provider.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface ProviderInfo {
|
|
|
10
10
|
requiresApiKey: boolean
|
|
11
11
|
optionalApiKey?: boolean
|
|
12
12
|
requiresEndpoint: boolean
|
|
13
|
+
/** When true, shows an optional Base URL field in provider settings (e.g. for proxies). */
|
|
14
|
+
optionalEndpoint?: boolean
|
|
13
15
|
defaultEndpoint?: string
|
|
14
16
|
}
|
|
15
17
|
|