TypeSafe 决策模型 Jev 使用教程:用 TypeScript 在 OpenRouter 上实现市场商品审核

OpenRouter:Announcements(RSS)·2026-09-23 08:00·23小时前·Kenny Rogers
AI 导读

OpenRouter 发布 Jev 使用教程,Jev 是 TypeSafe 推出的决策模型(模型 ID typesafe/jev-1.13),通过 OpenRouter Decisions API 接收 state 和问题,返回带概率的类型化答案而非生成文本。

OpenRouter:Announcements(RSS)
57AI 编辑部评分,满分 100

TypeSafe 决策模型 Jev 使用教程:用 TypeScript 在 OpenRouter 上实现市场商品审核

2026-09-23 08:00· 23小时前· Kenny Rogers
AI 导读

OpenRouter 发布 Jev 使用教程,Jev 是 TypeSafe 推出的决策模型(模型 ID typesafe/jev-1.13),通过 OpenRouter Decisions API 接收 state 和问题,返回带概率的类型化答案而非生成文本。

How to Use Jev: Moderation with the Jev API in TypeScript

Jev is a decision model from TypeSafe. It takes unstructured input and gives you typed judgments with probabilities in return, and your code decides what to do next. This guide shows how to use Jev on a problem of your own, worked end to end on one example, moderating listings on a marketplace in TypeScript.

How Jev works

Okay, so you understand what Jev is for and feel like you’re on the right track. Now, the question is: what does a Jev request look like under the hood?

Let’s start from the beginning. Every Jev request consists of a state and a set of questions. The state is the input that Jev should evaluate. It could be a string, JSON object, or array of strings. If you’re attempting to evaluate a sequence of items, such as messages in a conversation, the array is going to be the way to go. The questions are a list of judgments that Jev should perform on the state. A question consists of two elements, namely, instructions and criteria. The instructions describe the judgment that should be performed. The criteria describe the possible answers. When evaluating the state, Jev will determine how well each of the criteria fits the state and return that as a result. When the state is a JSON object, the instructions can refer to a named field in the state by wrapping it in backticks. We refer to this as the field path. For example, a valid field path could be listing.description. When this instruction is provided, Jev will read the value in the named field and perform the judgment on it.

The question types are choice, noul, and score. A choice question picks one option from a set defined by you. A noul question decides whether a statement is true. A score question places the subject on an ordered scale of levels you define.

Jev evaluates each of your questions individually and in parallel against the same state. It will give you only one answer per question. Each answer has a type, which is either a choice, a noul, or a score. A choice answer consists of: 1. The winning option, 2. The probability of each option, 3. A confidence between 0 and 1 that TypeSafe computes from the shape of the probability distribution. If the probability is highly concentrated in the winning option, confidence will be high. If it is spread out, it will be lower, even if the winning option has the highest probability individually. The confidence is not the probability of the winning option. It’s a summary of the entire distribution. A noul answer consists of: 1. A single probability that the statement is true, 2. No confidence, because the noul probability already expresses how confident the model is. A score answer consists of: 1. The probability-weighted average of the level numbers, 2. The probability of each level number, 3. A confidence, and 4. A legend mapping level numbers to the corresponding descriptions.

Each answer is an option or a number that your code can compare, threshold, and combine. No text is generated for you to parse. This is the reason to prefer Jev over a chat model asked for YES or NO. You get a distribution over the answers you defined, so uncertainty is a number you can route on.

Jev runs on the OpenRouter Decisions API using the typesafe/jev-1.13 model ID. Any usage against the API is billed to your OpenRouter account. A request to Jev consists solely of text, with a token budget being consumed by both the state and the questions asked. The Jev model page currently shows the budget and pricing. New users can follow the Jev tutorial to get their first answer in a few minutes.

A marketplace as the running example

Suppose you’re building a marketplace where users can list used items for sale. Sellers write a title and a description, select a category, note the item’s condition, and upload photos. Before a listing is published you want to make sure nobody is listing a prohibited item or asking to be paid off the platform. You also want to catch items in the wrong category and descriptions that contradict the title or the declared condition.

Some of these checks are just plain code, but others require actually reading the listing and interpreting what it means. This is what Jev is for. Each section below describes one part of using Jev in general, then how it works for the marketplace. The same pattern applies to ticket routing, agent tool gating, or whatever you’re building.

How Jev fits into a program

Jev is a System One model. That term is TypeSafe’s name for a model that produces typed decisions and calibrated probabilities. TypeSafe’s building guide describes how to build with such a model. Insert Jev where judgment is needed in a chain of otherwise normal software workflow. Code handles the control flow, deterministic rules, and side effects. Jev provides typed, state-based answers to a small set of questions prepared by code. Code transforms those answers into actions. Jev doesn’t decide its next action on its own.

In the marketplace, this principle creates a pipeline between a seller clicking publish and the listing going live. It takes a listing and returns publish, hold for a human, or reject with reasons.

  1. Hard rules run first, in code, such as number of photos, price bounds, and minimum text length. Listings that fail them are rejected before anything is sent to Jev.
  2. State construction combines the seller’s text with facts computed by the code, and decides what Jev sees.
  3. Judgment is one request asking Jev five typed questions about that state. Jev returns probabilities and never an action.
  4. Validation ensures the response matches the shape the policy expects, and halts processing if not.
  5. Policy compares those probabilities to thresholds calibrated on listings moderators have already decided.

Stage 3 is Jev and the other four are yours. This is the same for all Jev integrations. Jev is a state-to-evidence function, and everything else is ordinary software.

Decide what Jev decides and what your code decides

The first design question in integrating Jev is what should be sent to it for judgment. The test is whether the code can compute the answer without reading any text. If it can (a count, a date comparison, a lookup), keep it in code, where the result is exact and free. Jev is for judgment that requires interpreting natural language.

TypeSafe’s jaggedness page for Jev 1.13 lists arithmetic, exact counting, and date comparison as items that should stay in code rather than go into questions.

Applied to the marketplace, the test sorts the checks like this.

CheckWhere it livesWhy
Price within bounds, at least one photo, minimum lengthsCodeDeterministic. A model can only be less reliable than an if.
Price far below what this category usually sells forCodeArithmetic over your own sales data. Pass the result to Jev as a fact.
Item is a prohibited type (weapon, counterfeit, recalled child seat)JevSellers rarely use the banned word. “Mirror quality, same factory” never says counterfeit.
Listing asks the buyer to pay or chat off-platformJevPhone numbers are easy to regex. “I can also do the deal by phone” is not.
Item fits the category the seller choseJevSemantic judgment against a definition.
Description contradicts the title or declared conditionJevRequires comparing meaning.

