Skip to main content
Open Source ★ Featured Open Source

anvil-serving: Quality-Gated Local Model Routing

Impact Summary

I built anvil-serving as the model-routing companion to Anvil: one endpoint for Claude Code, Codex, Aider, OpenClaw, and OpenAI or Anthropic-compatible clients, backed by measured per-model and per-work-class quality profiles, local verification, and explicit cloud boundaries. The current source checkout declares version 0.10.0, with a stdlib-only Python hot path and zero required runtime dependencies.

Role

Creator & Maintainer

Timeline

2026-Present

Scale

  • Source checkout declares version 0.10.0
  • 0 required runtime dependencies
  • Anthropic Messages and OpenAI Chat Completions front doors
  • 5 intent presets and 6 work-class categories

Links

Decision Summary

Problem
Coding harnesses can point at a local model, but most routers decide by static model names, costs, or regexes. That is not enough for agent work. A model can return clean JSON that is still semantically wrong, and a bad plan can poison an entire run without throwing an error.
Constraints
  • Must work with unmodified coding harnesses through fields they already forward
  • Local tiers must not stream partial failed output before verification
  • Default behavior must not silently spend metered cloud tokens
  • Quality claims must distinguish measured data from seeded calibration
  • The hot path must stay small enough to audit
Tradeoffs Considered
Quality profile plus verify-and-fallback router (chosen) Chosen
Pros
  • + Routes by measured capability, not static provider preference
  • + Keeps local useful where it has earned trust
  • + Falls back before failed local output reaches the harness
  • + Works through standard Anthropic and OpenAI-compatible wire shapes
Cons
  • Requires calibration work before a local tier can be trusted for high-stakes classes
  • Adds a router that must be operated alongside the model serves
Generic proxy with model aliases
Pros
  • + Simple to configure and easy to understand
  • + Already familiar from existing LLM gateway tools
Cons
  • Cannot tell structurally valid output from semantically wrong output
  • Does not learn which local model can handle which work class
  • Can silently route planning work to a model that has not earned it
Put routing inside Anvil itself
Pros
  • + Would keep project state and model routing under one brand
  • + Could reuse Anvil task metadata directly
Cons
  • Wrong integration point: the traffic flows through coding harnesses, not the ledger
  • Would make Anvil less runtime-neutral
  • Would not help Claude Code, Codex, Aider, or OpenClaw unless each harness called Anvil first
Outcome
Measured the planning gap directly: local outputs were structurally valid at least 92 percent of the time, but blind-judge quality landed at 16.0/25 and 13.25/25 versus 24.75/25 for frontier
Kept Kept planning cloud-default until local models earn the class with fresh measurement
Preserved Preserved a zero-metered-cloud default by returning clean exhaustion instead of silently spending against an API key
Kept Kept the request path small and auditable with a stdlib-only Python package and no required runtime dependencies
Supporting Artifact
📄
Quality-gated router design
doc
View →

Problem

The result that changed the product was not a crash. It was worse than a crash.

I took Anvil’s real PRD-to-tasks planning prompt, sent it through two local models and a frontier model, then put the outputs in front of a blind judge. The local outputs were structurally valid at least 92 percent of the time. Clean JSON. Parseable task lists. No dangling references that a schema check would reject. Then the quality scores landed: frontier at 24.75 out of 25, fast local at 16.0, heavy local at 13.25.

The gap was dependency ordering. Frontier got it right. Local mostly did not.

That is the failure mode anvil-serving exists to catch. A coding agent plan with tasks in the wrong order does not necessarily fail when it is generated. It fails later, after the harness has already trusted it, claimed work, edited files, and spent context. A generic proxy cannot see that. A generic proxy can move tokens, route aliases, and retry on transport errors. It does not know whether local can do this class of work on this operator’s workload.

Anvil and anvil-serving are adjacent, but they are not the same layer. Anvil is the durable system of record: requirements, tasks, claims, leases, evidence. anvil-serving sits in front of coding harnesses and answers a different question: which model should be allowed to do this work right now?

Approach

Intent Travels Through the Model Field

The awkward constraint became the API. Most coding harnesses already forward a model field. They do not all forward custom routing metadata, and they do not agree on a native intent schema. So anvil-serving treats the model field as an intent channel.

Instead of asking for qwen35-awq-local or gpt-oss-20b, a caller can ask for:

planning
quick-edit
review
chat
long-context

Those presets can be bare or namespaced as anvil/planning. The router expands the preset into constraints: context length, privacy posture, structured-output needs, tool support, and cost ceiling. A direct model pin still exists for reproduction and debugging, but the normal surface is intent. The caller says what kind of work this is. The router owns which backend is currently trusted for it.

