The 98% Demotion

On October 13, GitHub replaced the LLM in its generic secret detection with a fine-tuned ModernBERT encoder, exported to ONNX int8. It reports more than 98% lower serving cost, 86% faster scans and higher precision and recall than the LLM it replaced. Why an encoder beats a prompted model on labels, a script that finds the classifier-shaped calls in your own logs, a five-step distillation recipe (labels, group split, ModernBERT fine-tune, two thresholds, ONNX int8), and a break-even showing which calls to distill and which to just route.

Published 2026-10-19 by Dor Amir on the Nadir blog.

Filed under Routing & Cascades.

TL;DR.

On October 13, GitHub took the LLM out of its generic secret detection. A remote LLM pipeline used to decide whether a string that looks like a credential in a push is a real secret. A fine-tuned ModernBERT encoder makes that call now, exported to ONNX with 8-bit quantization. GitHub reports three results against the LLM it replaced:

So it's cheaper, faster and more accurate, all at once. That isn't a fluke of secret scanning. It happens whenever a generative model is doing a job whose answer is a label. Most production LLM traffic has a few of those jobs hiding in it: triage, intent detection, moderation, "is this a duplicate", "does this need a human".

This post covers why the encoder wins, how to find the classifier-shaped calls in your own logs, a working distillation recipe (labels, group split, ModernBERT fine-tune, thresholds, ONNX int8), and a break-even showing which of those calls are worth a model and which should just be routed to a cheaper LLM.

GitHub figures are from its October 13, 2026 engineering post. Cost per million calls, encoder throughput, build cost and break-even volumes are our own illustrative calculations at October 2026 list prices, not GitHub or Nadir customer data.

What GitHub shipped.

Generic secrets are the hard case. A Stripe key has a prefix and a checksum, so a regex finds it. A database password in a config file looks like any other random string. So do build IDs, checksums, test fixtures and the sample keys in docs. The engineering post by Batu Ozturkler and Shengyu Fu puts it in one line: "Context is the deciding signal."

The new pipeline has two stages:

  1. Candidate generation. Regexes tuned for high recall flag every string that could be a credential. They're deterministic, fast and cheap, and they don't try to be precise.
  2. Context-aware classification. The fine-tuned ModernBERT encoder scores each candidate from the value, the file path, the secret type and a bounded window of surrounding code. Its output is a calibrated score checked against a threshold picked offline. Only high-confidence findings become alerts.

The training data came from three places: product feedback with human validation, regex candidates with human and weak labels, and synthetic hard cases (docs secrets, test fixtures, public keys, near-miss pairs) that passed deterministic and model-based checks. Before splitting into train and eval, rows were grouped by repository, candidate value and example, so near-duplicates couldn't leak from training into the test set. That one detail decides whether your offline numbers mean anything, and we come back to it in the recipe.

The rollout went offline eval on the same public traffic as the production baseline, then threshold selection, then validation on production-like traffic, then a customer A/B test against the LLM pipeline.

What the post doesn't say: which LLM it replaced, the parameter count or sequence length, dataset sizes, hardware, or absolute precision and recall. Every number is relative and measured on GitHub's own traffic. Treat the 98% as one well-run team's result, not a law. The cost math below suggests it's what you should expect.

Chart: the generalized pipeline. Input goes to a cheap high-recall filter, then a fine-tuned encoder that outputs a calibrated score. Scores at or above a high threshold act, scores at or below a low threshold drop with no tokens spent, and scores between the thresholds escalate to an LLM. GitHub ships the first two stages; the LLM band is our generalization.
Chart: the generalized pipeline. Input goes to a cheap high-recall filter, then a fine-tuned encoder that outputs a calibrated score. Scores at or above a high threshold act, scores at or below a low threshold drop with no tokens spent, and scores between the thresholds escalate to an LLM. GitHub ships the first two stages; the LLM band is our generalization.

Why the encoder wins on all three numbers.

Cost. A classification call to an LLM pays for its whole prompt every time: the instructions, the label definitions, the few-shot examples and then the item. A 1,500-token call that returns four tokens is more than 99% input. An encoder does one forward pass over a few hundred tokens of the item alone. The instructions live in its weights, and no output tokens are generated.

Speed. One forward pass on a local CPU against an API round trip plus prefill plus decoding. Running in-process also removes network jitter, rate limits and provider incidents. That's what let GitHub move detection from after the push to before it. A slower classifier would have stalled every git push.

