Development Journey — Jev Model Router on the Vercel AI Gateway

Date: 2026-09-18  ·  Deliverable: server.mjs + index.html in this folder  ·  Session: one Claude Code session, model Opus 5 then Fable 5.1
Brief: "Read transcript.txt. my vercel_gateway_api_key is in .env. you can find jev docs in https://docs.typesafe.ai/llms.txt and https://docs.typesafe.ai/introduction. now with the api key and these docs, please tell me if you can develop some of the jev applications mentioned in the transcript.txt. Do not run anything yet. just report."
Before you push this folder to a public repository. The gateway key sat in plain text in three files during this session: .env, .ignore/README.txt, and .ignore/vercel_gateway_api_key.txt. The .gitignore written at the end of the session excludes .env, .ignore/, and node_modules/. Two steps remain for you: (1) rotate the key on the Vercel AI Gateway page, because it was echoed in a session transcript; (2) run git status after git init and confirm those three paths do not appear.

1. The brief — a feasibility report, then a build

The session had two phases. Phase one was read-only: read a YouTube transcript (Riley Brown, "Jev" video, 2026), read the TypeSafe docs, and report which of the five apps in the video could be built with a Vercel AI Gateway key. Phase two was a build. The user picked three things from the report: TypeScript through the gateway, the model router first, and the Gmail MCP as the future email source.

Invisible constraints shaped every line of code and prose. Three standing modes were active: ASD-STE100 Simplified Technical English for all replies, a "ponytail" minimal-code mode at level full, and a "precise phrasing, never rhetorical" rule. The user's own rules also applied: questions are read-only, never kill a process by name, find a path before you read it, add no file that was not asked for, and report what was left undone. One of those rules was set aside at the end of the session. Section 12 names it.

The five apps in the transcript: email triage over 500 emails, a model router, "monitor everything", a Tesla-autopilot demo by a third party, and a stock trader by a third party. The report ranked them: build email triage and the router first; monitor-everything later; autopilot and trader as demos only, because Jev takes text input only and a trader needs a market feed.

2. Cold start — what the docs say, and what they do not

Minute zero: a folder with transcript.txt, README.txt, gpt6_summary.txt, vercel_gateway_api_key.txt, and .env. No package.json. No git. Node v22.22.0, npm 9.9.4, no pnpm. The .env variable was named VERCEL_GATEWAY_API_KEY. The README.txt already asked "AI_GATEWAY_API_KEY or VERCEL_GATEWAY_API_KEY ??". That question turned out to matter.

The TypeSafe docs are a Mintlify site. Append .md to any page path to get Markdown. The index at https://docs.typesafe.ai/llms.txt lists every page. Pages read in this session: api.md, introduction/quickstart.md, models.md, model-jaggedness/jev-1.13.md, agent-skill.md, confidence.md, sdk/javascript.md, sdk/python.md, concepts/system-one.md, introduction/machine-learning-primer.md, primitives/choice.md, and the SKILL.md on GitHub.

Facts from the docs that the build depends on

What the docs do not say

The TypeSafe docs do not mention the Vercel AI Gateway at all. The Vercel docs mention Jev on one model page and one AI SDK page. No page on either site gives the raw HTTP path the gateway uses for Jev. https://vercel.com/docs/ai-gateway/evaluation returned 404. The wire protocol had to be read from package source. Section 3 covers that.

Rule learned: when two vendors each document half of a bridge, read the SDK source that crosses it.

3. How Jev is reached through the gateway

There are two doors to Jev. The router uses the second one.

Direct TypeSafe APIVercel AI Gateway
URLPOST https://api.typesafe.ai/v1/systemonePOST https://ai-gateway.vercel.sh/v4/ai/evaluation-model
KeyTYPESAFE_API_KEYAI_GATEWAY_API_KEY
Model selector"model": "jev-latest" in the bodyHeader ai-model-id: typesafe-ai/jev
Yes/no type namenoul, answer field noulboolean, answer field probability
ConfidenceInside each Choice and Score answerMoved to providerMetadata.typesafe.confidence.<questionId>
Score legendReturned as legendNot in the gateway schema
BillingTypeSafe accountGateway credits, $0.042/M input, no markup

