hands-on labIDE in your browser

Route Between a Small and a Large Model with Jev

Build the router that decides, per request, whether a cheap fast model can answer or the large model has to.

time
60 min
checked steps
5
level
Intermediate
setup
None

$29.99a month with Pro

Pro opens all 5 steps of this lab and every other lab. Cancel any time.

no account needed · Pro opens all 5 steps

See Pro plans

New to Jev? Gate an Agent’s Tool Calls with Jev is free and takes 45 minutes.

Lab cockpit60 min · 5 stepsSession running
3 / 5 steps passingTune on dev, confirm on test · step 4 of 5
router.py▶ Run✓ Check
# ---------------------------------------------------------------- Step 4THRESHOLDS = [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]  def sweep(requests, outcomes, assessments, thresholds=THRESHOLDS):    """[(threshold, score report)] for the Jev router at each threshold."""  def pick_threshold(rows, target_accuracy):    """The cheapest threshold whose accuracy reaches the target, or None when none does.    On a cost tie the higher threshold wins: same result here, fewer large calls elsewhere."""  
TerminalOutput
step 4 of 5, replayed: Tune on dev, confirm on test. The code, the output and the check are the lab's own.

The job

Tallyboard's support assistant answers billing questions from a one-page policy. GLM-5.3 gets almost all of them right, and every answer costs fourteen times what GLM-5.3-flash charges. Flash is right about nine times in ten, and the tenth is a wrong refund amount sent to a customer. You build the router in between: Jev reads each request with the policy in under half a second, your code picks the model, and 110 answers recorded from both models tell you what the router costs and how often it is right.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Two models, one policy

    Tallyboard's support assistant answers billing questions from policy.md: eleven numbered rules about prices, seats, refunds and failed payments.

    you writeextract()answer()read this step below ↓

  2. 2

    Ask Jev what the request needs

    A router needs a signal it can read before any model answers.

    you writeassess()

  3. 3

    Route and score against recorded answers

    Calling both models on every request each time you change a threshold would be slow and noisy, because the same model can answer the same question differently from run to run.

    you writeroute()assess_all()score()

  4. 4

    Tune on dev, confirm on test

    Picking the threshold that looks best on all 110 requests, then quoting its numbers, measures how well you fit these requests.

    you writesweep()pick_threshold()

  5. 5

    Ready for production

    The router now sits in front of every support request, so it needs two failure paths.

    you writechoose_model()handle()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Two models, one policy

Tallyboard's support assistant answers billing questions from policy.md: eleven numbered rules about prices, seats, refunds and failed payments. Two models on OpenRouter can do the job:

GLM-5.3-flash (SMALL)GLM-5.3 (LARGE)
Right on the lab's 110 requestsabout 91%about 96%
Cost per answerabout $0.00007about $0.00096

The large model costs fourteen times more per answer. Most requests are lookups ("How much is Team per seat?") that both models get right. The small model's misses are the requests that combine rules, such as a nonprofit discount applied on top of annual pricing, or a refund counted in used months.

Both models reason before they answer. llm.chat(..., reasoning_effort="low") keeps the small model's reasoning short, which is how you would run a cheap tier in production.

do this

Open router.py.

1. Write extract(text). The model ends with ANSWER: <value>. Return the text after the last ANSWER: (strip spaces and * around it). The small model sometimes drops that line on one-word replies, so fall back to the last non-empty line. Return None for empty or missing text.

2. Write answer(model, request, chat=llm.chat). Make one chat call with a system message (SYSTEM, the instructions plus the policy) and a user message (the request). Use reasoning_effort="low" for SMALL and None for LARGE. Return {"model", "answer", "text", "cost", "ms"}.

3. Run. Five requests go to both models. The lookups come back right from both. Watch #44 (a nonprofit's saving from annual billing) and #56 (seats removed and added on the same day): the small model usually misses at least one of them, for about a fifteenth of the price.

router.py, the file you edit135 lines
"""Route Tallyboard support requests between a small and a large model, with Jev deciding.

The small model (GLM-5.3-flash) answers most billing questions correctly for a
fourteenth of the price. The large one (GLM-5.3) gets more of the hard ones
right. Jev reads each request and the policy, and your code picks the model.
"""
import json
import os
import re
from concurrent.futures import ThreadPoolExecutor

import jev
import llm

HERE = os.path.dirname(os.path.abspath(__file__))
SMALL = "z-ai/glm-5.3-flash"
LARGE = "z-ai/glm-5.3"

with open(os.path.join(HERE, "policy.md"), encoding="utf-8") as _f:
    POLICY = _f.read()

SYSTEM = (
    "You answer customer questions for Tallyboard using only the policy below. "
    "Work it out briefly, then end with one last line in the form ANSWER: <value>. "
    "For amounts give a plain number in dollars without the $ sign, rounded to 2 decimals. "
    "For yes/no questions answer yes or no. For dates answer like May 10.\n\n" + POLICY
)


def load_jsonl(path):
    with open(os.path.join(HERE, path), encoding="utf-8") as f:
        return [json.loads(line) for line in f if line.strip()]


def load_outcomes(path="outcomes.jsonl"):
    """{id: {"small": {"ok", "cost", "ms"}, "large": {...}}}: each model's recorded
    share of correct answers over three runs, mean cost in dollars and median latency."""
    return {row["id"]: row for row in load_jsonl(path)}