The checks also define the listing type every stage shares. Every field is one of three things, a hard-rule input, a fact computed for Jev, or text Jev reads. Conditions are ordered from worst to best, so the index of a declared condition lines up with the levels of the condition score later.

// listing.ts
export const CATEGORIES = {
  electronics: 'Phones, laptops, cameras, audio gear, game consoles, and their accessories.',
  furniture: 'Tables, chairs, sofas, beds, shelving, and other household furniture.',
  clothing: 'Apparel, shoes, bags, and fashion accessories.',
  sporting_goods: 'Bikes, fitness equipment, camping gear, and equipment for playing sports.',
  toys_and_baby: 'Toys, games, strollers, car seats, cribs, and other children’s items.',
} as const;

export type Category = keyof typeof CATEGORIES;

export const CONDITIONS = ['for_parts', 'fair', 'good', 'like_new', 'new'] as const;
export type Condition = (typeof CONDITIONS)[number];

export type Listing = {
  id: string;
  title: string;
  description: string;
  category: Category;
  condition: Condition;
  priceUsd: number;
  photoCount: number;
};

The category definitions are written as prose because Jev reads them from state when judging whether a listing belongs to the category. This is a general principle. If a judgment depends on a rule of yours, express the rule in the state in prose and reference it in your question, so the model uses your definition and not its own.

Hard rules are standard code and run before any call to Jev.

// rules.ts
import type { Category, Listing } from './listing';

// Typical sale price per category, in USD. In production this comes from your own sales data.
const TYPICAL_PRICE_USD: Record<Category, number> = {
  electronics: 180,
  furniture: 120,
  clothing: 35,
  sporting_goods: 90,
  toys_and_baby: 40,
};

export type PriceSignal = 'far_below_typical' | 'typical' | 'far_above_typical';

export function priceSignal(listing: Listing): PriceSignal {
  const ratio = listing.priceUsd / TYPICAL_PRICE_USD[listing.category];
  if (ratio < 0.2) return 'far_below_typical';
  if (ratio > 10) return 'far_above_typical';
  return 'typical';
}

// Hard rules. Anything here is a fact your code already knows, so no model is involved.
export function hardRuleFailures(listing: Listing): string[] {
  const failures: string[] = [];
  if (listing.photoCount < 1) failures.push('at least one photo is required');
  if (listing.priceUsd < 0.01 || listing.priceUsd > 50_000) failures.push('price must be between $0.01 and $50,000');
  if (listing.title.trim().length < 8) failures.push('title must be at least 8 characters');
  if (listing.description.trim().length < 30) failures.push('description must be at least 30 characters');
  return failures;
}

Price bounds show the split inside a single check. The bounds are implemented in code, but a handbag listed for $120 in a category that usually sells for $1,800 points to counterfeit concerns, and interpreting that signal is a judgment. The code does the comparison and sends the finished label to Jev.

Structure the state Jev sees

State is the input half of the interface between your code and Jev. TypeSafe’s state docs describe it as what a panel of experts would receive before being asked for a judgment. Jev knows nothing about the subject except through the state, and every question in the request sees the same state.

Three rules for building the state apply in any domain. Use descriptive names for your fields, because the questions reference fields by path and the names convey meaning to the model. Include only the information the questions need, since irrelevant context distracts the model.

Pass computed facts as finished labels. Jev can use price_signal: 'far_below_typical' directly, while the raw values price_usd: 120 and typical_price: 1800 hand it a division problem.

Here is the state for one marketplace listing.

{
  "listing": {
    "title": "Louis Vuitton Neverfull MM tote",
    "description": "Mirror quality 1:1, same factory as the boutique version. Nobody can tell the difference. Text me on WhatsApp for more photos and a better price.",
    "category": "clothing",
    "declared_condition": "new",
    "price_usd": 120
  },
  "category_definition": "Apparel, shoes, bags, and fashion accessories.",
  "price_signal": "typical"
}

Seller ID, timestamps, and photo URLs are omitted because none of the five questions use them. category_definition is included because the category-fit question compares the listing against it, and price_signal because the counterfeit criterion reads it.

Choose a question type for each judgment

Each Jev question has a type, and the type should follow from what the answer means. It fixes the shape of the answer, and the shape determines what you can do with it later.

Use choice when one option from a set you define should win and your code needs to know which one. You get the winner and a probability for every option, so you can see how much probability landed on the runners-up. Use noul when the answer is a proposition that is either true or false, and you get a single probability to threshold.

You can use a score to represent a degree. It is along an ordered scale. The result is a number which you can subtract, compare with other numbers, and which gives you the measure of the differences between them.

The criteria format varies by question type. choice questions receive an object that maps each option name to its description, with a maximum of 255 options. noul questions receive an optional object with true and false descriptions. An ideal question is concise and direct, so many questions don’t even need a description object. score questions receive an ordered array of level descriptions (with between 2 and 10 levels), and the answer is measured on the zero-based index of that array.

The marketplace uses all three. Prohibited item is a choice over five named categories and a none option, because the policy must know which rule was broken (a weapon is different from a counterfeit, as are the messages they trigger and the reviewers they go to). Category fit, title and description contradiction, and off-platform contact are noul questions, since each is a yes/no proposition and the only number the policy needs is the probability. Described condition is a score over five levels, because the policy asks how far apart the declared and described conditions are, and a distance can only be measured on an ordered scale.

Independent questions belong in one request. Jev evaluates them in parallel against the same state, so five questions cost one round trip, and a listing that trips two of them (the tote later in this post does) comes back with two distinct reasons. A broad question hides multiple judgments within one answer. Breaking broad questions into narrow, atomic ones makes them much easier to inspect, tune, and combine in code.

Write Jev criteria that survive a literal reading

Instructions say what to decide, and criteria define the answers. Jev applies both literally. That literalism is part of what makes Jev predictable, and it means the criteria are doing most of the work.

A few rules hold when writing criteria for any domain. Describe the options using the vocabulary present in the input text, because a criterion that names the situation (“a shared account with login details sent after payment”) matches text that never uses your policy’s word for it. For each noul, describe both sides so that near-misses fall on the correct side. Give every choice an explicit none for when nothing fits, so the model isn’t forced to pick something arbitrary.

