feat(websearch): manage provider API keys in dashboard

- add masked dashboard-managed API key state for Exa, Tavily, and Brave
- persist WebSearch keys through global_env while keeping WebSearch as the UX surface
- treat dashboard-managed keys as ready in status checks and cover the flow with route/UI tests
This commit is contained in:
Tam Nhu Tran
2026-03-27 13:25:26 -04:00
parent eea0f57688
commit 20c48c6105
10 changed files with 775 additions and 31 deletions
+4
View File
@@ -18,6 +18,8 @@ export type {
WebSearchConfig,
} from './types';
export type { WebSearchApiKeyState } from './provider-secrets';
// Gemini CLI
export {
getGeminiCliStatus,
@@ -56,5 +58,7 @@ export {
displayWebSearchStatus,
} from './status';
export { WEBSEARCH_API_KEY_PROVIDERS, getWebSearchApiKeyStates } from './provider-secrets';
// Profile Hook Injection
export { ensureProfileHooks, removeMigrationMarker } from './profile-hook-injector';
+132
View File
@@ -0,0 +1,132 @@
import { getGlobalEnvConfig } from '../../config/unified-config-loader';
import { maskSensitiveValue } from '../sensitive-keys';
export type WebSearchApiKeyProviderId = 'exa' | 'tavily' | 'brave';
export type WebSearchApiKeySource = 'global_env' | 'process_env' | 'both' | 'none';
export interface WebSearchApiKeyState {
envVar: string;
configured: boolean;
available: boolean;
source: WebSearchApiKeySource;
maskedValue?: string;
}
interface WebSearchApiKeyDescriptor {
envVar: string;
alternateEnvVar: string;
}
export const WEBSEARCH_API_KEY_PROVIDERS: Record<
WebSearchApiKeyProviderId,
WebSearchApiKeyDescriptor
> = {
exa: {
envVar: 'EXA_API_KEY',
alternateEnvVar: 'CCS_WEBSEARCH_EXA_API_KEY',
},
tavily: {
envVar: 'TAVILY_API_KEY',
alternateEnvVar: 'CCS_WEBSEARCH_TAVILY_API_KEY',
},
brave: {
envVar: 'BRAVE_API_KEY',
alternateEnvVar: 'CCS_WEBSEARCH_BRAVE_API_KEY',
},
};
function getTrimmedEnvValue(
env: Record<string, string | undefined>,
names: string[]
): string | undefined {
for (const name of names) {
const value = env[name]?.trim();
if (value) {
return value;
}
}
return undefined;
}
function resolveSource(
hasGlobalEnvValue: boolean,
hasProcessEnvValue: boolean
): WebSearchApiKeySource {
if (hasGlobalEnvValue && hasProcessEnvValue) {
return 'both';
}
if (hasGlobalEnvValue) {
return 'global_env';
}
if (hasProcessEnvValue) {
return 'process_env';
}
return 'none';
}
export function getWebSearchApiKeyStates(): Record<
WebSearchApiKeyProviderId,
WebSearchApiKeyState
> {
const globalEnvConfig = getGlobalEnvConfig();
const storedEnv = globalEnvConfig.env ?? {};
return (
Object.entries(WEBSEARCH_API_KEY_PROVIDERS) as Array<
[WebSearchApiKeyProviderId, WebSearchApiKeyDescriptor]
>
).reduce(
(acc, [providerId, descriptor]) => {
const names = [descriptor.envVar, descriptor.alternateEnvVar];
const globalEnvValue = getTrimmedEnvValue(storedEnv, names);
const processEnvValue = getTrimmedEnvValue(process.env, names);
const configured = Boolean(globalEnvValue || processEnvValue);
const available = Boolean(processEnvValue || (globalEnvConfig.enabled && globalEnvValue));
const visibleValue = globalEnvValue || processEnvValue;
acc[providerId] = {
envVar: descriptor.envVar,
configured,
available,
source: resolveSource(Boolean(globalEnvValue), Boolean(processEnvValue)),
maskedValue: visibleValue ? maskSensitiveValue(visibleValue) : undefined,
};
return acc;
},
{} as Record<WebSearchApiKeyProviderId, WebSearchApiKeyState>
);
}
export function applyWebSearchApiKeyUpdates(
env: Record<string, string>,
apiKeys: Partial<Record<WebSearchApiKeyProviderId, string | null>> | undefined
): Record<string, string> {
if (!apiKeys) {
return env;
}
const nextEnv = { ...env };
for (const [providerId, rawValue] of Object.entries(apiKeys) as Array<
[WebSearchApiKeyProviderId, string | null | undefined]
>) {
if (rawValue === undefined) {
continue;
}
const descriptor = WEBSEARCH_API_KEY_PROVIDERS[providerId];
delete nextEnv[descriptor.alternateEnvVar];
const normalizedValue = typeof rawValue === 'string' ? rawValue.trim() : '';
if (!normalizedValue) {
delete nextEnv[descriptor.envVar];
continue;
}
nextEnv[descriptor.envVar] = normalizedValue;
}
return nextEnv;
}
+17 -19
View File
@@ -11,16 +11,13 @@ import { getWebSearchConfig } from '../../config/unified-config-loader';
import { getGeminiCliStatus, isGeminiAuthenticated } from './gemini-cli';
import { getGrokCliStatus } from './grok-cli';
import { getOpenCodeCliStatus } from './opencode-cli';
import { getWebSearchApiKeyStates } from './provider-secrets';
import type { WebSearchCliInfo, WebSearchStatus } from './types';
function hasEnvValue(name: string): boolean {
return (process.env[name] || '').trim().length > 0;
}
function hasAnyEnvValue(names: string[]): boolean {
return names.some((name) => hasEnvValue(name));
}
function getLegacyProviderStatuses(): WebSearchCliInfo[] {
const wsConfig = getWebSearchConfig();
const geminiStatus = getGeminiCliStatus();
@@ -88,40 +85,41 @@ function getLegacyProviderStatuses(): WebSearchCliInfo[] {
*/
export function getWebSearchCliProviders(): WebSearchCliInfo[] {
const wsConfig = getWebSearchConfig();
const apiKeyStates = getWebSearchApiKeyStates();
const providers: WebSearchCliInfo[] = [
{
id: 'exa',
kind: 'backend',
name: 'Exa',
enabled: wsConfig.providers?.exa?.enabled ?? false,
available:
(wsConfig.providers?.exa?.enabled ?? false) &&
hasAnyEnvValue(['EXA_API_KEY', 'CCS_WEBSEARCH_EXA_API_KEY']),
available: (wsConfig.providers?.exa?.enabled ?? false) && apiKeyStates.exa.available,
version: null,
docsUrl: 'https://docs.exa.ai/reference/search',
requiresApiKey: true,
apiKeyEnvVar: 'EXA_API_KEY',
description: 'API-backed search with strong relevance and content extraction.',
detail: hasAnyEnvValue(['EXA_API_KEY', 'CCS_WEBSEARCH_EXA_API_KEY'])
detail: apiKeyStates.exa.available
? `API key detected (${wsConfig.providers?.exa?.max_results ?? 5} results)`
: 'Set EXA_API_KEY',
: apiKeyStates.exa.configured
? 'Stored in dashboard, but Global Env is disabled'
: 'Set EXA_API_KEY',
},
{
id: 'tavily',
kind: 'backend',
name: 'Tavily',
enabled: wsConfig.providers?.tavily?.enabled ?? false,
available:
(wsConfig.providers?.tavily?.enabled ?? false) &&
hasAnyEnvValue(['TAVILY_API_KEY', 'CCS_WEBSEARCH_TAVILY_API_KEY']),
available: (wsConfig.providers?.tavily?.enabled ?? false) && apiKeyStates.tavily.available,
version: null,
docsUrl: 'https://docs.tavily.com/documentation/api-reference/endpoint/search',
requiresApiKey: true,
apiKeyEnvVar: 'TAVILY_API_KEY',
description: 'Search API optimized for agent workflows and concise web result synthesis.',
detail: hasAnyEnvValue(['TAVILY_API_KEY', 'CCS_WEBSEARCH_TAVILY_API_KEY'])
detail: apiKeyStates.tavily.available
? `API key detected (${wsConfig.providers?.tavily?.max_results ?? 5} results)`
: 'Set TAVILY_API_KEY',
: apiKeyStates.tavily.configured
? 'Stored in dashboard, but Global Env is disabled'
: 'Set TAVILY_API_KEY',
},
{
id: 'duckduckgo',
@@ -140,17 +138,17 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] {
kind: 'backend',
name: 'Brave Search',
enabled: wsConfig.providers?.brave?.enabled ?? false,
available:
(wsConfig.providers?.brave?.enabled ?? false) &&
hasAnyEnvValue(['BRAVE_API_KEY', 'CCS_WEBSEARCH_BRAVE_API_KEY']),
available: (wsConfig.providers?.brave?.enabled ?? false) && apiKeyStates.brave.available,
version: null,
docsUrl: 'https://brave.com/search/api/',
requiresApiKey: true,
apiKeyEnvVar: 'BRAVE_API_KEY',
description: 'API-backed web search with cleaner result metadata.',
detail: hasAnyEnvValue(['BRAVE_API_KEY', 'CCS_WEBSEARCH_BRAVE_API_KEY'])
detail: apiKeyStates.brave.available
? `API key detected (${wsConfig.providers?.brave?.max_results ?? 5} results)`
: 'Set BRAVE_API_KEY',
: apiKeyStates.brave.configured
? 'Stored in dashboard, but Global Env is disabled'
: 'Set BRAVE_API_KEY',
},
];
+47 -4
View File
@@ -6,9 +6,20 @@ import { Router, Request, Response } from 'express';
import { mutateUnifiedConfig, getWebSearchConfig } from '../../config/unified-config-loader';
import type { WebSearchConfig } from '../../config/unified-config-types';
import { getWebSearchReadiness, getWebSearchCliProviders } from '../../utils/websearch-manager';
import {
applyWebSearchApiKeyUpdates,
getWebSearchApiKeyStates,
type WebSearchApiKeyProviderId,
} from '../../utils/websearch/provider-secrets';
const router = Router();
type WebSearchApiKeyUpdates = Partial<Record<WebSearchApiKeyProviderId, string | null>>;
interface WebSearchDashboardPayload extends Partial<WebSearchConfig> {
apiKeys?: WebSearchApiKeyUpdates;
}
/**
* GET /api/websearch - Get WebSearch configuration
* Returns: normalized WebSearch configuration
@@ -16,7 +27,10 @@ const router = Router();
router.get('/', (_req: Request, res: Response): void => {
try {
const config = getWebSearchConfig();
res.json(config);
res.json({
...config,
apiKeys: getWebSearchApiKeyStates(),
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
@@ -27,7 +41,7 @@ router.get('/', (_req: Request, res: Response): void => {
* Body: WebSearchConfig fields (enabled, providers)
*/
router.put('/', (req: Request, res: Response): void => {
const { enabled, providers } = req.body as Partial<WebSearchConfig>;
const { enabled, providers, apiKeys } = req.body as WebSearchDashboardPayload;
// Validate enabled
if (enabled !== undefined && typeof enabled !== 'boolean') {
@@ -41,8 +55,27 @@ router.put('/', (req: Request, res: Response): void => {
return;
}
if (apiKeys !== undefined && typeof apiKeys !== 'object') {
res.status(400).json({ error: 'Invalid value for apiKeys. Must be an object.' });
return;
}
if (apiKeys) {
for (const [providerId, value] of Object.entries(apiKeys)) {
if (!['exa', 'tavily', 'brave'].includes(providerId)) {
res.status(400).json({ error: `Unsupported WebSearch provider: ${providerId}` });
return;
}
if (value !== null && value !== undefined && typeof value !== 'string') {
res.status(400).json({ error: `Invalid value for ${providerId} API key` });
return;
}
}
}
try {
const existingConfig = mutateUnifiedConfig((config) => {
mutateUnifiedConfig((config) => {
config.websearch = {
enabled: enabled ?? config.websearch?.enabled ?? true,
providers: providers
@@ -116,11 +149,21 @@ router.put('/', (req: Request, res: Response): void => {
}
: config.websearch?.providers,
};
if (apiKeys) {
config.global_env = {
enabled: config.global_env?.enabled ?? true,
env: applyWebSearchApiKeyUpdates(config.global_env?.env ?? {}, apiKeys),
};
}
});
res.json({
success: true,
websearch: existingConfig.websearch,
websearch: {
...getWebSearchConfig(),
apiKeys: getWebSearchApiKeyStates(),
},
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
@@ -0,0 +1,221 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import type { Server } from 'http';
import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
import websearchRoutes from '../../../src/web-server/routes/websearch-routes';
const WEBSEARCH_ENV_KEYS = [
'EXA_API_KEY',
'TAVILY_API_KEY',
'BRAVE_API_KEY',
'CCS_WEBSEARCH_EXA_API_KEY',
'CCS_WEBSEARCH_TAVILY_API_KEY',
'CCS_WEBSEARCH_BRAVE_API_KEY',
] as const;
describe('websearch routes', () => {
let server: Server;
let baseUrl = '';
let tempHome: string;
let originalCcsHome: string | undefined;
let originalEnvValues: Record<(typeof WEBSEARCH_ENV_KEYS)[number], string | undefined>;
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use('/api/websearch', websearchRoutes);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve();
});
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-routes-test-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
originalEnvValues = WEBSEARCH_ENV_KEYS.reduce(
(acc, key) => {
acc[key] = process.env[key];
delete process.env[key];
return acc;
},
{} as Record<(typeof WEBSEARCH_ENV_KEYS)[number], string | undefined>
);
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
for (const key of WEBSEARCH_ENV_KEYS) {
const value = originalEnvValues[key];
if (value !== undefined) {
process.env[key] = value;
} else {
delete process.env[key];
}
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('returns masked API key state from dashboard-managed global env', async () => {
mutateUnifiedConfig((config) => {
config.websearch = {
enabled: true,
providers: {
...config.websearch?.providers,
exa: { enabled: true, max_results: 7 },
duckduckgo: { enabled: false, max_results: 5 },
},
};
config.global_env = {
enabled: true,
env: {
...(config.global_env?.env ?? {}),
EXA_API_KEY: 'exa-secret-12345678',
},
};
});
const response = await fetch(`${baseUrl}/api/websearch`);
expect(response.status).toBe(200);
const payload = await response.json();
expect(payload.providers.exa).toMatchObject({
enabled: true,
max_results: 7,
});
expect(payload.apiKeys.exa).toMatchObject({
envVar: 'EXA_API_KEY',
configured: true,
available: true,
source: 'global_env',
});
expect(payload.apiKeys.exa.maskedValue).toContain('*');
const statusResponse = await fetch(`${baseUrl}/api/websearch/status`);
expect(statusResponse.status).toBe(200);
const statusPayload = await statusResponse.json();
expect(statusPayload.readiness).toMatchObject({
status: 'ready',
});
expect(statusPayload.providers.find((provider: { id: string }) => provider.id === 'exa')).toMatchObject({
available: true,
detail: 'API key detected (7 results)',
});
});
it('stores and removes WebSearch API keys via the dashboard route', async () => {
const createResponse = await fetch(`${baseUrl}/api/websearch`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providers: {
exa: { enabled: true, max_results: 6 },
},
apiKeys: {
exa: 'exa-secret-abcdefgh',
},
}),
});
expect(createResponse.status).toBe(200);
const createdPayload = await createResponse.json();
expect(createdPayload.websearch.apiKeys.exa).toMatchObject({
configured: true,
source: 'global_env',
});
let config = loadOrCreateUnifiedConfig();
expect(config.global_env?.env.EXA_API_KEY).toBe('exa-secret-abcdefgh');
const removeResponse = await fetch(`${baseUrl}/api/websearch`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providers: {
exa: { enabled: true, max_results: 6 },
},
apiKeys: {
exa: '',
},
}),
});
expect(removeResponse.status).toBe(200);
const removedPayload = await removeResponse.json();
expect(removedPayload.websearch.apiKeys.exa).toMatchObject({
configured: false,
source: 'none',
});
config = loadOrCreateUnifiedConfig();
expect(config.global_env?.env.EXA_API_KEY).toBeUndefined();
});
it('preserves stored API keys when only provider settings change', async () => {
mutateUnifiedConfig((config) => {
config.global_env = {
enabled: true,
env: {
...(config.global_env?.env ?? {}),
EXA_API_KEY: 'exa-secret-12345678',
},
};
});
const response = await fetch(`${baseUrl}/api/websearch`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providers: {
exa: { enabled: true, max_results: 9 },
duckduckgo: { enabled: false, max_results: 5 },
},
}),
});
expect(response.status).toBe(200);
const payload = await response.json();
expect(payload.websearch.providers.exa).toMatchObject({
enabled: true,
max_results: 9,
});
expect(payload.websearch.apiKeys.exa).toMatchObject({
configured: true,
source: 'global_env',
});
const config = loadOrCreateUnifiedConfig();
expect(config.global_env?.env.EXA_API_KEY).toBe('exa-secret-12345678');
});
});
@@ -4,7 +4,7 @@
import { useCallback } from 'react';
import { useSettingsContext, useSettingsActions } from './context-hooks';
import type { WebSearchConfig } from '../types';
import type { WebSearchConfig, WebSearchSavePayload } from '../types';
export function useWebSearchConfig() {
const { state } = useSettingsContext();
@@ -40,14 +40,15 @@ export function useWebSearchConfig() {
}, [actions]);
const saveConfig = useCallback(
async (updates: Partial<WebSearchConfig>) => {
async (updates: WebSearchSavePayload) => {
const config = state.webSearchConfig;
if (!config) return false;
const optimisticConfig = {
const optimisticConfig: WebSearchConfig = {
...config,
...updates,
providers: { ...config.providers, ...updates.providers },
apiKeys: config.apiKeys,
};
actions.setWebSearchConfig(optimisticConfig);
@@ -55,10 +56,18 @@ export function useWebSearchConfig() {
actions.setWebSearchSaving(true);
actions.setWebSearchError(null);
const requestBody: WebSearchSavePayload = {
enabled: optimisticConfig.enabled,
providers: optimisticConfig.providers,
};
if (updates.apiKeys) {
requestBody.apiKeys = updates.apiKeys;
}
const res = await fetch('/api/websearch', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(optimisticConfig),
body: JSON.stringify(requestBody),
});
if (!res.ok) {
@@ -6,6 +6,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { MaskedInput } from '@/components/ui/masked-input';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
AlertCircle,
@@ -19,7 +20,12 @@ import {
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { useRawConfig, useWebSearchConfig } from '../../hooks';
import type { CliStatus, WebSearchProvidersConfig } from '../../types';
import type {
CliStatus,
WebSearchApiKeyProviderId,
WebSearchApiKeyState,
WebSearchProvidersConfig,
} from '../../types';
import { ProviderCard, type ProviderFieldConfig } from './provider-card';
type ProviderId = 'exa' | 'tavily' | 'brave' | 'duckduckgo' | 'gemini' | 'opencode' | 'grok';
@@ -325,6 +331,31 @@ function getConfiguredValue(
return String(configured ?? field.defaultValue);
}
function isApiKeyProvider(providerId: ProviderId): providerId is WebSearchApiKeyProviderId {
return providerId === 'exa' || providerId === 'tavily' || providerId === 'brave';
}
function getApiKeySummary(apiKeyState: WebSearchApiKeyState | undefined): string {
if (!apiKeyState?.configured) {
return 'Not stored';
}
if (!apiKeyState.available && apiKeyState.source === 'global_env') {
return 'Stored in dashboard, but Global Env is disabled';
}
switch (apiKeyState.source) {
case 'global_env':
return 'Stored in dashboard';
case 'process_env':
return 'Detected from shell env';
case 'both':
return 'Stored in dashboard + shell env';
case 'none':
return 'Not stored';
}
}
export default function WebSearchSection() {
const { t } = useTranslation();
const {
@@ -341,7 +372,13 @@ export default function WebSearchSection() {
} = useWebSearchConfig();
const { fetchRawConfig } = useRawConfig();
const [fieldDrafts, setFieldDrafts] = useState<Record<string, string>>({});
const [apiKeyDrafts, setApiKeyDrafts] = useState<
Partial<Record<WebSearchApiKeyProviderId, string>>
>({});
const [savedFieldId, setSavedFieldId] = useState<string | null>(null);
const [savedApiKeyProvider, setSavedApiKeyProvider] = useState<WebSearchApiKeyProviderId | null>(
null
);
const [legacyExpanded, setLegacyExpanded] = useState(false);
useEffect(() => {
@@ -368,7 +405,7 @@ export default function WebSearchSection() {
const toggleProvider = async (providerId: ProviderId, enabled: boolean) => {
const currentProviders = (config?.providers ?? {}) as WebSearchProvidersConfig;
await saveConfig({
const saved = await saveConfig({
providers: {
...currentProviders,
[providerId]: {
@@ -377,6 +414,10 @@ export default function WebSearchSection() {
},
},
});
if (saved) {
await fetchRawConfig();
}
};
const updateDraft = (providerId: ProviderId, fieldKey: ProviderFieldKey, value: string) => {
@@ -414,6 +455,7 @@ export default function WebSearchSection() {
});
if (saved) {
await fetchRawConfig();
setSavedFieldId(fieldId);
setTimeout(() => {
setSavedFieldId((current) => (current === fieldId ? null : current));
@@ -421,6 +463,45 @@ export default function WebSearchSection() {
}
};
const saveApiKey = async (providerId: WebSearchApiKeyProviderId) => {
const nextValue = apiKeyDrafts[providerId]?.trim() ?? '';
if (!nextValue) {
return;
}
const saved = await saveConfig({
apiKeys: {
[providerId]: nextValue,
},
});
if (saved) {
await fetchRawConfig();
setApiKeyDrafts((current) => ({ ...current, [providerId]: '' }));
setSavedApiKeyProvider(providerId);
setTimeout(() => {
setSavedApiKeyProvider((current) => (current === providerId ? null : current));
}, 1200);
}
};
const removeApiKey = async (providerId: WebSearchApiKeyProviderId) => {
const saved = await saveConfig({
apiKeys: {
[providerId]: '',
},
});
if (saved) {
await fetchRawConfig();
setApiKeyDrafts((current) => ({ ...current, [providerId]: '' }));
setSavedApiKeyProvider(providerId);
setTimeout(() => {
setSavedApiKeyProvider((current) => (current === providerId ? null : current));
}, 1200);
}
};
const buildFields = (provider: ProviderDefinition): ProviderFieldConfig[] =>
(provider.fields ?? []).map((field) => {
const fieldId = `${provider.id}.${field.key}`;
@@ -611,7 +692,80 @@ export default function WebSearchSection() {
void toggleProvider(provider.id, enabled);
}}
fields={buildFields(provider)}
/>
>
{isApiKeyProvider(provider.id) && (
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
API Key
</p>
<p className="mt-1 text-sm text-foreground/90">
{getApiKeySummary(config?.apiKeys?.[provider.id])}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{config?.apiKeys?.[provider.id]?.maskedValue
? `${config.apiKeys[provider.id]?.envVar} ${config.apiKeys[provider.id]?.maskedValue}`
: `Store ${provider.badge} here so the backend is ready immediately after you enable it.`}
</p>
</div>
{savedApiKeyProvider === provider.id && (
<span className="text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
Saved
</span>
)}
</div>
<MaskedInput
id={`${provider.id}.api-key`}
value={apiKeyDrafts[provider.id] ?? ''}
onChange={(event) =>
setApiKeyDrafts((current) => ({
...current,
[provider.id]: event.target.value,
}))
}
placeholder={
config?.apiKeys?.[provider.id]?.configured
? 'Enter a new key to rotate the stored secret'
: `Paste ${provider.badge}`
}
className="bg-background/80 font-mono text-sm"
disabled={saving}
/>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => {
void saveApiKey(provider.id);
}}
disabled={
saving || !(apiKeyDrafts[provider.id]?.trim() ?? '').length
}
>
{config?.apiKeys?.[provider.id]?.configured
? 'Update key'
: 'Save key'}
</Button>
{(config?.apiKeys?.[provider.id]?.source === 'global_env' ||
config?.apiKeys?.[provider.id]?.source === 'both') && (
<Button
variant="outline"
size="sm"
onClick={() => {
void removeApiKey(provider.id);
}}
disabled={saving}
>
Remove stored key
</Button>
)}
</div>
</div>
)}
</ProviderCard>
</div>
);
})}
@@ -1,4 +1,4 @@
import type { KeyboardEvent } from 'react';
import type { KeyboardEvent, ReactNode } from 'react';
import { ExternalLink } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -33,6 +33,7 @@ export interface ProviderCardProps {
docsUrl?: string;
installCommand?: string;
footerNote?: string;
children?: ReactNode;
}
const PROVIDER_TONE_STYLES = {
@@ -113,6 +114,7 @@ export function ProviderCard({
docsUrl,
installCommand,
footerNote,
children,
}: ProviderCardProps) {
const tone = PROVIDER_TONE_STYLES[badgeTone];
const status = getStatusToneStyles(statusTone);
@@ -217,6 +219,12 @@ export function ProviderCard({
</div>
)}
{enabled && children && (
<div className="relative mt-4 rounded-xl border border-border/65 bg-background/70 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.35)] backdrop-blur-sm">
{children}
</div>
)}
{(footerNote || installCommand || docsUrl) && (
<div className="relative mt-4 space-y-2 rounded-xl border border-border/65 bg-muted/30 p-3">
{footerNote && <p className="text-xs text-muted-foreground">{footerNote}</p>}
+18
View File
@@ -14,6 +14,17 @@ export interface ProviderConfig {
max_results?: number;
}
export type WebSearchApiKeyProviderId = 'exa' | 'tavily' | 'brave';
export type WebSearchApiKeySource = 'global_env' | 'process_env' | 'both' | 'none';
export interface WebSearchApiKeyState {
envVar: string;
configured: boolean;
available: boolean;
source: WebSearchApiKeySource;
maskedValue?: string;
}
export interface WebSearchProvidersConfig {
exa?: ProviderConfig;
tavily?: ProviderConfig;
@@ -27,6 +38,13 @@ export interface WebSearchProvidersConfig {
export interface WebSearchConfig {
enabled: boolean;
providers?: WebSearchProvidersConfig;
apiKeys?: Partial<Record<WebSearchApiKeyProviderId, WebSearchApiKeyState>>;
}
export interface WebSearchSavePayload {
enabled?: boolean;
providers?: WebSearchProvidersConfig;
apiKeys?: Partial<Record<WebSearchApiKeyProviderId, string | null>>;
}
export interface CliStatus {
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor, userEvent } from '@tests/setup/test-utils';
import WebSearchSection from '@/pages/settings/sections/websearch';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('WebSearchSection', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
let config = {
enabled: true,
providers: {
exa: { enabled: true, max_results: 5 },
tavily: { enabled: false, max_results: 5 },
brave: { enabled: false, max_results: 5 },
duckduckgo: { enabled: false, max_results: 5 },
gemini: { enabled: false, model: 'gemini-2.5-flash', timeout: 55 },
grok: { enabled: false, timeout: 55 },
opencode: { enabled: false, model: 'opencode/grok-code', timeout: 60 },
},
apiKeys: {
exa: {
envVar: 'EXA_API_KEY',
configured: false,
available: false,
source: 'none',
},
},
};
let status = {
providers: [
{
id: 'exa',
kind: 'backend',
name: 'Exa',
enabled: true,
available: false,
version: null,
requiresApiKey: true,
apiKeyEnvVar: 'EXA_API_KEY',
description: 'API-backed search with strong relevance and content extraction.',
detail: 'Set EXA_API_KEY',
},
],
readiness: {
status: 'needs_setup',
message: 'Exa: Set EXA_API_KEY',
},
};
fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
const method = init?.method ?? 'GET';
if (url === '/api/websearch' && method === 'GET') {
return jsonResponse(config);
}
if (url === '/api/websearch/status' && method === 'GET') {
return jsonResponse(status);
}
if (url === '/api/config/raw' && method === 'GET') {
return new Response('websearch:\n enabled: true\n');
}
if (url === '/api/websearch' && method === 'PUT') {
const body = JSON.parse(String(init?.body ?? '{}')) as {
enabled?: boolean;
providers?: typeof config.providers;
apiKeys?: { exa?: string | null };
};
config = {
...config,
enabled: body.enabled ?? config.enabled,
providers: body.providers ?? config.providers,
apiKeys: body.apiKeys?.exa
? {
...config.apiKeys,
exa: {
envVar: 'EXA_API_KEY',
configured: true,
available: true,
source: 'global_env',
maskedValue: 'exa-************5678',
},
}
: config.apiKeys,
};
status = {
providers: [
{
...status.providers[0],
enabled: config.providers.exa.enabled,
available: true,
detail: 'API key detected (5 results)',
},
],
readiness: {
status: 'ready',
message: 'Ready (Exa)',
},
};
return jsonResponse({ websearch: config });
}
return jsonResponse({ error: `Unhandled request: ${method} ${url}` }, 500);
});
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('saves an Exa API key from the WebSearch card', async () => {
render(<WebSearchSection />, { withSettingsProvider: true });
const input = await screen.findByPlaceholderText('Paste EXA_API_KEY');
await userEvent.type(input, 'exa-secret-12345678');
await userEvent.click(screen.getByRole('button', { name: 'Save key' }));
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'/api/websearch',
expect.objectContaining({
method: 'PUT',
})
);
});
const putCall = fetchMock.mock.calls.find(
([url, init]) =>
url === '/api/websearch' && (init as RequestInit | undefined)?.method === 'PUT'
);
expect(putCall).toBeDefined();
const requestBody = JSON.parse(String((putCall?.[1] as RequestInit | undefined)?.body ?? '{}'));
expect(requestBody.apiKeys).toMatchObject({
exa: 'exa-secret-12345678',
});
await screen.findByText('Stored in dashboard');
expect(await screen.findByText('Ready')).toBeInTheDocument();
});
});