Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smart-auto-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nanocollective/nanocoder": minor
---

Implement smart auto-routing cascading model strategy between lightweight and strong models (`/smartroute`).
130 changes: 130 additions & 0 deletions source/ai-sdk-client/smart-router.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import test from 'ava';
import {
autoSelectSimpleModel,
classifyTurnComplexity,
} from './smart-router.js';

// ── classifyTurnComplexity ──────────────────────────────────────────

test('returns simple for short, trivial prompts', t => {
t.is(classifyTurnComplexity('view file package.json'), 'simple');
t.is(classifyTurnComplexity('show status'), 'simple');
t.is(classifyTurnComplexity('what is line 5?'), 'simple');
});

test('returns simple for empty or whitespace-only input', t => {
t.is(classifyTurnComplexity(''), 'simple');
t.is(classifyTurnComplexity(' '), 'simple');
});

test('returns strong for a single code block', t => {
const prompt = 'Fix this:\n```js\nconsole.log("hello");\n```';
t.is(classifyTurnComplexity(prompt), 'strong');
});

test('returns strong for multiple code blocks', t => {
const prompt = [
'Compare these two:',
'```js',
'const a = 1;',
'```',
'and',
'```js',
'const b = 2;',
'```',
].join('\n');
t.is(classifyTurnComplexity(prompt), 'strong');
});

test('returns strong for complex keywords', t => {
t.is(
classifyTurnComplexity('Refactor authentication flow in login.tsx'),
'strong',
);
t.is(
classifyTurnComplexity('Architect a new state management system'),
'strong',
);
});

test('returns strong when no keyword matches (fail-safe default)', t => {
// "hello world" has no trivial or complex keywords — should default to strong
t.is(classifyTurnComplexity('hello world'), 'strong');
});

test('supports custom complex keywords', t => {
// "deploy" is NOT in the default complex list
t.is(classifyTurnComplexity('deploy to staging'), 'strong'); // no keyword → default strong
t.is(
classifyTurnComplexity('deploy to staging', {
customComplexKeywords: ['deploy'],
}),
'strong',
);
});

test('supports custom trivial keywords', t => {
// "inspect" is NOT in the default trivial list, so without custom it defaults to strong
t.is(classifyTurnComplexity('inspect log file'), 'strong');
t.is(
classifyTurnComplexity('inspect log file', {
customTrivialKeywords: ['inspect'],
}),
'simple',
);
});

test('respects word boundaries — does not match substrings', t => {
// "cat" is a trivial keyword, but "categories" should NOT trigger it.
// "categories" alone has no keyword match → defaults to strong.
t.is(classifyTurnComplexity('categories'), 'strong');
// But "cat the file" should match the trivial keyword "cat".
t.is(classifyTurnComplexity('cat the file'), 'simple');
});

test('respects threshold settings', t => {
const text = 'a'.repeat(150); // 150 chars, no keywords
t.is(classifyTurnComplexity(text, {threshold: 'high'}), 'strong'); // within 300 limit but no keyword → strong
t.is(classifyTurnComplexity(text, {threshold: 'low'}), 'strong'); // exceeds 100 limit → strong
});

test('long prompts are always strong regardless of keywords', t => {
const longTrivial = `view ${'a'.repeat(250)}`;
t.is(classifyTurnComplexity(longTrivial), 'strong');
});

// ── autoSelectSimpleModel ───────────────────────────────────────────

test('autoSelectSimpleModel finds lightweight models', t => {
const models = [
'gpt-4o',
'gpt-4o-mini',
'claude-3-5-sonnet-20241022',
'claude-3-5-haiku-20241022',
];
t.is(autoSelectSimpleModel(models), 'gpt-4o-mini');
});

test('autoSelectSimpleModel finds local lightweight models', t => {
const ollamaModels = ['llama3.3:70b', 'qwen2.5-coder:7b'];
t.is(autoSelectSimpleModel(ollamaModels), 'qwen2.5-coder:7b');
});

test('autoSelectSimpleModel falls back to first model when no pattern matches', t => {
const models = ['custom-model-a', 'custom-model-b'];
t.is(autoSelectSimpleModel(models), 'custom-model-a');
});

test('autoSelectSimpleModel returns undefined for empty list', t => {
t.is(autoSelectSimpleModel([]), undefined);
});

test('autoSelectSimpleModel supports custom patterns', t => {
const models = ['model-v1-custom-fast', 'model-v1-standard'];
t.is(
autoSelectSimpleModel(models, {
customLightweightPatterns: [/fast/i],
}),
'model-v1-custom-fast',
);
});
181 changes: 181 additions & 0 deletions source/ai-sdk-client/smart-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
export type ModelTier = 'simple' | 'strong';

export type SensitivityThreshold = 'low' | 'medium' | 'high';

export interface SmartRouteOptions {
threshold?: SensitivityThreshold;
/** Additional custom keywords to treat as complex/strong */
customComplexKeywords?: string[];
/** Additional custom keywords to treat as trivial/simple */
customTrivialKeywords?: string[];
/** Additional regex patterns for lightweight model detection */
customLightweightPatterns?: RegExp[];
}

export const DEFAULT_COMPLEX_KEYWORDS: readonly string[] = [
'refactor',
'architect',
'redesign',
'implement',
'rewrite',
'debug',
'security',
'performance',
'optimize',
'migration',
];