For a score, describe each level as a concrete situation, because the levels make up the scale. And a question ID like prohibited is the key your code reads the answer back by, so don’t expect the model to interpret it as an instruction.

The client, condition scale, and prohibited items criteria are below. The noul criteria occupy the request itself. Look further for them in the next section.

// judge.ts
import { OpenRouter } from '@openrouter/sdk';

import { CATEGORIES, CONDITIONS, type Listing } from './listing';
import { priceSignal } from './rules';

const openrouter = new OpenRouter({
  apiKey: process.env.OPENROUTER_API_KEY, // server-side only
});

export const PROHIBITED = {
  none: 'An ordinary secondhand or new item that a general marketplace allows.',
  weapon_or_weapon_part:
    'A firearm, firearm part or accessory, ammunition, stun gun, or a knife or tool marketed for fighting or self-defense.',
  medication_or_medical_claim:
    'Prescription medication, a controlled substance, or any product sold with a claim that it treats, cures, or prevents a medical condition.',
  counterfeit_or_replica:
    'An item that carries a brand name, logo, or signature design without being made by that brand, including items called replica, AAA, 1:1, mirror, inspired by, or same factory. A brand-name item whose `price_signal` is far_below_typical with no reason given for the low price also fits here.',
  recalled_or_unsafe_child_item:
    'A car seat, crib, bassinet, or child helmet that has been in a crash, is missing parts or straps, is described as recalled, or has no visible manufacture date or label.',
  account_or_digital_access:
    'Login credentials, subscription or streaming accounts, gift card codes, license keys, or in-game currency.',
} as const;

export type ProhibitedKind = keyof typeof PROHIBITED;

// Score levels are ordered from worst to best, and each one describes a concrete situation.
export const CONDITION_LEVELS = [
  'Does not work or is missing parts. Sold for parts or repair.',
  'Works, but has clear wear, damage, stains, or missing accessories that the buyer would notice immediately.',
  'Works fully. Light wear from normal use. Nothing broken or missing.',
  'Works fully and looks unused or nearly unused. Original packaging or accessories may be included.',
  'Brand new. Sealed, tagged, or never used.',
] as const;

Call the Jev API from TypeScript

You reach Jev through the OpenRouter Decisions API. The request has three components, model, state, and questions. The response has answers keyed by your question IDs, each tagged with its type, plus usage with the cost.

Do you need a special API key? Does it need to be linked to a TypeSafe account? No! Any valid OpenRouter API key is fine. No need for a TypeSafe account to use the Decisions API. There are only two setup steps: install the SDK with bun add --exact @openrouter/sdk@1.3.17, then set the OPENROUTER_API_KEY environment variable to your key. The full schema for the Decisions API is on the reference page.

This function sends the marketplace state and all five questions, then parses the response into a plain Judgment the policy can read.

// judge.ts, continued
export type Judgment = {
  prohibited: { kind: ProhibitedKind; confidence: number; probabilities: Record<string, number> };
  matchesCategory: number;
  descriptionContradictsTitle: number;
  offsiteTransaction: number;
  describedCondition: { score: number; confidence: number };
  costUsd: number;
};

function isProhibitedKind(value: string): value is ProhibitedKind {
  return Object.hasOwn(PROHIBITED, value);
}

export async function judgeListing(listing: Listing): Promise<Judgment> {
  const result = await openrouter.alpha.decisions.create({
    decisionsRequest: {
      model: 'typesafe/jev-1.13',
      state: {
        listing: {
          title: listing.title,
          description: listing.description,
          category: listing.category,
          declared_condition: listing.condition,
          price_usd: listing.priceUsd,
        },
        category_definition: CATEGORIES[listing.category],
        price_signal: priceSignal(listing),
      },
      questions: {
        prohibited: {
          type: 'choice',
          instructions: 'Which prohibited category, if any, does the item in `listing.title` and `listing.description` fall into? `price_signal` compares `listing.price_usd` with the typical price for its category.',
          criteria: PROHIBITED,
        },
        matches_category: {
          type: 'noul',
          instructions: 'Does the item described in `listing.title` and `listing.description` belong in the category defined by `category_definition`?',
          criteria: {
            true: 'The item is the kind of thing the category definition describes.',
            false: 'The item belongs in a different category, or the listing does not describe a physical item at all.',
          },
        },
        description_contradicts_title: {
          type: 'noul',
          instructions: 'Does `listing.description` contradict `listing.title` about the brand, model, size, quantity, or whether the item works?',
          criteria: {
            true: 'The two disagree on at least one of those facts, such as a title that says one brand and a description that names another.',
            false: 'The description adds detail or repeats the title without contradicting it.',
          },
        },
        offsite_transaction: {
          type: 'noul',
          instructions: 'Does the listing ask the buyer to contact the seller, pay, or complete the sale outside the marketplace?',
          criteria: {
            true: 'The text gives a phone number, email, messaging app handle, external link, or asks for wire transfer, cash app, crypto, or gift card payment.',
            false: 'The listing stays within the marketplace, including local pickup arranged through the marketplace.',
          },
        },
        described_condition: {
          type: 'score',
          instructions: 'Based only on `listing.description`, which level best describes the physical condition of the item?',
          criteria: [...CONDITION_LEVELS],
        },
      },
    },
  });

  const { prohibited, matches_category, description_contradicts_title, offsite_transaction, described_condition } =
    result.answers;

  if (
    prohibited?.type !== 'choice' ||
    matches_category?.type !== 'noul' ||
    description_contradicts_title?.type !== 'noul' ||
    offsite_transaction?.type !== 'noul' ||
    described_condition?.type !== 'score'
  ) {
    throw new Error('Unexpected answer types in Decisions response');
  }
  if (!isProhibitedKind(prohibited.choice)) {
    throw new Error(`Unknown prohibited kind ${prohibited.choice}`);
  }
  if (prohibited.confidence === undefined || prohibited.probabilities === undefined) {
    throw new Error('Choice answer did not include confidence and probabilities');
  }
  if (described_condition.confidence === undefined) {
    throw new Error('Score answer did not include confidence');
  }
  if (result.usage.cost === undefined) {
    throw new Error('Response did not include usage.cost');
  }

  return {
    prohibited: {
      kind: prohibited.choice,
      confidence: prohibited.confidence,
      probabilities: prohibited.probabilities,
    },
    matchesCategory: matches_category.noul,
    descriptionContradictsTitle: description_contradicts_title.noul,
    offsiteTransaction: offsite_transaction.noul,
    describedCondition: {
      score: described_condition.score,
      confidence: described_condition.confidence,
    },
    costUsd: result.usage.cost,
  };
}