Accuracy. This is the part people don't expect. A prompted LLM is a generalist reading a task description. A fine-tuned encoder has seen thousands of labeled examples from your distribution, including the near-misses. It also returns a probability you can calibrate, so you pick the operating point (the precision/recall trade) offline with a threshold. A prompted model gives you "yes" or "no". Asking it for a confidence number gets you a number, not a calibrated one.

Here's what one million calls costs at October list prices, for a typical shape: a 1,200-token instruction prefix, 300 tokens of item and context, a four-token label, no reasoning.

ClassifierCost per 1M callsvs encoder
Claude Opus 5.5 ($4 / $20)$6,08012,160x
Claude Sonnet 5.5 or GPT-6.1 Sol ($2 / $10)$3,0406,080x
Sonnet 5.5, instruction prefix cached$7601,520x
Claude Haiku 5.5 or GPT-6 Luna ($0.10 / $0.50)$152304x
Haiku 5.5, instruction prefix cached$4488x
Fine-tuned encoder + 5% fallback to cached Sonnet$38.5077x
Fine-tuned encoder, int8 on an 8-vCPU box$0.501x
Chart: cost per 1M classification calls on a log scale. Claude Opus 5.5 $6,080, Sonnet 5.5 or GPT-6.1 Sol $3,040, Sonnet 5.5 with a cached prefix $760, Haiku 5.5 or GPT-6 Luna $152, Haiku 5.5 with a cached prefix $44, encoder plus 5% fallback to Sonnet $38.50, fine-tuned encoder alone $0.50.
Chart: cost per 1M classification calls on a log scale. Claude Opus 5.5 $6,080, Sonnet 5.5 or GPT-6.1 Sol $3,040, Sonnet 5.5 with a cached prefix $760, Haiku 5.5 or GPT-6 Luna $152, Haiku 5.5 with a cached prefix $44, encoder plus 5% fallback to Sonnet $38.50, fine-tuned encoder alone $0.50.

Two things fall out of that table.

98% is the conservative end. Against the cheapest frontier-lab model with prompt caching switched on, the encoder is still 98.9% cheaper. Against a mid-tier model it's 99.98%. GitHub's ">98%" fits a baseline anywhere on the ladder.

The fallback band sets your bill, not the encoder. Send just 5% of calls back to Sonnet for the hard cases and the cost goes from $0.50 to $38.50 per million. That's still about 95% below Sonnet with caching, but it's 77 times the encoder alone. Every point of escalation rate costs more than the encoder's entire compute bill. Narrowing the uncertain band is the main cost lever after you've distilled.

Step 1: find the classifiers in your traffic.

You don't need to guess which calls are labels. They have a signature: the same system prompt, very high volume, tiny outputs, and a small set of distinct answers. This script reads a JSONL export of request logs (most gateways and observability tools can produce one) and ranks candidates by monthly spend.

# find_classifiers.py: rank classifier-shaped LLM calls by spend.
# Each log line: {"system": str, "output": str, "input_tokens": int,
#                 "output_tokens": int, "cost_usd": float}
import hashlib, json, sys
from collections import defaultdict
from statistics import median

groups = defaultdict(lambda: {"n": 0, "cost": 0.0, "out_tok": [], "answers": set(), "sample": ""})

for line in open(sys.argv[1]):
    r = json.loads(line)
    key = hashlib.sha1(r["system"].encode()).hexdigest()[:10]
    g = groups[key]
    g["n"] += 1
    g["cost"] += r["cost_usd"]
    g["out_tok"].append(r["output_tokens"])
    if len(g["answers"]) <= 50:
        g["answers"].add(r["output"].strip().lower()[:40])
    g["sample"] = g["sample"] or r["system"][:80]

days = float(sys.argv[2]) if len(sys.argv) > 2 else 30
rows = []
for key, g in groups.items():
    if g["n"] < 1000:
        continue
    shaped = median(g["out_tok"]) <= 8 and len(g["answers"]) <= 25
    if shaped:
        rows.append((g["cost"] * 30 / days, key, g["n"], len(g["answers"]), g["sample"]))

for monthly, key, n, k, sample in sorted(rows, reverse=True):
    print(f"${monthly:>10,.2f}/mo  {n:>9,} calls  {k:>2} labels  {key}  {sample!r}")

Run it on 30 days of logs. The top of the list is your shortlist. In our experience the usual suspects are routing and intent prompts ("which team owns this ticket"), moderation and policy checks, relevance filters in RAG pipelines, dedup checks, PII or secret detection, and LLM-as-judge pass/fail gates.

