mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user