export function declaredConditionIndex(listing: Listing): number {
  return CONDITIONS.indexOf(listing.condition);
}

In the offsite_transaction criteria, the local-pickup line makes use of the both-sides rule. Question: what would happen if we didn’t use the both-sides rule here? That might allow someone who reads literally to interpret “pickup arranged through the marketplace” as an off-platform contact.

The Judgment is the output part of the interface. It includes evidence and nothing else, meaning the winner and its distribution, three probabilities, a score with its confidence, and the cost.

The block of throw statements is the validation stage. The SDK’s type definitions leave confidence and probabilities optional, which means a response could arrive without them. Providing a default would mask a broken response behind a plausible decision. So the function refuses, and the orchestrator turns the refusal into a hold, the same place a network failure or an API error lands.

Same request over plain HTTP

In this example, we’ll see an SDK call (which in essence is a wrapped POST https://openrouter.ai/api/alpha/decisions). The example also includes two condensed questions and their output. The first question, the noul, is without its criteria, and that is the point, to demonstrate that they are optional. The response id will not match yours.

curl https://openrouter.ai/api/alpha/decisions \
  -H "Authorization: Bearer $OPENROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "typesafe/jev-1.13",
    "state": {
      "listing": {
        "title": "Peloton Bike, original model",
        "description": "Works great, we just never use it. Screen has a dead pixel line down the left side and the right pedal squeaks. Selling as is, no shoes or mat.",
        "declared_condition": "like_new"
      }
    },
    "questions": {
      "offsite_transaction": {
        "type": "noul",
        "instructions": "Does the listing ask the buyer to contact the seller, pay, or complete the sale outside the marketplace?"
      },
      "described_condition": {
        "type": "score",
        "instructions": "Based only on `listing.description`, which level best describes the physical condition of the item?",
        "criteria": [
          "Does not work or is missing parts. Sold for parts or repair.",
          "Works, but has clear wear, damage, stains, or missing accessories that the buyer would notice immediately.",
          "Works fully. Light wear from normal use. Nothing broken or missing.",
          "Works fully and looks unused or nearly unused. Original packaging or accessories may be included.",
          "Brand new. Sealed, tagged, or never used."
        ]
      }
    }
  }'
{
  "model": "typesafe/jev-1.13-20260917",
  "answers": {
    "offsite_transaction": { "type": "noul", "noul": 0.05 },
    "described_condition": {
      "type": "score",
      "score": 1,
      "legend": {
        "0": "Does not work or is missing parts. Sold for parts or repair.",
        "1": "Works, but has clear wear, damage, stains, or missing accessories that the buyer would notice immediately.",
        "2": "Works fully. Light wear from normal use. Nothing broken or missing.",
        "3": "Works fully and looks unused or nearly unused. Original packaging or accessories may be included.",
        "4": "Brand new. Sealed, tagged, or never used."
      },
      "probabilities": { "0": 0, "1": 1, "2": 0, "3": 0, "4": 0 },
      "confidence": 1
    }
  },
  "usage": { "input_tokens": 492, "output_tokens": 38, "cost": 0.000020664 },
  "id": "gen-dec-1790099229-ahAseXX5gNJzoLiCZICn",
  "provider": "TypeSafe"
}

In this response, a seller has declared an item like_new, with a Jev score of 1 on a scale from 0 through 4. The answers are keyed to question IDs and tagged by type, along with their associated probabilities and confidence. The response’s model names the dated build of typesafe/jev-1.13 that produced it.

Turn probabilities into publish, hold, or reject

Jev returns evidence, and policy turns it into an action. TypeSafe’s confidence docs give the general shape. Act on high confidence, send medium confidence to a human, and refuse to act on low confidence. Where those lines sit depends on what a wrong action costs, so different actions in the same system get different thresholds. Because the questions are independent, policy can also combine them. Each failing check adds a hold reason, and a check that is strong enough on its own rejects.

The marketplace has three actions. Auto-reject is reserved for evidence strong enough that a wrongly rejected seller is rarer than a wrongly published violation. Anything that looks wrong without that strength goes to a human. Publish is what’s left when nothing fires.

// moderate.ts
import { declaredConditionIndex, judgeListing, type Judgment } from './judge';
import type { Listing } from './listing';
import { hardRuleFailures } from './rules';

export type Action = 'publish' | 'hold' | 'reject';

export type Decision = {
  action: Action;
  reasons: string[];
  judgment?: Judgment;
};

export const THRESHOLDS = {
  rejectProhibitedConfidence: 0.8,
  holdNoneConfidence: 0.5,
  holdConditionConfidence: 0.3,
  rejectOffsite: 0.8,
  holdOffsite: 0.4,
  holdContradiction: 0.6,
  holdCategoryMismatch: 0.3,
  holdConditionGap: 1.5,
};

// Pure policy: turns saved answers into an action, so thresholds can be replayed without new inference.
export function decide(listing: Listing, judgment: Judgment, thresholds = THRESHOLDS): Decision {
  const reasons: string[] = [];
  const { prohibited, offsiteTransaction, descriptionContradictsTitle, matchesCategory, describedCondition } = judgment;

  if (prohibited.kind !== 'none' && prohibited.confidence >= thresholds.rejectProhibitedConfidence) {
    reasons.push(`prohibited: ${prohibited.kind} (confidence ${prohibited.confidence.toFixed(2)})`);
  }
  if (offsiteTransaction >= thresholds.rejectOffsite) {
    reasons.push(`asks to transact off platform (p=${offsiteTransaction.toFixed(2)})`);
  }
  if (reasons.length > 0) {
    return { action: 'reject', reasons, judgment };
  }

  if (prohibited.kind !== 'none') {
    reasons.push(`possible prohibited item: ${prohibited.kind} (confidence ${prohibited.confidence.toFixed(2)})`);
  }
  if (prohibited.kind === 'none' && prohibited.confidence < thresholds.holdNoneConfidence) {
    reasons.push(`unsure the item is allowed (none at confidence ${prohibited.confidence.toFixed(2)})`);
  }
  if (offsiteTransaction >= thresholds.holdOffsite) {
    reasons.push(`may ask to transact off platform (p=${offsiteTransaction.toFixed(2)})`);
  }
  if (descriptionContradictsTitle >= thresholds.holdContradiction) {
    reasons.push(`description contradicts title (p=${descriptionContradictsTitle.toFixed(2)})`);
  }
  if (matchesCategory < thresholds.holdCategoryMismatch) {
    reasons.push(`probably miscategorized (p=${matchesCategory.toFixed(2)} that it fits ${listing.category})`);
  }
  if (describedCondition.confidence < thresholds.holdConditionConfidence) {
    reasons.push(`unsure about the described condition (confidence ${describedCondition.confidence.toFixed(2)})`);
  }
  const conditionGap = declaredConditionIndex(listing) - describedCondition.score;
  if (conditionGap >= thresholds.holdConditionGap) {
    reasons.push(`declared ${listing.condition} but the description reads ${describedCondition.score.toFixed(1)} on the 0 to 4 scale`);
  }

  return { action: reasons.length > 0 ? 'hold' : 'publish', reasons, judgment };
}