Don't skip the volume column. A classifier that runs 20,000 times a month isn't worth a model, as the break-even section shows.

Step 2: build the dataset, and split it honestly.

You already have labels. Every logged LLM output on a classifier-shaped prompt is a weak label. That's distillation: the LLM is the teacher, and the encoder learns to reproduce its decisions. Three upgrades turn weak labels into a dataset that can beat the teacher, which is what GitHub got:

  1. Fold in product feedback. Tickets re-assigned by a human, alerts dismissed as false positives, moderation decisions overturned on appeal. Those are the cases where the teacher was wrong, and they're the most valuable rows you have.
  2. Hand-label a disagreement sample. Run two cheap prompts or two models over a sample and hand-label wherever they disagree. A few hundred human labels on hard cases do more than ten thousand easy ones.
  3. Add synthetic near-misses. For every class, generate examples that look like it and aren't: the test fixture that looks like a key, the angry message that isn't an escalation. Validate them before they go in.

Then split by group, not by row. If the same customer, repository, template or document shows up in both train and eval, the model memorizes it and your offline accuracy is fiction.

from sklearn.model_selection import GroupShuffleSplit
import pandas as pd

df = pd.read_json("labeled.jsonl", lines=True)   # text, label, group (repo / customer / template id)
gss = GroupShuffleSplit(n_splits=1, test_size=0.15, random_state=7)
train_idx, test_idx = next(gss.split(df, groups=df["group"]))
df.iloc[train_idx].to_json("train.jsonl", orient="records", lines=True)
df.iloc[test_idx].to_json("test.jsonl", orient="records", lines=True)

Step 3: fine-tune ModernBERT.

ModernBERT is a 2024 encoder from Answer.AI and LightOn with an 8,192-token context. That's long enough for an item plus a real window of context, which GitHub's classifier relies on. The base model has about 150M parameters. Fine-tuning it on tens of thousands of short examples takes minutes to an hour on a single rented GPU. You need transformers 4.48 or later.

from datasets import load_dataset
from transformers import (AutoTokenizer, AutoModelForSequenceClassification,
                          TrainingArguments, Trainer)

MODEL = "answerdotai/ModernBERT-base"
ds = load_dataset("json", data_files={"train": "train.jsonl", "test": "test.jsonl"})
labels = sorted(set(ds["train"]["label"]))
l2i = {l: i for i, l in enumerate(labels)}

tok = AutoTokenizer.from_pretrained(MODEL)
def prep(b):
    enc = tok(b["text"], truncation=True, max_length=512)
    enc["labels"] = [l2i[l] for l in b["label"]]
    return enc
ds = ds.map(prep, batched=True, remove_columns=ds["train"].column_names)

model = AutoModelForSequenceClassification.from_pretrained(
    MODEL, num_labels=len(labels),
    id2label=dict(enumerate(labels)), label2id=l2i)

args = TrainingArguments("out", learning_rate=3e-5, num_train_epochs=3,
                         per_device_train_batch_size=32, bf16=True,
                         eval_strategy="epoch", save_strategy="epoch",
                         load_best_model_at_end=True)
Trainer(model=model, args=args, train_dataset=ds["train"],
        eval_dataset=ds["test"], processing_class=tok).train()
model.save_pretrained("out/best"); tok.save_pretrained("out/best")

For a binary "act or don't" task, lay out the input the way GitHub does: put the structured fields first (path: config/db.yml | type: password |) and the context window after. Fields cost a few tokens and give the model the signal a human reviewer would look at first.

Step 4: pick two thresholds, not one.

This is where the cascade comes in. Score the held-out set and choose:

import numpy as np

def pick_band(p, y, target_precision=0.98, target_npv=0.995):
    # p: positive-class probability on held-out data, y: 0/1 truth
    hi = next(t for t in np.arange(0.50, 1.00, 0.01)
              if y[p >= t].mean() >= target_precision)
    lo = max(t for t in np.arange(0.00, 0.50, 0.01)
             if (1 - y[p <= t]).mean() >= target_npv)
    band = ((p > lo) & (p < hi)).mean()
    return round(lo, 2), round(hi, 2), band

lo, hi, band = pick_band(p_test, y_test)
print(f"drop <= {lo}, act >= {hi}, escalate {band:.1%} of traffic")