The gateway path was found by downloading @ai-sdk/gateway@4.0.87 from npm into the session scratchpad and reading package/dist/index.js. Two guesses at the tarball layout were wrong first: the shell's $TMP was not the scratchpad, and the file is index.js, not index.mjs. The relevant lines:

// @ai-sdk/gateway 4.0.87, dist/index.js, lines 2340–2346
getUrl() {
  return `${this.config.baseURL}/evaluation-model`;
}
getModelConfigHeaders() {
  return {
    "ai-evaluation-model-specification-version": "4",
    "ai-model-id": this.modelId
  };
}
// line 3624:  var AI_GATEWAY_PROTOCOL_VERSION = "0.0.1";
// line 3636:  baseURL default "https://ai-gateway.vercel.sh/v4/ai"
// line 3638:  Authorization: `Bearer ${auth.token}`, plus "ai-gateway-auth-method"

A reconstructed raw call. This shape was read from source. It was never sent in this session. The AI SDK sends it for you.

curl -X POST https://ai-gateway.vercel.sh/v4/ai/evaluation-model \
  -H "Authorization: Bearer $AI_GATEWAY_API_KEY" \
  -H "ai-model-id: typesafe-ai/jev" \
  -H "ai-evaluation-model-specification-version: 4" \
  -H "ai-gateway-protocol-version: 0.0.1" \
  -H "ai-gateway-auth-method: api-key" \
  -H "Content-Type: application/json" \
  -d '{ "state": "Hey, I'\''m Riley.", "questions": { "tier": { "type": "choice",
        "instructions": "Which model tier should answer this message?",
        "criteria": { "nano": "Greeting or trivial chat", "balanced": "Needs real reasoning or code" } } } }'

What the app uses instead. The ai package, version 7.0.107, exports experimental_evaluate. A plain string model id resolves through the gateway when AI_GATEWAY_API_KEY is set. No @ai-sdk/gateway import is needed.

import { experimental_evaluate as evaluate } from "ai";

const r = await evaluate({
  model: "typesafe-ai/jev",
  state: "Hey, I'm Riley.",
  questions: {
    tier: { type: "choice", instructions: "Which model tier should answer this message?",
            criteria: { nano: "Greeting or trivial chat", balanced: "Needs real reasoning or code" } },
    is_greeting: { type: "boolean", instructions: "Is this only a greeting?" },
  },
});

The raw response body from that probe, the first call of the session with the key. Provider time from startTime to endTime: 138 ms. Gateway generation id and the routing block are shortened.

{
  "answers": {
    "tier": { "type": "choice", "choice": "nano", "probabilities": { "nano": 1, "balanced": 0 } },
    "is_greeting": { "type": "boolean", "probability": 0.39 }
  },
  "rounding": { "probabilityDecimals": 2, "scoreDecimals": 2 },
  "usage": { "inputTokens": 336, "outputTokens": 51 },
  "warnings": [],
  "providerMetadata": {
    "typesafe": { "confidence": { "tier": 1 } },
    "gateway": { "routing": { "finalProvider": "typesafe-ai", "statusCode": 200, ... },
                 "cost": "0", "marketCost": "0.000014112", "generationId": "gen_..." }
  }
}

Three things in that body are not obvious. confidence lives under providerMetadata.typesafe, not in the answer. rounding says every probability is rounded to two decimals, so "nano": 1 means "at least 0.995", not certainty. cost is "0" while marketCost is "0.000014112", because the account was on free credits at that moment.

Env var: the packages read AI_GATEWAY_API_KEY (dist/index.js:180 and :3947). The folder's .env had VERCEL_GATEWAY_API_KEY. The user added the second name by hand. Node reads the file with node --env-file=.env, which needs Node 20.6 or later. No dotenv package.

4. How the router uses Jev

