lynkr
Version:
Self-hosted LLM gateway and tier-routing proxy for Claude Code, Cursor, and Codex. Routes across Ollama, AWS Bedrock, OpenRouter, Databricks, Azure OpenAI, llama.cpp, and LM Studio with prompt caching, MCP tools, and 60-80% cost savings.
1,041 lines (891 loc) • 34 kB
JavaScript
/**
* Complexity Analyzer Module
*
* Analyzes request complexity to determine optimal model routing.
* Implements all 5 phases of auto model selection:
* - Phase 1: Basic Scoring (token count, tool count, task classification)
* - Phase 2: Advanced Classification (code complexity, reasoning detection)
* - Phase 3: Learning & Tracking (metrics, feedback storage)
* - Phase 4: ML-Based (embeddings similarity)
* - Phase 5: Structural Analysis (code-review-graph blast radius & dependency signals)
*
* @module routing/complexity-analyzer
*/
const logger = require('../logger');
const config = require('../config');
const codeGraph = require('../tools/code-graph');
// ============================================================================
// PHASE 1: Basic Scoring Patterns
// ============================================================================
// Pre-compiled regex patterns for performance
const PATTERNS = {
// Greetings - always local
greeting: /^(hi|hello|hey|good\s*(morning|afternoon|evening)|howdy|greetings|sup|yo|thanks?|thank\s*you)[\s\.\!\?,]*$/i,
// Simple questions - likely local
simpleQuestion: /^(what\s+is|what's|define|who\s+is|when\s+was|where\s+is)\s+\w/i,
// Yes/No questions - local
yesNo: /^(is\s+it|are\s+there|can\s+i|do\s+you|does\s+it|will\s+it|should\s+i)\s+/i,
// Technical keywords
technical: /\b(code|function|class|method|variable|api|database|server|client|module|import|export|async|await|promise|component|interface|type|struct|enum)\b/i,
// File/path references
fileReference: /\b(\w+\.(js|ts|py|rb|go|rs|java|cpp|c|h|jsx|tsx|vue|svelte|md|json|yaml|yml|toml|sql|sh|bash))\b|[\.\/]\w+\//i,
};
// ============================================================================
// PHASE 2: Advanced Classification Patterns
// ============================================================================
const ADVANCED_PATTERNS = {
// Code complexity indicators
codeComplexity: {
multiFile: /\b(\d+\s*\+?\s*files?|multiple\s+files?|across\s+files?|all\s+files?|every\s+file)\b/i,
architecture: /\b(architect(ure)?|microservice|distributed|system\s+design|scalab|infrastructure)\b/i,
concurrent: /\b(async|concurrent|parallel|thread|worker|queue|mutex|lock|race\s+condition)\b/i,
security: /\b(security|auth(entication)?|authori[sz]ation|encrypt|decrypt|vulnerab|injection|xss|csrf|sanitiz)\b/i,
testing: /\b(test\s+coverage|integration\s+test|e2e|end.to.end|unit\s+test|regression|benchmark)\b/i,
performance: /\b(optimi[sz]e|performance|memory\s+leak|profil|bottleneck|cach(e|ing))\b/i,
database: /\b(migration|schema|index|query\s+optimi|transaction|rollback|backup)\b/i,
},
// Reasoning indicators - needs cloud
reasoning: {
stepByStep: /\b(step\s+by\s+step|think\s+through|let'?s\s+reason|reasoning|chain\s+of\s+thought)\b/i,
tradeoffs: /\b(trade.?off|pros?\s+and\s+cons?|compare\s+options?|weigh\s+(the\s+)?options?|advantages?\s+and\s+disadvantages?)\b/i,
analysis: /\b(analy[sz]e|evaluat|assess|review\s+(the|this|my)|audit|investigate|diagnos)\b/i,
planning: /\b(plan(ning)?|strategy|approach|roadmap|design\s+doc|rfc|proposal)\b/i,
edgeCases: /\b(edge\s+case|corner\s+case|what\s+if|exception|error\s+handling|fallback)\b/i,
},
// Task scope indicators
taskScope: {
entire: /\b(entire|whole|complete|full|all\s+of)\s+(codebase|project|app|application|system|repo)/i,
refactor: /\b(refactor|restructure|reorgani[sz]e|rewrite|overhaul|migrate)\b/i,
implement: /\b(implement|build|create|develop)\s+(a\s+)?(new\s+)?(feature|system|module|service|api)/i,
fromScratch: /\b(from\s+scratch|ground\s+up|greenfield|bootstrap|scaffold)\b/i,
},
};
// Force cloud patterns - always route to cloud regardless of score
const FORCE_CLOUD_PATTERNS = [
/\b(security\s+(audit|review|assessment)|penetration\s+test|vulnerability\s+scan)\b/i,
/\b(architect(ure)?\s+(review|design|diagram)|system\s+design)\b/i,
/\b(refactor\s+(entire|whole|all|the\s+entire)|complete\s+rewrite)\b/i,
/\b(code\s+review|pr\s+review|pull\s+request\s+review)\b/i,
/\b(debug(ging)?\s+(complex|difficult|hard|tricky))\b/i,
/\b(production\s+(issue|bug|incident|outage))\b/i,
];
// Force local patterns - always route to local regardless of score
const FORCE_LOCAL_PATTERNS = [
/^(hi|hello|hey|thanks?|thank\s*you|bye|goodbye)[\s\.\!\?]*$/i,
/^what\s+(time|day|date)\s+is\s+it/i,
/^(yes|no|ok|okay|sure|got\s+it|understood)[\s\.\!\?]*$/i,
/^(help|menu|commands?|options?)[\s\.\!\?]*$/i,
];
// Weighted Scoring (15 Dimensions)
const DIMENSION_WEIGHTS = {
// Content Analysis (35%)
tokenCount: 0.08,
promptComplexity: 0.10,
technicalDepth: 0.10,
domainSpecificity: 0.07,
// Tool Analysis (25%)
toolCount: 0.08,
toolComplexity: 0.10,
toolChainPotential: 0.07,
// Reasoning Requirements (25%)
multiStepReasoning: 0.10,
codeGeneration: 0.08,
analysisDepth: 0.07,
// Context Factors (15%)
conversationDepth: 0.05,
priorToolUsage: 0.05,
ambiguity: 0.05,
};
// Tool complexity weights (higher = more complex)
const TOOL_COMPLEXITY_WEIGHTS = {
Bash: 0.9,
bash: 0.9,
shell: 0.9,
Write: 0.8,
write_file: 0.8,
Edit: 0.7,
edit_file: 0.7,
NotebookEdit: 0.7,
Task: 0.9,
agent_task: 0.9,
WebSearch: 0.5,
WebFetch: 0.4,
Read: 0.3,
read_file: 0.3,
Glob: 0.2,
Grep: 0.2,
default: 0.5,
};
// Domain-specific keywords for complexity
const DOMAIN_KEYWORDS = {
security: /\b(auth|encrypt|vulnerability|injection|xss|csrf|jwt|oauth|password|credential|secret)\b/i,
ml: /\b(model|train|inference|tensor|embedding|neural|llm|gpt|transformer|pytorch|tensorflow)\b/i,
distributed: /\b(microservice|kafka|redis|queue|scale|cluster|replicate|kubernetes|docker|container)\b/i,
database: /\b(sql|nosql|migration|index|query|transaction|orm|postgres|mongodb|mysql)\b/i,
frontend: /\b(react|vue|angular|svelte|css|html|component|state|redux|hooks)\b/i,
devops: /\b(ci\/cd|pipeline|deploy|terraform|ansible|github\s*actions|jenkins)\b/i,
};
// ============================================================================
// PHASE 3: Metrics Tracking
// ============================================================================
// In-memory metrics (persisted via memory system if enabled)
const routingMetrics = {
decisions: [],
maxDecisions: 1000, // Keep last 1000 decisions
record(decision) {
this.decisions.push({
...decision,
timestamp: Date.now(),
});
// Trim old decisions
if (this.decisions.length > this.maxDecisions) {
this.decisions = this.decisions.slice(-this.maxDecisions);
}
},
getStats() {
if (this.decisions.length === 0) return null;
const localCount = this.decisions.filter(d => d.provider === 'ollama' || d.provider === 'llamacpp' || d.provider === 'lmstudio').length;
const cloudCount = this.decisions.length - localCount;
const avgScore = this.decisions.reduce((sum, d) => sum + d.score, 0) / this.decisions.length;
return {
total: this.decisions.length,
local: localCount,
cloud: cloudCount,
localPercent: Math.round((localCount / this.decisions.length) * 100),
avgComplexityScore: Math.round(avgScore),
};
},
};
// ============================================================================
// PHASE 5: Structural Analysis Helpers (code-review-graph)
// ============================================================================
/** Pattern to match file paths in message content */
const FILE_PATH_PATTERN = /(?:^|\s|["'`(])([.\w/-]+\.(?:js|ts|py|rb|go|rs|java|cpp|c|h|jsx|tsx|vue|svelte|json|yaml|yml|toml|sql|sh|bash|css|scss|html))\b/gi;
/**
* Extract file paths from text using the FILE_PATH_PATTERN regex.
* @param {string} text
* @param {Set<string>} paths — accumulator set
*/
function extractPathsFromText(text, paths) {
if (typeof text !== 'string') return;
for (const match of text.matchAll(FILE_PATH_PATTERN)) {
paths.add(match[1]);
}
}
/**
* Extract file paths from the full conversation payload.
*
* Supports both Anthropic and OpenAI message formats:
* - Anthropic: system (string/array), messages with tool_use/tool_result blocks
* - OpenAI: messages with role=system, tool_calls with function.arguments
* - Cursor/Windsurf: file context embedded in system prompts
* - Codex CLI / Aider: function call arguments with file paths
*
* @param {Object} payload — request payload
* @returns {string[]} deduplicated file paths
*/
function extractFilePaths(payload) {
const paths = new Set();
if (!payload) return [];
// --- Anthropic system prompt (string or array of content blocks) ---
if (typeof payload.system === 'string') {
extractPathsFromText(payload.system, paths);
} else if (Array.isArray(payload.system)) {
for (const block of payload.system) {
if (block?.type === 'text' && block.text) {
extractPathsFromText(block.text, paths);
}
}
}
if (!Array.isArray(payload.messages)) return Array.from(paths);
for (const msg of payload.messages) {
// --- String content (both Anthropic and OpenAI) ---
if (typeof msg.content === 'string') {
extractPathsFromText(msg.content, paths);
} else if (Array.isArray(msg.content)) {
for (const block of msg.content) {
// Text blocks (Anthropic format)
if (block?.type === 'text' && block.text) {
extractPathsFromText(block.text, paths);
}
// Tool use blocks (Anthropic format — Claude Code, Cline, Zed)
if (block?.type === 'tool_use' && block.input) {
const input = block.input;
if (typeof input.file_path === 'string') paths.add(input.file_path);
if (typeof input.path === 'string') paths.add(input.path);
if (typeof input.command === 'string') {
extractPathsFromText(input.command, paths);
}
}
// Tool result blocks (Anthropic format)
if (block?.type === 'tool_result') {
const resultContent = Array.isArray(block.content) ? block.content : [];
for (const rc of resultContent) {
if (rc?.type === 'text' && rc.text) {
extractPathsFromText(rc.text, paths);
}
}
}
}
}
// --- OpenAI tool_calls format (Codex CLI, Aider, Continue.dev) ---
if (Array.isArray(msg.tool_calls)) {
for (const tc of msg.tool_calls) {
if (tc?.function?.arguments) {
try {
// function.arguments is a JSON string in OpenAI format
const args = typeof tc.function.arguments === 'string'
? JSON.parse(tc.function.arguments)
: tc.function.arguments;
if (typeof args.file_path === 'string') paths.add(args.file_path);
if (typeof args.path === 'string') paths.add(args.path);
if (typeof args.command === 'string') {
extractPathsFromText(args.command, paths);
}
// Also scan the full arguments text for paths
if (typeof tc.function.arguments === 'string') {
extractPathsFromText(tc.function.arguments, paths);
}
} catch {
// If arguments isn't valid JSON, scan as text
if (typeof tc.function.arguments === 'string') {
extractPathsFromText(tc.function.arguments, paths);
}
}
}
}
}
// --- OpenAI function_call format (legacy, some tools still use it) ---
if (msg.function_call?.arguments) {
try {
const args = typeof msg.function_call.arguments === 'string'
? JSON.parse(msg.function_call.arguments)
: msg.function_call.arguments;
if (typeof args.file_path === 'string') paths.add(args.file_path);
if (typeof args.path === 'string') paths.add(args.path);
} catch {
if (typeof msg.function_call.arguments === 'string') {
extractPathsFromText(msg.function_call.arguments, paths);
}
}
}
}
return Array.from(paths);
}
/**
* Calculate score adjustment from Graphify complexity signals.
* Capped at +35 (increased from +25 due to richer signals).
*
* @param {{ blast_radius: number, dependency_depth: number, test_coverage_pct: number, is_infrastructure: boolean, god_node_touched: boolean, community_count: number, cohesion: number }} signals
* @returns {{ adjustment: number, reasons: string[] }}
*/
function scoreGraphSignals(signals) {
let adjustment = 0;
const reasons = [];
// Blast radius — how many files are affected
if (signals.blast_radius > 30) {
adjustment += 15;
reasons.push('blast_radius_high');
} else if (signals.blast_radius > 10) {
adjustment += 10;
reasons.push('blast_radius_medium');
} else if (signals.blast_radius > 5) {
adjustment += 5;
reasons.push('blast_radius_low');
}
// Dependency depth — deep call chains are harder to reason about
if (signals.dependency_depth > 4) {
adjustment += 5;
reasons.push('deep_dependencies');
}
// Infrastructure files — config/CI/deploy changes are high-risk
if (signals.is_infrastructure) {
adjustment += 10;
reasons.push('infrastructure_file');
}
// Low test coverage — changes in untested areas are riskier
if (signals.test_coverage_pct < 30) {
adjustment += 5;
reasons.push('low_test_coverage');
}
// God node touched — editing a hub class that many things depend on
if (signals.god_node_touched) {
adjustment += 10;
reasons.push('god_node_touched');
}
// Low community cohesion — loosely coupled code is harder to change safely
if (typeof signals.cohesion === 'number' && signals.cohesion < 0.15 && signals.community_count > 1) {
adjustment += 5;
reasons.push('low_community_cohesion');
}
return { adjustment: Math.min(adjustment, 35), reasons };
}
// ============================================================================
// CORE ANALYSIS FUNCTIONS
// ============================================================================
/**
* Extract text content from request payload
*/
function extractContent(payload) {
if (!payload?.messages || !Array.isArray(payload.messages)) {
return '';
}
// Get last user message
for (let i = payload.messages.length - 1; i >= 0; i--) {
const msg = payload.messages[i];
if (msg?.role === 'user') {
if (typeof msg.content === 'string') {
return msg.content;
}
if (Array.isArray(msg.content)) {
return msg.content
.filter(block => block?.type === 'text')
.map(block => block.text || '')
.join(' ');
}
}
}
return '';
}
/**
* Estimate token count.
*
* Phase 1.1: delegates to the tiktoken-backed tokenizer (graceful fallback to
* chars/4 if js-tiktoken is unavailable).
*/
const { countPayloadTokens } = require('./tokenizer');
function estimateTokens(payload) {
if (!payload?.messages) return 0;
return countPayloadTokens(payload, payload?.model);
}
/**
* Score based on token count (0-20 points)
*/
function scoreTokens(payload) {
const tokens = estimateTokens(payload);
if (tokens < 500) return 0; // Very simple
if (tokens < 1000) return 4; // Simple
if (tokens < 2000) return 8; // Medium
if (tokens < 4000) return 12; // Complex
if (tokens < 8000) return 16; // Very complex
return 20; // Extremely complex
}
/**
* Score based on tool count (0-20 points)
*/
function scoreTools(payload) {
const toolCount = Array.isArray(payload?.tools) ? payload.tools.length : 0;
if (toolCount === 0) return 0; // No tools
if (toolCount <= 3) return 4; // Few tools - local can handle
if (toolCount <= 6) return 8; // Moderate tools
if (toolCount <= 10) return 12; // Many tools
if (toolCount <= 15) return 16; // Heavy tools
return 20; // Very heavy tools
}
/**
* Score based on task type (0-25 points)
*/
function scoreTaskType(content) {
const contentLower = content.toLowerCase();
// Check force patterns first
for (const pattern of FORCE_LOCAL_PATTERNS) {
if (pattern.test(content)) {
return { score: 0, reason: 'force_local', pattern: 'greeting_or_simple' };
}
}
for (const pattern of FORCE_CLOUD_PATTERNS) {
if (pattern.test(content)) {
return { score: 25, reason: 'force_cloud', pattern: pattern.source.slice(0, 30) };
}
}
// Greetings
if (PATTERNS.greeting.test(content)) {
return { score: 0, reason: 'greeting' };
}
// Simple questions without technical content
if (PATTERNS.simpleQuestion.test(content) && !PATTERNS.technical.test(content)) {
return { score: 3, reason: 'simple_question' };
}
// Yes/No questions
if (PATTERNS.yesNo.test(content)) {
return { score: 2, reason: 'yes_no_question' };
}
// Task scope analysis
if (ADVANCED_PATTERNS.taskScope.entire.test(content)) {
return { score: 22, reason: 'entire_codebase' };
}
if (ADVANCED_PATTERNS.taskScope.fromScratch.test(content)) {
return { score: 20, reason: 'from_scratch' };
}
if (ADVANCED_PATTERNS.taskScope.implement.test(content)) {
return { score: 18, reason: 'new_implementation' };
}
if (ADVANCED_PATTERNS.taskScope.refactor.test(content)) {
return { score: 16, reason: 'refactoring' };
}
// Technical content
if (PATTERNS.technical.test(content)) {
return { score: 10, reason: 'technical_content' };
}
// Default for non-technical
return { score: 5, reason: 'general' };
}
/**
* Score code complexity (0-20 points)
* Phase 2: Advanced classification
*/
function scoreCodeComplexity(content) {
let score = 0;
const reasons = [];
// Multi-file operations
if (ADVANCED_PATTERNS.codeComplexity.multiFile.test(content)) {
score += 5;
reasons.push('multi_file');
}
// Architecture concerns
if (ADVANCED_PATTERNS.codeComplexity.architecture.test(content)) {
score += 5;
reasons.push('architecture');
}
// Concurrency
if (ADVANCED_PATTERNS.codeComplexity.concurrent.test(content)) {
score += 3;
reasons.push('concurrency');
}
// Security
if (ADVANCED_PATTERNS.codeComplexity.security.test(content)) {
score += 4;
reasons.push('security');
}
// Testing
if (ADVANCED_PATTERNS.codeComplexity.testing.test(content)) {
score += 2;
reasons.push('testing');
}
// Performance
if (ADVANCED_PATTERNS.codeComplexity.performance.test(content)) {
score += 3;
reasons.push('performance');
}
// Database
if (ADVANCED_PATTERNS.codeComplexity.database.test(content)) {
score += 3;
reasons.push('database');
}
return { score: Math.min(score, 20), reasons };
}
/**
* Score reasoning requirements (0-15 points)
* Phase 2: Advanced classification
*/
function scoreReasoning(content) {
let score = 0;
const reasons = [];
// Step-by-step reasoning
if (ADVANCED_PATTERNS.reasoning.stepByStep.test(content)) {
score += 4;
reasons.push('step_by_step');
}
// Trade-off analysis
if (ADVANCED_PATTERNS.reasoning.tradeoffs.test(content)) {
score += 4;
reasons.push('tradeoffs');
}
// General analysis
if (ADVANCED_PATTERNS.reasoning.analysis.test(content)) {
score += 3;
reasons.push('analysis');
}
// Planning
if (ADVANCED_PATTERNS.reasoning.planning.test(content)) {
score += 3;
reasons.push('planning');
}
// Edge cases
if (ADVANCED_PATTERNS.reasoning.edgeCases.test(content)) {
score += 2;
reasons.push('edge_cases');
}
return { score: Math.min(score, 15), reasons };
}
// ============================================================================
// WEIGHTED SCORING FUNCTION (15 Dimensions)
// ============================================================================
/**
* Calculate weighted complexity score (0-100)
* Uses 15 dimensions with configurable weights
* @param {Object} payload - Request payload
* @param {string} content - Extracted content
* @returns {Object} Weighted score result
*/
function calculateWeightedScore(payload, content) {
const dimensions = {};
// 1. Token count (0-100)
const tokens = estimateTokens(payload);
dimensions.tokenCount = tokens < 500 ? 10 : tokens < 2000 ? 30 : tokens < 5000 ? 50 : tokens < 10000 ? 70 : 90;
// 2. Prompt complexity (sentence structure, avg length)
const sentences = content.split(/[.!?]+/).filter(s => s.trim().length > 0);
const avgLength = content.length / Math.max(sentences.length, 1);
dimensions.promptComplexity = Math.min(avgLength / 2, 100);
// 3. Technical depth (keyword density)
const techMatches = (content.match(PATTERNS.technical) || []).length;
dimensions.technicalDepth = Math.min(techMatches * 15, 100);
// 4. Domain specificity (how many domains are touched)
let domainScore = 0;
const domainsMatched = [];
for (const [domain, regex] of Object.entries(DOMAIN_KEYWORDS)) {
if (regex.test(content)) {
domainScore += 20;
domainsMatched.push(domain);
}
}
dimensions.domainSpecificity = Math.min(domainScore, 100);
// 5. Tool count
const toolCount = payload?.tools?.length ?? 0;
dimensions.toolCount = toolCount === 0 ? 0 :
toolCount <= 3 ? 20 :
toolCount <= 6 ? 40 :
toolCount <= 10 ? 60 :
toolCount <= 15 ? 80 : 100;
// 6. Tool complexity (weighted by tool types)
if (payload?.tools?.length > 0) {
const totalWeight = payload.tools.reduce((sum, t) => {
const name = t.name || t.function?.name || '';
return sum + (TOOL_COMPLEXITY_WEIGHTS[name] || TOOL_COMPLEXITY_WEIGHTS.default);
}, 0);
const avgWeight = totalWeight / payload.tools.length;
dimensions.toolComplexity = avgWeight * 100;
} else {
dimensions.toolComplexity = 0;
}
// 7. Tool chain potential (sequential operations)
dimensions.toolChainPotential = /\b(then|after|next|finally|first.*then|step\s*\d+)\b/i.test(content) ? 70 : 20;
// 8. Multi-step reasoning
dimensions.multiStepReasoning = ADVANCED_PATTERNS.reasoning.stepByStep.test(content) ? 80 :
ADVANCED_PATTERNS.reasoning.planning.test(content) ? 60 : 20;
// 9. Code generation requirement
dimensions.codeGeneration = /\b(write|create|implement|build|generate)\s+(a\s+)?(new\s+)?(function|class|module|api|endpoint|service|component)/i.test(content) ? 80 : 20;
// 10. Analysis depth
dimensions.analysisDepth = ADVANCED_PATTERNS.reasoning.tradeoffs.test(content) ? 80 :
ADVANCED_PATTERNS.reasoning.analysis.test(content) ? 60 : 20;
// 11. Conversation depth
const messageCount = payload?.messages?.length ?? 0;
dimensions.conversationDepth = messageCount < 3 ? 10 :
messageCount < 6 ? 30 :
messageCount < 10 ? 50 : 70;
// 12. Prior tool usage (tool results in conversation)
const toolResults = (payload?.messages || []).filter(m =>
m.role === 'user' && Array.isArray(m.content) && m.content.some(c => c.type === 'tool_result')
).length;
dimensions.priorToolUsage = toolResults === 0 ? 10 :
toolResults < 3 ? 40 :
toolResults < 6 ? 60 : 80;
// 13. Ambiguity (inverse of specificity)
const hasSpecifics = /\b(file|function|line\s*\d+|error|bug|at\s+[\w.]+:\d+|\/[\w/]+\.\w+)\b/i.test(content);
dimensions.ambiguity = hasSpecifics ? 20 : content.length < 50 ? 70 : 40;
// Calculate weighted total
let weightedTotal = 0;
for (const [dimension, weight] of Object.entries(DIMENSION_WEIGHTS)) {
weightedTotal += (dimensions[dimension] || 0) * weight;
}
return {
score: Math.round(weightedTotal),
dimensions,
weights: DIMENSION_WEIGHTS,
meta: {
tokens,
toolCount,
messageCount,
toolResults,
domainsMatched,
},
};
}
/**
* Get threshold based on SMART_TOOL_SELECTION_MODE
*/
function getThreshold() {
const mode = config.smartToolSelection?.mode ?? 'heuristic';
switch (mode) {
case 'aggressive':
return 60; // More requests go to local
case 'conservative':
return 25; // More requests go to cloud
case 'heuristic':
default:
return 40; // Balanced
}
}
/**
* Analyze request complexity and return full analysis
*
* @param {Object} payload - Request payload
* @param {Object} options - Analysis options
* @returns {Object} Complexity analysis result
*/
async function analyzeComplexity(payload, options = {}) {
const content = extractContent(payload);
const messageCount = payload?.messages?.length ?? 0;
const useWeighted = options.weighted ?? config.routing?.weightedScoring ?? false;
// Use weighted scoring if enabled
if (useWeighted) {
const weighted = calculateWeightedScore(payload, content);
const threshold = getThreshold();
const mode = config.smartToolSelection?.mode ?? 'heuristic';
// Check force patterns
const taskTypeResult = scoreTaskType(content);
let recommendation;
if (taskTypeResult.reason === 'force_local') {
recommendation = 'local';
} else if (taskTypeResult.reason === 'force_cloud') {
recommendation = 'cloud';
} else {
recommendation = weighted.score >= threshold ? 'cloud' : 'local';
}
const result = {
score: weighted.score,
threshold,
mode: 'weighted',
recommendation,
breakdown: weighted.dimensions,
weights: weighted.weights,
meta: weighted.meta,
forceReason: taskTypeResult.reason?.startsWith('force_') ? taskTypeResult.reason : null,
content: content.slice(0, 100) + (content.length > 100 ? '...' : ''),
graphSignals: null,
};
// Phase 5: Structural Analysis (code-review-graph, optional)
try {
const filePaths = extractFilePaths(payload);
const graphOpts = { filePaths, workspace: options?.workspace };
const graphAvailable = await codeGraph.isAvailable(graphOpts);
if (graphAvailable && filePaths.length > 0) {
const signals = await codeGraph.getComplexitySignals(filePaths, graphOpts);
if (signals) {
const { adjustment, reasons } = scoreGraphSignals(signals);
result.score = Math.min(result.score + adjustment, 100);
result.graphSignals = { ...signals, adjustment, reasons };
// Re-evaluate recommendation with adjusted score
if (!result.forceReason) {
result.recommendation = result.score >= threshold ? 'cloud' : 'local';
}
logger.debug({
filePaths: filePaths.slice(0, 5),
signals,
adjustment,
reasons,
}, '[complexity] Phase 5: graph signals applied');
}
}
} catch (err) {
logger.debug({ err: err.message }, '[complexity] Phase 5: code-graph query failed');
}
return result;
}
// Standard scoring (original logic)
const tokenScore = scoreTokens(payload);
const toolScore = scoreTools(payload);
const taskTypeResult = scoreTaskType(content);
const codeComplexityResult = scoreCodeComplexity(content);
const reasoningResult = scoreReasoning(content);
// Calculate total score (0-100)
const totalScore = Math.min(
tokenScore +
toolScore +
taskTypeResult.score +
codeComplexityResult.score +
reasoningResult.score,
100
);
// Conversation length bonus (long conversations tend to be complex)
const conversationBonus = messageCount > 10 ? 5 : (messageCount > 5 ? 2 : 0);
let adjustedScore = Math.min(totalScore + conversationBonus, 100);
// Determine recommendation
const threshold = getThreshold();
const mode = config.smartToolSelection?.mode ?? 'heuristic';
let recommendation;
if (taskTypeResult.reason === 'force_local') {
recommendation = 'local';
} else if (taskTypeResult.reason === 'force_cloud') {
recommendation = 'cloud';
} else {
recommendation = adjustedScore >= threshold ? 'cloud' : 'local';
}
const result = {
score: adjustedScore,
threshold,
mode,
recommendation,
breakdown: {
tokens: { score: tokenScore, estimated: estimateTokens(payload) },
tools: { score: toolScore, count: payload?.tools?.length ?? 0 },
taskType: taskTypeResult,
codeComplexity: codeComplexityResult,
reasoning: reasoningResult,
conversationBonus,
},
content: content.slice(0, 100) + (content.length > 100 ? '...' : ''),
graphSignals: null,
};
// Phase 5: Structural Analysis (code-review-graph, optional)
try {
const filePaths = extractFilePaths(payload);
const graphOpts = { filePaths, workspace: options?.workspace };
const graphAvailable = await codeGraph.isAvailable(graphOpts);
if (graphAvailable && filePaths.length > 0) {
const signals = await codeGraph.getComplexitySignals(filePaths, graphOpts);
if (signals) {
const { adjustment, reasons } = scoreGraphSignals(signals);
result.score = Math.min(result.score + adjustment, 100);
result.graphSignals = { ...signals, adjustment, reasons };
// Re-evaluate recommendation with adjusted score
if (taskTypeResult.reason !== 'force_local' && taskTypeResult.reason !== 'force_cloud') {
result.recommendation = result.score >= threshold ? 'cloud' : 'local';
}
logger.debug({
filePaths: filePaths.slice(0, 5),
signals,
adjustment,
reasons,
}, '[complexity] Phase 5: graph signals applied');
}
}
} catch (err) {
logger.debug({ err: err.message }, '[complexity] Phase 5: code-graph query failed');
}
return result;
}
/**
* Quick check if request should be forced to local
*/
function shouldForceLocal(payload) {
const content = extractContent(payload);
return FORCE_LOCAL_PATTERNS.some(pattern => pattern.test(content));
}
/**
* Quick check if request should be forced to cloud
*/
function shouldForceCloud(payload) {
const content = extractContent(payload);
return FORCE_CLOUD_PATTERNS.some(pattern => pattern.test(content));
}
// ============================================================================
// PHASE 4: Embeddings-Based Similarity (Optional Enhancement)
// ============================================================================
/**
* Get embeddings for content (if embeddings are configured)
* This is a placeholder for future ML-based routing
*/
async function getContentEmbedding(content) {
// Check if embeddings are configured
if (!config.ollama?.embeddingsModel && !config.llamacpp?.embeddingsEndpoint) {
return null;
}
try {
const endpoint = config.ollama?.embeddingsEndpoint ||
config.llamacpp?.embeddingsEndpoint;
if (!endpoint) return null;
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: config.ollama?.embeddingsModel || 'nomic-embed-text',
prompt: content.slice(0, 512), // Limit for performance
}),
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return null;
const data = await response.json();
return data.embedding;
} catch (err) {
logger.debug({ err: err.message }, 'Failed to get embedding for routing');
return null;
}
}
/**
* Calculate cosine similarity between two vectors
*/
function cosineSimilarity(a, b) {
if (!a || !b || a.length !== b.length) return 0;
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
// Reference embeddings for complex vs simple tasks (computed lazily)
let referenceEmbeddings = null;
/**
* Analyze complexity using embeddings (Phase 4)
* Compares request to known complex/simple reference prompts
*/
async function analyzeWithEmbeddings(payload) {
const content = extractContent(payload);
if (content.length < 20) return null; // Too short for meaningful embedding
const embedding = await getContentEmbedding(content);
if (!embedding) return null;
// Lazy initialize reference embeddings
if (!referenceEmbeddings) {
const complexRef = await getContentEmbedding(
"Refactor the entire codebase to use microservices architecture with proper error handling and comprehensive test coverage"
);
const simpleRef = await getContentEmbedding(
"What is a variable in programming"
);
if (complexRef && simpleRef) {
referenceEmbeddings = { complex: complexRef, simple: simpleRef };
}
}
if (!referenceEmbeddings) return null;
const complexSimilarity = cosineSimilarity(embedding, referenceEmbeddings.complex);
const simpleSimilarity = cosineSimilarity(embedding, referenceEmbeddings.simple);
// Convert to score adjustment (-10 to +10)
const embeddingAdjustment = Math.round((complexSimilarity - simpleSimilarity) * 20);
return {
complexSimilarity,
simpleSimilarity,
adjustment: embeddingAdjustment,
};
}
// ============================================================================
// EXPORTS
// ============================================================================
module.exports = {
// Core analysis
analyzeComplexity,
extractContent,
estimateTokens,
// Quick checks
shouldForceLocal,
shouldForceCloud,
// Individual scoring (for testing/debugging)
scoreTokens,
scoreTools,
scoreTaskType,
scoreCodeComplexity,
scoreReasoning,
// Weighted scoring
calculateWeightedScore,
// Configuration
getThreshold,
// Phase 3: Metrics
routingMetrics,
// Phase 4: Embeddings
analyzeWithEmbeddings,
getContentEmbedding,
// Phase 5: Structural Analysis (code-review-graph)
extractFilePaths,
scoreGraphSignals,
// Constants (for testing)
PATTERNS,
ADVANCED_PATTERNS,
FORCE_CLOUD_PATTERNS,
FORCE_LOCAL_PATTERNS,
DIMENSION_WEIGHTS,
TOOL_COMPLEXITY_WEIGHTS,
DOMAIN_KEYWORDS,
};