feat(websearch): add OpenCode CLI as third WebSearch provider

- Add OpenCodeWebSearchConfig type with enabled, model, timeout options
- Implement getOpenCodeCliStatus() for CLI detection with caching
- Add hasOpenCodeCli() and clearOpenCodeCliCache() helper functions
- Update getWebSearchCliProviders() to include OpenCode provider info
- Update hasAnyWebSearchCli() to check all three providers
- Update getCliInstallHints() with OpenCode install command
- Update WebSearchStatus interface to include opencodeCli
- Update getWebSearchReadiness() to report OpenCode status
- Update routes.ts PUT /api/websearch to handle opencode config
- Update routes.ts GET /api/websearch/status to return opencodeCli
- Add OpenCode provider card in settings.tsx UI
- Add purple-themed installation hints for OpenCode
- Order providers: Gemini (1st) -> OpenCode (2nd) -> Grok (3rd/last)

OpenCode uses `opencode run` with `opencode/gpt-5-nano` model for
web search. FREE tier available via OpenCode Zen, no API key required.
This commit is contained in:
kaitranntt
2025-12-16 21:37:31 -05:00
parent 81e46bd0e1
commit 482cda0f8e
4 changed files with 250 additions and 30 deletions
+16 -2
View File
@@ -121,20 +121,34 @@ export interface GrokWebSearchConfig {
timeout?: number;
}
/**
* OpenCode CLI WebSearch configuration.
*/
export interface OpenCodeWebSearchConfig {
/** Enable OpenCode CLI for WebSearch (default: false) */
enabled?: boolean;
/** Model to use (default: opencode/gpt-5-nano) */
model?: string;
/** Timeout in seconds (default: 60) */
timeout?: number;
}
/**
* WebSearch providers configuration.
* Currently supports Gemini CLI and Grok CLI.
* Supports Gemini CLI, Grok CLI, and OpenCode.
*/
export interface WebSearchProvidersConfig {
/** Gemini CLI - uses google_web_search tool (FREE tier: 1000 req/day) */
gemini?: GeminiWebSearchConfig;
/** Grok CLI - xAI web search (requires GROK_API_KEY) */
grok?: GrokWebSearchConfig;
/** OpenCode - built-in web search (FREE via OpenCode Zen) */
opencode?: OpenCodeWebSearchConfig;
}
/**
* WebSearch configuration.
* Uses CLI tools (Gemini CLI, Grok CLI) to provide WebSearch for third-party profiles.
* Uses CLI tools (Gemini CLI, Grok CLI, OpenCode) for third-party profiles.
* Third-party providers don't have server-side WebSearch access.
*/
export interface WebSearchConfig {
+119 -25
View File
@@ -204,12 +204,99 @@ export function clearGrokCliCache(): void {
grokCliCache = null;
}
// ========== OpenCode CLI Detection ==========
/**
* OpenCode CLI installation status
*/
export interface OpenCodeCliStatus {
installed: boolean;
path: string | null;
version: string | null;
}
// Cache for OpenCode CLI status (per process)
let opencodeCliCache: OpenCodeCliStatus | null = null;
/**
* Check if OpenCode CLI is installed globally
*
* OpenCode provides built-in web search via opencode/gpt-5-nano model.
* Install: curl -fsSL https://opencode.ai/install | bash
*
* @returns OpenCode CLI status with path and version
*/
export function getOpenCodeCliStatus(): OpenCodeCliStatus {
// Return cached result if available
if (opencodeCliCache) {
return opencodeCliCache;
}
const result: OpenCodeCliStatus = {
installed: false,
path: null,
version: null,
};
try {
const isWindows = process.platform === 'win32';
const whichCmd = isWindows ? 'where opencode' : 'which opencode';
const pathResult = execSync(whichCmd, {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const opencodePath = pathResult.trim().split('\n')[0]; // First result on Windows
if (opencodePath) {
result.installed = true;
result.path = opencodePath;
// Try to get version
try {
const versionResult = execSync('opencode --version', {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
result.version = versionResult.trim();
} catch {
// Version check failed, but CLI is installed
result.version = 'unknown';
}
}
} catch {
// Command not found - OpenCode CLI not installed
}
// Cache result
opencodeCliCache = result;
return result;
}
/**
* Check if OpenCode CLI is available (quick boolean check)
*/
export function hasOpenCodeCli(): boolean {
return getOpenCodeCliStatus().installed;
}
/**
* Clear OpenCode CLI cache (for testing or after installation)
*/
export function clearOpenCodeCliCache(): void {
opencodeCliCache = null;
}
/**
* Clear all CLI caches
*/
export function clearAllCliCaches(): void {
geminiCliCache = null;
grokCliCache = null;
opencodeCliCache = null;
}
// ========== CLI Provider Info ==========
@@ -219,7 +306,7 @@ export function clearAllCliCaches(): void {
*/
export interface WebSearchCliInfo {
/** Provider ID */
id: 'gemini' | 'grok';
id: 'gemini' | 'grok' | 'opencode';
/** Display name */
name: string;
/** CLI command name */
@@ -248,6 +335,7 @@ export interface WebSearchCliInfo {
export function getWebSearchCliProviders(): WebSearchCliInfo[] {
const geminiStatus = getGeminiCliStatus();
const grokStatus = getGrokCliStatus();
const opencodeStatus = getOpenCodeCliStatus();
return [
{
@@ -262,6 +350,18 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] {
description: 'Google Gemini with web search (FREE tier: 1000 req/day)',
freeTier: true,
},
{
id: 'opencode',
name: 'OpenCode',
command: 'opencode',
installed: opencodeStatus.installed,
version: opencodeStatus.version,
installCommand: 'curl -fsSL https://opencode.ai/install | bash',
docsUrl: 'https://github.com/sst/opencode',
requiresApiKey: false,
description: 'OpenCode with built-in web search (FREE via Zen)',
freeTier: true,
},
{
id: 'grok',
name: 'Grok CLI',
@@ -282,7 +382,7 @@ export function getWebSearchCliProviders(): WebSearchCliInfo[] {
* Check if any WebSearch CLI is available
*/
export function hasAnyWebSearchCli(): boolean {
return hasGeminiCli() || hasGrokCli();
return hasGeminiCli() || hasGrokCli() || hasOpenCodeCli();
}
/**
@@ -296,6 +396,7 @@ export function getCliInstallHints(): string[] {
return [
'[i] WebSearch: No CLI tools installed',
' Gemini CLI (FREE): npm i -g @google/gemini-cli',
' OpenCode (FREE): curl -fsSL https://opencode.ai/install | bash',
' Grok CLI (paid): npm i -g @vibe-kit/grok-cli',
];
}
@@ -520,6 +621,7 @@ export interface WebSearchStatus {
readiness: WebSearchReadiness;
geminiCli: boolean;
grokCli: boolean;
opencodeCli: boolean;
message: string;
}
@@ -537,38 +639,29 @@ export function getWebSearchReadiness(): WebSearchStatus {
readiness: 'unavailable',
geminiCli: false,
grokCli: false,
opencodeCli: false,
message: 'Disabled in config',
};
}
// Check both CLIs
// Check all CLIs
const geminiInstalled = hasGeminiCli();
const grokInstalled = hasGrokCli();
const opencodeInstalled = hasOpenCodeCli();
if (geminiInstalled && grokInstalled) {
// Build message based on installed CLIs
const installedClis: string[] = [];
if (geminiInstalled) installedClis.push('Gemini');
if (grokInstalled) installedClis.push('Grok');
if (opencodeInstalled) installedClis.push('OpenCode');
if (installedClis.length > 0) {
return {
readiness: 'ready',
geminiCli: true,
grokCli: true,
message: 'Ready (Gemini + Grok CLI)',
};
}
if (geminiInstalled) {
return {
readiness: 'ready',
geminiCli: true,
grokCli: false,
message: 'Ready (Gemini CLI)',
};
}
if (grokInstalled) {
return {
readiness: 'ready',
geminiCli: false,
grokCli: true,
message: 'Ready (Grok CLI)',
geminiCli: geminiInstalled,
grokCli: grokInstalled,
opencodeCli: opencodeInstalled,
message: `Ready (${installedClis.join(' + ')})`,
};
}
@@ -576,6 +669,7 @@ export function getWebSearchReadiness(): WebSearchStatus {
readiness: 'unavailable',
geminiCli: false,
grokCli: false,
opencodeCli: false,
message: 'Install: npm i -g @google/gemini-cli',
};
}
+22 -1
View File
@@ -62,6 +62,7 @@ import {
getWebSearchReadiness,
getGeminiCliStatus,
getGrokCliStatus,
getOpenCodeCliStatus,
} from '../utils/websearch-manager';
export const apiRoutes = Router();
@@ -1489,6 +1490,20 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
timeout:
providers.grok?.timeout ?? existingConfig.websearch?.providers?.grok?.timeout ?? 55,
},
opencode: {
enabled:
providers.opencode?.enabled ??
existingConfig.websearch?.providers?.opencode?.enabled ??
false,
model:
providers.opencode?.model ??
existingConfig.websearch?.providers?.opencode?.model ??
'opencode/gpt-5-nano',
timeout:
providers.opencode?.timeout ??
existingConfig.websearch?.providers?.opencode?.timeout ??
60,
},
}
: existingConfig.websearch?.providers,
};
@@ -1506,12 +1521,13 @@ apiRoutes.put('/websearch', (req: Request, res: Response): void => {
/**
* GET /api/websearch/status - Get WebSearch status
* Returns: { geminiCli, grokCli, readiness }
* Returns: { geminiCli, grokCli, opencodeCli, readiness }
*/
apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
try {
const geminiCli = getGeminiCliStatus();
const grokCli = getGrokCliStatus();
const opencodeCli = getOpenCodeCliStatus();
const readiness = getWebSearchReadiness();
res.json({
@@ -1525,6 +1541,11 @@ apiRoutes.get('/websearch/status', (_req: Request, res: Response): void => {
path: grokCli.path,
version: grokCli.version,
},
opencodeCli: {
installed: opencodeCli.installed,
path: opencodeCli.path,
version: opencodeCli.version,
},
readiness: {
status: readiness.readiness,
message: readiness.message,
+93 -2
View File
@@ -28,9 +28,14 @@ interface ProviderConfig {
timeout?: number;
}
interface OpenCodeProviderConfig extends ProviderConfig {
model?: string;
}
interface WebSearchProvidersConfig {
gemini?: ProviderConfig;
grok?: ProviderConfig;
opencode?: OpenCodeProviderConfig;
}
interface WebSearchConfig {
@@ -47,6 +52,7 @@ interface CliStatus {
interface WebSearchStatus {
geminiCli: CliStatus;
grokCli: CliStatus;
opencodeCli: CliStatus;
readiness: {
status: 'ready' | 'unavailable';
message: string;
@@ -136,9 +142,10 @@ export function SettingsPage() {
const providers = config?.providers || {};
const currentState = providers.gemini?.enabled ?? false;
const grokState = providers.grok?.enabled ?? false;
const opencodeState = providers.opencode?.enabled ?? false;
saveConfig({
enabled: !currentState || grokState, // Enable WebSearch if any provider is enabled
enabled: !currentState || grokState || opencodeState, // Enable WebSearch if any provider is enabled
providers: {
...providers,
gemini: {
@@ -192,6 +199,7 @@ export function SettingsPage() {
const isGeminiEnabled = config?.providers?.gemini?.enabled ?? false;
const isGrokEnabled = config?.providers?.grok?.enabled ?? false;
const isOpenCodeEnabled = config?.providers?.opencode?.enabled ?? false;
// Toggle Grok provider
const toggleGrok = () => {
@@ -199,7 +207,7 @@ export function SettingsPage() {
const currentState = providers.grok?.enabled ?? false;
saveConfig({
enabled: isGeminiEnabled || !currentState, // Enable WebSearch if any provider is enabled
enabled: isGeminiEnabled || !currentState || isOpenCodeEnabled, // Enable WebSearch if any provider is enabled
providers: {
...providers,
grok: {
@@ -210,6 +218,23 @@ export function SettingsPage() {
});
};
// Toggle OpenCode provider
const toggleOpenCode = () => {
const providers = config?.providers || {};
const currentState = providers.opencode?.enabled ?? false;
saveConfig({
enabled: isGeminiEnabled || isGrokEnabled || !currentState, // Enable WebSearch if any provider is enabled
providers: {
...providers,
opencode: {
...providers.opencode,
enabled: !currentState,
},
},
});
};
if (loading) {
return (
<div className="h-[calc(100vh-100px)] flex items-center justify-center">
@@ -352,6 +377,72 @@ export function SettingsPage() {
</div>
)}
{/* OpenCode CLI Provider */}
<div
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${
isOpenCodeEnabled
? 'border-primary/50 bg-primary/5'
: 'border-border bg-background'
}`}
>
<div className="flex items-center gap-3">
<Terminal
className={`w-5 h-5 ${isOpenCodeEnabled ? 'text-primary' : 'text-muted-foreground'}`}
/>
<div>
<div className="flex items-center gap-2">
<p className="font-mono font-medium">opencode</p>
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
FREE
</span>
{status?.opencodeCli?.installed ? (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 font-medium">
installed
</span>
) : (
<span className="text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-600 font-medium">
not installed
</span>
)}
</div>
<p className="text-sm text-muted-foreground">
OpenCode (web search via Zen)
</p>
</div>
</div>
<Switch
checked={isOpenCodeEnabled}
onCheckedChange={toggleOpenCode}
disabled={saving || !status?.opencodeCli?.installed}
/>
</div>
{/* OpenCode Installation hint when not installed */}
{!status?.opencodeCli?.installed && !statusLoading && (
<div className="p-4 rounded-lg border border-purple-200 bg-purple-50 dark:border-purple-900/50 dark:bg-purple-900/20">
<p className="text-sm font-medium text-purple-800 dark:text-purple-200 mb-2">
OpenCode not installed
</p>
<p className="text-sm text-purple-700 dark:text-purple-300 mb-3">
Install globally (FREE tier available):
</p>
<code className="text-sm bg-purple-100 dark:bg-purple-900/40 px-2 py-1 rounded font-mono">
curl -fsSL https://opencode.ai/install | bash
</code>
<div className="mt-3">
<a
href="https://github.com/sst/opencode"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-purple-700 dark:text-purple-300 hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
View documentation
</a>
</div>
</div>
)}
{/* Grok CLI Provider */}
<div
className={`flex items-center justify-between p-4 rounded-lg border transition-colors ${