export async function moderate(listing: Listing): Promise<Decision> {
  const failures = hardRuleFailures(listing);
  if (failures.length > 0) {
    return { action: 'reject', reasons: failures };
  }
  try {
    const judgment = await judgeListing(listing);
    return decide(listing, judgment);
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    return { action: 'hold', reasons: [`judgment unavailable: ${message}`] };
  }
}

decide() is pure. It takes a listing and a saved judgment and returns an action with reasons, and it makes no network calls. That purity is what makes tuning cheap later, because you can replay it over answers you already paid for. Each reason names the question and the number that triggered it, so a reviewer sees what the system saw.

The reject checks run first and return early. The hold checks are cumulative, so an ambiguous listing arrives in the queue with every applicable reason attached.

Prohibited checks are based purely on confidence rather than looking at the probability of the winning answer. This is because a winning answer is not necessarily the same as a confident one. The specific checks are: if none wins, and the remaining probability is distributed between a number of prohibited options, then that is a plurality for the ‘fine’ answer. This should not result in a publication. The reason the condition floor is lower than the prohibited one is that it is possible for a score falling between two levels to spread probability to the adjacent levels, even if the description is clear. The condition gap is calculated by subtracting Jev’s score from the declared level’s index. This is why this mechanism is implemented in code and why it only triggers in one direction. For example, if a seller declares like_new but describes fair, a hold is recorded. However, if the seller has undersold a good item, no hold is recorded, since the buyer is the one being protected.

moderate() owns the flow, running hard rules, then judgment, then policy. A listing with no photos never reaches Jev, and the catch turns any failed judgment into a hold with the error as the reason.

There are two properties of this shape. First, any failure along the model path is enough to put the listing in the hold queue and prevent it from proceeding to publication. Second, policy is a pure function of saved answers, so changing a threshold just replays judgments you already paid for, with no new calls to Jev.

Every number in THRESHOLDS is a starting point. If the hold queue fills with harmless listings, raise the threshold that put them there. If violations slip through, lower it.

Set confidence thresholds from a labeled sample

Confidence on a choice or score measures how peaked the probability distribution is. TypeSafe’s confidence docs are blunt that it says nothing about accuracy on your domain. So a threshold is a judgment call based on your data and your costs. Reject an innocent seller and you lose a seller. Publish a counterfeit and the buyer loses money, and you may hear from a lawyer.

The tuning process is the same for every Jev integration. Take examples a human has already decided, judge each once, save the answers, and replay candidate thresholds over them. For the marketplace I labeled 24 listings as publish, hold, or reject, mixing clean listings, clear violations, and the border cases a real queue produces (a chef’s knife filed under furniture, melatonin gummies, an airsoft toy).

// labeled.ts
import type { Listing } from './listing';

export type Labeled = { listing: Listing; expected: 'publish' | 'hold' | 'reject' };