The router is one HTTP server in server.mjs and one page in index.html. No framework. Node's built-in http module serves the page on GET / and takes a message on POST /chat. The flow for one message:

  1. The page posts { "message": "..." }.
  2. The server caps the message at 20,000 characters. This keeps the state under Jev's 32k-token limit.
  3. The server sends one Choice question to Jev. The state is { user_message: message }. The options are the four tier names with their rubric strings.
  4. Jev returns a tier, a probability for every tier, and a confidence in metadata.
  5. The server looks up the model id for that tier and calls it with generateText through the same gateway key.
  6. The response carries the tier, the model id, the probabilities, the confidence, both timings, and the reply.

The two blocks a reviewer must read. Everything else in the file is plumbing.

const JEV = "typesafe-ai/jev";
const TIERS = {
  nano:     { model: "openai/gpt-5-nano",          rubric: "Greeting, small talk, a one-line fact, or a trivial request" },
  fast:     { model: "google/gemini-3-flash",      rubric: "A simple question or short task with a clear answer; no deep reasoning" },
  balanced: { model: "anthropic/claude-sonnet-5",  rubric: "Needs real reasoning, planning, a careful explanation, or writing code" },
  frontier: { model: "anthropic/claude-fable-5.1", rubric: "Large multi-step or high-stakes work where a wrong answer is costly: a complete system, hard math, a long document" },
};
const TIER_QUESTION = {
  type: "choice",
  instructions: "Which model tier is the cheapest one that can answer this user message well?",
  criteria: Object.fromEntries(Object.entries(TIERS).map(([k, v]) => [k, v.rubric])),
};
async function route(message) {
  const t0 = performance.now();
  const ev = await evaluate({ model: JEV, state: { user_message: message }, questions: { tier: TIER_QUESTION } });
  const jevMs = Math.round(performance.now() - t0);
  const { choice: tier, probabilities } = ev.answers.tier;
  const confidence = ev.providerMetadata?.typesafe?.confidence?.tier ?? null;
  const model = TIERS[tier].model;
  const t1 = performance.now();
  // ponytail: the reply model may be refused on the gateway free tier; keep Jev's decision visible either way
  let reply = "", replyError = null, usage = null;
  try { const gen = await generateText({ model, prompt: message }); reply = gen.text; usage = gen.usage; }
  catch (e) { replyError = String(e.message ?? e); }
  return { tier, model, probabilities, confidence, jevMs, replyMs: Math.round(performance.now() - t1), reply, replyError, usage };
}

What Jev sees, and what it never sees

Jev receives the state, the instruction string, and the criteria map. The criteria map is option name to rubric string. The docs say: "The option names and their descriptions are both sent to the model." Jev never sees the question id tier. The docs say: "The model never sees the question id." Jev never sees the model ids in TIERS. It does not know that "balanced" means Claude Sonnet 5. It picks a word from four words, guided by four rubric sentences. Rename balanced to medium and the answer can change, because the option name is part of the input.

The router is stateless. generateText({ model, prompt: message }) sends no chat history and no system prompt. Each message is a fresh conversation for the reply model. That is a deliberate cut. A chat with memory needs a message array and a store. Section 12 lists it as not built.

5. The four tiers — which model is which, and why

Riley's video shows four tiers: "tiny or nano, fast, balanced, and frontier". The video shows Claude Sonnet 5 as the balanced pick. It does not show the other three model ids. The ids below are this session's picks from the gateway price list at GET https://ai-gateway.vercel.sh/v1/models, which needs no key. Prices are per million tokens.

TierModel id on the gatewayInputOutputWhy this one
nanoopenai/gpt-5-nano$0.05$0.40Cheapest named-vendor text model on the list. Also the only reply model that worked on the free tier.
fastgoogle/gemini-3-flash$0.50$3.00Ten times nano's input price. A step up in quality with low latency.
balancedanthropic/claude-sonnet-5$2.00$10.00The model Riley's video shows for this tier.
frontieranthropic/claude-fable-5.1$10.00$50.00Highest-priced Anthropic model on the list. Same price as openai/gpt-6-astra.