export const DEFAULT_TRIVIAL_KEYWORDS: readonly string[] = [
'hi',
'hey',
'thanks',
'thank you',
'ok',
'okay',
'yes',
'no',
'view',
'show',
'list',
'cat',
'read',
'check',
'find',
'where',
'what is',
'typo',
'format',
'status',
'help',
];

export const DEFAULT_LIGHTWEIGHT_MODEL_PATTERNS: readonly RegExp[] = [
/mini/i,
/haiku/i,
/flash/i,
/instant/i,
/lite/i,
/small/i,
/nano/i,
/8b/i,
/7b/i,
/3b/i,
/1b/i,
];

/**
* Count occurrences of fenced code blocks (``` ... ```) in the text.
*/
function countCodeBlocks(text: string): number {
const matches = text.match(/```/g);
// Each code block has an opening and closing fence
return matches ? Math.floor(matches.length / 2) : 0;
}

/**
* Helper to test word boundary matching (avoids false positives inside subwords).
* e.g. "cat" won't match inside "categories".
*/
function containsWord(text: string, word: string): boolean {
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`\\b${escaped}\\b`, 'i');
return regex.test(text);
}

/**
* Classifies turn complexity based on prompt heuristics and configurable options.
*
* Evaluation order:
* 1. Multiple code blocks (≥2) → always strong
* 2. Complex keyword match → strong
* 3. Prompt length exceeds threshold → strong
* 4. Single code block present → strong
* 5. Trivial keyword match → simple
* 6. No signal either way → default to strong (fail-safe)
*/
export function classifyTurnComplexity(
promptText: string,
options: SmartRouteOptions = {},
): ModelTier {
const text = promptText.trim();
const threshold = options.threshold ?? 'medium';

// Empty or whitespace-only input — nothing to reason about
if (text.length === 0) {
return 'simple';
}

const maxSimpleLength =
threshold === 'high' ? 300 : threshold === 'low' ? 100 : 200;

const codeBlockCount = countCodeBlocks(text);

// Rule 1: Multiple code blocks signal heavy edits / multi-file work
if (codeBlockCount >= 2) {
return 'strong';
}

// Merge default and custom keywords
const complexKeywords = [
...DEFAULT_COMPLEX_KEYWORDS,
...(options.customComplexKeywords ?? []),
];

const trivialKeywords = [
...DEFAULT_TRIVIAL_KEYWORDS,
...(options.customTrivialKeywords ?? []),
];

// Rule 2: Complex keyword detected
const hasComplexKeyword = complexKeywords.some(kw => containsWord(text, kw));
if (hasComplexKeyword) {
return 'strong';
}

// Rule 3: Prompt length exceeds threshold
if (text.length > maxSimpleLength) {
return 'strong';
}

// Rule 4: A single code block still warrants the strong model
if (codeBlockCount === 1) {
return 'strong';
}

// Rule 5: Trivial keyword detected → safe to use simple model
const hasTrivialKeyword = trivialKeywords.some(kw => containsWord(text, kw));
if (hasTrivialKeyword) {
return 'simple';
}

// Rule 6: No signal either way — default to strong to avoid
// sending ambiguous requests to an underpowered model.
return 'strong';
}

/**
* Automatically selects a suitable simple/fast model from a provider's list of available models.
* Allows custom regex patterns to extend the default lightweight model detection.
*/
export function autoSelectSimpleModel(
availableModels: string[],
options: SmartRouteOptions = {},
): string | undefined {
if (!availableModels || availableModels.length === 0) {
return undefined;
}

const patterns = [
...DEFAULT_LIGHTWEIGHT_MODEL_PATTERNS,
...(options.customLightweightPatterns ?? []),
];

for (const pattern of patterns) {
const match = availableModels.find(m => pattern.test(m));
if (match) {
return match;
}
}

return availableModels[0];
}
3 changes: 3 additions & 0 deletions source/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ export default function App({
onApiCallComplete: record =>
appState.setApiCallHistory(prev => [...prev, record]),
tune: appState.tune,
smartRouting: appState.smartRouting,
subagentsReady: appState.subagentsReady,
privacySessionMapRef: appState.privacySessionMapRef,
privacyEnabled: getPrivacyPreference(),
Expand Down Expand Up @@ -480,6 +481,8 @@ export default function App({
currentTheme: appState.currentTheme,
developmentMode: appState.developmentMode,
tune: appState.tune,
smartRouting: appState.smartRouting,
setSmartRouting: appState.setSmartRouting,
lastApiUsage: appState.lastApiUsage,
apiCallHistory: appState.apiCallHistory,
abortController: appState.abortController,
Expand Down
8 changes: 8 additions & 0 deletions source/app/utils/app-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from './handlers/create-handler';
import {handleRetryCommand} from './handlers/retry-handler';
import {handleResumeCommand} from './handlers/session-handler';
import {handleSmartRouteCommand} from './handlers/smartroute-handler';

/**
* "Special commands" need access to app-level state (setting modes, mutating
Expand Down Expand Up @@ -588,6 +589,13 @@ async function handleSlashCommand(
const commandParts = message.slice(1).trim().split(/\s+/);

if (await handleCompactCommand(commandParts, options)) return;
if (
await handleSmartRouteCommand(
commandParts,
options as Parameters<typeof handleSmartRouteCommand>[1],
)
)
return;
if (await handleContextMaxCommand(commandParts, options)) return;
if (await handleCommandCreate(commandParts, options)) return;
if (await handleAgentCreate(commandParts, options)) return;
Expand Down
Loading
Loading