/** * P1: URL Pattern LLM Completion * * After BFS crawl completes, runs a single LLM call to predict URLs that exist * but were never linked from within the app. Validates each candidate with * HTTP HEAD before emitting as crawl evidence (URL_CANDIDATES). * * Cost: ~1 LLM call + N HEAD requests. Takes ~5-30 seconds depending on * candidate count. Does NOT launch a browser. */ import { logger as rootLogger } from './logger.js'; const logger = rootLogger.child({ module: 'url-pattern-completer' }); export interface UrlCandidate { url: string; confidence: number; reason: string; status: 'hit' | 'miss' | 'error'; } interface LlmCfg { provider: string; model: string; apiKey?: string; baseUrl?: string; } // Only these two providers are supported by this module (they use standard SDK calls). // NVIDIA, groq, ollama, openrouter, etc. are skipped — fall through to env fallback. const SUPPORTED = new Set(['openai', 'anthropic']); /** * Resolve the effective LLM to use. Priority: openai → anthropic → skip others. * Falls back to env vars when the platform-configured provider is unsupported. */ function resolveTextLLM(llmCfg: LlmCfg | null | undefined): { provider: 'openai' | 'anthropic'; apiKey: string; model: string; baseUrl?: string } | null { // Use platform config if it's a supported provider with a key if (llmCfg?.provider === 'openai' && llmCfg.apiKey) { return { provider: 'openai', apiKey: llmCfg.apiKey, model: llmCfg.model, baseUrl: llmCfg.baseUrl }; } if (llmCfg?.provider === 'anthropic' && llmCfg.apiKey) { return { provider: 'anthropic', apiKey: llmCfg.apiKey, model: llmCfg.model }; } // Platform provider is unsupported (nvidia, groq, etc.) or no key — fall back to env vars. // OpenAI takes priority over Anthropic. if (process.env.OPENAI_API_KEY) { return { provider: 'openai', apiKey: process.env.OPENAI_API_KEY, model: 'gpt-4o-mini' }; } if (process.env.ANTHROPIC_API_KEY) { return { provider: 'anthropic', apiKey: process.env.ANTHROPIC_API_KEY, model: 'claude-haiku-4-5-20251001' }; } return null; } /** * Returns validated URL candidates (HEAD=200) predicted by LLM from * the pattern of already-discovered URLs. */ export async function runUrlPatternCompletion( discoveredUrls: string[], appUrl: string, llmCfg: LlmCfg | null | undefined, ): Promise { const effective = resolveTextLLM(llmCfg); if (!effective || discoveredUrls.length < 2) return []; const origin = (() => { try { return new URL(appUrl).origin; } catch { return ''; } })(); if (!origin) return []; // Only send internal URLs (same origin, no assets) const ASSET_EXT = /\.(png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|eot|css|js|map|json|xml|txt|pdf)$/i; const internalUrls = discoveredUrls .filter(u => { try { return new URL(u).origin === origin && !ASSET_EXT.test(u); } catch { return false; } }) .slice(0, 60); // cap context size if (internalUrls.length < 2) return []; const systemPrompt = 'You are a URL pattern analyst. Always respond with valid JSON only.'; const userPrompt = `You are analyzing a web application's URL structure to find screens that exist but weren't discovered during automated crawling. Discovered URLs: ${internalUrls.map(u => { try { return new URL(u).pathname + new URL(u).search; } catch { return u; } }).join('\n')} Based on these URL patterns, suggest up to 15 additional URLs that likely exist in this application but weren't found — typically settings subpages, admin panels, profile variants, paginated views, or feature-gated routes. Rules: - Only suggest URLs on the same domain - Use relative paths (starting with /) - Skip generic guesses (/login, /register, /home) unless the pattern strongly implies them - Focus on patterns YOU SEE in the discovered URLs (e.g. if /settings/profile exists, try /settings/security, /settings/billing) - Confidence 0.0–1.0: how certain you are the page exists Respond with ONLY valid JSON, no explanation: {"candidates":[{"path":"/settings/billing","confidence":0.85,"reason":"settings subpage pattern from /settings/profile"},{"path":"/admin/users","confidence":0.6,"reason":"admin prefix implied by /admin/dashboard"}]}`; try { let text = ''; if (effective.provider === 'openai') { const { OpenAI } = await import('openai'); const client = new OpenAI({ apiKey: effective.apiKey, ...(effective.baseUrl ? { baseURL: effective.baseUrl } : {}) }); const response = await client.chat.completions.create({ model: effective.model ?? 'gpt-4o-mini', max_tokens: 1024, temperature: 0, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], }); text = response.choices[0]?.message?.content ?? ''; } else { const Anthropic = (await import('@anthropic-ai/sdk')).default; const client = new Anthropic({ apiKey: effective.apiKey }); const response = await client.messages.create({ model: effective.model ?? 'claude-haiku-4-5-20251001', max_tokens: 1024, temperature: 0, // @ts-ignore: cache_control supported in beta system: [{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } }], messages: [{ role: 'user', content: userPrompt }], }); text = (response.content[0] as any)?.text ?? ''; } const jsonMatch = text.match(/\{[\s\S]*\}/); if (!jsonMatch) return []; const parsed = JSON.parse(jsonMatch[0]); const rawCandidates: Array<{ path: string; confidence: number; reason: string }> = parsed.candidates ?? []; if (!Array.isArray(rawCandidates) || rawCandidates.length === 0) return []; // HEAD-validate each candidate const results: UrlCandidate[] = []; for (const c of rawCandidates.slice(0, 20)) { if (!c.path || typeof c.path !== 'string') continue; const fullUrl = c.path.startsWith('http') ? c.path : `${origin}${c.path.startsWith('/') ? '' : '/'}${c.path}`; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 6_000); const res = await fetch(fullUrl, { method: 'HEAD', signal: controller.signal, redirect: 'follow' }).catch(() => null); clearTimeout(timeout); const status: UrlCandidate['status'] = res?.ok ? 'hit' : 'miss'; results.push({ url: fullUrl, confidence: c.confidence ?? 0.5, reason: c.reason ?? '', status }); logger.info({ url: fullUrl, httpStatus: res?.status ?? 'timeout', status }, '[url-completer] candidate checked'); } catch { results.push({ url: fullUrl, confidence: c.confidence ?? 0.5, reason: c.reason ?? '', status: 'error' }); } } const hits = results.filter(r => r.status === 'hit'); logger.info({ total: results.length, hits: hits.length, provider: effective.provider }, '[url-completer] pattern completion done'); return results; } catch (err) { logger.warn({ err: String(err) }, '[url-completer] LLM call failed — skipping pattern completion'); return []; } }