The price ratio nano to frontier is 200 to 1 on input. That ratio is the point of a router. A greeting sent to frontier costs 200 times what it needs to. An "Anthropic only" set was offered as an option (claude-3-haiku, claude-haiku-4.5, claude-sonnet-5, claude-fable-5.1). The user picked the default set.

Cost per Jev routing decision, measured: 336 input tokens at $0.042/M is $0.000014. The gateway reported the same figure as marketCost. Cost per reply depends on the tier: about $0.0001 for nano, up to a few cents for frontier on a long answer. Total API spend for the whole session, all probes and checks: $0.014.

6. Where the probabilities come from

This is the part the user asked to demystify. Here is what is documented, what was observed, and what is not public.

Documented: the training objective

The TypeSafe AI primer names the method: RLCD, "reinforcement learning for calibrated decisions". It contrasts it with RLHF (trains a model to say what people prefer) and RLVR (trains reasoning with verifiable rewards). The primer's claim is: "Higher probability should correspond to a greater chance that the answer is correct." Calibration is defined across groups: "Outcomes assigned a probability of 0.8 should occur about 80% of the time." The primer adds: "These rates describe groups of predictions, not a guarantee about any single answer."

The models page says the same weights serve every account. There is no fine-tuning and no LoRA. You shape the answer through state, instructions, and criteria only.

Documented: what a Choice returns

The choice page: "probabilities: The full probability distribution across every option. The sum of all values is 1." And: "confidence: A number from 0 to 1 computed from how probabilities is spread. A flat shape ... means low confidence. A single peak on one option means high confidence." The confidence page says the formula is TypeSafe's chosen statistic and that a different one may suit you, which is why the full distribution is returned.

Observed: four distributions and their confidence values

InputProbabilities (rounded to 2 decimals by the API)Confidence
"Hey, I'm Riley."{ nano: 1, fast: 0, balanced: 0, frontier: 0 }1
"Hey, I'm Riley. I want to build an app that uses AI as a wrapper. Tell me the best way to do it."{ balanced: 0.8, fast: 0.2, nano: 0, frontier: 0 }0.73
"Please generate all of the code for this. Make sure it's perfect."{ balanced: 0.74, frontier: 0.25, fast: 0.01, nano: 0 }0.65
Docs quickstart example, three options{ billing: 0.84, technical: 0.159, sales: 0.001 }0.596

Two readings of that table. First, confidence is not the top probability: 0.8 gives 0.73, and 0.84 gives 0.596. Second, confidence is not the margin between first and second: 0.8 minus 0.2 is 0.6, and the reported value is 0.73. The formula is a function of the whole distribution. It is not published. This document does not try to reverse-engineer it from four points.

Compare with Riley's video. His third prompt showed 95% for balanced. This session's third prompt gave 74% balanced and 25% frontier. The prompts are close but not identical, the rubrics differ, and the model version may differ. The numbers are not comparable as a benchmark.

Not public

Consequences for a router

7. The free-tier wall — the crux of the session

The build took about twenty minutes. The free tier took the rest of the session.

Timeline

  1. Probe call: success. One Jev call.
  2. First --check run: prompt 1 succeeded (Jev call, then gpt-5-nano reply). Prompt 2 failed. The check printed AssertionError [ERR_ASSERTION]: unknown tier undefined and nothing else. The self-check had swallowed the real error. Two Jev calls had succeeded in that burst.
  3. The check was patched to print r.error. Second run: GatewayRateLimitError: Free tier requests on this model are rate-limited. Upgrade to paid credits at https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dtop-up for unrestricted access. This time on prompt 1.
  4. The Jev-only probe was re-run to isolate the cause. It returned the same 429 with "modelAttempts": [{ "canonicalSlug": "typesafe-ai/jev", "success": false }]. So Jev itself was limited.
  5. The credit endpoint showed balance 4.9998, total_used 0.0002, free credits.
  6. The user ran the check later on their own machine. Prompt 1 passed. Prompt 2 failed with a different message: Free tier users do not have access to this model. Upgrade to paid credits .... Which of the two calls in route() threw — Jev or the reply model — could not be determined, because route() had no try/catch between them at that time.
  7. A probe of seven reply models, one tiny call each: openai/gpt-5-nano answered. google/gemini-3-flash, anthropic/claude-sonnet-5, anthropic/claude-fable-5.1, anthropic/claude-haiku-4.5, google/gemini-2.5-flash-lite, openai/gpt-5-mini all returned the "rate-limited" 429.
  8. That probe used up the window. The next check run returned the Jev 429 again.
  9. The user bought credits. Balance moved to the paid tier. The next check run passed all three prompts.

