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 committed 2026-03-27 13:25:26 -04:00
1 parent eea0f57688
commit 20c48c6105
10 files changed
+775 -31

No files matched your search

+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',
},
];