diff --git a/package-lock.json b/package-lock.json index c9f9a68..38a4013 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@blockrun/llm": "^3.5.2", + "@blockrun/router-core": "https://codeload.github.com/BlockRunAI/router-core/tar.gz/6a790ebec60161825bbc8c4093fd221006b4e5fb", "@colbymchenry/codegraph": "^1.5.0", "@modelcontextprotocol/sdk": "^1.29.0", "@polymarket/builder-relayer-client": "^0.0.10", @@ -134,6 +135,15 @@ } } }, + "node_modules/@blockrun/router-core": { + "version": "0.1.0", + "resolved": "https://codeload.github.com/BlockRunAI/router-core/tar.gz/6a790ebec60161825bbc8c4093fd221006b4e5fb", + "integrity": "sha512-DZly4fJb8IKav+XWBJqUkHvD+EME78EgKaAiajd4zqfKNK4wB18/mjEP3Ydf79JEivf3VDjb3TmWrcOZivrexw==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@colbymchenry/codegraph": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colbymchenry/codegraph/-/codegraph-1.5.0.tgz", diff --git a/package.json b/package.json index f608d53..96db8a0 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ }, "dependencies": { "@blockrun/llm": "^3.5.2", + "@blockrun/router-core": "https://codeload.github.com/BlockRunAI/router-core/tar.gz/6a790ebec60161825bbc8c4093fd221006b4e5fb", "@colbymchenry/codegraph": "^1.5.0", "@modelcontextprotocol/sdk": "^1.29.0", "@polymarket/builder-relayer-client": "^0.0.10", diff --git a/src/agent/loop.ts b/src/agent/loop.ts index b979453..a6aa187 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -45,8 +45,8 @@ import { writeLiveAgent } from '../session/live-registry.js'; import { estimateCost, OPUS_PRICING } from '../pricing.js'; import { maybeMidSessionExtract } from '../learnings/extractor.js'; import { extractMentions, buildEntityContext, loadEntities } from '../brain/store.js'; -import { routeRequest, routeRequestAsync, resolveTierToModel, parseRoutingProfile, getFallbackChain, pickFreeFallback, isVisionModel, messageNeedsVision, pickVisionSibling } from '../router/index.js'; -import type { Tier, RoutingProfile } from '../router/index.js'; +import { routeRequest, parseRoutingProfile, getFallbackChain, pickFreeFallback, isVisionModel, messageNeedsVision, pickVisionSibling } from '../router/index.js'; +import type { Tier, RoutingProfile, RoutingResult } from '../router/index.js'; import { recordOutcome } from '../router/local-elo.js'; import { shouldPlan, getPlanningPrompt, getExecutorModel, isExecutorStuck, toolCallSignature } from './planner.js'; import { shouldVerify, runVerification } from './verification.js'; @@ -1041,6 +1041,11 @@ export async function interactiveSession( // the same threshold would flap on every iteration once crossed. let bloatCompactedThisTurn = false; let maxTokensOverride: number | undefined; + // Auto picks once for the whole user turn. Internal tool/planning rounds + // reuse the decision unless a real provider/payment failure deliberately + // switches config.model to a fallback. + let pinnedRouting: RoutingResult | undefined; + let routingCandidates: string[] = []; const turnIdleReference = lastSessionActivity; lastSessionActivity = Date.now(); @@ -1472,18 +1477,23 @@ export async function interactiveSession( const turnNeedsVision = loopCount === 1 && messageNeedsVision(lastUserInput); // ── Router: resolve routing profiles to concrete models ── - // Uses the tier already decided by the turn-analyzer — one LLM call - // up-front rather than a separate classifier here. Fallback to the - // stand-alone classifier if analyzer wasn't available. + // The shared Router is local/deterministic and runs once per user turn. + // The turn analyzer remains useful for planning and pushback, but no + // longer overrides model selection or adds a router-classifier call. const routingProfile = parseRoutingProfile(config.model); let resolvedModel = config.model; let routingTier: Tier | undefined; let routingConfidence: number | undefined; let routingSavings: number | undefined; if (routingProfile) { - const routing = turnAnalysis - ? resolveTierToModel(turnAnalysis.tier, routingProfile, turnNeedsVision) - : await routeRequestAsync(lastUserInput || '', routingProfile, undefined, turnNeedsVision); + const routing = pinnedRouting ?? routeRequest(lastUserInput || '', routingProfile, { + needsVision: turnNeedsVision, + maxOutputTokens: maxTokens, + hasTools: activeCapabilityMap.size > 0, + toolNames: [...activeCapabilityMap.keys()], + }); + pinnedRouting ??= routing; + routingCandidates = routing.candidates ?? [routing.model]; resolvedModel = routing.model; routingTier = routing.tier; routingConfidence = routing.confidence; @@ -1516,6 +1526,11 @@ export async function interactiveSession( }); } + // The pre-routing cap was calculated from the virtual profile name. + // Re-apply it to the concrete pick so capability filtering and the + // actual request use the same model output limit. + maxTokens = Math.min(maxTokensOverride ?? CAPPED_MAX_TOKENS, getMaxOutputTokens(resolvedModel)); + // Update token estimation model for more accurate byte-per-token ratio setEstimationModel(resolvedModel); @@ -1946,8 +1961,9 @@ export async function interactiveSession( const streak = (serverErrorsByModel.get(resolvedModel) ?? 0) + 1; serverErrorsByModel.set(resolvedModel, streak); if (streak >= SERVER_ERROR_STREAK_BEFORE_SWITCH) { - const fallbackChain = getFallbackChain(routingTier ?? 'MEDIUM', - parseRoutingProfile(config.model) ?? 'auto'); + const fallbackChain = routingCandidates.length > 0 + ? routingCandidates + : getFallbackChain(routingTier ?? 'MEDIUM', parseRoutingProfile(config.model) ?? 'auto'); const nextModel = fallbackChain.find(m => m !== resolvedModel && (serverErrorsByModel.get(m) ?? 0) < SERVER_ERROR_STREAK_BEFORE_SWITCH ); diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 0f1f56c..3ef7e64 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -25,7 +25,6 @@ import { import { routeRequest, parseRoutingProfile, - getFallbackChain as getRouterFallbackChain, isVisionModel, messagesNeedVision, pickVisionSibling, @@ -310,6 +309,7 @@ export function createProxy(options: ProxyOptions): http.Server { req.on('end', async () => { let requestModel = currentModel || options.modelOverride || 'unknown'; let usedFallback = false; + let routerCandidates: string[] = []; try { if (options.debug) logger.debug(`[franklin] request: ${req.method} ${req.url} currentModel=${currentModel || 'none'}`); @@ -387,11 +387,53 @@ export function createProxy(options: ProxyOptions): http.Server { } } - // Route the request — propagate vision-need so AUTO_TIERS' V4 - // Pro default doesn't get picked for an image-bearing turn. - const routing = routeRequest(promptText, routingProfile, proxyNeedsVision); + const toolNames = (Array.isArray(parsed.tools) ? parsed.tools : []) + .map((tool: { name?: string; function?: { name?: string } }) => tool.name ?? tool.function?.name) + .filter((name: unknown): name is string => typeof name === 'string'); + const toolChoice = parsed.tool_choice; + const toolChoiceType = typeof toolChoice === 'object' && toolChoice !== null + ? String(toolChoice.type) + : undefined; + const forbidsTools = toolChoice === 'none' || toolChoiceType === 'none'; + const requiresTools = !forbidsTools && (toolChoice === 'required' + || (toolChoiceType !== undefined + && ['any', 'tool', 'function'].includes(toolChoiceType))); + const explicitToolRequirement = forbidsTools + ? false + : requiresTools + ? true + : undefined; + const responseFormat = parsed.response_format; + const requiresStructuredOutput = !!responseFormat + && responseFormat.type !== undefined + && responseFormat.type !== 'text'; + const systemPrompt = typeof parsed.system === 'string' + ? parsed.system + : Array.isArray(parsed.system) + ? parsed.system + .filter((part: { type?: string }) => part.type === 'text') + .map((part: { text?: string }) => part.text ?? '') + .join('\n') + : undefined; + + // Same local Router core as ClawRouter. The proxy supplies + // concrete request capabilities; no classifier call is added. + const routing = routeRequest(promptText, routingProfile, { + needsVision: proxyNeedsVision, + maxOutputTokens: typeof parsed.max_tokens === 'number' + ? parsed.max_tokens + : DEFAULT_MAX_TOKENS, + hasTools: toolNames.length > 0, + toolNames, + ...(explicitToolRequirement !== undefined + ? { requiresTools: explicitToolRequirement } + : {}), + requiresStructuredOutput, + systemPrompt, + }); parsed.model = routing.model; requestModel = routing.model; + routerCandidates = routing.candidates ?? [routing.model]; logger.info( `[franklin] 🧠 Smart routing: ${routingProfile} → ${routing.tier} → ${routing.model} ` + @@ -488,7 +530,12 @@ export function createProxy(options: ProxyOptions): http.Server { if (fallbackEnabled && body && requestPath.includes('messages')) { const fallbackConfig: FallbackConfig = { ...DEFAULT_FALLBACK_CONFIG, - chain: buildFallbackChain(requestModel), + chain: [ + ...new Set([ + ...routerCandidates, + ...buildFallbackChain(requestModel), + ]), + ], }; const result = await fetchWithPaymentFallback( diff --git a/src/router/index.ts b/src/router/index.ts index 9d9c4ea..66e4080 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -12,6 +12,11 @@ import fs from 'node:fs'; import path from 'node:path'; +import { + DEFAULT_ROUTING_CONFIG as SHARED_ROUTING_CONFIG, + route as routeWithSharedCore, + type TaskType, +} from '@blockrun/router-core'; import { MODEL_PRICING, OPUS_PRICING } from '../pricing.js'; import { BLOCKRUN_DIR } from '../config.js'; import { detectCategory, mapCategoryToTier, type Category } from './categories.js'; @@ -55,6 +60,39 @@ export interface RoutingResult { signals: string[]; savings: number; category?: Category; + /** Ordered capability-eligible recovery chain. The selected model is first. */ + candidates?: string[]; + /** Explainable task class produced by the shared Router core. */ + taskType?: TaskType; + /** Shared Router implementation that made this decision. */ + routerVersion?: 'v2-rules' | 'v3-portfolio' | 'franklin-legacy'; + reasoning?: string; +} + +/** Request capabilities known by the Franklin host at routing time. */ +export interface RoutingContext { + needsVision?: boolean; + maxOutputTokens?: number; + hasTools?: boolean; + toolNames?: readonly string[]; + requiresTools?: boolean; + requiresStructuredOutput?: boolean; + systemPrompt?: string; +} + +const SHARED_MODEL_PRICING = new Map( + Object.entries(MODEL_PRICING).map(([model, pricing]) => [ + model, + { + inputPrice: pricing.input, + outputPrice: pricing.output, + ...(pricing.perCall !== undefined ? { flatPrice: pricing.perCall } : {}), + }, + ]), +); + +function normalizeRoutingContext(context: boolean | RoutingContext): RoutingContext { + return typeof context === 'boolean' ? { needsVision: context } : context; } // ─── Tier Model Configs ─── @@ -457,23 +495,27 @@ export async function llmClassifyRequest(prompt: string): Promise { } /** - * Async router — LLM classifier first, keyword classifier as fallback. - * Profile-specific tier tables (AUTO / ECO / PREMIUM / FREE) still pick - * the concrete model; the classifier only picks the TIER. + * Compatibility async router. Production Auto routing is local and delegates + * directly to the shared Router core, so it adds no classifier round trip. + * Tests and third-party integrations may still inject an explicit classifier; + * that legacy path remains available during the migration window. */ export async function routeRequestAsync( prompt: string, profile: RoutingProfile = 'auto', - classify: TierClassifier = llmClassifyRequest, - needsVision = false, + classify?: TierClassifier, + context: boolean | RoutingContext = false, ): Promise { - // Free / short-circuit profiles — no classifier needed. - if (profile === 'free') return routeRequest(prompt, profile, needsVision); + // The production path intentionally has no extra model call. Keeping the + // function async avoids breaking existing callers while removing router + // latency and classifier spend. + if (!classify || profile === 'free') return routeRequest(prompt, profile, context); + + const normalizedContext = normalizeRoutingContext(context); const tier = await classify(prompt).catch(() => null); if (!tier) { - // Classifier miss or disabled — fall through to the sync keyword router. - return routeRequest(prompt, profile, needsVision); + return routeRequest(prompt, profile, normalizedContext); } // Build a RoutingResult from the LLM-picked tier using the same tier @@ -481,7 +523,7 @@ export async function routeRequestAsync( let model: string; let finalTier: Tier = tier; const signals: string[] = ['llm-classified']; - if (needsVision) { + if (normalizedContext.needsVision) { const v = pickVisionTierModel(tier); model = v.model; finalTier = v.tier; @@ -554,25 +596,72 @@ export function resolveTierToModel( export function routeRequest( prompt: string, profile: RoutingProfile = 'auto', - needsVision = false, + context: boolean | RoutingContext = false, ): RoutingResult { + const normalizedContext = normalizeRoutingContext(context); + // Free profile — always use free model if (profile === 'free') { return { model: 'nvidia/qwen3-next-80b-a3b-instruct', tier: 'SIMPLE', confidence: 1.0, - signals: needsVision ? ['free-profile', 'vision-unsupported'] : ['free-profile'], + signals: normalizedContext.needsVision ? ['free-profile', 'vision-unsupported'] : ['free-profile'], savings: 1.0, + candidates: FREE_MODELS_BY_CATEGORY.chat, + }; + } + + // Emergency rollback for operators. This keeps the former Franklin rules + // available without making them the default or mixing their Elo state into + // the shared Router decision. + if (process.env.FRANKLIN_ROUTER_STRATEGY === 'legacy') { + return { + ...classicRouteRequest(prompt, profile, normalizedContext.needsVision), + routerVersion: 'franklin-legacy', }; } - // Auto profile bypasses learned routing. The learned Elo scores grow with - // usage volume rather than pure quality, which biased the router toward - // cheap/weak models on agentic work. Classic AUTO_TIERS defaults are - // agent-tuned (Sonnet-tier backbone) and more predictable for users. + // Auto now uses the same local, deterministic Router core as ClawRouter. + // Hard capability requirements filter candidates before portfolio scoring; + // no network request, wallet access, benchmark grader or settlement adapter + // runs in this path. if (profile === 'auto') { - return classicRouteRequest(prompt, profile, needsVision); + const toolNames = normalizedContext.toolNames ?? []; + const decision = routeWithSharedCore( + prompt, + normalizedContext.systemPrompt, + Math.max(1, normalizedContext.maxOutputTokens ?? 4_096), + { + config: { + ...SHARED_ROUTING_CONFIG, + strategy: process.env.FRANKLIN_ROUTER_STRATEGY === 'rules' ? 'rules' : 'portfolio', + }, + modelPricing: SHARED_MODEL_PRICING, + routingProfile: 'auto', + hasTools: normalizedContext.hasTools ?? toolNames.length > 0, + toolCount: toolNames.length, + toolNames, + ...(normalizedContext.requiresTools !== undefined + ? { requiresTools: normalizedContext.requiresTools } + : {}), + hasVision: normalizedContext.needsVision ?? false, + requiresStructuredOutput: normalizedContext.requiresStructuredOutput ?? false, + }, + ); + const category = detectCategory(prompt, loadLearnedWeights()?.category_keywords).category; + return { + model: decision.model, + tier: decision.tier, + confidence: decision.confidence, + signals: [decision.routerVersion ?? decision.method, ...(decision.taskType ? [decision.taskType] : [])], + savings: decision.savings, + category, + candidates: decision.candidates ?? [decision.model], + taskType: decision.taskType, + routerVersion: decision.routerVersion, + reasoning: decision.reasoning, + }; } // ── Learned routing (if weights available) ── @@ -605,7 +694,7 @@ export function routeRequest( // the turn needs vision, swap to the tier's first vision-capable model. // We deliberately don't blend Elo with vision capability — vision is a // hard requirement, not a quality dimension. - if (needsVision && !isVisionModel(selected.model)) { + if (normalizedContext.needsVision && !isVisionModel(selected.model)) { const v = pickVisionTierModel(tier); return { model: v.model, @@ -630,7 +719,7 @@ export function routeRequest( } // ── Classic routing (keyword-based fallback) ── - return classicRouteRequest(prompt, profile, needsVision); + return classicRouteRequest(prompt, profile, normalizedContext.needsVision); } function computeSavings(model: string): number { diff --git a/src/router/vision.ts b/src/router/vision.ts index d8d20f8..de508e4 100644 --- a/src/router/vision.ts +++ b/src/router/vision.ts @@ -53,10 +53,12 @@ const VISION_MODELS = new Set([ // xAI — only Grok 4 base supports vision; grok-4-1-fast-reasoning is text-only 'xai/grok-4-0709', 'xai/grok-3', - // Moonshot — K3 (flagship, 2026-07) is multimodal (image + text input); - // gateway catalog lists it under the "vision" category. Replaced the - // retired K2.7/K2.6 line. + // Moonshot — K3 is the Solana flagship; the K2.x compatibility line remains + // routable on both gateways and is also catalogued as multimodal. 'moonshot/kimi-k3', + 'moonshot/kimi-k2.7', + 'moonshot/kimi-k2.6', + 'moonshot/kimi-k2.5', // NVIDIA inference — Nemotron Nano VL is multimodal; deepseek/qwen-coder are // not. Llama 4 Maverick dropped 2026-07-14: it left the gateway catalog, and // listing it here contradicted routeRequest()'s own "maverick is text-only" diff --git a/test/e2e.mjs b/test/e2e.mjs index b02ec36..6d03d28 100644 --- a/test/e2e.mjs +++ b/test/e2e.mjs @@ -163,9 +163,9 @@ function parseTokenCount(raw) { // ─── Tests ───────────────────────────────────────────────────────────────── -test('startup: banner on stdout and model line on stderr', { timeout: 10_000 }, async () => { +test('startup: banner on stdout and model line on stderr', { timeout: 20_000 }, async () => { // Startup should be observable without waiting on a model response. - const { stdout, stderr } = await franklin('/exit', { timeoutMs: 10_000 }); + const { stdout, stderr } = await franklin('/exit', { timeoutMs: 20_000 }); assert.ok( stdout.includes('blockrun.ai') && stdout.includes('The AI agent with a wallet'), `Missing banner tagline. stdout:\n${stdout}` diff --git a/test/local.mjs b/test/local.mjs index d79eb1b..48dcda1 100644 --- a/test/local.mjs +++ b/test/local.mjs @@ -421,6 +421,149 @@ test('proxy server handles OPTIONS and local model switching without backend cal } }); +test('proxy Auto routes through the shared portfolio before forwarding', async () => { + const originalHome = process.env.HOME; + const fakeHome = mkdtempSync(join(tmpdir(), 'rc-proxy-auto-home-')); + const proxyUrl = new URL('../dist/proxy/server.js', import.meta.url); + const routerUrl = new URL('../dist/router/index.js', import.meta.url); + const forwarded = []; + const backend = createServer(async (req, res) => { + let raw = ''; + for await (const chunk of req) raw += chunk.toString(); + forwarded.push(JSON.parse(raw)); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + id: 'msg_auto_router', + type: 'message', + role: 'assistant', + model: forwarded.at(-1)?.model, + content: [{ type: 'text', text: 'auto routed' }], + stop_reason: 'end_turn', + usage: { input_tokens: 4, output_tokens: 2 }, + })); + }); + + let proxy; + try { + process.env.HOME = fakeHome; + const backendPort = await listenOnRandomPort(backend); + const { createProxy } = await import(`${proxyUrl.href}?t=${Date.now()}`); + const { routeRequest } = await import(`${routerUrl.href}?t=${Date.now()}`); + proxy = createProxy({ + port: 0, + apiUrl: `http://127.0.0.1:${backendPort}`, + chain: 'base', + modelOverride: 'blockrun/auto', + fallbackEnabled: false, + }); + const proxyPort = await listenOnRandomPort(proxy); + const prompt = 'Inspect the repository, fix the failing tests, and verify the result.'; + const systemPrompt = 'You are Franklin, a coding agent.'; + const toolNames = ['Read', 'Edit', 'Bash']; + const expected = routeRequest(prompt, 'auto', { + hasTools: true, + toolNames, + requiresTools: true, + maxOutputTokens: 8_192, + systemPrompt, + }); + + const response = await fetch(`http://127.0.0.1:${proxyPort}/api/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'blockrun/auto', + system: systemPrompt, + messages: [{ role: 'user', content: prompt }], + tools: toolNames.map((name) => ({ name, input_schema: { type: 'object' } })), + tool_choice: { type: 'any' }, + max_tokens: 8_192, + }), + }); + + assert.equal(response.status, 200, `Expected Auto proxy response 200, got ${response.status}`); + assert.equal(forwarded.length, 1, 'Auto should make one serving request'); + assert.equal(forwarded[0].model, expected.model); + assert.notEqual(forwarded[0].model, 'blockrun/auto'); + assert.equal(expected.routerVersion, 'v3-portfolio'); + assert.equal(expected.taskType, 'tool_agent'); + } finally { + if (proxy) await new Promise((resolve) => proxy.close(() => resolve())); + await new Promise((resolve) => backend.close(() => resolve())); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + rmSync(fakeHome, { recursive: true, force: true }); + } +}); + +test('proxy Auto honors tool_choice none as a hard no-tool constraint', async () => { + const originalHome = process.env.HOME; + const fakeHome = mkdtempSync(join(tmpdir(), 'rc-proxy-auto-none-home-')); + const proxyUrl = new URL('../dist/proxy/server.js', import.meta.url); + const routerUrl = new URL('../dist/router/index.js', import.meta.url); + let forwardedModel = ''; + const backend = createServer(async (req, res) => { + let raw = ''; + for await (const chunk of req) raw += chunk.toString(); + forwardedModel = JSON.parse(raw).model; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + id: 'msg_auto_no_tools', + type: 'message', + role: 'assistant', + model: forwardedModel, + content: [{ type: 'text', text: 'no tools' }], + stop_reason: 'end_turn', + usage: { input_tokens: 4, output_tokens: 2 }, + })); + }); + + let proxy; + try { + process.env.HOME = fakeHome; + const backendPort = await listenOnRandomPort(backend); + const { createProxy } = await import(`${proxyUrl.href}?t=${Date.now()}`); + const { routeRequest } = await import(`${routerUrl.href}?t=${Date.now()}`); + proxy = createProxy({ + port: 0, + apiUrl: `http://127.0.0.1:${backendPort}`, + chain: 'base', + modelOverride: 'blockrun/auto', + fallbackEnabled: false, + }); + const proxyPort = await listenOnRandomPort(proxy); + const prompt = 'Cancel my flight booking and refund the ticket.'; + const expected = routeRequest(prompt, 'auto', { + hasTools: true, + toolNames: ['CancelBooking'], + requiresTools: false, + maxOutputTokens: 4_096, + }); + + const response = await fetch(`http://127.0.0.1:${proxyPort}/api/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'blockrun/auto', + messages: [{ role: 'user', content: prompt }], + tools: [{ name: 'CancelBooking', input_schema: { type: 'object' } }], + tool_choice: { type: 'none' }, + max_tokens: 4_096, + }), + }); + + assert.equal(response.status, 200, `Expected no-tool Auto response 200, got ${response.status}`); + assert.equal(forwardedModel, expected.model); + assert.notEqual(expected.taskType, 'tool_agent'); + } finally { + if (proxy) await new Promise((resolve) => proxy.close(() => resolve())); + await new Promise((resolve) => backend.close(() => resolve())); + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + rmSync(fakeHome, { recursive: true, force: true }); + } +}); + test('proxy server falls back when the paid BlockRun request times out', async () => { const originalHome = process.env.HOME; const fakeHome = mkdtempSync(join(tmpdir(), 'rc-proxy-timeout-home-')); @@ -5724,6 +5867,39 @@ test('router LLM classifier also returns a real local-elo category', async () => assert.ok(routing.signals.includes('llm-classified')); }); +test('router v3.4: Auto uses the shared local portfolio with hard request capabilities', async () => { + const { routeRequest, routeRequestAsync, isVisionModel } = await import('../dist/router/index.js'); + + const agent = routeRequest( + 'Inspect the repository, fix the failing tests, and verify the result.', + 'auto', + { + hasTools: true, + toolNames: ['Read', 'Edit', 'TerminalExec'], + maxOutputTokens: 8_192, + }, + ); + assert.equal(agent.routerVersion, 'v3-portfolio'); + assert.equal(agent.taskType, 'tool_agent'); + assert.ok(agent.candidates.length > 1, 'Auto should expose an ordered recovery chain'); + assert.equal(agent.candidates[0], agent.model, 'selected model must lead the recovery chain'); + + const vision = routeRequest('Describe this screenshot.', 'auto', { needsVision: true }); + assert.equal(vision.taskType, 'vision'); + assert.ok(isVisionModel(vision.model), `vision hard filter selected ${vision.model}`); + + const structured = routeRequest('Extract these fields.', 'auto', { + requiresStructuredOutput: true, + }); + assert.equal(structured.taskType, 'extraction'); + assert.equal(structured.routerVersion, 'v3-portfolio'); + + // No explicit classifier means the async compatibility entry delegates to + // the same synchronous local core. This must not create a second LLM call. + const asyncLocal = await routeRequestAsync('Fix the test suite.', 'auto'); + assert.equal(asyncLocal.routerVersion, 'v3-portfolio'); +}); + test('router: legacy eco/premium profile strings still parse to auto', async () => { // Eco / Premium routing profiles were retired 2026-05-03 — Auto now spans // the cost/quality range that Eco and Premium used to split. Old configs