// Hand-labeled for this post. In production, pull listings your moderators already decided on.
export const LABELED: Labeled[] = [
  {
    expected: 'publish',
    listing: {
      id: 'T-01', category: 'furniture', condition: 'good', priceUsd: 35, photoCount: 3,
      title: 'IKEA KALLAX shelf, 4x2, white',
      description: 'Assembled, a few scratches on the top surface. Sturdy. Pickup only, second floor with elevator.',
    },
  },
  {
    expected: 'publish',
    listing: {
      id: 'T-02', category: 'electronics', condition: 'like_new', priceUsd: 240, photoCount: 6,
      title: 'Nintendo Switch OLED with two games',
      description: 'Bought in 2024, works perfectly. Includes dock, two Joy-Cons, Zelda TOTK and Mario Kart 8. Screen has no scratches.',
    },
  },
  {
    expected: 'publish',
    listing: {
      id: 'T-03', category: 'clothing', condition: 'fair', priceUsd: 60, photoCount: 4,
      title: 'Patagonia Nano Puff jacket, women’s M',
      description: 'Worn two seasons, small snag on the left sleeve that I patched with tenacious tape. Zipper works fine. Smoke-free home.',
    },
  },
  {
    expected: 'publish',
    listing: {
      id: 'T-04', category: 'sporting_goods', condition: 'good', priceUsd: 380, photoCount: 5,
      title: 'Trek Marlin 5 mountain bike, size L',
      description: 'Ridden about 300 miles. New brake pads last month. Some chips in the paint on the down tube. Shifts clean through all gears.',
    },
  },
  {
    expected: 'publish',
    listing: {
      id: 'T-05', category: 'toys_and_baby', condition: 'new', priceUsd: 420, photoCount: 3,
      title: 'LEGO Technic Bugatti Chiron 42083, sealed',
      description: 'Sealed in the original box, never opened. Box has a small dent on one corner from storage. Retired set.',
    },
  },
  {
    expected: 'hold',
    listing: {
      id: 'T-06', category: 'furniture', condition: 'good', priceUsd: 25, photoCount: 2,
      title: 'Kitchen chef’s knife, 8 inch, Victorinox Fibrox',
      description: 'Used in my home kitchen for a year, sharpened twice. No chips in the blade. Handle is in great shape.',
    },
  },
  {
    expected: 'publish',
    listing: {
      id: 'T-07', category: 'electronics', condition: 'good', priceUsd: 1150, photoCount: 7,
      title: 'Canon EOS R6 body only',
      description: 'Shutter count around 18k. Works flawlessly. Comes with battery, charger, and strap. No lens included. Selling because I moved to Sony.',
    },
  },
  {
    expected: 'hold',
    listing: {
      id: 'T-08', category: 'toys_and_baby', condition: 'new', priceUsd: 12, photoCount: 1,
      title: 'Melatonin gummies, 2 sealed bottles',
      description: 'Bought too many during a sale. Two unopened bottles, 60 gummies each, best before 2027. Standard over-the-counter sleep supplement.',
    },
  },
  {
    expected: 'reject',
    listing: {
      id: 'T-09', category: 'sporting_goods', condition: 'new', priceUsd: 75, photoCount: 2,
      title: 'Glock 19 magazines, 3 pack',
      description: 'Three factory 15-round magazines, never loaded. Local meetup only.',
    },
  },
  {
    expected: 'reject',
    listing: {
      id: 'T-10', category: 'toys_and_baby', condition: 'new', priceUsd: 300, photoCount: 1,
      title: 'Ozempic pens, 2 left',
      description: 'Prescribed to me but I stopped using them. 2 unopened 1mg pens, kept refrigerated. Message for details.',
    },
  },
  {
    expected: 'reject',
    listing: {
      id: 'T-11', category: 'electronics', condition: 'new', priceUsd: 20, photoCount: 1,
      title: 'Netflix premium account, 1 year',
      description: 'Private 4K profile on a shared premium account. Login details sent after payment. Warranty for 12 months.',
    },
  },
  {
    expected: 'reject',
    listing: {
      id: 'T-12', category: 'clothing', condition: 'new', priceUsd: 95, photoCount: 4,
      title: 'Nike Air Jordan 1 Chicago, size 10',
      description: 'Top tier batch, UA quality, comes with box and tags. Not retail but you will not be able to tell. Can ship worldwide.',
    },
  },
  {
    expected: 'reject',
    listing: {
      id: 'T-13', category: 'toys_and_baby', condition: 'good', priceUsd: 40, photoCount: 2,
      title: 'Chicco KeyFit 30 infant car seat',
      description: 'Base included. No idea when it was made, the label came off. Straps have some fraying near the buckle but still clip in.',
    },
  },
  {
    expected: 'reject',
    listing: {
      id: 'T-14', category: 'electronics', condition: 'like_new', priceUsd: 600, photoCount: 1,
      title: 'MacBook Pro 14 M3, barely used',
      description: 'Selling fast, paying with Zelle or Venmo only, I will ship after payment clears. Email me at quicksale.mbp@example.com.',
    },
  },
  {
    expected: 'reject',
    listing: {
      id: 'T-15', category: 'sporting_goods', condition: 'new', priceUsd: 30, photoCount: 3,
      title: 'Tactical push dagger, boot knife',
      description: 'Compact fixed blade for self-defense, fits in a boot or waistband. Kydex sheath included. Razor sharp out of the box.',
    },
  },
  {
    expected: 'reject',
    listing: {
      id: 'T-16', category: 'toys_and_baby', condition: 'new', priceUsd: 45, photoCount: 1,
      title: 'Turmeric extract capsules',
      description: 'Cures joint inflammation and reverses early arthritis in 30 days. Doctors do not want you to know about this. 3 bottles.',
    },
  },
  {
    expected: 'hold',
    listing: {
      id: 'T-17', category: 'electronics', condition: 'good', priceUsd: 350, photoCount: 2,
      title: 'Samsung 55 inch 4K TV',
      description: 'It is actually an LG C1 48 inch OLED, I reused an old listing title. Works great, remote included.',
    },
  },
  {
    expected: 'hold',
    listing: {
      id: 'T-18', category: 'electronics', condition: 'like_new', priceUsd: 150, photoCount: 3,
      title: 'Dyson V11 cordless vacuum',
      description: 'Battery only holds a charge for about 5 minutes and the motor makes a grinding noise. Selling for parts or if you want to fix it.',
    },
  },
  {
    expected: 'hold',
    listing: {
      id: 'T-19', category: 'clothing', condition: 'good', priceUsd: 400, photoCount: 4,
      title: 'West Elm mid-century dining table',
      description: 'Solid wood, seats six. One leg is a bit wobbly and there is a water ring on the top. Ships freight or local pickup.',
    },
  },
  {
    expected: 'hold',
    listing: {
      id: 'T-20', category: 'sporting_goods', condition: 'good', priceUsd: 90, photoCount: 2,
      title: 'Wilson Pro Staff tennis racket',
      description: 'Great racket, restrung last month. Grip is fresh. Frame has no cracks. I can also do the deal by phone if that is easier, whatever works.',
    },
  },
  {
    expected: 'publish',
    listing: {
      id: 'T-21', category: 'clothing', condition: 'like_new', priceUsd: 40, photoCount: 3,
      title: 'Coach leather crossbody bag',
      description: 'Got it as a gift from a friend who travels a lot, not sure where she bought it. Leather feels a little stiff. Comes with dust bag.',
    },
  },
  {
    expected: 'publish',
    listing: {
      id: 'T-22', category: 'toys_and_baby', condition: 'good', priceUsd: 45, photoCount: 3,
      title: 'Airsoft M4 replica, spring powered',
      description: 'Toy airsoft rifle, orange tip intact, shoots plastic BBs. Comes with 2 magazines and safety glasses. For ages 16 and up.',
    },
  },
  {
    expected: 'publish',
    listing: {
      id: 'T-23', category: 'toys_and_baby', condition: 'like_new', priceUsd: 50, photoCount: 3,
      title: 'Graco Pack n Play playard',
      description: 'Used at grandma’s house a handful of times. All parts and the mattress pad included, manufacture label from 2024 still attached.',
    },
  },
  {
    expected: 'publish',
    listing: {
      id: 'T-24', category: 'clothing', condition: 'good', priceUsd: 30, photoCount: 2,
      title: 'Prescription glasses frames, Warby Parker',
      description: 'Frames only, lenses removed. No scratches on the frame. Case included. Take them to any optician for new lenses.',
    },
  },
];