def grade(got, want, kind):
    """True when the extracted answer matches the expected one."""
    if got is None:
        return False
    g = got.lower()
    if kind == "number":
        nums = re.findall(r"-?\d[\d,]*\.?\d*", g)
        if not nums:
            return want == 0 and ("no" in g or "none" in g)
        value = float(nums[0].replace(",", ""))
        return abs(value - want) <= max(0.02, abs(want) * 0.001)
    if kind == "yesno":
        return re.match(r"\W*" + want + r"\b", g) is not None
    if kind == "date":
        return want.lower() in g
    if want == "not allowed":
        return any(w in g for w in ("not allowed", "not possible", "cannot", "can't", "not"))
    if want == "Team and Business":
        return "team" in g and "business" in g and "starter" not in g
    return want.lower() in g


# ---------------------------------------------------------------- Step 1
def extract(text):
    """The value on the last "ANSWER:" line; without one, the last non-empty line; None for no text."""
    # TODO (Step 1): the text after the LAST "ANSWER:" line (strip spaces and * around it).
    # No ANSWER line: the last non-empty line. No text at all: None.
    raise NotImplementedError("Step 1: write extract()")


def answer(model, request, chat=llm.chat):
    """Ask `model` the request with the policy as the system prompt."""
    # TODO (Step 1): one chat(model, messages, reasoning_effort=...) call
    #   messages: a "system" message with SYSTEM, then a "user" message with the request
    #   reasoning_effort: "low" for SMALL, None for LARGE
    # return {"model", "answer": extract(reply["text"]), "text", "cost", "ms"}
    raise NotImplementedError("Step 1: write answer()")


# ---------------------------------------------------------------- Step 2
QUESTIONS = {
    # "combine" and "topic" arrive in Step 2
}


def assess(request, post=jev.post):
    """One Jev request about a support request. Returns {"combine", "topic", "cost"}."""
    raise NotImplementedError("assess() arrives in Step 2")


# ---------------------------------------------------------------- Step 3
def route(a, threshold=0.5):
    """LARGE when Jev thinks the answer combines rules, SMALL otherwise."""
    raise NotImplementedError("route() arrives in Step 3")


def assess_all(requests, post=jev.post):
    """{id: assessment} for every request, sixteen Jev requests at a time."""
    raise NotImplementedError("assess_all() arrives in Step 3")


def score(requests, outcomes, choose, assessments=None):
    """Replay the recorded outcomes for the model `choose(request, assessment)` picks.

    `assessments` is None for a router that does not ask Jev (then choose gets None
    and no Jev cost is added)."""
    raise NotImplementedError("score() arrives in Step 3")


# ---------------------------------------------------------------- Step 4
THRESHOLDS = [0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]


def sweep(requests, outcomes, assessments, thresholds=THRESHOLDS):
    """[(threshold, score report)] for the Jev router at each threshold."""
    raise NotImplementedError("sweep() arrives in Step 4")


def pick_threshold(rows, target_accuracy):
    """The cheapest threshold whose accuracy reaches the target, or None when none does.
    On a cost tie the higher threshold wins: same result here, fewer large calls elsewhere."""
    raise NotImplementedError("pick_threshold() arrives in Step 4")


# ---------------------------------------------------------------- Step 5
def choose_model(request, threshold, post=jev.post):
    """(model, reason, assessment). reason is "jev", or "jev_error" when Jev failed and the
    request goes to LARGE so a Jev outage costs money instead of answers."""
    raise NotImplementedError("choose_model() arrives in Step 5")


def handle(request, threshold, post=jev.post, chat=llm.chat):
    """Answer one request the way production would. When the small model gives no usable
    answer, ask the large one. Returns {"model", "answer", "reason", "escalated", "cost"}."""
    raise NotImplementedError("handle() arrives in Step 5")

provided for you:jev.pyllm.pyoutcomes.jsonlpolicy.mdrequests.jsonltry_it.py

what you get

  • An editor and terminal in your browser
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Basic Python: functions, dictionaries, lists, lambdas
  • No API keys: the lab's proxy provides access to Jev and both GLM models

Part of the AI agents and MCP track

Lab 24 of 26. Tool loops, ReAct, MCP servers and clients, multi-agent supervisors and agent evaluation.

Questions about this lab

Do I need an OpenRouter or Jev API key for this lab?

No. The lab sandbox reaches Jev and both GLM models through Preporato's model proxy, which holds the keys.

Why ask Jev instead of letting the small model decide?

The router runs on every request, so it has to be cheap and fast. Jev answers a typed question about the request in a few hundred milliseconds, for about a twentieth of a cent per thousand tokens, and the routing rule stays in your code where you can test it.

Why replay recorded answers instead of calling the models?

Each model answered every request three times, and the lab stores how often each answer was right, what it cost and how long it took. Replaying those makes every router comparison exact and repeatable. You still call both models live in the first and last steps.

How much does routing save?

On the lab's held-out test split, a threshold tuned on the dev split matches the large model's accuracy for roughly two thirds of its cost. The exact numbers come out of your own run.

How to route LLM requests between a small and a large model

Most requests to an LLM feature are easy, and a small model answers them as well as a large one for a fraction of the price. The hard ones are where the small model makes mistakes. A router decides per request. It is only worth having if it spots the hard ones reliably, costs little and adds almost no latency. A decision model like Jev fits that job: one typed question about the request, a probability back in a few hundred milliseconds, and the choice stays in your code. In this lab you route billing questions between GLM-5.3-flash and GLM-5.3 on OpenRouter. You ask Jev whether each request needs several policy rules applied together, and you learn why "would a small model get this wrong?" is the wrong question to ask. You measure routers against recorded answers from both models, including one that routes on message length. Then you tune the threshold on a dev split, check it on a test split it never saw, and add the production parts: what happens when Jev is down, and what happens when the small model returns no usable answer.