@swarmclawai/swarmclaw 1.5.45 → 1.5.47
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 +20 -0
- package/package.json +1 -1
- package/src/app/api/providers/[id]/route.ts +1 -1
- package/src/app/api/settings/route.ts +1 -1
- package/src/app/api/setup/check-provider/route.ts +33 -7
- package/src/components/agents/agent-sheet.tsx +9 -5
- 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/provider-sets.ts +3 -0
- package/src/lib/providers/anthropic.ts +64 -66
- package/src/lib/providers/codex-cli.ts +65 -6
- package/src/lib/providers/copilot-cli.ts +46 -0
- package/src/lib/providers/index.ts +35 -2
- package/src/lib/providers/provider-defaults.ts +1 -1
- package/src/lib/server/build-llm.ts +1 -0
- package/src/lib/server/connectors/connector-inbound.ts +4 -1
- package/src/lib/server/playwright-proxy.mjs +2 -0
- package/src/lib/server/provider-endpoint.ts +27 -1
- package/src/lib/server/session-tools/search-providers.test.ts +165 -0
- package/src/lib/server/session-tools/search-providers.ts +70 -0
- package/src/lib/server/storage.ts +1 -0
- package/src/types/app-settings.ts +3 -1
- package/src/types/provider.ts +2 -0
- package/src/views/settings/section-web-search.tsx +20 -0
package/README.md
CHANGED
|
@@ -396,6 +396,26 @@ Operational docs: https://swarmclaw.ai/docs/observability
|
|
|
396
396
|
|
|
397
397
|
## Releases
|
|
398
398
|
|
|
399
|
+
### v1.5.47 Highlights
|
|
400
|
+
|
|
401
|
+
- **MCP injection for GitHub Copilot CLI and OpenAI Codex CLI agents**: agents using the `copilot-cli` or `codex-cli` providers now run with their assigned MCP servers attached at runtime. Copilot CLI receives the servers via `--additional-mcp-config @<tempfile>`; Codex CLI gets per-session `[mcp_servers.*]` TOML sections appended to a scoped `config.toml`. Stdio transports (command, args, env, cwd) and SSE / streamable-http transports (url, headers) are both supported. Skills assigned to the agent continue to be injected via the system prompt.
|
|
402
|
+
- **Skills and MCP panel visible for copilot-cli and codex-cli in the agent editor**: the Advanced Settings section now opens for these two providers so you can attach skills and MCP servers from the UI. Routing, memory, and voice panels stay hidden since these providers are worker-only.
|
|
403
|
+
- **Codex CLI approval policy change**: Codex CLI sessions now launch with `--dangerously-bypass-approvals-and-sandbox` instead of `--full-auto`. The old flag silently cancels MCP tool calls via Codex's approval gate, which is why MCP tool results were not landing. SwarmClaw itself runs in its own sandbox, so Codex's additional sandbox was not load-bearing, but be aware of the change if you were relying on it for a specific agent.
|
|
404
|
+
- **Under the hood**: `~/.codex-sessions/<session.id>/` replaces `/tmp/swarmclaw-codex-*` as the per-session Codex config directory because Codex refuses to create helper binaries under `/tmp`. The Playwright MCP proxy now passes an explicit `cwd: process.cwd()` when spawning, so it no longer crashes with `uv_cwd ENOENT` when the server is restarted after a directory move.
|
|
405
|
+
- **Exa as a new web search provider**: Settings > Web Search gains an Exa option alongside Tavily, Brave, SearXNG, DuckDuckGo, Google, and Bing. Exa uses neural search with AI-generated summaries and falls back to highlights, then raw text when summaries are unavailable. Configure the key via the UI, the `EXA_API_KEY` environment variable, or the secrets store. Requests carry an `x-exa-integration: swarmclaw` tracking header so usage attributed to SwarmClaw is visible to Exa.
|
|
406
|
+
|
|
407
|
+
Thanks to [@borislavnnikolov](https://github.com/borislavnnikolov) and [@tgonzalezc5](https://github.com/tgonzalezc5) for the contributions.
|
|
408
|
+
|
|
409
|
+
### v1.5.46 Highlights
|
|
410
|
+
|
|
411
|
+
- **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.
|
|
412
|
+
- **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.
|
|
413
|
+
- **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.
|
|
414
|
+
- **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.
|
|
415
|
+
- **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`.
|
|
416
|
+
|
|
417
|
+
Thanks to [@Llugaes](https://github.com/Llugaes) for the contribution.
|
|
418
|
+
|
|
399
419
|
### v1.5.45 Highlights
|
|
400
420
|
|
|
401
421
|
- **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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.47",
|
|
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",
|
|
@@ -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,
|
|
@@ -26,7 +26,7 @@ const TASK_QG_MIN_EVIDENCE_MIN = 0
|
|
|
26
26
|
const TASK_QG_MIN_EVIDENCE_MAX = 8
|
|
27
27
|
const SESSION_RESET_TIMEOUT_MIN = 0
|
|
28
28
|
const SESSION_RESET_TIMEOUT_MAX = 365 * 24 * 60 * 60
|
|
29
|
-
const SECRET_SETTING_KEYS = ['elevenLabsApiKey', 'tavilyApiKey', 'braveApiKey'] as const
|
|
29
|
+
const SECRET_SETTING_KEYS = ['elevenLabsApiKey', 'tavilyApiKey', 'braveApiKey', 'exaApiKey'] as const
|
|
30
30
|
|
|
31
31
|
function parseIntSetting(value: unknown, fallback: number, min: number, max: number): number {
|
|
32
32
|
const parsed = typeof value === 'number'
|
|
@@ -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':
|
|
@@ -12,7 +12,7 @@ import { toast } from 'sonner'
|
|
|
12
12
|
import { ModelCombobox } from '@/components/shared/model-combobox'
|
|
13
13
|
import type { ProviderType, ClaudeSkill, AgentPackManifest, AgentRoutingStrategy, AgentRoutingTarget } from '@/types'
|
|
14
14
|
import { AVAILABLE_TOOLS, PLATFORM_TOOLS } from '@/lib/tool-definitions'
|
|
15
|
-
import { NATIVE_CAPABILITY_PROVIDER_IDS, NON_LANGGRAPH_PROVIDER_IDS, WORKER_ONLY_PROVIDER_IDS } from '@/lib/provider-sets'
|
|
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'
|
|
17
17
|
import { AgentAvatar } from './agent-avatar'
|
|
18
18
|
import { AgentPickerList } from '@/components/shared/agent-picker-list'
|
|
@@ -179,6 +179,7 @@ export function AgentSheet() {
|
|
|
179
179
|
const dynamicSkills = useAppStore((s) => s.skills)
|
|
180
180
|
const mcpServers = useAppStore((s) => s.mcpServers)
|
|
181
181
|
const loadSkills = useAppStore((s) => s.loadSkills)
|
|
182
|
+
const loadMcpServersAction = useAppStore((s) => s.loadMcpServers)
|
|
182
183
|
const [claudeSkills, setClaudeSkills] = useState<ClaudeSkill[]>([])
|
|
183
184
|
const [claudeSkillsLoading, setClaudeSkillsLoading] = useState(false)
|
|
184
185
|
const loadClaudeSkills = async () => {
|
|
@@ -390,6 +391,7 @@ export function AgentSheet() {
|
|
|
390
391
|
loadGatewayProfiles()
|
|
391
392
|
loadCredentials()
|
|
392
393
|
loadSkills()
|
|
394
|
+
loadMcpServersAction()
|
|
393
395
|
loadProjects()
|
|
394
396
|
loadClaudeSkills()
|
|
395
397
|
// Fetch enabled extension IDs so we can filter tool toggles
|
|
@@ -1656,7 +1658,7 @@ export function AgentSheet() {
|
|
|
1656
1658
|
</div>
|
|
1657
1659
|
)}
|
|
1658
1660
|
|
|
1659
|
-
{currentProvider?.requiresEndpoint && (provider !== 'ollama' || ollamaMode === 'local') && (
|
|
1661
|
+
{(currentProvider?.requiresEndpoint || currentProvider?.optionalEndpoint) && (provider !== 'ollama' || ollamaMode === 'local') && (
|
|
1660
1662
|
<div className="mb-8">
|
|
1661
1663
|
<SectionLabel>{provider === 'openclaw' ? 'OpenClaw Endpoint' : provider === 'hermes' ? 'Hermes API Endpoint' : 'Endpoint'}</SectionLabel>
|
|
1662
1664
|
<input type="text" value={apiEndpoint || ''} onChange={(e) => setApiEndpoint(e.target.value || null)} placeholder={currentProvider.defaultEndpoint || 'http://localhost:11434'} className={`${inputClass} font-mono text-[14px]`} />
|
|
@@ -2007,13 +2009,14 @@ export function AgentSheet() {
|
|
|
2007
2009
|
</SectionCard>
|
|
2008
2010
|
)}
|
|
2009
2011
|
|
|
2010
|
-
{!WORKER_ONLY_PROVIDER_IDS.has(provider) && (
|
|
2012
|
+
{(!WORKER_ONLY_PROVIDER_IDS.has(provider) || MCP_INJECTION_PROVIDER_IDS.has(provider)) && (
|
|
2011
2013
|
<AdvancedSettingsSection
|
|
2012
2014
|
open={showAdvancedSettings}
|
|
2013
2015
|
onToggle={() => setShowAdvancedSettings((current) => !current)}
|
|
2014
2016
|
summary={advancedSummary}
|
|
2015
2017
|
badges={agentAdvancedBadges}
|
|
2016
2018
|
>
|
|
2019
|
+
{!WORKER_ONLY_PROVIDER_IDS.has(provider) && (<>
|
|
2017
2020
|
<SectionCard
|
|
2018
2021
|
title="Context & Tool Access"
|
|
2019
2022
|
description="Control how many tools are described in this agent's system prompt. Scoped (default) keeps the agent focused and saves ~3 k input tokens per turn; Universal gives it visibility into every built-in tool."
|
|
@@ -2450,6 +2453,7 @@ export function AgentSheet() {
|
|
|
2450
2453
|
</div>
|
|
2451
2454
|
)}
|
|
2452
2455
|
</SectionCard>
|
|
2456
|
+
</>)}
|
|
2453
2457
|
|
|
2454
2458
|
<SectionCard
|
|
2455
2459
|
title="Tools & Skills"
|
|
@@ -2539,13 +2543,13 @@ export function AgentSheet() {
|
|
|
2539
2543
|
{provider === 'claude-cli'
|
|
2540
2544
|
? 'Claude CLI uses its own built-in capabilities — no additional local tool/platform configuration is needed.'
|
|
2541
2545
|
: provider === 'codex-cli'
|
|
2542
|
-
? 'OpenAI Codex CLI uses its own built-in tools (shell, files, etc.)
|
|
2546
|
+
? 'OpenAI Codex CLI uses its own built-in tools (shell, files, etc.). Skills and MCP servers assigned below will be injected at runtime.'
|
|
2543
2547
|
: provider === 'opencode-cli'
|
|
2544
2548
|
? 'OpenCode CLI uses its own built-in tools (shell, files, etc.) — no additional local tool configuration is needed.'
|
|
2545
2549
|
: provider === 'gemini-cli'
|
|
2546
2550
|
? 'Gemini CLI uses its own built-in tools and runtime — SwarmClaw does not inject local platform tools for it.'
|
|
2547
2551
|
: provider === 'copilot-cli'
|
|
2548
|
-
? 'GitHub Copilot CLI uses its own built-in tools and runtime
|
|
2552
|
+
? 'GitHub Copilot CLI uses its own built-in tools and runtime. Skills and MCP servers assigned below will be injected at runtime.'
|
|
2549
2553
|
: provider === 'droid-cli'
|
|
2550
2554
|
? 'Factory Droid CLI uses its own built-in tools and autonomy controls — SwarmClaw does not inject local platform tools for it.'
|
|
2551
2555
|
: provider === 'cursor-cli'
|
|
@@ -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
|
}
|
package/src/lib/provider-sets.ts
CHANGED
|
@@ -9,3 +9,6 @@ export const NATIVE_CAPABILITY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli'
|
|
|
9
9
|
|
|
10
10
|
/** Providers that can only act as workers — no coordinator role, no heartbeat, no advanced settings. */
|
|
11
11
|
export const WORKER_ONLY_PROVIDER_IDS = new Set(['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'copilot-cli', 'droid-cli', 'cursor-cli', 'qwen-code-cli', 'goose', 'openclaw', 'hermes'])
|
|
12
|
+
|
|
13
|
+
/** CLI providers that support MCP server and skill injection at runtime (via provider-specific config mechanisms). */
|
|
14
|
+
export const MCP_INJECTION_PROVIDER_IDS = new Set(['copilot-cli', 'codex-cli'])
|
|
@@ -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
|
})
|
|
@@ -6,6 +6,8 @@ import type { StreamChatOptions } from './index'
|
|
|
6
6
|
import { log } from '../server/logger'
|
|
7
7
|
import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
|
|
8
8
|
import { resolveCliBinary, buildCliEnv, probeCliAuth, attachAbortHandler, symlinkConfigFiles, isStderrNoise } from './cli-utils'
|
|
9
|
+
import { getAgent } from '@/lib/server/agents/agent-repository'
|
|
10
|
+
import { loadMcpServers } from '@/lib/server/storage'
|
|
9
11
|
|
|
10
12
|
const TAG = 'provider-codex'
|
|
11
13
|
|
|
@@ -31,7 +33,10 @@ export function streamCodexCliChat({ session, message, imagePath, systemPrompt,
|
|
|
31
33
|
args.push('resume', session.codexThreadId)
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
// Use --dangerously-bypass-approvals-and-sandbox instead of --full-auto so that
|
|
37
|
+
// MCP tool calls are not silently cancelled by codex's approval gate.
|
|
38
|
+
// SwarmClaw runs in its own sandboxed environment so bypassing codex's sandbox is safe.
|
|
39
|
+
args.push('--json', '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check')
|
|
35
40
|
|
|
36
41
|
if (session.model) args.push('-m', session.model)
|
|
37
42
|
if (codexModelRequiresReasoningDowngrade(session.model)) {
|
|
@@ -64,19 +69,73 @@ export function streamCodexCliChat({ session, message, imagePath, systemPrompt,
|
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
// System prompt
|
|
72
|
+
// System prompt + MCP injection: create a temp CODEX_HOME when needed
|
|
68
73
|
// Symlink auth files from the real config dir so auth still works
|
|
69
74
|
let tempCodexHome: string | null = null
|
|
70
|
-
|
|
75
|
+
const agentForMcp = session.agentId ? getAgent(session.agentId as string) : null
|
|
76
|
+
const agentMcpServerIds: string[] = agentForMcp?.mcpServerIds || []
|
|
77
|
+
const needsTempHome = (systemPrompt && !session.codexThreadId) || agentMcpServerIds.length > 0
|
|
78
|
+
if (needsTempHome) {
|
|
71
79
|
const realCodexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
|
|
72
|
-
|
|
80
|
+
// Use ~/.codex-sessions/ not /tmp — codex refuses to create helper binaries under /tmp
|
|
81
|
+
const sessionsDir = path.join(os.homedir(), '.codex-sessions')
|
|
82
|
+
tempCodexHome = path.join(sessionsDir, session.id)
|
|
73
83
|
fs.mkdirSync(tempCodexHome, { recursive: true })
|
|
74
84
|
|
|
75
85
|
// Symlink auth/config files from real CODEX_HOME into temp dir
|
|
76
86
|
symlinkConfigFiles(realCodexHome, tempCodexHome)
|
|
77
87
|
|
|
78
|
-
// Write system prompt as AGENTS.override.md
|
|
79
|
-
|
|
88
|
+
// Write system prompt as AGENTS.override.md (first turn only)
|
|
89
|
+
if (systemPrompt && !session.codexThreadId) {
|
|
90
|
+
fs.writeFileSync(path.join(tempCodexHome, 'AGENTS.override.md'), systemPrompt)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Inject agent-assigned MCP servers into config.toml
|
|
94
|
+
if (agentMcpServerIds.length > 0) {
|
|
95
|
+
try {
|
|
96
|
+
const allMcpServers = loadMcpServers()
|
|
97
|
+
const tomlParts: string[] = []
|
|
98
|
+
for (const serverId of agentMcpServerIds) {
|
|
99
|
+
const config = allMcpServers[serverId]
|
|
100
|
+
if (!config) continue
|
|
101
|
+
const name = config.name.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
102
|
+
if (config.transport === 'stdio' && config.command) {
|
|
103
|
+
tomlParts.push(`[mcp_servers.${name}]`)
|
|
104
|
+
tomlParts.push(`command = ${JSON.stringify(config.command)}`)
|
|
105
|
+
const argsStr = (config.args || []).map((a: string) => JSON.stringify(a)).join(', ')
|
|
106
|
+
tomlParts.push(`args = [${argsStr}]`)
|
|
107
|
+
if (config.cwd) tomlParts.push(`cwd = ${JSON.stringify(config.cwd)}`)
|
|
108
|
+
tomlParts.push('')
|
|
109
|
+
// Env vars go in a separate subsection: [mcp_servers.name.env]
|
|
110
|
+
if (config.env && Object.keys(config.env).length > 0) {
|
|
111
|
+
tomlParts.push(`[mcp_servers.${name}.env]`)
|
|
112
|
+
for (const [k, v] of Object.entries(config.env as Record<string, string>)) {
|
|
113
|
+
tomlParts.push(`${k} = ${JSON.stringify(v)}`)
|
|
114
|
+
}
|
|
115
|
+
tomlParts.push('')
|
|
116
|
+
}
|
|
117
|
+
} else if ((config.transport === 'sse' || config.transport === 'streamable-http') && config.url) {
|
|
118
|
+
tomlParts.push(`[mcp_servers.${name}]`)
|
|
119
|
+
tomlParts.push(`url = ${JSON.stringify(config.url)}`)
|
|
120
|
+
tomlParts.push('')
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (tomlParts.length > 0) {
|
|
124
|
+
const realConfigPath = path.join(realCodexHome, 'config.toml')
|
|
125
|
+
const existingConfig = fs.existsSync(realConfigPath)
|
|
126
|
+
? fs.readFileSync(realConfigPath, 'utf-8')
|
|
127
|
+
: ''
|
|
128
|
+
const tempConfigPath = path.join(tempCodexHome, 'config.toml')
|
|
129
|
+
// Remove symlink created by symlinkConfigFiles before writing our own file
|
|
130
|
+
try { fs.unlinkSync(tempConfigPath) } catch { /* no symlink — ignore */ }
|
|
131
|
+
fs.writeFileSync(tempConfigPath, existingConfig + '\n' + tomlParts.join('\n'))
|
|
132
|
+
log.info('codex-cli', `Injecting ${agentMcpServerIds.length} MCP server(s) via config.toml`)
|
|
133
|
+
}
|
|
134
|
+
} catch (mcpErr) {
|
|
135
|
+
log.warn('codex-cli', `Failed to build MCP config: ${mcpErr}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
80
139
|
env.CODEX_HOME = tempCodexHome
|
|
81
140
|
}
|
|
82
141
|
|
|
@@ -6,6 +6,8 @@ import type { StreamChatOptions } from './index'
|
|
|
6
6
|
import { log } from '../server/logger'
|
|
7
7
|
import { loadRuntimeSettings } from '@/lib/server/runtime/runtime-settings'
|
|
8
8
|
import { resolveCliBinary, buildCliEnv, probeCliAuth, attachAbortHandler, symlinkConfigFiles, isStderrNoise } from './cli-utils'
|
|
9
|
+
import { getAgent } from '@/lib/server/agents/agent-repository'
|
|
10
|
+
import { loadMcpServers } from '@/lib/server/storage'
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* GitHub Copilot CLI provider — spawns `copilot -p <message> --output-format=json -s --yolo`.
|
|
@@ -65,6 +67,44 @@ export function streamCopilotCliChat({ session, message, imagePath, systemPrompt
|
|
|
65
67
|
env.COPILOT_HOME = tempCopilotHome
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
// Inject agent-assigned MCP servers via --additional-mcp-config flag
|
|
71
|
+
let mcpAdditionalConfigPath: string | null = null
|
|
72
|
+
try {
|
|
73
|
+
const agentForMcp = session.agentId ? getAgent(session.agentId as string) : null
|
|
74
|
+
const agentMcpServerIds: string[] = agentForMcp?.mcpServerIds || []
|
|
75
|
+
if (agentMcpServerIds.length > 0) {
|
|
76
|
+
const allMcpServers = loadMcpServers()
|
|
77
|
+
const mcpServerEntries: Record<string, Record<string, unknown>> = {}
|
|
78
|
+
for (const serverId of agentMcpServerIds) {
|
|
79
|
+
const config = allMcpServers[serverId]
|
|
80
|
+
if (!config) continue
|
|
81
|
+
const name = config.name.replace(/[^a-zA-Z0-9_-]/g, '-')
|
|
82
|
+
if (config.transport === 'stdio' && config.command) {
|
|
83
|
+
mcpServerEntries[name] = {
|
|
84
|
+
command: config.command,
|
|
85
|
+
args: config.args || [],
|
|
86
|
+
...(config.env && Object.keys(config.env).length > 0 ? { env: config.env } : {}),
|
|
87
|
+
...(config.cwd ? { cwd: config.cwd } : {}),
|
|
88
|
+
}
|
|
89
|
+
} else if ((config.transport === 'sse' || config.transport === 'streamable-http') && config.url) {
|
|
90
|
+
mcpServerEntries[name] = {
|
|
91
|
+
type: config.transport,
|
|
92
|
+
url: config.url,
|
|
93
|
+
...(config.headers && Object.keys(config.headers).length > 0 ? { headers: config.headers } : {}),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (Object.keys(mcpServerEntries).length > 0) {
|
|
98
|
+
mcpAdditionalConfigPath = path.join(os.tmpdir(), `swarmclaw-copilot-mcp-${session.id}.json`)
|
|
99
|
+
fs.writeFileSync(mcpAdditionalConfigPath, JSON.stringify({ mcpServers: mcpServerEntries }))
|
|
100
|
+
args.push('--additional-mcp-config', `@${mcpAdditionalConfigPath}`)
|
|
101
|
+
log.info('copilot-cli', `Injecting ${Object.keys(mcpServerEntries).length} MCP server(s)`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch (mcpErr) {
|
|
105
|
+
log.warn('copilot-cli', `Failed to build MCP config: ${mcpErr}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
68
108
|
log.info('copilot-cli', `Spawning: ${binary}`, {
|
|
69
109
|
args: args.map((a) => a.length > 100 ? a.slice(0, 100) + '...' : a),
|
|
70
110
|
cwd: session.cwd,
|
|
@@ -221,6 +261,9 @@ export function streamCopilotCliChat({ session, message, imagePath, systemPrompt
|
|
|
221
261
|
if (tempCopilotHome) {
|
|
222
262
|
try { fs.rmSync(tempCopilotHome, { recursive: true }) } catch { /* ignore */ }
|
|
223
263
|
}
|
|
264
|
+
if (mcpAdditionalConfigPath) {
|
|
265
|
+
try { fs.unlinkSync(mcpAdditionalConfigPath) } catch { /* ignore */ }
|
|
266
|
+
}
|
|
224
267
|
if ((code ?? 0) !== 0 && !fullResponse.trim()) {
|
|
225
268
|
const msg = stderrText.trim()
|
|
226
269
|
? `Copilot CLI exited with code ${code ?? 'unknown'}${sig ? ` (${sig})` : ''}: ${stderrText.trim().slice(0, 1200)}`
|
|
@@ -236,6 +279,9 @@ export function streamCopilotCliChat({ session, message, imagePath, systemPrompt
|
|
|
236
279
|
if (tempCopilotHome) {
|
|
237
280
|
try { fs.rmSync(tempCopilotHome, { recursive: true }) } catch { /* ignore */ }
|
|
238
281
|
}
|
|
282
|
+
if (mcpAdditionalConfigPath) {
|
|
283
|
+
try { fs.unlinkSync(mcpAdditionalConfigPath) } catch { /* ignore */ }
|
|
284
|
+
}
|
|
239
285
|
write(`data: ${JSON.stringify({ t: 'err', text: e.message })}\n\n`)
|
|
240
286
|
resolve(fullResponse)
|
|
241
287
|
})
|
|
@@ -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
|
|
@@ -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
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { log } from '@/lib/server/logger'
|
|
2
2
|
import { genId } from '@/lib/id'
|
|
3
|
+
import { NON_LANGGRAPH_PROVIDER_IDS } from '@/lib/provider-sets'
|
|
3
4
|
import {
|
|
4
5
|
loadConnectors,
|
|
5
6
|
loadSession,
|
|
@@ -1022,6 +1023,8 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
1022
1023
|
}
|
|
1023
1024
|
}
|
|
1024
1025
|
|
|
1026
|
+
|
|
1027
|
+
|
|
1025
1028
|
// Build system prompt: [identity] \n\n [userPrompt] \n\n [soul] \n\n [systemPrompt]
|
|
1026
1029
|
const settings = loadSettings()
|
|
1027
1030
|
const promptParts: string[] = []
|
|
@@ -1196,7 +1199,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1196
1199
|
const transcript = typeof params.transcript === 'string' ? params.transcript.trim() : ''
|
|
1197
1200
|
if (transcript) currentChannelDeliveryRef.current?.transcripts.push(transcript)
|
|
1198
1201
|
}
|
|
1199
|
-
const hasTools = getEnabledCapabilityIds(session).length > 0 && session.provider
|
|
1202
|
+
const hasTools = getEnabledCapabilityIds(session).length > 0 && !NON_LANGGRAPH_PROVIDER_IDS.has(session.provider as string)
|
|
1200
1203
|
log.info(TAG, `Routing message to agent "${agent.name}" (${session.provider}/${session.model}), hasTools=${!!hasTools}`)
|
|
1201
1204
|
|
|
1202
1205
|
if (hasTools) {
|
|
@@ -33,10 +33,12 @@ const child = cliPath
|
|
|
33
33
|
? spawn(process.execPath, [cliPath], {
|
|
34
34
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
35
35
|
env: sanitizePlaywrightEnv(process.env),
|
|
36
|
+
cwd: process.cwd(),
|
|
36
37
|
})
|
|
37
38
|
: spawn('npx', ['@playwright/mcp@latest'], {
|
|
38
39
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
39
40
|
env: sanitizePlaywrightEnv(process.env),
|
|
41
|
+
cwd: process.cwd(),
|
|
40
42
|
})
|
|
41
43
|
|
|
42
44
|
// Graceful EPIPE handling — dev server restarts break stdio pipes
|
|
@@ -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(/\/+$/, '')
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
describe('getSearchProvider — exa', () => {
|
|
5
|
+
const originalEnv = process.env.EXA_API_KEY
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
if (originalEnv === undefined) delete process.env.EXA_API_KEY
|
|
9
|
+
else process.env.EXA_API_KEY = originalEnv
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('throws when no API key is available', async () => {
|
|
13
|
+
delete process.env.EXA_API_KEY
|
|
14
|
+
const { getSearchProvider } = await import('./search-providers')
|
|
15
|
+
await assert.rejects(
|
|
16
|
+
() => getSearchProvider({ webSearchProvider: 'exa' }),
|
|
17
|
+
(err: Error) => {
|
|
18
|
+
assert.match(err.message, /Exa requires an API key/)
|
|
19
|
+
return true
|
|
20
|
+
},
|
|
21
|
+
)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('resolves an ExaProvider from EXA_API_KEY env var', async () => {
|
|
25
|
+
process.env.EXA_API_KEY = 'test-key-123'
|
|
26
|
+
const { getSearchProvider } = await import('./search-providers')
|
|
27
|
+
const provider = await getSearchProvider({ webSearchProvider: 'exa' })
|
|
28
|
+
assert.equal(provider.id, 'exa')
|
|
29
|
+
assert.equal(provider.name, 'Exa')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('prefers settings key over env var', async () => {
|
|
33
|
+
process.env.EXA_API_KEY = 'env-key'
|
|
34
|
+
const { getSearchProvider } = await import('./search-providers')
|
|
35
|
+
const provider = await getSearchProvider({ webSearchProvider: 'exa', exaApiKey: 'settings-key' })
|
|
36
|
+
assert.equal(provider.id, 'exa')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('ExaProvider.search — response parsing', () => {
|
|
41
|
+
const FIXTURE = {
|
|
42
|
+
requestId: 'test-req-1',
|
|
43
|
+
results: [
|
|
44
|
+
{
|
|
45
|
+
title: 'Exa AI Search',
|
|
46
|
+
url: 'https://exa.ai',
|
|
47
|
+
publishedDate: '2024-01-15',
|
|
48
|
+
author: 'Exa Team',
|
|
49
|
+
text: 'Exa is a search engine for AI.',
|
|
50
|
+
highlights: ['Exa provides neural search.', 'Built for developers.'],
|
|
51
|
+
highlightScores: [0.95, 0.88],
|
|
52
|
+
summary: 'Exa is an AI-powered search engine built for developers.',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
title: 'Getting Started with Exa',
|
|
56
|
+
url: 'https://docs.exa.ai/getting-started',
|
|
57
|
+
text: 'Learn how to integrate Exa into your application.',
|
|
58
|
+
highlights: [],
|
|
59
|
+
summary: '',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
title: 'Minimal Result',
|
|
63
|
+
url: 'https://example.com/minimal',
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let originalFetch: typeof globalThis.fetch
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
originalFetch = globalThis.fetch
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
globalThis.fetch = originalFetch
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('parses a full API response into SearchResult[]', async () => {
|
|
79
|
+
globalThis.fetch = mock.fn(async () => new Response(JSON.stringify(FIXTURE), {
|
|
80
|
+
status: 200,
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
})) as unknown as typeof globalThis.fetch
|
|
83
|
+
|
|
84
|
+
process.env.EXA_API_KEY = 'test-key'
|
|
85
|
+
const { getSearchProvider } = await import('./search-providers')
|
|
86
|
+
const provider = await getSearchProvider({ webSearchProvider: 'exa' })
|
|
87
|
+
const results = await provider.search('exa search', 10)
|
|
88
|
+
|
|
89
|
+
assert.equal(results.length, 3)
|
|
90
|
+
assert.equal(results[0].title, 'Exa AI Search')
|
|
91
|
+
assert.equal(results[0].url, 'https://exa.ai')
|
|
92
|
+
// Summary is preferred when available
|
|
93
|
+
assert.equal(results[0].snippet, 'Exa is an AI-powered search engine built for developers.')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('falls back to text when summary and highlights are empty', async () => {
|
|
97
|
+
globalThis.fetch = mock.fn(async () => new Response(JSON.stringify(FIXTURE), {
|
|
98
|
+
status: 200,
|
|
99
|
+
headers: { 'Content-Type': 'application/json' },
|
|
100
|
+
})) as unknown as typeof globalThis.fetch
|
|
101
|
+
|
|
102
|
+
process.env.EXA_API_KEY = 'test-key'
|
|
103
|
+
const { getSearchProvider } = await import('./search-providers')
|
|
104
|
+
const provider = await getSearchProvider({ webSearchProvider: 'exa' })
|
|
105
|
+
const results = await provider.search('exa search', 10)
|
|
106
|
+
|
|
107
|
+
// Second result has empty summary and empty highlights, should fall back to text
|
|
108
|
+
assert.equal(results[1].snippet, 'Learn how to integrate Exa into your application.')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('returns empty snippet when no content fields are present', async () => {
|
|
112
|
+
globalThis.fetch = mock.fn(async () => new Response(JSON.stringify(FIXTURE), {
|
|
113
|
+
status: 200,
|
|
114
|
+
headers: { 'Content-Type': 'application/json' },
|
|
115
|
+
})) as unknown as typeof globalThis.fetch
|
|
116
|
+
|
|
117
|
+
process.env.EXA_API_KEY = 'test-key'
|
|
118
|
+
const { getSearchProvider } = await import('./search-providers')
|
|
119
|
+
const provider = await getSearchProvider({ webSearchProvider: 'exa' })
|
|
120
|
+
const results = await provider.search('exa search', 10)
|
|
121
|
+
|
|
122
|
+
// Third result has no summary, no highlights, no text
|
|
123
|
+
assert.equal(results[2].snippet, '')
|
|
124
|
+
assert.equal(results[2].title, 'Minimal Result')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('sends the integration tracking header', async () => {
|
|
128
|
+
let capturedHeaders: Record<string, string> = {}
|
|
129
|
+
globalThis.fetch = mock.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
130
|
+
const headers = init?.headers as Record<string, string> | undefined
|
|
131
|
+
if (headers) capturedHeaders = { ...headers }
|
|
132
|
+
return new Response(JSON.stringify({ results: [] }), {
|
|
133
|
+
status: 200,
|
|
134
|
+
headers: { 'Content-Type': 'application/json' },
|
|
135
|
+
})
|
|
136
|
+
}) as unknown as typeof globalThis.fetch
|
|
137
|
+
|
|
138
|
+
process.env.EXA_API_KEY = 'test-key'
|
|
139
|
+
const { getSearchProvider } = await import('./search-providers')
|
|
140
|
+
const provider = await getSearchProvider({ webSearchProvider: 'exa' })
|
|
141
|
+
await provider.search('test', 5)
|
|
142
|
+
|
|
143
|
+
assert.equal(capturedHeaders['x-exa-integration'], 'swarmclaw')
|
|
144
|
+
assert.equal(capturedHeaders['x-api-key'], 'test-key')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('throws on non-OK HTTP response', async () => {
|
|
148
|
+
globalThis.fetch = mock.fn(async () => new Response('Unauthorized', {
|
|
149
|
+
status: 401,
|
|
150
|
+
statusText: 'Unauthorized',
|
|
151
|
+
})) as unknown as typeof globalThis.fetch
|
|
152
|
+
|
|
153
|
+
process.env.EXA_API_KEY = 'bad-key'
|
|
154
|
+
const { getSearchProvider } = await import('./search-providers')
|
|
155
|
+
const provider = await getSearchProvider({ webSearchProvider: 'exa' })
|
|
156
|
+
|
|
157
|
+
await assert.rejects(
|
|
158
|
+
() => provider.search('test', 5),
|
|
159
|
+
(err: Error) => {
|
|
160
|
+
assert.match(err.message, /401/)
|
|
161
|
+
return true
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
})
|
|
@@ -243,6 +243,65 @@ class BraveProvider implements WebSearchProvider {
|
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Exa (API key required — from secrets or EXA_API_KEY env var)
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
interface ExaSearchResult {
|
|
251
|
+
title?: string
|
|
252
|
+
url?: string
|
|
253
|
+
publishedDate?: string | null
|
|
254
|
+
author?: string | null
|
|
255
|
+
text?: string
|
|
256
|
+
highlights?: string[]
|
|
257
|
+
highlightScores?: number[]
|
|
258
|
+
summary?: string
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
class ExaProvider implements WebSearchProvider {
|
|
262
|
+
id = 'exa'
|
|
263
|
+
name = 'Exa'
|
|
264
|
+
|
|
265
|
+
constructor(private apiKey: string) {}
|
|
266
|
+
|
|
267
|
+
async search(query: string, maxResults: number): Promise<SearchResult[]> {
|
|
268
|
+
const res = await fetch('https://api.exa.ai/search', {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
headers: {
|
|
271
|
+
'Content-Type': 'application/json',
|
|
272
|
+
'x-api-key': this.apiKey,
|
|
273
|
+
'x-exa-integration': 'swarmclaw',
|
|
274
|
+
},
|
|
275
|
+
body: JSON.stringify({
|
|
276
|
+
query,
|
|
277
|
+
type: 'auto',
|
|
278
|
+
numResults: maxResults,
|
|
279
|
+
contents: {
|
|
280
|
+
highlights: { numSentences: 3 },
|
|
281
|
+
text: { maxCharacters: 500 },
|
|
282
|
+
summary: true,
|
|
283
|
+
},
|
|
284
|
+
}),
|
|
285
|
+
signal: AbortSignal.timeout(15000),
|
|
286
|
+
})
|
|
287
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`)
|
|
288
|
+
const data = await res.json()
|
|
289
|
+
const rawResults: ExaSearchResult[] = Array.isArray(data.results) ? data.results : []
|
|
290
|
+
return rawResults.slice(0, maxResults).map((r) => ({
|
|
291
|
+
title: r.title || '',
|
|
292
|
+
url: r.url || '',
|
|
293
|
+
snippet: buildExaSnippet(r),
|
|
294
|
+
}))
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function buildExaSnippet(result: ExaSearchResult): string {
|
|
299
|
+
if (result.summary) return result.summary
|
|
300
|
+
if (result.highlights && result.highlights.length > 0) return result.highlights.join(' … ')
|
|
301
|
+
if (result.text) return result.text
|
|
302
|
+
return ''
|
|
303
|
+
}
|
|
304
|
+
|
|
246
305
|
// ---------------------------------------------------------------------------
|
|
247
306
|
// Factory
|
|
248
307
|
// ---------------------------------------------------------------------------
|
|
@@ -279,6 +338,17 @@ export async function getSearchProvider(settings: Partial<AppSettings>): Promise
|
|
|
279
338
|
if (!apiKey) throw new Error('Brave Search requires an API key. Set one in Settings > Web Search.')
|
|
280
339
|
return new BraveProvider(apiKey)
|
|
281
340
|
}
|
|
341
|
+
case 'exa': {
|
|
342
|
+
let apiKey = settings.exaApiKey
|
|
343
|
+
if (!apiKey) apiKey = process.env.EXA_API_KEY ?? null
|
|
344
|
+
if (!apiKey) {
|
|
345
|
+
const { getSecret } = await import('../storage')
|
|
346
|
+
const secret = await getSecret('exa')
|
|
347
|
+
apiKey = secret?.value ?? null
|
|
348
|
+
}
|
|
349
|
+
if (!apiKey) throw new Error('Exa requires an API key. Set one in Settings > Web Search or set the EXA_API_KEY environment variable.')
|
|
350
|
+
return new ExaProvider(apiKey)
|
|
351
|
+
}
|
|
282
352
|
default:
|
|
283
353
|
return new DuckDuckGoProvider()
|
|
284
354
|
}
|
|
@@ -115,8 +115,10 @@ export interface AppSettings {
|
|
|
115
115
|
// Theme
|
|
116
116
|
themeHue?: string
|
|
117
117
|
// Web search provider
|
|
118
|
-
webSearchProvider?: 'duckduckgo' | 'google' | 'bing' | 'searxng' | 'tavily' | 'brave'
|
|
118
|
+
webSearchProvider?: 'duckduckgo' | 'google' | 'bing' | 'searxng' | 'tavily' | 'brave' | 'exa'
|
|
119
119
|
searxngUrl?: string
|
|
120
|
+
exaApiKey?: string | null
|
|
121
|
+
exaApiKeyConfigured?: boolean
|
|
120
122
|
// Task custom field definitions
|
|
121
123
|
taskCustomFieldDefs?: Array<{ key: string; label: string; type: 'text' | 'number' | 'select'; options?: string[] }>
|
|
122
124
|
// OpenClaw sync settings
|
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
|
|
|
@@ -6,6 +6,7 @@ export function WebSearchSection({ appSettings, patchSettings, inputClass }: Set
|
|
|
6
6
|
const provider = appSettings.webSearchProvider || 'duckduckgo'
|
|
7
7
|
const hasTavilyKey = appSettings.tavilyApiKeyConfigured === true
|
|
8
8
|
const hasBraveKey = appSettings.braveApiKeyConfigured === true
|
|
9
|
+
const hasExaKey = appSettings.exaApiKeyConfigured === true
|
|
9
10
|
|
|
10
11
|
return (
|
|
11
12
|
<div className="mb-10">
|
|
@@ -30,6 +31,7 @@ export function WebSearchSection({ appSettings, patchSettings, inputClass }: Set
|
|
|
30
31
|
<option value="searxng">SearXNG (self-hosted, no key required)</option>
|
|
31
32
|
<option value="tavily">Tavily (API key required)</option>
|
|
32
33
|
<option value="brave">Brave Search (API key required)</option>
|
|
34
|
+
<option value="exa">Exa (API key required)</option>
|
|
33
35
|
</select>
|
|
34
36
|
</div>
|
|
35
37
|
|
|
@@ -82,6 +84,24 @@ export function WebSearchSection({ appSettings, patchSettings, inputClass }: Set
|
|
|
82
84
|
<p className="text-[11px] text-text-3/60 mt-2">Get your API key from <a href="https://brave.com/search/api/" target="_blank" rel="noopener noreferrer" className="text-accent-bright hover:underline">brave.com/search/api</a></p>
|
|
83
85
|
</div>
|
|
84
86
|
)}
|
|
87
|
+
|
|
88
|
+
{provider === 'exa' && (
|
|
89
|
+
<div>
|
|
90
|
+
<label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Exa API Key</label>
|
|
91
|
+
<input
|
|
92
|
+
type="password"
|
|
93
|
+
value={appSettings.exaApiKey || ''}
|
|
94
|
+
onChange={(e) => patchSettings({ exaApiKey: e.target.value || null })}
|
|
95
|
+
placeholder={hasExaKey ? 'Stored securely. Enter a new key to replace it.' : 'exa-...'}
|
|
96
|
+
className={inputClass}
|
|
97
|
+
style={{ fontFamily: 'inherit' }}
|
|
98
|
+
/>
|
|
99
|
+
{hasExaKey && (
|
|
100
|
+
<p className="text-[11px] text-emerald-400/90 mt-1.5">Stored securely. Clear the field and save to remove it.</p>
|
|
101
|
+
)}
|
|
102
|
+
<p className="text-[11px] text-text-3/60 mt-2">Get your API key from <a href="https://exa.ai" target="_blank" rel="noopener noreferrer" className="text-accent-bright hover:underline">exa.ai</a>. You can also set the <code className="text-[11px] font-mono text-text-2">EXA_API_KEY</code> environment variable.</p>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
85
105
|
</div>
|
|
86
106
|
</div>
|
|
87
107
|
)
|