The tuning script sweeps the highest-stakes threshold, the auto-reject confidence for prohibited items. For each candidate it prints what would be auto-rejected, how many of those match the labels, and how many go to review. It also prints the least confident answers among listings labeled publish, because that is the floor the confidence-only holds have to stay under. For thousands of listings, borrow the bounded concurrency from the classification cookbook.

// tune.ts
import { judgeListing } from './judge';
import { LABELED } from './labeled';
import { decide } from './moderate';

// Judge every labeled listing once, then replay candidate thresholds over the saved answers.
const judged = await Promise.all(
  LABELED.map(async ({ listing, expected }) => ({ listing, expected, judgment: await judgeListing(listing) })),
);
type Judged = (typeof judged)[number];

function leastConfident(rows: Judged[], confidenceOf: (row: Judged) => number): Judged | undefined {
  return rows.reduce<Judged | undefined>((min, row) => (min === undefined || confidenceOf(row) < confidenceOf(min) ? row : min), undefined);
}

const totalCost = judged.reduce((sum, row) => sum + row.judgment.costUsd, 0);
console.log(`judged ${judged.length} listings for $${totalCost.toFixed(6)}\n`);

console.log('threshold  auto_rejected  correct  wrong  sent_to_review');
for (const threshold of [0.5, 0.6, 0.7, 0.8, 0.9, 0.95]) {
  const flagged = judged.filter((row) => row.judgment.prohibited.kind !== 'none');
  const autoRejected = flagged.filter((row) => row.judgment.prohibited.confidence >= threshold);
  const correct = autoRejected.filter((row) => row.expected === 'reject').length;
  const wrong = autoRejected.length - correct;
  const sentToReview = flagged.length - autoRejected.length;
  console.log(
    `${threshold.toFixed(2).padEnd(10)} ${String(autoRejected.length).padEnd(14)} ${String(correct).padEnd(8)} ${String(wrong).padEnd(6)} ${sentToReview}`,
  );
}

console.log('\nlistings Jev flagged as prohibited:');
for (const { listing, expected, judgment } of judged) {
  if (judgment.prohibited.kind === 'none') continue;
  console.log(`  ${listing.id} expected=${expected.padEnd(7)} ${judgment.prohibited.kind}@${judgment.prohibited.confidence.toFixed(2)}`);
}

console.log('\nlistings labeled reject that Jev did not flag as prohibited:');
for (const { listing, expected, judgment } of judged) {
  if (expected !== 'reject' || judgment.prohibited.kind !== 'none') continue;
  console.log(`  ${listing.id} offsite=${judgment.offsiteTransaction.toFixed(2)}`);
}

console.log('\nleast confident answers among listings labeled publish:');
const publishable = judged.filter((row) => row.expected === 'publish');
const shakiestNone = leastConfident(
  publishable.filter((row) => row.judgment.prohibited.kind === 'none'),
  (row) => row.judgment.prohibited.confidence,
);
console.log(shakiestNone ? `  none pick: ${shakiestNone.listing.id} @${shakiestNone.judgment.prohibited.confidence.toFixed(2)}` : '  none pick: no publish-labeled listing came back none');
const shakiestCondition = leastConfident(publishable, (row) => row.judgment.describedCondition.confidence);
console.log(shakiestCondition ? `  condition score: ${shakiestCondition.listing.id} @${shakiestCondition.judgment.describedCondition.confidence.toFixed(2)}` : '  condition score: no publish-labeled listings');

console.log('\nfull policy with THRESHOLDS vs labels:');
let agreed = 0;
for (const { listing, expected, judgment } of judged) {
  const decision = decide(listing, judgment);
  if (decision.action === expected) {
    agreed += 1;
    continue;
  }
  console.log(`  ${listing.id} expected=${expected} got=${decision.action} ${decision.reasons.join('; ') || '(no reasons)'}`);
}
console.log(`  ${agreed} of ${judged.length} match the label`);

Here’s the run from Jev 1.13 on September 22, 2026.

judged 24 listings for $0.001131

threshold  auto_rejected  correct  wrong  sent_to_review
0.50       9              7        2      0
0.60       8              7        1      1
0.70       8              7        1      1
0.80       7              7        0      2
0.90       7              7        0      2
0.95       7              7        0      2

listings Jev flagged as prohibited:
  T-08 expected=hold    medication_or_medical_claim@0.77
  T-09 expected=reject  weapon_or_weapon_part@1.00
  T-10 expected=reject  medication_or_medical_claim@1.00
  T-11 expected=reject  account_or_digital_access@1.00
  T-12 expected=reject  counterfeit_or_replica@0.99
  T-13 expected=reject  recalled_or_unsafe_child_item@1.00
  T-15 expected=reject  weapon_or_weapon_part@1.00
  T-16 expected=reject  medication_or_medical_claim@1.00
  T-22 expected=publish weapon_or_weapon_part@0.52

listings labeled reject that Jev did not flag as prohibited:
  T-14 offsite=0.97

least confident answers among listings labeled publish:
  none pick: T-21 @0.70
  condition score: T-24 @0.56

full policy with THRESHOLDS vs labels:
  T-22 expected=publish got=hold possible prohibited item: weapon_or_weapon_part (confidence 0.52)
  23 of 24 match the label

Three things to read off a run like this, in any domain.

First, where the clear violations land relative to the borderline cases. Here every real violation scored near 1.0 and the two borderline listings (melatonin gummies and an airsoft toy) sat well below, so any threshold in the gap between them works on this sample. Pick one with margin on both sides, because individual probabilities move between runs and a threshold sitting on top of a borderline case will flip. On this sample, 0.8 is the lowest threshold with zero wrong rejects, and that is what ships.

Second, whether the confidence floors clear your clean examples. The last block prints the least confident none and the least confident condition score among listings labeled publish. Set each floor under those values, and expect the condition floor to be the lower of the two.

Third, what the disagreements say about the questions. The one reject Jev didn’t flag as prohibited was a MacBook listing asking for Zelle, and the separate offsite_transaction question caught it, which is the atomic-question principle paying off. The one hold the labels disagree with is the airsoft toy, and I’m keeping that hold. A replica rifle getting a human glance before it goes live is a cost worth paying.

The whole run, 24 listings with five questions each, cost about a tenth of a cent. Current pricing is on the Jev model page.

Wire it all together

The entry point is short. For each listing it calls moderate() and prints the action, the reasons, and the raw numbers. Keep that logging in production, otherwise a reviewer sees a bare HOLD and has no idea which question fired.

