diff --git a/src/cli.ts b/src/cli.ts index 17752b7..6ed4a79 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1307,7 +1307,8 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications") // If websearch is enabled, regenerate wrapper files // Support both new 'websearch' field and old 'proxy' field for backward compatibility - const hasWebsearch = meta.patches.websearch || !!meta.patches.proxy; + const hasWebsearch = + meta.patches.websearch || !!meta.patches.websearchProxy || !!meta.patches.proxy; if (hasWebsearch) { // Determine forward target: apiBase > proxy (legacy) > default const forwardTarget = @@ -1319,6 +1320,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications") meta.name, forwardTarget, meta.patches.standalone || false, + meta.patches.websearchProxy || false, ); execTargetPath = wrapperScript; if (verbose) { diff --git a/src/websearch-external.ts b/src/websearch-external.ts index 7f4b503..db3dc17 100644 --- a/src/websearch-external.ts +++ b/src/websearch-external.ts @@ -17,9 +17,14 @@ const fs = require('fs'); const DEBUG = process.env.DROID_SEARCH_DEBUG === '1'; const PORT = parseInt(process.env.SEARCH_PROXY_PORT || '0'); const FACTORY_API = 'https://api.factory.ai'; +const SEARCH_ROUTE_ALIASES = new Set(['/api/tools/web-search', '/api/tools/exa/search']); function log() { if (DEBUG) console.error.apply(console, ['[websearch]'].concat(Array.from(arguments))); } +function isSearchRequest(url, method) { + return method === 'POST' && SEARCH_ROUTE_ALIASES.has(url.pathname); +} + // === External Search Providers === async function searchSmitheryExa(query, numResults) { @@ -192,7 +197,7 @@ const server = http.createServer(async (req, res) => { return; } - if (url.pathname === '/api/tools/exa/search' && req.method === 'POST') { + if (isSearchRequest(url, req.method)) { let body = ''; req.on('data', function(c) { body += c; }); req.on('end', async function() { diff --git a/src/websearch-native.ts b/src/websearch-native.ts index 12f03a5..d40809a 100644 --- a/src/websearch-native.ts +++ b/src/websearch-native.ts @@ -21,7 +21,6 @@ export function generateNativeSearchProxyServer( const http = require('http'); const https = require('https'); -const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const os = require('os'); @@ -29,13 +28,18 @@ const os = require('os'); const DEBUG = process.env.DROID_SEARCH_DEBUG === '1'; const PORT = parseInt(process.env.SEARCH_PROXY_PORT || '0'); const FACTORY_API = '${factoryApiUrl}'; +const SEARCH_ROUTE_ALIASES = new Set(['/api/tools/web-search', '/api/tools/exa/search']); +const SUPPORTED_PROVIDERS = new Set(['anthropic', 'openai']); function log(...args) { if (DEBUG) console.error('[websearch]', ...args); } -// === Settings Configuration === +function isSearchRequest(url, method) { + return method === 'POST' && SEARCH_ROUTE_ALIASES.has(url.pathname); +} let cachedSettings = null; let settingsLastModified = 0; +let lastObservedProvider = null; function getFactorySettings() { const settingsPath = path.join(os.homedir(), '.factory', 'settings.json'); @@ -51,151 +55,409 @@ function getFactorySettings() { } } -function getCurrentModelConfig() { - const settings = getFactorySettings(); - if (!settings) return null; - - const currentModelId = settings.sessionDefaultSettings?.model; - if (!currentModelId) return null; - - const customModels = settings.customModels || []; - const modelConfig = customModels.find(m => m.id === currentModelId); - - if (modelConfig) { - log('Model:', modelConfig.displayName, '| Provider:', modelConfig.provider); - return modelConfig; +function listCustomModels(settings) { + return Array.isArray(settings && settings.customModels) ? settings.customModels : []; +} + +function isSupportedModel(modelConfig, preferredProvider) { + return !!( + modelConfig && + SUPPORTED_PROVIDERS.has(modelConfig.provider) && + (!preferredProvider || modelConfig.provider === preferredProvider) && + modelConfig.id && + modelConfig.baseUrl && + modelConfig.apiKey && + modelConfig.model + ); +} + +function buildCandidateModelIds(settings) { + const candidates = [ + process.env.DROID_SEARCH_MODEL_ID, + settings && settings.sessionDefaultSettings && settings.sessionDefaultSettings.model, + settings && settings.missionModelSettings && settings.missionModelSettings.workerModel, + settings && settings.missionModelSettings && settings.missionModelSettings.validationWorkerModel, + settings && settings.missionModelSettings && settings.missionModelSettings.orchestratorModel, + ]; + const unique = []; + for (const candidate of candidates) { + if (candidate && !unique.includes(candidate)) unique.push(candidate); } - - if (!currentModelId.startsWith('custom:')) return null; - log('Model not found:', currentModelId); - return null; + return unique; } -// === Native Provider WebSearch === +function summarizeSupportedModels(settings, preferredProvider) { + return listCustomModels(settings) + .filter(function(modelConfig) { return isSupportedModel(modelConfig, preferredProvider); }) + .map(function(modelConfig) { return modelConfig.id + ' [' + modelConfig.provider + ']'; }) + .join(', '); +} -async function searchAnthropicNative(query, numResults, modelConfig) { - const { baseUrl, apiKey, model } = modelConfig; - - try { - const requestBody = { - model: model, - max_tokens: 4096, - stream: false, - system: 'You are a web search assistant. Use the web_search tool to find relevant information and return the results.', - tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 1 }], - tool_choice: { type: 'tool', name: 'web_search' }, - messages: [{ role: 'user', content: 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.' }] +function getCurrentModelConfig(preferredProvider) { + const settings = getFactorySettings(); + if (!settings) { + return { + error: 'Failed to load ~/.factory/settings.json for native websearch', + statusCode: 500, }; - - let endpoint = baseUrl; - if (!endpoint.endsWith('/v1/messages')) endpoint = endpoint.replace(/\\/$/, '') + '/v1/messages'; - - log('Anthropic search:', query, '→', endpoint); - - const bodyStr = JSON.stringify(requestBody).replace(/'/g, "'\\\\''"); - const curlCmd = 'curl -s -X POST "' + endpoint + '" -H "Content-Type: application/json" -H "anthropic-version: 2023-06-01" -H "x-api-key: ' + apiKey + '" -d \\'' + bodyStr + "\\'"; - const responseStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 60000 }); - - let response; - try { response = JSON.parse(responseStr); } catch { return null; } - if (response.error) { log('API error:', response.error.message); return null; } - - const results = []; - for (const block of (response.content || [])) { - if (block.type === 'web_search_tool_result') { - for (const result of (block.content || [])) { - if (result.type === 'web_search_result') { - results.push({ - title: result.title || '', - url: result.url || '', - content: result.snippet || result.page_content || '' - }); - } + } + + const customModels = listCustomModels(settings); + const candidateIds = buildCandidateModelIds(settings); + for (const candidateId of candidateIds) { + const modelConfig = customModels.find(function(model) { + return model.id === candidateId && isSupportedModel(model, preferredProvider); + }); + if (modelConfig) { + lastObservedProvider = modelConfig.provider; + log('Resolved model:', modelConfig.id, '| Provider:', modelConfig.provider); + return { modelConfig: modelConfig, source: candidateId }; + } + } + + const fallbackModels = customModels.filter(function(modelConfig) { + return isSupportedModel(modelConfig, preferredProvider); + }); + if (fallbackModels.length === 1) { + lastObservedProvider = fallbackModels[0].provider; + log('Falling back to only supported model:', fallbackModels[0].id); + return { modelConfig: fallbackModels[0], source: 'single-supported-model' }; + } + + const providerLabel = preferredProvider || 'anthropic/openai'; + const supported = summarizeSupportedModels(settings, preferredProvider); + const message = supported + ? 'Could not resolve an active ' + providerLabel + ' custom model for native websearch. Available models: ' + supported + : 'No supported ' + providerLabel + ' custom models found in ~/.factory/settings.json'; + + return { error: message, statusCode: 400 }; +} + +function normalizeEndpoint(baseUrl, suffix) { + return baseUrl.endsWith(suffix) ? baseUrl : baseUrl.replace(/\\/$/, '') + suffix; +} + +function extractErrorMessage(value) { + if (!value) return 'Unknown upstream error'; + if (typeof value === 'string') return value; + if (typeof value.message === 'string') return value.message; + return JSON.stringify(value); +} + +function sleep(ms) { + return new Promise(function(resolve) { setTimeout(resolve, ms); }); +} + +function getRetryDelayMs(attempt) { + return Math.min(250 * Math.pow(2, attempt - 1), 2000); +} + +function isRetryableSearchFailure(statusCode, message) { + if (statusCode === 408 || statusCode === 425 || statusCode === 429) return true; + if (statusCode >= 500 && statusCode <= 599) return true; + + const normalized = String(message || '').toLowerCase(); + return normalized.includes('proxy failed:') || + normalized.includes('client network socket disconnected before secure tls connection was established') || + normalized.includes('fetch failed') || + normalized.includes('socket hang up') || + normalized.includes('request timed out') || + normalized.includes('timed out') || + normalized.includes('econnreset') || + normalized.includes('ecconnreset') || + normalized.includes('econnrefused') || + normalized.includes('ehostunreach') || + normalized.includes('enotfound') || + normalized.includes('eai_again'); +} + +function pushUniqueResult(results, result) { + if (!result || !result.url) return; + if (results.some(function(existing) { return existing.url === result.url; })) return; + results.push({ + title: result.title || result.url, + url: result.url, + content: result.content || '', + }); +} + +function parseOpenAITextResults(text, numResults) { + const results = []; + const lines = String(text || '').split(/\\r?\\n/); + let current = null; + + function flushCurrent() { + if (!current || !current.url) { + current = null; + return; + } + pushUniqueResult(results, { + title: current.title, + url: current.url, + content: current.content.join(' ').trim(), + }); + current = null; + } + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + + const titleMatch = line.match(/^\\d+\\.\\s+(?:\\*\\*(.+?)\\*\\*|(.+))$/); + if (titleMatch) { + flushCurrent(); + current = { + title: (titleMatch[1] || titleMatch[2] || '').trim(), + url: '', + content: [], + }; + continue; + } + + const urlMatch = line.match(/^(?:[-*]\\s+)?(https?:\\/\\/\\S+)/); + if (urlMatch) { + if (!current) { + current = { title: urlMatch[1], url: '', content: [] }; + } + current.url = urlMatch[1].replace(/[),.;]+$/, ''); + continue; + } + + const bulletTextMatch = line.match(/^[-*]\\s+(.+)/); + const contentText = (bulletTextMatch ? bulletTextMatch[1] : line).trim(); + if (!current) { + continue; + } + if (contentText) { + current.content.push(contentText); + } + } + + flushCurrent(); + return results.slice(0, numResults); +} + +async function postJson(endpoint, headers, requestBody) { + const maxAttempts = 5; + let lastError = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(function() { controller.abort(); }, 60000); + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: headers, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + const responseText = await response.text(); + let payload = {}; + if (responseText) { + try { + payload = JSON.parse(responseText); + } catch { + throw new Error('Invalid JSON response from ' + endpoint); + } + } + + if (!response.ok) { + const message = extractErrorMessage(payload && payload.error) || ('HTTP ' + response.status); + if (attempt < maxAttempts && isRetryableSearchFailure(response.status, message)) { + lastError = new Error(message); + const delayMs = getRetryDelayMs(attempt); + log('Retrying request after upstream error:', response.status, message, '| next attempt', attempt + 1, 'of', maxAttempts); + await sleep(delayMs); + continue; + } + throw new Error(message); + } + + if (payload && payload.error) { + const message = extractErrorMessage(payload.error); + if (attempt < maxAttempts && isRetryableSearchFailure(undefined, message)) { + lastError = new Error(message); + const delayMs = getRetryDelayMs(attempt); + log('Retrying request after payload error:', message, '| next attempt', attempt + 1, 'of', maxAttempts); + await sleep(delayMs); + continue; } + throw new Error(message); } + + return payload; + } catch (e) { + const message = e && e.name === 'AbortError' ? 'Request timed out' : (e && e.message ? e.message : String(e)); + if (attempt < maxAttempts && isRetryableSearchFailure(undefined, message)) { + lastError = new Error(message); + const delayMs = getRetryDelayMs(attempt); + log('Retrying request after transport error:', message, '| next attempt', attempt + 1, 'of', maxAttempts); + await sleep(delayMs); + continue; + } + throw new Error(message); + } finally { + clearTimeout(timeoutId); } - - log('Results:', results.length); - return results.length > 0 ? results.slice(0, numResults) : null; - } catch (e) { - log('Anthropic error:', e.message); - return null; } + + throw lastError || new Error('Request failed'); +} + +async function searchAnthropicNative(query, numResults, modelConfig) { + const endpoint = normalizeEndpoint(modelConfig.baseUrl, '/v1/messages'); + const requestBody = { + model: modelConfig.model, + max_tokens: 4096, + stream: false, + system: 'You are a web search assistant. Use the web_search tool to find relevant information and return the results.', + tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 1 }], + tool_choice: { type: 'tool', name: 'web_search' }, + messages: [{ role: 'user', content: 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.' }], + }; + + log('Anthropic search:', query, '→', endpoint); + const response = await postJson(endpoint, { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'x-api-key': modelConfig.apiKey, + }, requestBody); + + const results = []; + for (const block of (response.content || [])) { + if (block.type !== 'web_search_tool_result') continue; + for (const result of (block.content || [])) { + if (result.type !== 'web_search_result') continue; + results.push({ + title: result.title || '', + url: result.url || '', + content: result.snippet || result.page_content || '', + }); + } + } + + log('Anthropic results:', results.length); + return results.slice(0, numResults); } async function searchOpenAINative(query, numResults, modelConfig) { - const { baseUrl, apiKey, model } = modelConfig; - - try { - // Note: instructions will be added by openai4droid proxy plugin - const requestBody = { - model: model, - stream: false, - tools: [{ type: 'web_search' }], - tool_choice: 'required', - input: 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.' - }; - - let endpoint = baseUrl; - if (!endpoint.endsWith('/responses')) endpoint = endpoint.replace(/\\/$/, '') + '/responses'; - - log('OpenAI search:', query, '→', endpoint); - - const bodyStr = JSON.stringify(requestBody).replace(/'/g, "'\\\\''"); - const curlCmd = 'curl -s -X POST "' + endpoint + '" -H "Content-Type: application/json" -H "Authorization: Bearer ' + apiKey + '" -d \\'' + bodyStr + "\\'"; - const responseStr = execSync(curlCmd, { encoding: 'utf-8', timeout: 60000 }); - - let response; - try { response = JSON.parse(responseStr); } catch { return null; } - if (response.error) { log('API error:', response.error.message); return null; } - - // Extract results from url_citation annotations in message output - const results = []; - for (const item of (response.output || [])) { - if (item.type === 'message' && Array.isArray(item.content)) { + const endpoint = normalizeEndpoint(modelConfig.baseUrl, '/responses'); + const input = 'Search the web for: ' + query + '\\n\\nReturn up to ' + numResults + ' relevant results.'; + const requestVariants = [ + { + label: 'web_search', + body: { + model: modelConfig.model, + stream: false, + tools: [{ type: 'web_search' }], + tool_choice: 'required', + input: input, + }, + }, + { + label: 'web_search_preview', + body: { + model: modelConfig.model, + stream: false, + tools: [{ type: 'web_search_preview' }], + input: input, + }, + }, + ]; + + let lastError = null; + for (const variant of requestVariants) { + try { + log('OpenAI search:', query, '→', endpoint, '(' + variant.label + ')'); + const response = await postJson(endpoint, { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + modelConfig.apiKey, + }, variant.body); + + const results = []; + const textBlocks = []; + for (const item of (response.output || [])) { + if (item.type !== 'message' || !Array.isArray(item.content)) continue; for (const content of item.content) { - if (content.type === 'output_text' && Array.isArray(content.annotations)) { - for (const annotation of content.annotations) { - if (annotation.type === 'url_citation' && annotation.url) { - results.push({ - title: annotation.title || '', - url: annotation.url || '', - content: annotation.title || '' - }); - } - } + if (content.type !== 'output_text') continue; + if (content.text) { + textBlocks.push(content.text); + } + if (!Array.isArray(content.annotations)) continue; + for (const annotation of content.annotations) { + if (annotation.type !== 'url_citation' || !annotation.url) continue; + pushUniqueResult(results, { + title: annotation.title || '', + url: annotation.url || '', + content: annotation.title || '', + }); } } } + + if (results.length === 0) { + for (const textBlock of textBlocks) { + for (const parsedResult of parseOpenAITextResults(textBlock, numResults)) { + pushUniqueResult(results, parsedResult); + } + if (results.length >= numResults) break; + } + } + + log('OpenAI results:', results.length, 'via', variant.label); + return results.slice(0, numResults); + } catch (e) { + lastError = e; + log('OpenAI variant failed:', variant.label, '-', e.message); } - - log('Results:', results.length); - return results.length > 0 ? results.slice(0, numResults) : null; - } catch (e) { - log('OpenAI error:', e.message); - return null; } + + throw lastError || new Error('OpenAI web search failed'); } async function search(query, numResults) { numResults = numResults || 10; log('Search:', query); - - const modelConfig = getCurrentModelConfig(); - if (!modelConfig) { - log('No custom model configured'); - return { results: [], source: 'none' }; + + const resolved = getCurrentModelConfig(lastObservedProvider); + if (!resolved.modelConfig) { + return { + results: [], + source: 'none', + error: resolved.error, + statusCode: resolved.statusCode || 400, + }; + } + + try { + let results = []; + if (resolved.modelConfig.provider === 'anthropic') { + results = await searchAnthropicNative(query, numResults, resolved.modelConfig); + } else if (resolved.modelConfig.provider === 'openai') { + results = await searchOpenAINative(query, numResults, resolved.modelConfig); + } else { + return { + results: [], + source: 'none', + error: 'Unsupported provider: ' + resolved.modelConfig.provider, + statusCode: 400, + }; + } + + return { + results: results, + source: 'native-' + resolved.modelConfig.provider, + modelId: resolved.modelConfig.id, + }; + } catch (e) { + return { + results: [], + source: 'none', + error: e && e.message ? e.message : String(e), + statusCode: 502, + }; } - - const provider = modelConfig.provider; - let results = null; - - if (provider === 'anthropic') results = await searchAnthropicNative(query, numResults, modelConfig); - else if (provider === 'openai') results = await searchOpenAINative(query, numResults, modelConfig); - else log('Unsupported provider:', provider); - - if (results && results.length > 0) return { results: results, source: 'native-' + provider }; - return { results: [], source: 'none' }; } // === HTTP Proxy Server === @@ -209,14 +471,20 @@ const server = http.createServer(async (req, res) => { return; } - if (url.pathname === '/api/tools/exa/search' && req.method === 'POST') { + if (isSearchRequest(url, req.method)) { let body = ''; req.on('data', function(c) { body += c; }); req.on('end', async function() { try { const parsed = JSON.parse(body); const result = await search(parsed.query, parsed.numResults || 10); - log('Results:', result.results.length, 'from', result.source); + if (result.error) { + log('Search failed:', result.error); + res.writeHead(result.statusCode || 500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: result.error, results: [] })); + return; + } + log('Results:', result.results.length, 'from', result.source, 'model', result.modelId || 'unknown'); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ results: result.results })); } catch (e) { @@ -251,6 +519,8 @@ const server = http.createServer(async (req, res) => { } // Simple proxy - no SSE transformation (handled by proxy plugin) + if (url.pathname.startsWith('/api/llm/a/')) lastObservedProvider = 'anthropic'; + if (url.pathname.startsWith('/api/llm/o/')) lastObservedProvider = 'openai'; log('Proxy:', req.method, url.pathname); const proxyUrl = new URL(FACTORY_API + url.pathname + url.search); const proxyModule = proxyUrl.protocol === 'https:' ? https : http; diff --git a/src/websearch-patch.ts b/src/websearch-patch.ts index d51ff97..517c9e3 100644 --- a/src/websearch-patch.ts +++ b/src/websearch-patch.ts @@ -66,6 +66,9 @@ if ! start_proxy; then exit 1; fi export FACTORY_API_BASE_URL_OVERRIDE="http://127.0.0.1:$PORT" export FACTORY_API_BASE_URL="http://127.0.0.1:$PORT" +export FACTORY_APP_BASE_URL_OVERRIDE="http://127.0.0.1:$PORT" +export FACTORY_APP_BASE_URL="http://127.0.0.1:$PORT" +export TOOLS_WEBSEARCH_BASE_URL="http://127.0.0.1:$PORT" exec "$DROID_BIN" "$@" `; } @@ -151,7 +154,7 @@ should_passthrough() { for arg in "$@"; do if [ "$arg" = "--" ]; then end_opts=1; continue; fi if [ "$end_opts" -eq 0 ] && [[ "$arg" == -* ]]; then continue; fi - case "$arg" in help|version|completion|completions|exec|plugin) return 0 ;; esac + case "$arg" in help|version|completion|completions|plugin) return 0 ;; esac break done return 1 @@ -205,6 +208,9 @@ rm -f "$PORT_FILE" export FACTORY_API_BASE_URL_OVERRIDE="http://127.0.0.1:$ACTUAL_PORT" export FACTORY_API_BASE_URL="http://127.0.0.1:$ACTUAL_PORT" +export FACTORY_APP_BASE_URL_OVERRIDE="http://127.0.0.1:$ACTUAL_PORT" +export FACTORY_APP_BASE_URL="http://127.0.0.1:$ACTUAL_PORT" +export TOOLS_WEBSEARCH_BASE_URL="http://127.0.0.1:$ACTUAL_PORT" "$DROID_BIN" "$@" DROID_EXIT_CODE=$? exit $DROID_EXIT_CODE @@ -240,7 +246,6 @@ for %%a in (%*) do ( if "%%a"=="version" set "PASSTHROUGH=1" if "%%a"=="completion" set "PASSTHROUGH=1" if "%%a"=="completions" set "PASSTHROUGH=1" - if "%%a"=="exec" set "PASSTHROUGH=1" if "%%a"=="plugin" set "PASSTHROUGH=1" ) if "%PASSTHROUGH%"=="1" ( @@ -294,6 +299,9 @@ del "%PORT_FILE%" 2>nul set "FACTORY_API_BASE_URL_OVERRIDE=http://127.0.0.1:%ACTUAL_PORT%" set "FACTORY_API_BASE_URL=http://127.0.0.1:%ACTUAL_PORT%" +set "FACTORY_APP_BASE_URL_OVERRIDE=http://127.0.0.1:%ACTUAL_PORT%" +set "FACTORY_APP_BASE_URL=http://127.0.0.1:%ACTUAL_PORT%" +set "TOOLS_WEBSEARCH_BASE_URL=http://127.0.0.1:%ACTUAL_PORT%" "%DROID_BIN%" %* set "DROID_EXIT_CODE=%ERRORLEVEL%" @@ -373,6 +381,7 @@ const { execSync } = require('child_process'); const PORT = process.env.DROID_SEARCH_PORT || 23119; const FACTORY_API = 'https://api.factory.ai'; +const SEARCH_ROUTE_ALIASES = new Set(['/api/tools/web-search', '/api/tools/exa/search']); async function searchGooglePSE(query, num) { const apiKey = process.env.GOOGLE_PSE_API_KEY; @@ -416,6 +425,10 @@ async function search(query, num) { return searchDuckDuckGo(query, num); } +function isSearchRequest(url, method) { + return method === 'POST' && SEARCH_ROUTE_ALIASES.has(url.pathname); +} + function isPortInUse(port) { try { execSync(\`curl -s http://127.0.0.1:\${port}/health\`, { timeout: 1000 }); return true; } catch { return false; } } @@ -428,7 +441,7 @@ if (!isPortInUse(PORT)) { res.end(JSON.stringify({ status: 'ok' })); return; } - if (url.pathname === '/api/tools/exa/search' && req.method === 'POST') { + if (isSearchRequest(url, req.method)) { let body = ''; req.on('data', c => body += c); req.on('end', async () => { diff --git a/test/websearch-native.test.mjs b/test/websearch-native.test.mjs new file mode 100644 index 0000000..4dd192e --- /dev/null +++ b/test/websearch-native.test.mjs @@ -0,0 +1,467 @@ +import assert from "node:assert/strict"; +import { execFile, spawn } from "node:child_process"; +import { createServer } from "node:http"; +import test from "node:test"; +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir, platform } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const IS_WINDOWS = platform() === "win32"; +const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url)); +const CLI_PATH = fileURLToPath(new URL("../dist/cli.mjs", import.meta.url)); + +async function createFakeDroidBinary(dir) { + const binaryPath = join(dir, IS_WINDOWS ? "droid.cmd" : "droid"); + const script = IS_WINDOWS + ? `@echo off +if "%~1"=="--version" ( + echo 0.90.0 + exit /b 0 +) +echo noop +` + : `#!/bin/sh +if [ "$1" = "--version" ]; then + printf '0.90.0\\n' + exit 0 +fi +printf 'noop\\n' +`; + + await writeFile(binaryPath, script, "utf8"); + if (!IS_WINDOWS) { + await chmod(binaryPath, 0o755); + } + return binaryPath; +} + +async function writeFactorySettings(homeDir, settings) { + const factoryDir = join(homeDir, ".factory"); + await mkdir(factoryDir, { recursive: true }); + await writeFile(join(factoryDir, "settings.json"), JSON.stringify(settings, null, 2), "utf8"); +} + +async function createNativeAlias(homeDir, droidPath, alias) { + const binDir = join(homeDir, "bin"); + await mkdir(binDir, { recursive: true }); + await execFileAsync(process.execPath, [CLI_PATH, "--websearch-proxy", "-p", droidPath, alias], { + cwd: PROJECT_ROOT, + env: { ...process.env, HOME: homeDir, PATH: `${binDir}:${process.env.PATH}` }, + }); +} + +async function waitForPortFile(portFile) { + for (let index = 0; index < 50; index++) { + try { + const value = (await readFile(portFile, "utf8")).trim(); + if (value) return Number(value); + } catch {} + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for proxy port file: ${portFile}`); +} + +async function waitForHealthyPort(port) { + for (let index = 0; index < 50; index++) { + try { + const response = await fetch(`http://127.0.0.1:${port}/health`); + if (response.ok) return; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out waiting for proxy health on port ${port}`); +} + +async function stopChild(child) { + if (child.killed) return; + await new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill("SIGKILL"); + }, 2000); + child.once("exit", () => { + clearTimeout(timer); + resolve(); + }); + child.kill("SIGTERM"); + }); +} + +async function startNativeProxy(homeDir, alias) { + const proxyScriptPath = join(homeDir, ".droid-patch", "proxy", `${alias}-proxy.js`); + const portFile = join(homeDir, `${alias}.port`); + const child = spawn(process.execPath, [proxyScriptPath], { + cwd: PROJECT_ROOT, + env: { + ...process.env, + HOME: homeDir, + SEARCH_PROXY_PORT: "0", + SEARCH_PROXY_PORT_FILE: portFile, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + try { + const port = await waitForPortFile(portFile); + await waitForHealthyPort(port); + return { child, port }; + } catch (error) { + await stopChild(child); + throw error; + } +} + +async function requestSearch(port, payload, pathname = "/api/tools/web-search") { + return fetch(`http://127.0.0.1:${port}${pathname}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); +} + +async function startOpenAIStubServer(mode = "annotations") { + let attempts = 0; + const server = createServer((req, res) => { + if (req.url !== "/responses" || req.method !== "POST") { + res.writeHead(404); + res.end(); + return; + } + + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + attempts += 1; + const parsed = JSON.parse(body); + assert.ok(parsed.tools?.length); + if (mode === "retry-once" && attempts === 1) { + res.writeHead(502, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: + "Proxy failed: Client network socket disconnected before secure TLS connection was established", + }), + ); + return; + } + if (mode === "retry-thrice" && attempts <= 3) { + res.writeHead(502, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: + "Proxy failed: Client network socket disconnected before secure TLS connection was established", + }), + ); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + const content = + mode === "text-only" + ? [ + { + type: "output_text", + annotations: [], + text: [ + "Here are up to 3 relevant results:", + "", + "1. **React Activity**", + " - https://www.npmjs.com/package/react-activity", + " - Official npm package page with install details.", + "", + "2. **React Activity GitHub**", + " - https://github.com/example/react-activity", + " - Source repository with usage examples.", + ].join("\n"), + }, + ] + : [ + { + type: "output_text", + annotations: [ + { + type: "url_citation", + url: "https://example.com/result", + title: "Example Result", + }, + ], + }, + ]; + res.end( + JSON.stringify({ + output: [ + { + type: "message", + content: content, + }, + ], + }), + ); + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + getAttempts() { + return attempts; + }, + async close() { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + }, + }; +} + +void test("native websearch proxy falls back to mission model settings", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "droid-patch-native-")); + const upstream = await startOpenAIStubServer(); + + try { + await writeFactorySettings(homeDir, { + customModels: [ + { + id: "custom:gpt-5-4-test", + model: "gpt-5.4", + baseUrl: upstream.baseUrl, + apiKey: "test-key", + displayName: "GPT 5.4 test", + provider: "openai", + }, + ], + missionModelSettings: { + workerModel: "custom:gpt-5-4-test", + }, + }); + + const droidPath = await createFakeDroidBinary(homeDir); + await createNativeAlias(homeDir, droidPath, "droid-native"); + + const { child, port } = await startNativeProxy(homeDir, "droid-native"); + try { + const response = await requestSearch(port, { query: "factory ai", numResults: 3 }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.results.length, 1); + assert.equal(payload.results[0].url, "https://example.com/result"); + } finally { + await stopChild(child); + } + } finally { + await upstream.close(); + await rm(homeDir, { recursive: true, force: true }); + } +}); + +void test("native websearch proxy parses text-only OpenAI search results", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "droid-patch-native-")); + const upstream = await startOpenAIStubServer("text-only"); + + try { + await writeFactorySettings(homeDir, { + customModels: [ + { + id: "custom:gpt-5-4-text", + model: "gpt-5.4", + baseUrl: upstream.baseUrl, + apiKey: "test-key", + displayName: "GPT 5.4 text", + provider: "openai", + }, + ], + sessionDefaultSettings: { + model: "custom:gpt-5-4-text", + }, + }); + + const droidPath = await createFakeDroidBinary(homeDir); + await createNativeAlias(homeDir, droidPath, "droid-native-text"); + + const { child, port } = await startNativeProxy(homeDir, "droid-native-text"); + try { + const response = await requestSearch(port, { query: "factory ai", numResults: 3 }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.results.length, 2); + assert.equal(payload.results[0].url, "https://www.npmjs.com/package/react-activity"); + assert.equal(payload.results[1].url, "https://github.com/example/react-activity"); + } finally { + await stopChild(child); + } + } finally { + await upstream.close(); + await rm(homeDir, { recursive: true, force: true }); + } +}); + +void test("native websearch proxy retries transient OpenAI upstream failures", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "droid-patch-native-")); + const upstream = await startOpenAIStubServer("retry-once"); + + try { + await writeFactorySettings(homeDir, { + customModels: [ + { + id: "custom:gpt-5-4-retry", + model: "gpt-5.4", + baseUrl: upstream.baseUrl, + apiKey: "test-key", + displayName: "GPT 5.4 retry", + provider: "openai", + }, + ], + sessionDefaultSettings: { + model: "custom:gpt-5-4-retry", + }, + }); + + const droidPath = await createFakeDroidBinary(homeDir); + await createNativeAlias(homeDir, droidPath, "droid-native-retry"); + + const { child, port } = await startNativeProxy(homeDir, "droid-native-retry"); + try { + const response = await requestSearch(port, { query: "factory ai", numResults: 3 }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.results.length, 1); + assert.equal(upstream.getAttempts(), 2); + } finally { + await stopChild(child); + } + } finally { + await upstream.close(); + await rm(homeDir, { recursive: true, force: true }); + } +}); + +void test("native websearch proxy survives repeated transient OpenAI upstream failures", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "droid-patch-native-")); + const upstream = await startOpenAIStubServer("retry-thrice"); + + try { + await writeFactorySettings(homeDir, { + customModels: [ + { + id: "custom:gpt-5-4-retry-many", + model: "gpt-5.4", + baseUrl: upstream.baseUrl, + apiKey: "test-key", + displayName: "GPT 5.4 retry many", + provider: "openai", + }, + ], + sessionDefaultSettings: { + model: "custom:gpt-5-4-retry-many", + }, + }); + + const droidPath = await createFakeDroidBinary(homeDir); + await createNativeAlias(homeDir, droidPath, "droid-native-retry-many"); + + const { child, port } = await startNativeProxy(homeDir, "droid-native-retry-many"); + try { + const response = await requestSearch(port, { query: "factory ai", numResults: 3 }); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.results.length, 1); + assert.equal(upstream.getAttempts(), 4); + } finally { + await stopChild(child); + } + } finally { + await upstream.close(); + await rm(homeDir, { recursive: true, force: true }); + } +}); + +void test("native websearch proxy returns explicit errors for unsupported models", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "droid-patch-native-")); + + try { + await writeFactorySettings(homeDir, { + customModels: [ + { + id: "custom:generic-test", + model: "generic-model", + baseUrl: "http://127.0.0.1:1/generic", + apiKey: "test-key", + displayName: "Generic test", + provider: "generic-chat-completion-api", + }, + ], + sessionDefaultSettings: { + model: "custom:generic-test", + }, + }); + + const droidPath = await createFakeDroidBinary(homeDir); + await createNativeAlias(homeDir, droidPath, "droid-native-error"); + + const { child, port } = await startNativeProxy(homeDir, "droid-native-error"); + try { + const response = await requestSearch(port, { query: "factory ai", numResults: 3 }); + assert.equal(response.status, 400); + const payload = await response.json(); + assert.match(payload.error, /No supported/); + } finally { + await stopChild(child); + } + } finally { + await rm(homeDir, { recursive: true, force: true }); + } +}); + +void test("native websearch proxy still supports legacy /api/tools/exa/search", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "droid-patch-native-")); + const upstream = await startOpenAIStubServer(); + + try { + await writeFactorySettings(homeDir, { + customModels: [ + { + id: "custom:gpt-5-4-legacy-endpoint", + model: "gpt-5.4", + baseUrl: upstream.baseUrl, + apiKey: "test-key", + displayName: "GPT 5.4 legacy endpoint", + provider: "openai", + }, + ], + sessionDefaultSettings: { + model: "custom:gpt-5-4-legacy-endpoint", + }, + }); + + const droidPath = await createFakeDroidBinary(homeDir); + await createNativeAlias(homeDir, droidPath, "droid-native-legacy-endpoint"); + + const { child, port } = await startNativeProxy(homeDir, "droid-native-legacy-endpoint"); + try { + const response = await requestSearch( + port, + { query: "factory ai", numResults: 3 }, + "/api/tools/exa/search", + ); + assert.equal(response.status, 200); + const payload = await response.json(); + assert.equal(payload.results.length, 1); + assert.equal(payload.results[0].url, "https://example.com/result"); + } finally { + await stopChild(child); + } + } finally { + await upstream.close(); + await rm(homeDir, { recursive: true, force: true }); + } +}); + +void test("websearch-proxy update path regenerates native wrappers", async () => { + const src = await readFile(new URL("../src/cli.ts", import.meta.url), "utf8"); + assert.match(src, /meta\.patches\.websearchProxy/); + assert.match(src, /createWebSearchUnifiedFiles\([\s\S]*meta\.patches\.websearchProxy \|\| false/); +}); diff --git a/test/wrappers.test.mjs b/test/wrappers.test.mjs index 1c1ad37..cf27511 100644 --- a/test/wrappers.test.mjs +++ b/test/wrappers.test.mjs @@ -5,12 +5,28 @@ import { readFile } from "node:fs/promises"; void test("websearch wrapper includes passthrough logic", async () => { const src = await readFile(new URL("../src/websearch-patch.ts", import.meta.url), "utf8"); assert.match(src, /should_passthrough\(\)/); - assert.match(src, /help\|version\|completion\|completions\|exec\|plugin/); + assert.match(src, /help\|version\|completion\|completions\|plugin/); + assert.doesNotMatch(src, /help\|version\|completion\|completions\|exec\|plugin/); + assert.match( + src, + /SEARCH_ROUTE_ALIASES = new Set\(\['\/api\/tools\/web-search', '\/api\/tools\/exa\/search'\]\)/, + ); + assert.match(src, /function isSearchRequest\(url, method\)/); + assert.match(src, /FACTORY_APP_BASE_URL_OVERRIDE/); + assert.match(src, /TOOLS_WEBSEARCH_BASE_URL/); }); void test("dist bundle contains passthrough logic (published output)", async () => { const dist = await readFile(new URL("../dist/cli.mjs", import.meta.url), "utf8"); assert.match(dist, /should_passthrough\(\)/); + assert.doesNotMatch(dist, /help\|version\|completion\|completions\|exec\|plugin/); + assert.match( + dist, + /SEARCH_ROUTE_ALIASES = new Set\(\['\/api\/tools\/web-search', '\/api\/tools\/exa\/search'\]\)/, + ); + assert.match(dist, /function isSearchRequest\(url, method\)/); + assert.match(dist, /FACTORY_APP_BASE_URL_OVERRIDE/); + assert.match(dist, /TOOLS_WEBSEARCH_BASE_URL/); assert.doesNotMatch(dist, /--statusline/); assert.doesNotMatch(dist, /--sessions/); });