Jev API Tutorial: Make Your First Call in 10 Minutes
You have an API key and ten minutes — that is genuinely all a first Jev call takes. Because the model’s main demo channel is OpenRouter’s OpenAI-compatible endpoint, the request is the same shape you already send to chat models; what changes is what comes back. This tutorial walks through a complete, copy-pasteable call in curl and Python, explains every field, lists every error that catches first-timers, and ends with the five checks you should make before any call goes near production.
TL;DR: POST to
https://openrouter.ai/api/v1/chat/completionswithAuthorization: Bearer <key>, the Jev model id, and your question phrased as a judgment, choice or scoring question. The answer comes back inside the standard OpenAI-compatible envelope as structured JSON — an answer, a confidence score and a rationale. Parse the message content as JSON and read the fields.
From zero to first call: a 7-step runbook
This runbook assumes you have nothing yet — no account, no key, no code. Follow the steps in order; each step has a concrete expected output so you know when to proceed.
| Step | Action | UI path / field | Expected output |
|---|---|---|---|
| 1 | Create an OpenRouter account | openrouter.ai → Sign Up | Confirmation email received and account active |
| 2 | Add credits | Dashboard → Credits → Add Credits → choose amount → pay | Credits balance shows non-zero in the top bar |
| 3 | Create an API key | Dashboard → Keys → Create Key → set a name and optional spend limit | A key string starting with sk-or- copied to clipboard |
| 4 | Store the key in your environment | export OPENROUTER_API_KEY="sk-or-…" in your shell profile | echo $OPENROUTER_API_KEY prints the key without None |
| 5 | Confirm the current model slug | openrouter.ai → Models → search “jev” → open the model page | Exact slug string, e.g. typesafe/jev-1.13 visible |
| 6 | Send the test call | Copy the curl command from the next section; paste and run | HTTP 200 and a JSON response body in the terminal |
| 7 | Parse the answer | Extract choices[0].message.content; run json.loads on it | A dict with answer, confidence and rationale fields |
Two things account for almost every failure in step 6: the key is in the clipboard but not exported to the environment, and the model slug is different from what the guide shows. Fix those two before debugging anything else. A quick sanity check before step 6: open a fresh terminal, run echo $OPENROUTER_API_KEY and echo $JEV_MODEL_SLUG (if you stored the slug separately), and confirm both print non-empty strings. If either is blank, the following call will fail with a 401 or 404 respectively — no amount of request debugging will fix a missing environment variable.
The request, field by field
A first call is a judgment question — the simplest of the three primitives. Here is the minimal working curl:
# Confirm the exact model slug on the OpenRouter model page before running
curl https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "typesafe/jev-1.13",
"messages": [
{
"role": "user",
"content": "Judgment question: does this comment contain spam? Comment: \"Great post, check my site for cheap meds\""
}
]
}'
What each part does:
| Part | Value | Purpose |
|---|---|---|
| URL | https://openrouter.ai/api/v1/chat/completions | OpenRouter’s OpenAI-compatible endpoint — no path variation needed |
Authorization header | Bearer $OPENROUTER_API_KEY | Reads the key from the environment variable; never paste raw keys in scripts |
Content-Type | application/json | Tells the server the body is JSON; omitting this causes a 400 |
model | typesafe/jev-1.13 | The Jev model id — confirm the exact slug on the OpenRouter model page |
messages[0].role | user | A single-turn question; Jev does not carry conversation state |
messages[0].content | Your question | Phrase it as judgment / choice / scoring and embed the input inline |
Note what is not required: no special “judgment mode” parameter, no bespoke endpoint, no session token. The question type is carried entirely by how you phrase the content field. For the official TypeSafe AI channel, confirm the actual endpoint and fields in the official documentation at typesafe.ai.
The response, decoded
The HTTP response is the standard OpenAI-compatible envelope. The outer object contains a choices array; the first element’s message.content holds the structured answer as a JSON string. Inside that string — example fixture, confirm exact field names in the official documentation:
{
"answer": "yes",
"confidence": 0.97,
"rationale": "The comment promotes an unrelated pharmaceutical site, a classic spam pattern."
}
Reading the fields:
answer— for judgment questions, one ofyes,noorunclear. For choice questions it is one of your listed options; for scoring, a number plus thescaleit came from.confidence— a 0–1 score you can branch on. Route anything below your threshold to human review rather than accepting it automatically.rationale— a short explanation. Log it; when you are debugging question wording, the rationale shows you exactly how the model read the input.
The usage object in the envelope — present when you add "usage": {"include": true} to the request body — reports prompt_tokens, completion_tokens and an estimated cost. Read this section on measuring cost and latency for how to use it.
The full extraction path in pseudocode:
envelope = JSON parse HTTP body
content = envelope["choices"][0]["message"]["content"]
result = JSON parse content ← the line people forget
answer = result["answer"]
The same call in Python
import json, os, requests
resp = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}",
"Content-Type": "application/json",
},
json={
# Confirm the exact model slug on the OpenRouter model page
"model": "typesafe/jev-1.13",
"messages": [{
"role": "user",
"content": (
"Judgment question: does this comment contain spam? "
'Comment: "Great post, check my site for cheap meds"'
),
}],
"usage": {"include": True}, # returns token counts and estimated cost
},
timeout=30,
)
resp.raise_for_status() # raises on 4xx/5xx before we parse
envelope = resp.json()
result = json.loads(envelope["choices"][0]["message"]["content"])
print(result["answer"], result["confidence"])
# Optional: log cost for the first few calls to calibrate
usage = envelope.get("usage", {})
print(f"tokens: {usage.get('prompt_tokens')}+{usage.get('completion_tokens')}")
Three implementation notes worth reading before you copy-paste:
resp.raise_for_status()must come beforeresp.json()or a failed request body might parse as valid JSON that silently swallows the error code.- The
json=parameter inrequests.postboth serialises the body and setsContent-Type: application/jsonautomatically — you do not need a separate header for it, but the header in the example makes the intent explicit for readers. json.loads(envelope["choices"][0]["message"]["content"])is the line that catches everyone. The message content is a string containing JSON, not an already-parsed dict. If you skip this step you will get aTypeErroror a string instead of a dict, depending on how you access it.
Failure modes: what goes wrong and why
Four HTTP status codes cover essentially every first-call failure. The table below gives the cause, how to confirm it, and what to fix:
| Code | Most common cause | How to confirm | Fix |
|---|---|---|---|
401 Unauthorized | Key missing from environment, wrong env var name, or key revoked | print(os.environ.get("OPENROUTER_API_KEY")) — if None, the export did not run in this shell | Re-export the key; check that the var name matches exactly (case-sensitive) |
403 Forbidden | Key exists but has no credits, or key has a spend limit that is already reached | Log into the OpenRouter dashboard → Credits; check remaining balance and per-key limits | Add credits, or raise the key’s spend cap |
404 Not Found | Model slug is wrong, outdated, or typed with a typo | Compare the model value in your request to the exact string on the OpenRouter model page | Copy the slug fresh from the model page; do not rely on a cached value |
429 Too Many Requests | Concurrency or per-minute request rate exceeds the account’s tier limit | The response body usually includes a Retry-After header | Back off exponentially; reduce max_workers; check your tier’s documented limits |
400 Bad Request | Missing or malformed JSON body; Content-Type header absent; model field missing entirely | Read the response body — it usually names the missing field | Validate the JSON body before sending; confirm all required fields are present |
TypeError / JSONDecodeError in client | json.loads was skipped on message content, or the content was not valid JSON for this call | Print the raw message.content string before parsing | Wrap the json.loads call in a try/except; on parse failure, log the raw content and retry once |
One subtle failure: the key is correctly exported but the env var name has a typo. For example OPENROUTER_APIKEY instead of OPENROUTER_API_KEY. Python’s os.environ['OPENROUTER_API_KEY'] raises KeyError immediately; os.environ.get('OPENROUTER_API_KEY') returns None silently and then fails when the header is built. Check the name character by character if a key that “should be there” keeps returning None.
Measuring cost and latency on the first call
Do not wait for your first invoice to understand what a Jev call costs. On the very first run, add "usage": {"include": True} to the request body and read the envelope before parsing the answer:
envelope = resp.json()
usage = envelope.get("usage", {})
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
# OpenRouter includes an estimated cost field — confirm the field name on the model page
estimated_cost_usd = usage.get("total_cost", 0)
print(f"prompt={prompt_tokens} completion={completion_tokens} cost≈${estimated_cost_usd:.6f}")
For latency, use Python’s time.perf_counter() around the request and run the same prompt three times back-to-back:
import time
times = []
for i in range(3):
t0 = time.perf_counter()
resp = requests.post(URL, headers=HEADERS, json=BODY, timeout=30)
resp.raise_for_status()
times.append(time.perf_counter() - t0)
print(f"latency: {min(times):.2f}s / {sum(times)/3:.2f}s / {max(times):.2f}s (min/avg/max)")
Three data points are not a benchmark, but they tell you whether your prompt is costing tenths of cents or whole cents, and whether latency is sub-second or multi-second — both of which affect how you design the pipeline. The cost-and-latency guide covers the full measurement methodology once you move past the prototype.
Two things to log from the start even before you care about cost optimisation: the prompt_tokens per call (it tells you whether prompt size is drifting as inputs change) and the wall-clock latency per call (p95 is the number that matters for user-facing pipelines). Set up these logs on day one; retrofitting observability into an already-running pipeline is always harder.
Common question-wording mistakes
Getting the request to return HTTP 200 is step one. Getting a useful answer is step two, and question wording is where most debugging time goes. Three patterns cause most wording errors:
Forgetting to embed the input. The question must contain the thing being judged. "Judgment question: is this spam?" is unanswerable without the text to evaluate. The full form is "Judgment question: is this spam? Text: \"…\"". A useful habit: write the question template first with a {input} placeholder, then format it before every call — this prevents the accidental omission and also makes the template unit-testable independently of the API.
Phrasing a judgment question as a chat request. "Tell me whether this is spam" is an instruction to a chat assistant. Jev is not a chat assistant — it answers typed questions. Use the explicit question form: "Judgment question: does this text contain spam? Text: \"…\"".
Mixing question types in one message. Asking "Is this spam? And if so, rate the severity from 1–10" in a single content field blends a judgment and a scoring question. Send two separate calls, one per question type, and combine the results in your own code. The three-primitives guide covers each question type in full, including how to chain them.
A useful debugging loop: when a call returns confidence below 0.7, read the rationale field. It usually tells you exactly what the model was uncertain about — missing context, ambiguous phrasing, or an input that genuinely straddles two categories. Adjust the question wording and rerun; most low-confidence results clear up in one or two iterations.
Before you go to production: a 5-point checklist
These five checks take under fifteen minutes but prevent the five most common production incidents:
-
Key is in an environment variable, not in source code. Scan your repo for the
sk-or-prefix before any commit. A leaked key can be drained by a crawler within minutes of appearing in a public repository. -
The model slug is read from config, not hardcoded. Put the slug in an environment variable (
JEV_MODEL_SLUG) so a version change is one config update, not a code deploy. Confirm the current slug on the OpenRouter model page at least once before launch. -
There is a spend cap on the key. Set a per-key spend limit in the OpenRouter dashboard. A retry loop bug or a sudden traffic spike should exhaust the key limit, not the account balance.
-
raise_for_status()is called before parsing. Any code path that callsresp.json()without first checking the status code will silently swallow errors. The failure only surfaces later as a confusingKeyErrororAttributeErrorwhen the answer is accessed. -
The
json.loadson message content is wrapped in a try/except. Jev returns structured JSON reliably, but network retries, model version changes and edge-case inputs can all produce unexpected content. Log the raw string on parse failure before retrying or alerting — you will need it to debug.
Once all five are green, the confidence-and-fallback guide covers the next layer: choosing a confidence threshold, routing low-confidence results to human review, and measuring the real-world precision/recall of your threshold choice.
What comes next
This guide covers the mechanics of a single call. The guides that follow cover what you build on top:
| Guide | What it covers |
|---|---|
| Three primitives | Judgment, choice and scoring question types; when to use each; how to chain them |
| API channels | OpenRouter vs the official TypeSafe AI channel; one adapter that covers both; migration runbook |
| Confidence and fallback | Choosing a confidence threshold; routing low-confidence results to human review |
| Cost and latency | Measuring per-call economics; prompt-size effect; batching and concurrency trade-offs |
| Troubleshooting | Full error-to-fix table beyond the four codes in this guide |
The spam comment detection case shows this exact call wired into a moderation pipeline end to end — a useful read once the mechanics above are working and you want to see how the runbook translates into a real production integration with retry logic, logging, and a human review queue.
This guide applies to Jev 1.13. If your model page shows a different version, the request shape remains the same — only the slug and response fixture field names may differ; confirm those in the current official documentation.