That choice matters because models churn. Hardware changes. A quant swap can invalidate yesterday’s confidence. The preset vocabulary stays stable while the tier behind it can move from fast-local to heavy-local to cloud based on measurement.

Filter, Then Rank

The routing rule is deliberately boring: filter by hard constraints first, then rank by the quality profile. A model that cannot fit the context, violates a privacy requirement, or lacks the needed wire behavior is out before quality scoring starts.

The quality profile is the product. It is keyed by model and work class, with decisions like allow, allow-with-verify, and deny. Planning is the clean example. The hard eval says local was structurally valid but weak on dependency reasoning, so planning stays cloud-default until fresh measurement proves otherwise. That is not pessimism. It is engineering discipline.

Here is the shape of the operator contract:

[router]
profile_path = "./profile.candidate.json"
exhaustion_status = 503

[router.metered_cloud]
planning = true
review = true

[[tiers]]
id = "fast-local"
privacy = "local"
provider = "openai-compatible"
base_url = "http://127.0.0.1:30001/v1"

[[tiers]]
id = "heavy-local"
privacy = "local"
provider = "openai-compatible"
base_url = "http://127.0.0.1:30000/v1"

The important part is what is not implicit. Metered cloud is opt-in by work class. A local miss does not silently spend against a cloud key unless the operator configured that class to allow it.

Verification Happens Before Trust

Routing ahead of time catches the known bad classes. Verification catches the cheap obvious failures that still happen in real traffic.

Local responses pass through gates for empty output, truncation, malformed tool-call JSON, invalid code shape, broken diffs, and refusal markers. The depth depends on the work class and tier. If a local response fails, the router walks the tier ladder: fast-local, heavy-local, then cloud if explicitly configured.

Streaming made this harder than I wanted. If a local tier starts streaming bad output, the harness has already seen it. You cannot unstream a bad token. So verify-gated local paths use a commit window: buffer enough to verify before the first byte leaves the router. Cloud can still stream true upstream SSE when it is the trusted tier. Local pays a little first-token latency where the evidence says it should.

That is the tradeoff I accepted. I would rather delay the first token than let a failed local answer leak into an agent run.

The Serving Layer Is Part of the Product

A router with opinions about quality needs a serving substrate it can inspect and operate. anvil-serving includes the substrate commands because “just point it at a model” was not enough.

models sync builds a local model catalog. deploy and init generate consistent configs. preflight checks correctness against a serving endpoint. benchmark measures capacity. multiplexer swaps models on one GPU while draining in-flight requests. Later releases added router and serve lifecycle commands, harness sync for OpenClaw, and host management for WSL and Docker Desktop incidents that were painful enough to turn into code.

The request path itself stays intentionally small. The Python package has zero required runtime dependencies. The hot path uses stdlib HTTP machinery instead of a web framework. That is not a universal recommendation. It is a tradeoff for this project: fewer moving pieces in the part that has to decide whether a model answer can be trusted.

What I Learned

Structurally valid is not the same as good. This is the lesson that keeps showing up in agent infrastructure. A schema can tell you the JSON parsed. It cannot tell you that the dependency direction is wrong. In long agent runs, the semantically wrong answer is more dangerous than the malformed one because it looks safe enough to continue.

The integration point is the harness, not the ledger. I wanted the Anvil relationship to be tighter at first. That instinct was wrong. Anvil keeps the record of work. The model call goes through Claude Code, Codex, Aider, OpenClaw, or another harness. If the router does not sit where the model traffic already flows, it becomes another beautiful internal abstraction that the actual request never touches.

Local-first needs humility to be useful. I want local models to carry more of my coding workload. That does not mean pretending they are ready for every class. The honest version is narrower and stronger: local where it has been proven, cloud where it has not, verified at the boundary. That posture makes local useful because it stops asking local to be magical.

Defaults are a trust contract. The local-only default matters. If a router silently spends cloud tokens every time local misses, it is not local-first. It is a cloud proxy with local optimism. anvil-serving’s default exhaustion response is intentionally blunt: no local tier cleared the gate, and no metered cloud path was authorized. The operator decides where money can be spent.

The transferable principle is simple: own the measurement, not the model. Models will keep changing under the system. That is fine. The durable thing is knowing what each one has earned the right to do, checking that trust at the boundary, and refusing to guess when the data is not there.

Co-authored with AI, based on the author's working sessions, dictations, and notes.

Explore the source

fakoli/anvil-serving

Star it, fork it, or open an issue — contributions and feedback welcome.

Related Projects