Skip to content
Merged
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
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 26 additions & 10 deletions src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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
);
Expand Down
57 changes: 52 additions & 5 deletions src/proxy/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
import {
routeRequest,
parseRoutingProfile,
getFallbackChain as getRouterFallbackChain,
isVisionModel,
messagesNeedVision,
pickVisionSibling,
Expand Down Expand Up @@ -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'}`);
Expand Down Expand Up @@ -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} ` +
Expand Down Expand Up @@ -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(
Expand Down
127 changes: 108 additions & 19 deletions src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 ───
Expand Down Expand Up @@ -457,31 +495,35 @@ export async function llmClassifyRequest(prompt: string): Promise<Tier | null> {
}

/**
* 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<RoutingResult> {
// 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
// tables the keyword path uses. Keeps downstream code path-identical.
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;
Expand Down Expand Up @@ -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) ──
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
Loading
Loading