A wrong statement, corrected

After step 4, the report to the user said: "The limit is on Jev, not on the reply models." Step 7 showed six reply models limited too. The correct statement is: on the free tier, Jev is limited to a small burst, and most reply models are limited or refused. The only reply model proven to work on free credits was openai/gpt-5-nano.

What the Vercel docs say, read after the fact

Pricing page, updated 2026-09-08: "The free tier includes a subset of models, not the full catalog." "Free tier requests are also rate limited per model, with lower limits than the paid tier." "Once you purchase credits, your account transitions to the paid tier and the monthly free credit no longer applies." Rate-limits page: "AI Gateway does not rate limit paid-tier requests." "Limits can change, so this page describes behavior rather than fixed numbers."

The free-tier model list itself was not found. A fetch of the models page with ?freeTier=true returned models priced at $0, which is a different thing. The /v1/models JSON has no free-tier field. So the list of free-tier models is unknown, except for the one proven by a call.

Rule learned: two error strings that both end in "Upgrade to paid credits" are the same wall. Do not spend turns telling them apart.

Rule learned: a self-check that asserts on a field must print the error that made the field empty. assert.ok(x, `... ${r.error}`) costs one line and saves a run.

8. Tools and features used

ToolWhat it did in this session
Read, Glob, Bash cat/sedRead the transcript, the notes, and the env var names. The key value was redacted in every shell output with sed -E 's/vck_[A-Za-z0-9]+/vck_<redacted>/g'.
WebFetch, curlFetched the TypeSafe Markdown pages and the Vercel docs pages. curl for pages where WebFetch's summary dropped detail.
curl to /v1/models and /v1/models/typesafe-ai/jev/endpointsPublic, no key. Confirmed the model id, price, zdr: "all", no_training: "all", and supports_implicit_caching: false.
npm registry + tarballDownloaded @ai-sdk/gateway@4.0.87 into the scratchpad to read the evaluation endpoint path. Session-transient; the findings are in section 3.
npm init -y, npm i aiInstalled ai 7.0.107. One dependency.
Write, Editprobe.mjs, server.mjs, index.html, HANDOFF.md, .gitignore, this document.
AskUserQuestionTwo rounds. Round one: app shape, dispatch or pick-only, tier models, permission to probe. Round two: audience and depth for this document.
advisorTwo consultations. The first, before the feasibility report, added the env-var mismatch, the "no confidence in the gateway schema" flag, and the "not verified" list. The second, before this document, added the rounding finding, the corrected count of successful calls, and the push-safety warning at the top.
Skill: handoff-after-clearTriggered by a context-size nudge at about 160k tokens. Produced HANDOFF.md and one memory file.
Skill: dev-journeyThis document.

No subagents were spawned. All work ran inline. The model was switched from Opus 5 to Fable 5.1 by the user mid-session with /model fable, and effort was set to high.

Human-in-the-loop moments