The printed band is your escalation rate, and from the cost table it's also roughly your bill. Running the encoder alone means a band of zero and a single threshold, which is what GitHub describes. Keeping a band buys a safety net while the model earns trust. Then tighten it every time you retrain on the band's LLM-labeled outputs.

Step 5: export to ONNX int8 and measure.

pip install "optimum[onnxruntime]"
optimum-cli export onnx --model out/best --task text-classification onnx/
from optimum.onnxruntime import ORTQuantizer, ORTModelForSequenceClassification
from optimum.onnxruntime.configuration import AutoQuantizationConfig
from transformers import AutoTokenizer, pipeline
import time

q = ORTQuantizer.from_pretrained("onnx/")
q.quantize(save_dir="onnx-int8/",
           quantization_config=AutoQuantizationConfig.avx512_vnni(is_static=False, per_channel=False))

model = ORTModelForSequenceClassification.from_pretrained("onnx-int8/", file_name="model_quantized.onnx")
clf = pipeline("text-classification", model=model, tokenizer=AutoTokenizer.from_pretrained("out/best"))

HOURLY_PRICE = 0.36                      # your instance's on-demand $/hour
texts = held_out_texts[:2000]            # real items from your test split
t = time.perf_counter(); clf(texts, batch_size=32, truncation=True); dt = time.perf_counter() - t
per_s = len(texts) / dt
print(f"{per_s:,.0f} items/s -> ${HOURLY_PRICE / (per_s * 3600) * 1e6:.2f} per 1M items")

Two checks before you ship:

  1. Re-score the test set with the int8 model and confirm precision and recall at your thresholds moved by less than a point. Dynamic quantization is usually close to lossless on encoders, but "usually" is not a release criterion.
  2. Put your real hourly instance price in HOURLY_PRICE. Our chart assumes an 8-vCPU box at $0.36 an hour doing 200 items per second, which works out to $0.50 per million. Your number will differ with sequence length and hardware, but it won't move the order of magnitude.

Who should build this, and who shouldn't.

A distilled classifier has a fixed cost: labeling, a couple of engineer-weeks, GPU time, and a retraining loop as your data drifts. We put the build at $15,000 and asked how many calls a month repay it in six months, with 5% of traffic still escalating to the original model.

Your current classifierSaving per 1M callsCalls per month to repay in 6 months
Claude Opus 5.5about $5,775433K
Sonnet 5.5, uncachedabout $2,887866K
Sonnet 5.5, prefix cachedabout $7223.5M
Haiku 5.5, uncachedabout $14417M
Haiku 5.5, prefix cachedabout $4161M
Chart: monthly classification calls needed to repay a $15,000 encoder build in six months, log scale. Against Claude Opus 5.5, 433K. Against Sonnet 5.5 uncached, 866K. Against Sonnet 5.5 with a cached prefix, 3.5M. Against Haiku 5.5 uncached, 17M. Against Haiku 5.5 with a cached prefix, 61M.
Chart: monthly classification calls needed to repay a $15,000 encoder build in six months, log scale. Against Claude Opus 5.5, 433K. Against Sonnet 5.5 uncached, 866K. Against Sonnet 5.5 with a cached prefix, 3.5M. Against Haiku 5.5 uncached, 17M. Against Haiku 5.5 with a cached prefix, 61M.

Read the table from both ends.

The general lesson.

GitHub didn't make its LLM cheaper. It noticed that one stage of its pipeline wasn't asking a generative question, and gave that stage a model shaped like the answer. The 98% came from changing the shape, not from negotiating a price.

Most teams that audit their LLM spend find the same split. A small set of calls really needs a frontier model. A big set needs a cheaper model. And a surprising slice doesn't need generation at all: it's decisions that happen to be phrased as prompts. Fine-tuning versus routing for the middle tier is covered in Fine-Tune or Route. The same logic applies here one level down.

Where Nadir fits.

Nadir is built on exactly this split, and we use it on ourselves. The decision of which model should answer a prompt is a classification, so it doesn't go to an LLM. POST /v1/bucket returns simple, medium or complex with a probability for each, from a trained classifier, without spending provider tokens. We wrote up why it's a trained head and not a prompted model in How our binary classifier routes prompts in 50ms.

For everything that stays generative, Nadir's OpenAI compatible gateway does the routing half of the table above. Send model="auto" and each prompt goes to the cheapest model you've configured that clears the quality floor you set per API key. Every response reports which model answered and what it cost. That per-call log is also the input to Step 1: a week of traffic through Nadir shows you which prompts are classifier-shaped, how often they run and what they cost, so you know which ones to distill and which ones routing already handles.

