ClawRouter, Franklin, Hermes and dsh-clawrouter look like four different products.
They make the same routing decision, because they all run this package.
One engine. No inference call. Same answer everywhere.
Local, deterministic, and product-neutral — no wallet, no gateway, no network on the hot path.
@blockrun/router-coredecides which model should answer a request. It classifies the request across 15 local dimensions, removes every model that cannot satisfy the request contract, ranks only the survivors on task fit, cost, speed and reliability, and returns the winner plus an ordered fallback chain — in about a quarter of a millisecond, with no inference call and no network access.It is deliberately product-neutral. There is no wallet, no gateway client, no proxy server, no agent loop, no payment handling, no telemetry transport, and no benchmark runner in this repository. Those live in the products. This is the part they share.
Routing logic that lives inside a product gets shaped by that product. Four different products with four copies of "pick a model" is four different answers to the same question, four sets of stale model tables, and four places to fix a routing bug.
So the engine was extracted. Every product that routes a request now routes it here, and a fix lands once.
The constraint-first ordering is the design commitment worth naming: hard requirements decide who may compete, scoring decides who wins. A model that cannot call tools, cannot read an image, or cannot hold the conversation is dropped before anything is scored — because being cheap or fast never compensates for failing the contract.
The banner above is the shape of it; the mechanism differs per package, and that difference is what decides how an upgrade propagates.
| Product | How it reaches Router Core | Where to look |
|---|---|---|
| ClawRouter | Direct dependency. src/router/index.ts is a single export * from "@blockrun/router-core" — the product keeps its public router surface, the logic is all here. |
@blockrun/clawrouter |
| Franklin | Direct dependency. Imports route() and DEFAULT_ROUTING_CONFIG, then layers its own learned Elo weights on top of the shared decision. |
@blockrun/franklin |
| blockrun-llm-ts | Direct dependency. src/router-adapter.ts wires this engine to smartChat() and the blockrun/auto · blockrun/eco · blockrun/premium aliases. |
@blockrun/llm |
| blockrun-llm | Line-by-line Python port under blockrun_llm/router_core/, pinned to an upstream commit of this repo. A _js.py shim reproduces JavaScript toFixed, Date.parse and ASCII regex semantics so an identical request routes identically in both languages. |
blockrun-llm (PyPI) |
| ClawRouter-Hermes | Indirect. The plugin supervises a local @blockrun/clawrouter proxy, so every Hermes request is routed by this engine one process away. |
hermes-plugin-clawrouter |
| dsh-clawrouter | Indirect. Built on @blockrun/llm, which carries Router Core with it. |
dsh-clawrouter (npm) |
The Go SDK (blockrun-llm-go) implements the same four-tier profile contract independently. It is not a port of this package, and its decisions are not guaranteed to match.
This package is not published to npm. Consumers install it straight from a commit tarball, which pins the routing engine to an exact, auditable revision:
A tarball install runs no build step, so dist/ is committed to this repository and CI fails the build when it drifts from source. Upgrading a consumer means bumping one SHA — and because the SHA is in the lockfile, a routing change can never arrive unnoticed.
Node ≥ 20.19. Zero runtime dependencies.
import { DEFAULT_ROUTING_CONFIG, route } from "@blockrun/router-core";
const decision = route(prompt, systemPrompt, maxOutputTokens, {
config: DEFAULT_ROUTING_CONFIG,
modelPricing, // Map<modelId, { inputPrice, outputPrice }> per 1M tokens
hasTools: true, // tools are attached to this request
requiresTools: true, // ...and this turn actually has to use them
});
decision.model; // the model that should serve the request
decision.candidates; // ordered fallback chain — walk it on a 5xx or timeout
decision.reasoning; // human-readable explanation of the pickReal output from the bundled defaults (node on the committed dist/):
// route("What is the capital of France?", undefined, 256, { ... })
{
"model": "google/gemini-2.5-flash",
"tier": "SIMPLE",
"taskType": "chat",
"profile": "auto",
"confidence": 0.77,
"candidates": ["google/gemini-2.5-flash", "google/gemini-3-flash-preview", "deepseek/deepseek-chat", "…"],
"reasoning": "score=-0.10 | short (8 tokens), simple (what is, capital of) | v3 task=chat agentRisk=standard … candidates=9"
}
// route("Prove that the sum of two odd integers is even, step by step.", undefined, 4096, { ... })
{
"model": "deepseek/deepseek-v4-pro",
"tier": "REASONING",
"taskType": "reasoning",
"confidence": 0.97,
"candidates": ["deepseek/deepseek-v4-pro", "xai/grok-4-1-fast-reasoning", "xai/grok-4-fast-reasoning", "…"]
}
// same request, tools attached: "Cancel order B-42 and book the 9am flight to SFO."
{
"model": "anthropic/claude-opus-4.8",
"taskType": "tool_agent_parallel",
"profile": "agentic",
"reasoning": "… | agentic (tools) | v3 task=tool_agent_parallel agentRisk=high … candidates=12"
}Measured cost of a decision: ~0.05 ms warm, ~0.15 ms including JIT warm-up (mixed prompt shapes up to 33KB, Node 22, M-series laptop — feature extraction is bounded, so a 400KB prompt still routes in under 0.1 ms). No process leaves the machine.
Four stages, all local, all deterministic for identical inputs, config, model metadata and time.
A 15-dimension weighted scorer maps the request onto a capability tier, and a task classifier labels the shape of the work.
tokenCount · codePresence · reasoningMarkers · technicalTerms · creativeMarkers
simpleIndicators · multiStepPatterns · questionComplexity · imperativeVerbs
constraintCount · outputFormat · referenceComplexity · negationComplexity
domainSpecificity · agenticTask
Alongside the tier, the router derives task shape and risk from the prompt, the visible tool names and tool count, requested output size, and language: chat, extraction, code_edit, code_agent, tool_agent, tool_agent_parallel, debug, reasoning, reasoning_mcq, reasoning_math, long_context, vision.
This is not an LLM classifier on the hot path. Every feature is local, bounded, testable, and available before inference.
Candidates that cannot satisfy the request are removed before anything is scored:
- no tool-calling support for a tool-required request
- no vision support for image input
- insufficient context window for the conversation
- insufficient max-output capacity for the requested length
- incompatible structured-output path
- absent from the active model catalog
Capability errors never enter the preference stage. This is what "constraint-first" means, and it is why the router fails closed rather than optimistically cheap.
Six bounded factors, per profile. Auto's defaults:
| Component | Auto weight | Role |
|---|---|---|
| Task quality | 0.47 | Prefer models validated for the detected work |
| Capability | 0.20 | Preserve the request contract after hard filtering |
| Estimated cost | 0.18 | Reward efficient qualified candidates |
| Speed | 0.07 | Improve ordinary interactive latency |
| Reliability | 0.03 | Prefer stable candidates |
| Curated order | 0.05 | Retain a small, explainable prior |
Eco raises the cost weight; Premium raises quality and reliability. High-stakes and latency-sensitive requests shift the balance without changing eligibility. An affinityFloorGap stops a candidate materially below the best task affinity from winning on price alone.
Everything that survived filtering stays, in rank order, as decision.candidates. Hosts walk that chain on a timeout or 5xx. The winner is chosen once — there is no mid-task model drift and no auxiliary routing request.
Four capability tiers — SIMPLE, MEDIUM, COMPLEX, REASONING — crossed with four routing profiles:
| Profile | Intent |
|---|---|
eco |
Cheapest capable model; the first stop is the free tier, so simple requests can cost $0.00 |
auto |
Default. Best balance of cost and quality |
premium |
Quality-critical work |
agentic |
Auto-selected when the turn actually needs tools; prefers models that keep going instead of stopping to ask |
Every decision is explainable: tier, taskType, confidence, ranked candidates, per-candidate candidateScores (quality / cost / speed / reliability) and a reasoning string.
Two independent evidence streams feed this repository, and they answer different questions.
Routing quality is measured on full agent sessions, not prompt labels, because the only question that matters is whether the selected model completes the task. Three public benchmark families, run through one host framework with their own validators:
| Source family | Share of strict cohort | What it measures |
|---|---|---|
| τ-bench family | 55% | Stateful tool use under domain policies |
| BrowseComp | 25% | Multi-hop web research ending in an exact answer |
| Terminal-Bench | 20% | End-to-end terminal and repository work |
Three arms per task triple — previous rules router, this constraint-first router, and fixed flagship — sharing a frozen model catalog, pricing snapshot, tool surface and scorer.
V3.4 checkpoint: verified task success 49% → 57% (+8 points). Normalized cost per successful task fell 6.4%. Against pinning the flagship on every task, the router used 8.9% of the normalized token cost while giving up 10 points of success.
And the limits, because a benchmark that only publishes wins is not evidence: the paired 95% interval on the quality gain is −1.9 to +15.5 points and crosses zero; the router is not statistically proven better across the production distribution; it does not match flagship quality; p95 session latency regressed. The machine-readable scorecard records releaseEligible: false.
📄 Full method, figures and the four failure classes: A Constraint-First Model Router for AI Agents
model-profiles.generated.json carries speed and reliability observations refreshed from BlockRun's gateway benchmark and published live at blockrun.ai/observatory:
"openai/gpt-5.3-codex": {
"measuredAt": "2026-07-21T10:21:31Z",
"latencyMs": 4617.1,
"p95LatencyMs": 5800.7,
"outputTokensPerSecond": 12.48,
"errorRate": 0,
"samples": 3
}Two rules govern this file, and both are load-bearing:
- These are weak priors, never task-quality labels. They inform the
speedandreliabilityterms only. A model is not "better" because it is fast. - Historical numbers are never presented as a current provider SLA. Hosts are expected to inject fresher observations (see below); the committed snapshot exists so the engine is safe and useful when a catalog is temporarily unavailable.
The repository ships 30 live profiles plus 13 auditable historical seeds, and a built-in capability snapshot for 46 models.
The engine never makes a network call. Everything current is injected by the host, which keeps routing on the hot path while still tracking a catalog that moves faster than this package releases.
const decision = route(prompt, systemPrompt, maxOutputTokens, {
config: DEFAULT_ROUTING_CONFIG,
modelPricing, // required — current prices from your catalog
modelCapabilities, // optional — overrides the built-in 46-model snapshot
modelPerformance, // optional — fresh speed/reliability, e.g. the Observatory feed
routingProfile: "auto",
hasTools, toolCount, toolNames, requiresTools,
hasVision,
requiresStructuredOutput,
unavailableModels, // optional — models the host observed dead at the gateway (400/410)
now, // optional — override time for promotion-window tests
});Wiring the live health feed to modelPerformance is the intended production setup:
import { LIVE_MODEL_PROFILES } from "@blockrun/router-core";
// The same feed the Observatory renders: per-model p50/p95/p99, uptime and
// error rate over a 24h window.
const { timestamp, models } = await fetch("https://blockrun.ai/api/v1/health/models").then((r) =>
r.json(),
);
const modelPerformance = Object.fromEntries(
models
// `synthetic: true` means "no real traffic, showing a default" — those rows
// carry zero latency and would poison the speed term. Skip them entirely
// and let the committed prior stand in.
.filter((m) => !m.synthetic && m.callCount24h > 0)
.map((m) => [
m.model,
{
measuredAt: timestamp,
latencyMs: m.latency.avg,
p95LatencyMs: m.latency.p95,
// This feed measures latency, not throughput. `outputTokensPerSecond`
// is required and feeds half the speed term, so carry the committed
// prior forward rather than sending a 0 that reads as "slowest model".
outputTokensPerSecond: LIVE_MODEL_PROFILES[m.model]?.outputTokensPerSecond ?? 100,
errorRate: m.errorRate24h,
samples: m.callCount24h,
},
]),
);Freshness is priced in: observations decay on a 30-day half-life and are down-weighted below ten samples, so three quick probes can nudge a ranking but cannot overturn a curated order on a transient provider tail.
Omitted entries fall back to a weak historical prior rather than disqualifying a model — the router degrades, it does not fail.
| Export | Purpose |
|---|---|
route(prompt, systemPrompt, maxOutputTokens, options) |
The entry point. Returns a RoutingDecision. |
DEFAULT_ROUTING_CONFIG |
Router Core V3.4 config: tiers, profiles, weights, promotions. |
DEFAULT_MODEL_CAPABILITIES |
Built-in capability snapshot (context, max output, tools, vision). |
LIVE_MODEL_PROFILES / HISTORICAL_MODEL_PROFILES |
Performance priors. |
PortfolioStrategy / RulesStrategy |
V3 portfolio scorer and the stable V2 rules selector. |
getStrategy / registerStrategy |
Swap in your own RouterStrategy. |
classifyByRules |
Tier classification on its own. |
inferToolRequirement |
Whether a turn actually needs the attached tools. |
getFallbackChain / getFallbackChainFiltered |
Ordered recovery chains. |
applyUnavailableModels |
Drop host-declared-dead models from a tier map, promoting surviving rungs. |
filterByToolCalling / filterByVision / filterByExcludeList / filterCandidatesByCapacity |
The hard filters, individually. |
calculateModelCost |
Cost estimate for a model and token count. |
- One-line rollback.
config.strategy = "rules"reverts to the V2 tier selector without a code change. - Shadow evaluation.
config.shadow = { strategy: "rules", sampleRate: 0.1 }recomputes a comparison decision locally without changing the model that serves the request. The host emits decision metadata only — it never persists prompt content and never makes a second call. - Promotions. Time-windowed
tierOverridesthat apply themselves inside their date range and are ignored outside it, so a launch promo needs no release. - Dead-rung kill-switch.
options.unavailableModelshard-removes models the host has observed dead at the gateway (a 400/404/410 on a direct call, a provider EOL) from every chain before selection — never restored by an eligibility fail-open. Effective on the next request, no core release or consumer repin required; the committed chains then catch up in their own time. - Custom strategies. Implement
RouterStrategy,registerStrategy(yours), pointconfig.strategyat it.
npm ci
npm run typecheck # tsc --noEmit
npm test # vitest — 104 tests across 6 files
npm run build # tsup → dist/
npm run check # all three, and what CI runsThree rules keep consumers safe:
dist/is committed and must match source. Tarball installs run no build step, so a source edit without a rebuild would silently ship a stale artifact to every pinned consumer. CI runsgit diff --exit-code -- dist.- Routing changes need test coverage. The decision path is the product for six downstream packages;
portfolio.test.ts,selector.test.ts,strategy.test.tsandtool-intent.test.tsare the contract. - Refactors must not move a decision.
decisions.snapshot.test.tsroutes a frozen 88-request corpus (22 prompts × 4 profiles, mixed tool/vision/structured-output shapes) and compares full decisions byte-for-byte againstdecisions.snapshot.json. A behavior-preserving change leaves it green untouched; a deliberate routing change regenerates it withUPDATE_DECISION_SNAPSHOT=1 npm test, and the fixture diff in review shows exactly which requests moved.
When the routing logic changes, the Python port has to follow — it is pinned to an upstream commit and its tests assert cross-language parity.
|
The routing engine underneath all of it You're here. Classify, filter, rank — locally, deterministically, in under a millisecond.
|
The LLM router built for autonomous agents Wallet signatures instead of API keys, USDC per request instead of credit cards. This engine, wrapped in a proxy an agent can actually use.
|
💵 FranklinThe AI agent with a wallet Spends USDC autonomously to get real work done. Routes with this engine, then personalizes with learned Elo weights.
|
📦 SDKsTypeScript · Python
|
|
ClawRouter for NousResearch Hermes Supervises the ClawRouter proxy for
|
A second brain for DeepSeek Harness A stronger model reviews the dangerous command before it runs. Built on the TypeScript SDK.
|
| Resource | What it covers |
|---|---|
| Constraint-first router report | Full V3.4 method, figures, statistics and limits |
| Observatory | Live model latency, p95, uptime and error rate |
| Model pricing | Current catalog and prices |
| Routing profiles | Eco / Auto / Premium in product terms |
| 15-dimension classifier | How the scorer reads a request |
| BlockRun docs | Gateway, payments, everything else |
Can I use this without BlockRun?
Yes. It is MIT-licensed and has no BlockRun dependency — you supply modelPricing and, optionally, modelCapabilities. The default config happens to be tuned against BlockRun's catalog; replace it and the engine routes across yours.
Does it call an LLM to decide? No. There is no classifier model, no network access, and no I/O on the decision path. A warm decision costs ~0.05 ms.
Is it deterministic?
Yes, for identical inputs, configuration, model metadata and time. options.now exists so time-windowed promotions are testable.
Why isn't it on npm? Commit-tarball installs pin the routing engine to an exact revision in the consumer's lockfile. Six packages depend on this decision path; a routing change should never arrive as a silent patch bump.
Why is dist/ in the repository?
Because a tarball install runs no build step. CI verifies it matches source on every push.
MIT licensed · Built by BlockRun