9. What went wrong, and the fixes

  1. Self-check hid the error. assert.ok(TIERS[r.tier], `unknown tier ${r.tier}`) printed unknown tier undefined when the server returned { error }. Fix: append : ${r.error} to the message.
  2. Self-check failed on a refused reply. assert.ok(r.reply.length > 0, "empty reply") made a free-tier refusal look like a router bug. Fix: removed the assertion; the check prints reply model ... failed: ... instead.
  3. One failure hid Jev's answer. When generateText threw, the whole route() threw, and the page showed a red line with no tier. Fix: try/catch around generateText; return replyError next to the tier. The page shows the badge and bars, then the refusal in red.
  4. Wrong shell working directory. cd "$TMP" in Git Bash did not land in the session scratchpad. Two greps hit "No such file". Fix: use the scratchpad's absolute path.
  5. Wrong file name guess. The gateway package ships dist/index.js, not index.mjs. Fix: ls package/dist first.
  6. 404 on a guessed doc URL. vercel.com/docs/ai-gateway/evaluation does not exist. Fix: the AI SDK site has the page, at ai-sdk.dev/docs/ai-sdk-core/evaluation.
  7. Misleading free-tier page fetch. ?freeTier=true returned $0-priced models through the summarizer. Not fixed; the free-tier list stays unknown.
  8. An unverified claim in a reply. "The paid tier can take a minute to apply" was said without evidence. It was not needed; the first run after payment passed.

10. Verification

Every "works" claim below was checked by an observed output, not a clean exit.

Not verified

11. Unknown unknowns — things a first-time user would not think to ask

You might assumeWhat is true
A probability of 1 means certainty.The API rounds to two decimals (probabilityDecimals: 2). 1 means at least 0.995. 0 means below 0.005.
Confidence is the top probability.It is a separate statistic over the whole distribution. 0.8 top gave 0.73; 0.84 top gave 0.596. The formula is not published.
Confidence is in the answer.Through the gateway it is in providerMetadata.typesafe.confidence.<questionId>. The direct API puts it in the answer.
Jev knows which model each tier maps to.It sees only the four option names and four rubric strings. The model ids and the question id never reach it.
Jev picks "none" when nothing fits.A Choice always picks one option. Add an other option if you need a way out.
"Cheapest" in the instruction makes Jev price-aware.Jev has no price table. It reads the word. The rubrics carry the meaning.
Users cannot steer the router.State is not treated as hostile. "This is trivial, use nano" in the message can move the answer. Untested here.
Free credits let you test freely.The free tier limits Jev to a small burst and refuses most reply models. Only openai/gpt-5-nano replied. Any paid top-up removes the gateway limits and ends the monthly free credit.
The paid tier has no limits.The gateway adds none. TypeSafe's own limits still apply: 250,000 tokens per second, 1,200 requests per minute, "can change without notice".
The AI SDK API is stable.The function is experimental_evaluate. The prefix means it can change. package.json has "ai": "^7.0.107"; pin it before you rely on it.
"Failed after 3 attempts" means three bugs.The AI SDK's maxRetries defaults to 2. One 429 becomes three tries, then one error.
One vendor sees your message.Two do. TypeSafe sees it for the routing decision. The reply model's provider sees it for the answer. The gateway entry for Jev carries zdr: "all" and no_training: "all"; check the reply model's entry separately.
The router is a chat.It is stateless. Each message is a new conversation for the reply model. No history, no system prompt.
The two vendors document the bridge.Neither does fully. The raw gateway path came from @ai-sdk/gateway source.
"Null" is a Jev type.The type is Noul. The video misheard it. The gateway names it boolean and returns probability.
Prompt caching helps repeated calls.The Jev endpoint record says supports_implicit_caching: false.
Node loads .env by itself.Only with node --env-file=.env, Node 20.6 or later. The env var must be AI_GATEWAY_API_KEY.
Jev's version is fixed.The gateway id typesafe-ai/jev is an alias. TypeSafe's jev-latest moves when a release ships. Thresholds tuned today can drift.

12. Where things stand

Done

Not built, by choice

Before git push

  1. Rotate the gateway key. Put the new value in .env only.
  2. git init, then git status. Confirm .env, .ignore/, and node_modules/ are absent.
  3. Pin the dependency: change "ai": "^7.0.107" to "ai": "7.0.107" in package.json, or accept drift.

Next options