// run.ts
import type { Listing } from './listing';
import { moderate } from './moderate';

const SAMPLES: Listing[] = [
  {
    id: 'L-101',
    title: 'Sony WH-1000XM5 headphones, black',
    description:
      'Bought in 2025, used daily for commuting. Light scuff on the right earcup, everything works, battery still lasts a full week. Comes with the case and cable.',
    category: 'electronics',
    condition: 'good',
    priceUsd: 190,
    photoCount: 4,
  },
  {
    id: 'L-102',
    title: 'Louis Vuitton Neverfull MM tote',
    description:
      'Mirror quality 1:1, same factory as the boutique version. Nobody can tell the difference. Text me on WhatsApp for more photos and a better price.',
    category: 'clothing',
    condition: 'new',
    priceUsd: 120,
    photoCount: 3,
  },
  {
    id: 'L-103',
    title: 'Graco 4Ever DLX car seat',
    description:
      'Our kid outgrew it. Was in a minor fender bender last year but looks totally fine. One of the chest clip straps is missing but you can order it online.',
    category: 'toys_and_baby',
    condition: 'like_new',
    priceUsd: 60,
    photoCount: 2,
  },
  {
    id: 'L-104',
    title: 'Peloton Bike, original model',
    description:
      'Works great, we just never use it. Screen has a dead pixel line down the left side and the right pedal squeaks. Selling as is, no shoes or mat.',
    category: 'sporting_goods',
    condition: 'like_new',
    priceUsd: 450,
    photoCount: 5,
  },
];

let total = 0;
for (const listing of SAMPLES) {
  const decision = await moderate(listing);
  total += decision.judgment?.costUsd ?? 0;
  console.log(`${listing.id} ${decision.action.toUpperCase()}`);
  for (const reason of decision.reasons) console.log(`  - ${reason}`);
  if (decision.judgment) {
    const j = decision.judgment;
    console.log(
      `  prohibited=${j.prohibited.kind}@${j.prohibited.confidence.toFixed(2)} category=${j.matchesCategory.toFixed(2)} contradiction=${j.descriptionContradictsTitle.toFixed(2)} offsite=${j.offsiteTransaction.toFixed(2)} condition=${j.describedCondition.score.toFixed(2)} cost=$${j.costUsd.toFixed(6)}`,
    );
  }
}
console.log(`total Jev cost for ${SAMPLES.length} listings: $${total.toFixed(6)}`);
L-101 PUBLISH
  prohibited=none@1.00 category=0.99 contradiction=0.04 offsite=0.03 condition=1.99 cost=$0.000048
L-102 REJECT
  - prohibited: counterfeit_or_replica (confidence 1.00)
  - asks to transact off platform (p=0.96)
  prohibited=counterfeit_or_replica@1.00 category=0.97 contradiction=0.06 offsite=0.96 condition=3.57 cost=$0.000047
L-103 REJECT
  - prohibited: recalled_or_unsafe_child_item (confidence 1.00)
  prohibited=recalled_or_unsafe_child_item@1.00 category=0.98 contradiction=0.08 offsite=0.04 condition=0.90 cost=$0.000048
L-104 HOLD
  - declared like_new but the description reads 1.0 on the 0 to 4 scale
  prohibited=none@0.99 category=0.98 contradiction=0.07 offsite=0.04 condition=1.00 cost=$0.000047
total Jev cost for 4 listings: $0.000190

Each result is a Jev principle in operation. The headphones publish with a described condition sitting right on the seller’s good, which is the score type handing policy a number it can compare. The tote is rejected for two independent reasons from two separate questions.

The car seat never says “recalled” or “unsafe” and is rejected anyway, because the criterion named the actual situations, a crash and missing straps. The Peloton is held because the seller declared like_new while describing a squeaky pedal and dead pixels, and that gap between a declared fact and a Jev judgment is what the code makes visible.

Run it again and the probabilities move by a hundredth or two while the actions stay put. That is the shape you want, policy that is steady under the noise in the evidence.

Next steps

To take this beyond the marketplace, keep the shape. Code does the deterministic work. State is minimal, holding only what the questions need, with computed facts passed as labels. Questions are narrow and typed, asked together in one request, with each type chosen by what the answer means, literal criteria, and an explicit none. Answers are evidence. Validate their shape, map them to actions with a pure policy whose thresholds come from labeled examples, and route uncertainty and failures to a person.

This approach works for routing tickets, controlling agent tools, and classifying content. Jev doesn’t generate text, so a task like “rewrite this listing title” goes to a generative model. How to pair Jev with an LLM is covered in the Jev vs LLM post.

To build this, first create an OpenRouter API key. Then add the code to your project (the first line in each code block names the file) and test it on listings from your own queue with bun run run.ts.

FAQ

How do I use Jev with OpenRouter?

Create an OpenRouter API key, install @openrouter/sdk, and call openrouter.alpha.decisions.create() with model typesafe/jev-1.13, a state object, and one or more typed questions (choice, noul, or score). Jev returns a typed answer with probabilities for each question, and your code turns those numbers into actions. The same request works over plain HTTP at POST https://openrouter.ai/api/alpha/decisions.

What is the Jev API endpoint and model ID?

Jev is served through OpenRouter’s Decisions API at POST https://openrouter.ai/api/alpha/decisions. The model ID is typesafe/jev-1.13, and the alias ~typesafe/jev-latest tracks the newest release. The request body carries the model, state, and questions.

Do I need a TypeSafe account to get access to Jev?

No. Anyone with an OpenRouter API key can use Jev, billed to your OpenRouter account. There’s no waitlist, and you don’t need a separate TypeSafe signup.

What confidence threshold should I use with Jev?

Pick it from your own labeled data and the cost of a wrong action, since Jev’s confidence measures how concentrated the probability distribution is and says nothing about accuracy on your domain. Judge a labeled sample once, save the answers, and replay candidate thresholds over them. In our 24-listing sample, every real violation scored 0.99 or higher, and 0.8 routed the two borderline cases to a reviewer.

What should I use Jev for, and what should stay in code?

Jev is good at semantic judgments on a bounded set: which category something belongs to, whether a proposition is true, and where it falls on a scale you define. Keep arithmetic, counting, date comparisons, lookups, and execution in your code, and feed the results to Jev as state. Jev can’t generate text, so tasks that involve writing still need a generative model.

来源:OpenRouter:Announcements(RSS)· openrouter.ai