Start with a free key, point a week of traffic at it in Observe mode (it records the decision Nadir would make without changing a single response), and sort your spend by prompt. The decisions dressed up as generation are usually near the top.


Sources: GitHub Blog, ["GitHub's model for secret detection: Finding more secrets with less noise"](https://github.blog/ai-and-ml/githubs-model-for-secret-detection-finding-more-secrets-with-less-noise/), Batu Ozturkler and Shengyu Fu, October 13, 2026, for the pipeline design, training data, rollout and every GitHub figure (98%, 86%, 13%, 19%, 11.5%). [Help Net Security coverage](https://www.helpnetsecurity.com/?p=387865) of the same announcement. ModernBERT: Warner et al., ["Smarter, Better, Faster, Longer"](https://arxiv.org/abs/2412.13663), December 2024. Model prices from our [October 2026 LLM API pricing comparison](/blog/llm-api-pricing-june-2026-complete-comparison). The call shape, encoder throughput and instance price, the 5% fallback, the $15,000 build cost, every cost-per-million and break-even figure, and all three charts are our own illustrative calculations.

More on routing & cascades

What Nadir is

Nadir is an LLM router. Nadir sends each prompt to the cheapest model that can handle it. A small decision model reads each prompt against your model menu and picks the lowest-cost model it expects to handle it. When the decision model is uncertain or unavailable, the request goes to the strongest model on the menu. The routing step makes no call to your model provider.

Nadir runs two ways. The decision API returns a model, reasoning-effort, cache, context, and policy recommendation without calling a model provider, beside the gateway you already run. That is how a shadow-mode evaluation works, and its projected savings stay advisory. The OpenAI compatible managed proxy executes the route, and migration is a two-line change: point the base URL at api.getnadir.com and set model to auto. On that path an optional verifier can score a complete non-streaming answer and escalate to a stronger model when it misses the configured bar. Streaming bypasses post-generation verification. BYOK is supported on every tier.

For coding agents, Nadir Auto is a terminal launcher for Claude Code or Codex that asks Nadir for the main-session model before each turn, with your configured model as the fallback and cost ceiling. Delegation integrations for Claude Code, Codex, or Cursor instead recommend a model tier for subagent work, and the agent decides; the Claude plugin can also move a main-thread turn to Haiku when Nadir's cache check prices it cheaper, unless its switch_main_model option is off. Either way inference runs on your own provider account, so Nadir holds no provider keys on that path.

What the numbers are, and what they are not

Nadir publishes each evaluation with its scope. On checkable code, run-check-escalate solved 392 of 395 common HumanEval and MBPP problems (99.2%), graded by running the canonical tests; that applies only to tasks with runnable deterministic tests. Nadir-Tumbler posts an arena_score of 72.3 on RouterArena's public scorer, 5th of 23 routers, which measures the routing decision on RouterArena's own model pool. A reference-assisted RouterBench evaluation over 11,420 held-out triples produced a 60% lower projected cost than always-Opus with about 98% retained quality and a 1.7% catastrophic-route rate. That experiment gave the verifier the expensive-model reference answer, which production does not have, so it is a research ceiling and not the deployed path.

None of these is a production guarantee, a universal savings rate, or a forecast for any particular workload. Customer savings are reported from measured execution against a declared baseline, and customer quality only from outcome-labelled traffic. Projected savings and realized savings are separate artifacts and are never blended.

Design-partner program

Three rungs, picked by risk appetite. Rung 0 Shadow runs advisory decision calls alongside live traffic and returns a projected receipt, with nothing in the request path changed. Rung 1 Hosted is the two-line swap on a production slice and returns a realized receipt. Rung 2 On-prem is a supervised six-week proof of concept inside the partner's VPC, where no prompt, response, or usage reaches Nadir. The commitments are the same at every rung. Apply for a rung directly: Rung 0 Shadow, Rung 1 Hosted, or Rung 2 On-prem. Not sure which fits? Start at getnadir.com/contact/?reason=design-partner.

Licensing

NadirClaw is the self-hosted core, source-available under the PolyForm Noncommercial License. Source-available is the correct label; NadirClaw is not open source. Nadir Route's hosted plan has no base fee and charges a variable fee only on measured savings from requests Nadir executed.

Pages on this site

Machine-readable summaries of this site: llms.txt and llms-full.txt.