Skip to content
Docs

Moderate product reviews with Jev, TanStack AI, and AI Gateway

Moderate product reviews for an e-commerce storefront with Jev from TypeSafe AI and TanStack AI's decide() function. The @tanstack/ai-vercel-gateway adapter returns typed choices, scores, and boolean probabilities through Vercel AI Gateway, so your code can publish clear reviews and hold flagged ones.

Ben SabicContent Engineer

Your storefront has a decision to make every time a customer submits a review. Before it appears under the product, something has to decide whether it's spam, whether it exposes personal information, and which section it belongs in. Keyword rules can't tell a shipping complaint from a product defect, or a discount code in spam from one a customer mentions in passing. Asking a language model means waiting on generated text and parsing it before your code can act.

Jev, a System One model from TypeSafe AI, evaluates supplied state against typed questions and returns choices, scores, and boolean probabilities without generating prose. TanStack AI exposes those answers through its decide() function, and the @tanstack/ai-vercel-gateway adapter sends each call through Vercel AI Gateway, so evaluation requests share credentials, logs, budgets, and data controls with the rest of your Gateway traffic.

Your application decides what happens next, so a topic choice can place a review in the right section, a sentiment score can feed a rating summary, and a spam flag or uncertain answer can hold the review for a moderator. Keeping those rules in code lets you change how the application responds without redefining what you ask the model to assess.

Copy link to headingOverview

In this guide, you'll learn how to:

  • Ask a single yes-or-no question about a piece of state
  • Answer several typed questions in one request from a TanStack Start app
  • Branch on probability and confidence so clear reviews publish automatically and flagged or uncertain ones go to a moderator
  • Unit-test that branching without calling Jev
  • Set Gateway data controls, cancel in-flight requests, and track token usage

Copy link to headingPrerequisites

Before you begin, make sure you have:

If you're new to Jev, read how to classify, route, and score with Jev first. That guide covers how Jev differs from a language model in more depth. This guide focuses on the TanStack AI API.

Copy link to headingHow TanStack AI evaluates with Jev

TanStack AI's decide() function takes an adapter that names the model and provider, a state to evaluate, and a map of questions built with the choice(), score(), and boolean() helpers. Unlike chat(), it doesn't stream. The call resolves to one object with a typed answer for every question key.

The @tanstack/ai-vercel-gateway adapter provides vercelGatewayDecider, which sends the request to AI Gateway's evaluation endpoint.

TanStack AI also ships an adapter that calls TypeSafe directly. Both implement the same evaluate activity, so switching means changing the adapter and nothing else.

Copy link to headingThree question helpers

Each TanStack AI helper corresponds to a Jev question type. Answers appear directly on the result under their question keys, so questions.topic becomes result.topic.

The meta key holds usage and model information and cannot be used as a question name.

HelperWhat it doesConfigurationAnswer fields
choice()Picks one option from a named setoptions: map of option keys to descriptions (null for none)type, value, probability, probabilities, confidence
score()Grades the state against an ordered rubriclevels: array of at least two labels, lowest to highesttype, value, score, probability, probabilities, legend, confidence
boolean()Estimates whether a statement is trueOptional criteria: { true, false } descriptionstype, value, probability

Reading the answer fields:

  • type is 'choice', 'score', or 'boolean', so you can narrow on it when handling answers generically.
  • value is the decision. For choice(), it's the selected option key, typed as a union of your keys. For score(), it's the label of the level nearest to score. For boolean(), it's true when probability is 0.5 or higher.
  • probability describes the returned value for choice and score answers. For boolean answers, it estimates whether the statement is true, so 0.98 is a strong yes and 0.02 is a strong no.
  • probabilities contains the full distribution, using option names for choice answers and level indices for score answers. The score answer's legend maps those indices to labels.
  • score is a fractional position on the level scale, indexed from zero. 1.08 falls between the second and third levels, and value names the nearest.
  • confidence is TypeSafe's statistic for how concentrated the distribution is, from 0 (spread evenly) to 1 (all on one answer). It's returned for choice() and score() answers, not for boolean().

The AI SDK's experimental_evaluate returns answers under result.answers and TypeSafe's confidence statistics in providerMetadata. TanStack AI's decide() places answers directly on the result, with confidence included on choice and score answers.

You can adapt the support-ticket example in the AI SDK guide to compare how the two APIs express the same routing decisions.

Copy link to headingSteps

Copy link to heading1. Install TanStack AI and the AI Gateway adapter

Install the core package and the adapter:

Terminal
pnpm i @tanstack/ai @tanstack/ai-vercel-gateway
Terminal
npm i @tanstack/ai @tanstack/ai-vercel-gateway
Terminal
yarn add @tanstack/ai @tanstack/ai-vercel-gateway
Terminal
bun add @tanstack/ai @tanstack/ai-vercel-gateway

@tanstack/ai exports decide() and the question helpers.

@tanstack/ai-vercel-gateway exports vercelGatewayDecider along with the chat, embedding, image, and summarize adapters covered in Using TanStack AI with Vercel AI Gateway.

Copy link to heading2. Authenticate with AI Gateway

The adapter reads AI_GATEWAY_API_KEY from your environment. When that variable isn't set, it falls back to VERCEL_OIDC_TOKEN. Deployments on Vercel receive an OIDC token automatically, so no key is needed in production.

For local development, connect to AI Gateway using one of the following methods. You can pull an OIDC token from your linked Vercel project or add an AI Gateway API key to .env.local.

Copy link to headingOption 1: Vercel OIDC

Link the directory to your Vercel project and pull its development environment variables. This writes a VERCEL_OIDC_TOKEN to .env.local:

Terminal
vercel link
vercel env pull .env.local

The local token expires after 12 hours. Re-run vercel env pull .env.local when a request returns an unauthorized error.

Copy link to headingOption 2: AI Gateway API key

API keys work anywhere, including CI and servers outside Vercel. Create a key in the Vercel dashboard and set it as AI_GATEWAY_API_KEY in .env.local:

.env.local
AI_GATEWAY_API_KEY=YOUR_KEY_HERE

To pass a key explicitly instead, use the create* factory. This is useful when the key comes from a service like Vercel Connect rather than an environment variable:

import { createVercelGatewayDecider } from '@tanstack/ai-vercel-gateway'
const adapter = createVercelGatewayDecider('typesafe-ai/jev', process.env.AI_GATEWAY_API_KEY!)

Whichever option you choose, keep the credential on the server. decide() runs in server code and is called from the browser over fetch.

Copy link to heading3. Ask one boolean question

Start by asking whether a customer review contains promotional content. Pass the review text as state and name the question isPromotional to read its typed answer from result.isPromotional.

src/lib/is-promotional.ts
import { decide, boolean } from '@tanstack/ai'
import { vercelGatewayDecider } from '@tanstack/ai-vercel-gateway'
export async function isPromotional(review: string) {
const result = await decide({
adapter: vercelGatewayDecider('typesafe-ai/jev'),
state: review,
questions: {
isPromotional: boolean({
instructions: 'Is this review promotional content rather than a customer opinion?',
criteria: {
true: 'The text advertises another product, store, or service, or includes links, discount codes, or contact details.',
false: 'The text describes the reviewer\'s own experience with the product.',
},
}),
},
})
return result.isPromotional.probability
}

For a review like 'Skip this one. Get the real thing 40% off at dealz-outlet dot com with code SAVE40.', expect isPromotional() to return a probability close to 1, with result.isPromotional.value set to true.

The criteria on a boolean question are optional, but they sharpen the decision by telling the model exactly what counts as true and false.

Copy link to heading4. Answer several questions against structured state in one request

Jev evaluates the questions in a request independently and in parallel. You can combine choice(), score(), and boolean() questions against the same state.

Pass a string, object, or array as state without serializing it yourself. An array, such as a message history, provides one shared context for all questions; unrelated inputs need separate evaluations.

On a headless storefront, reviews can reach your server route from a custom form built with Vercel Shop or Hydrogen, where Shopify or another commerce backend serves the product data, or through a webhook from a reviews app.

The following TanStack Start server route reuses an adapter created at module scope to evaluate each review's topic, sentiment, and two content flags in one call.

src/routes/api.moderate.ts
import { decide, choice, score, boolean } from '@tanstack/ai'
import { vercelGatewayDecider } from '@tanstack/ai-vercel-gateway'
import { createFileRoute } from '@tanstack/react-router'
export type Review = {
productName: string
rating: number // 1 to 5 stars, chosen by the customer
title: string
body: string
}
function parseReview(body: unknown): Review | undefined {
if (typeof body !== 'object' || body === null || !('review' in body)) return undefined
const r = (body as { review: unknown }).review
if (typeof r !== 'object' || r === null) return undefined
const { productName, rating, title, body: text } = r as Record<string, unknown>
if (typeof productName !== 'string' || typeof title !== 'string' || typeof text !== 'string') return undefined
if (typeof rating !== 'number' || !Number.isInteger(rating) || rating < 1 || rating > 5) return undefined
return { productName, rating, title, body: text }
}
const ADAPTER = vercelGatewayDecider('typesafe-ai/jev')
export function evaluateReview(review: Review) {
return decide({
adapter: ADAPTER,
state: review,
questions: {
topic: choice({
instructions: 'What is this review mainly about?',
options: {
productQuality: 'The product itself, including how it works, its build, fit, or durability',
shipping: 'Delivery speed, packaging, or damage in transit',
customerService: 'Support interactions, returns, or refunds',
notAReview: 'A question, unrelated text, or content that is not about a purchase',
},
}),
sentiment: score({
instructions: 'How does the reviewer feel about their purchase, based on the text?',
levels: [
'Very negative, would not buy again',
'Mostly negative with some positives',
'Mixed or neutral',
'Mostly positive with minor complaints',
'Very positive, would recommend',
],
}),
isPromotional: boolean({
instructions: 'Is this review promotional content rather than a customer opinion?',
}),
mentionsPersonalInfo: boolean({
instructions: 'Does the review include personal information such as full names, phone numbers, email addresses, or order numbers?',
}),
},
})
}
export type ReviewEvaluation = Awaited<ReturnType<typeof evaluateReview>>
export const Route = createFileRoute('/api/moderate')({
server: {
handlers: {
POST: async ({ request }) => {
let body: unknown
try {
body = await request.json()
} catch {
return new Response('Request body must be JSON', { status: 400 })
}
const review = parseReview(body)
if (!review) {
return new Response('Invalid review', { status: 400 })
}
return Response.json(await evaluateReview(review))
},
},
},
})

TanStack Start exposes src/routes/api.moderate.ts at /api/moderate.

parseReview checks the four fields Jev will see and rejects anything else with a 400. For a production form, a schema library such as Zod does the same job with less code. Whether the reviewer is a verified purchaser belongs to your commerce backend, not the request body, so look it up there when your publish rules need it.

The supplied state includes the customer's star rating, while the sentiment question asks Jev to assess the review text. Comparing them can reveal mismatches, such as a five-star review describing a poor experience with the product.

Start the dev server and send a product review:

Terminal
pnpm dev
Terminal
npm run dev
Terminal
yarn dev
Terminal
bun dev
Terminal
curl -X POST http://localhost:3000/api/moderate \
-H "Content-Type: application/json" \
-d '{
"review": {
"productName": "Trailhead 40L Backpack",
"rating": 2,
"title": "Straps frayed within a month",
"body": "The bag looks great, but the shoulder straps started fraying after three weeks of commuting. Shipping was fast, no complaints there."
}
}'

The response contains a typed answer under each question's key, with the resolved model and token usage in meta. Jev reports completionTokens but bills only for input tokens. The example below shows possible values for this review, which the policy in step five maps to a moderation decision using fixed thresholds:

{
"topic": {
"type": "choice",
"value": "productQuality",
"probability": 0.89,
"confidence": 0.84,
"probabilities": { "productQuality": 0.89, "shipping": 0.09, "customerService": 0.01, "notAReview": 0.01 }
},
"sentiment": {
"type": "score",
"value": "Mostly negative with some positives",
"score": 1.08,
"probability": 0.71,
"confidence": 0.66,
"legend": {
"0": "Very negative, would not buy again",
"1": "Mostly negative with some positives",
"2": "Mixed or neutral",
"3": "Mostly positive with minor complaints",
"4": "Very positive, would recommend"
},
"probabilities": { "0": 0.12, "1": 0.71, "2": 0.14, "3": 0.03, "4": 0 }
},
"isPromotional": { "type": "boolean", "value": false, "probability": 0.02 },
"mentionsPersonalInfo": { "type": "boolean", "value": false, "probability": 0.01 },
"meta": {
"model": "typesafe-ai/jev",
"usage": { "promptTokens": 312, "completionTokens": 21, "totalTokens": 333 }
}
}

Reading the response:

  • topic.value is typed from the declared option keys, so TypeScript flags comparisons with an unknown option, such as value === 'quality'.
  • topic.probabilities covers every option, and the selected value always has the highest probability. Here the review mentions shipping, and the distribution reflects that without changing the answer.
  • sentiment.value names the level nearest to sentiment.score, which isn't always the level with the highest probability. sentiment.legend maps the index keys in sentiment.probabilities back to their labels.
  • isPromotional.probability is the estimated probability that the review is promotional, not a confidence in the answer.

Copy link to heading5. Branch on probability and confidence

Use the returned statistics to decide which reviews can publish automatically and which need human review. Set stricter thresholds where a wrong decision has greater consequences.

Publishing spam or someone's phone number is costly and holding a review for a moderator is cheap, so the two flags use a low floor while the topic gate is stricter.

The moderation policy below uses three paths:

ConditionWhat your code does
isPromotional or mentionsPersonalInfo probability is ≥ 0.3Hold the review and record which flag tripped
Topic confidence is below 0.6, the selected option's probability is below 0.7, or the topic is notAReviewHold the review as unclear or off-topic
OtherwisePublish the review in the section for its topic

Keep the moderation policy in a pure function that accepts the evaluation result. Separating it from decide() lets the tests in step six check policy decisions using fixed answer objects, without calling Jev.

src/lib/moderate-review.ts
import type { ReviewEvaluation } from '../routes/api.moderate'
type Answers = Pick<ReviewEvaluation, 'topic' | 'sentiment' | 'isPromotional' | 'mentionsPersonalInfo'>
export function moderateReview({ topic, sentiment, isPromotional, mentionsPersonalInfo }: Answers) {
// Publishing spam or personal data is costly. Holding a review is cheap.
if (isPromotional.probability >= 0.3) {
return { action: 'hold' as const, reason: 'possible promotional content' }
}
if (mentionsPersonalInfo.probability >= 0.3) {
return { action: 'hold' as const, reason: 'possible personal information' }
}
// Below either floor, the model can't tell what the review is about. Don't guess.
if (topic.confidence < 0.6 || topic.probability < 0.7 || topic.value === 'notAReview') {
return { action: 'hold' as const, reason: 'unclear or off-topic review' }
}
return {
action: 'publish' as const,
section: topic.value, // narrowed to 'productQuality' | 'shipping' | 'customerService'
sentiment: sentiment.value,
}
}

Then update the POST handler from step four to return the moderation decision instead of the raw answers:

// src/routes/api.moderate.ts (handler only)
import { moderateReview } from '../lib/moderate-review'
// ...inside createFileRoute
POST: async ({ request }) => {
let body: unknown
try {
body = await request.json()
} catch {
return new Response('Request body must be JSON', { status: 400 })
}
const review = parseReview(body)
if (!review) {
return new Response('Invalid review', { status: 400 })
}
const evaluation = await evaluateReview(review)
return Response.json(moderateReview(evaluation))
},

Sending the same curl request from step four now returns a decision:

{
"action": "publish",
"section": "productQuality",
"sentiment": "Mostly negative with some positives"
}

action: 'publish' means the evaluation passed the moderation thresholds above. Whether the review goes live still depends on rules Jev doesn't see, such as a verified-purchase requirement, per-account review limits, or a product that has been delisted, so keep those as a second check in your code.

Treat the example thresholds as starting points. Calibration describes how predicted probabilities match observed outcomes across many examples; it doesn't guarantee an individual answer is correct. Run labeled reviews through the same questions, then choose cutoffs based on the errors your workflow can tolerate.

Copy link to heading6. Test the moderation policy without calling Jev

Because moderateReview takes a result object rather than a review, you can test each branch with fixed answers and no network call.

Add Vitest as a development dependency:

Terminal
pnpm add -D vitest
Terminal
npm install -D vitest
Terminal
yarn add -D vitest
Terminal
bun add -d vitest

The clearAnswers fixture is typed against ReviewEvaluation, so TypeScript catches an answer shape that drifts from the questions in step four. Each hold test isolates one condition, so a test fails if that condition is removed from the policy, and the boundary tests pin the 0.3, 0.6, and 0.7 cutoffs.

src/lib/moderate-review.test.ts
import { describe, expect, it } from 'vitest'
import type { ReviewEvaluation } from '../routes/api.moderate'
import { moderateReview } from './moderate-review'
type Answers = Pick<ReviewEvaluation, 'topic' | 'sentiment' | 'isPromotional' | 'mentionsPersonalInfo'>
const legend = {
0: 'Very negative, would not buy again',
1: 'Mostly negative with some positives',
2: 'Mixed or neutral',
3: 'Mostly positive with minor complaints',
4: 'Very positive, would recommend',
}
const clearAnswers: Answers = {
topic: {
type: 'choice',
value: 'productQuality',
probability: 0.89,
confidence: 0.84,
probabilities: { productQuality: 0.89, shipping: 0.09, customerService: 0.01, notAReview: 0.01 },
},
sentiment: {
type: 'score',
value: 'Mostly negative with some positives',
score: 1.08,
probability: 0.71,
confidence: 0.66,
legend,
probabilities: { 0: 0.12, 1: 0.71, 2: 0.14, 3: 0.03, 4: 0 },
},
isPromotional: { type: 'boolean', value: false, probability: 0.02 },
mentionsPersonalInfo: { type: 'boolean', value: false, probability: 0.01 },
}
describe('moderateReview', () => {
it('publishes when the topic is clear and no flag trips', () => {
expect(moderateReview(clearAnswers)).toEqual({
action: 'publish',
section: 'productQuality',
sentiment: 'Mostly negative with some positives',
})
})
it('publishes at exactly the confidence and probability floors', () => {
const decision = moderateReview({
...clearAnswers,
topic: { ...clearAnswers.topic, confidence: 0.6, probability: 0.7 },
})
expect(decision.action).toBe('publish')
})
it('holds when the promotional flag reaches its floor', () => {
const decision = moderateReview({
...clearAnswers,
isPromotional: { type: 'boolean', value: false, probability: 0.3 },
})
expect(decision).toEqual({ action: 'hold', reason: 'possible promotional content' })
})
it('holds when the personal information flag trips', () => {
const decision = moderateReview({
...clearAnswers,
mentionsPersonalInfo: { type: 'boolean', value: true, probability: 0.9 },
})
expect(decision).toEqual({ action: 'hold', reason: 'possible personal information' })
})
it('holds when topic confidence alone is too low', () => {
const decision = moderateReview({
...clearAnswers,
topic: { ...clearAnswers.topic, confidence: 0.59 },
})
expect(decision).toEqual({ action: 'hold', reason: 'unclear or off-topic review' })
})
it('holds when the selected topic probability alone is too low', () => {
const decision = moderateReview({
...clearAnswers,
topic: { ...clearAnswers.topic, probability: 0.69 },
})
expect(decision).toEqual({ action: 'hold', reason: 'unclear or off-topic review' })
})
it('holds when the text is not a review, even with high confidence', () => {
const decision = moderateReview({
...clearAnswers,
topic: {
type: 'choice',
value: 'notAReview',
probability: 0.95,
confidence: 0.93,
probabilities: { productQuality: 0.02, shipping: 0.02, customerService: 0.01, notAReview: 0.95 },
},
})
expect(decision).toEqual({ action: 'hold', reason: 'unclear or off-topic review' })
})
})

Run the tests:

Terminal
pnpm vitest run src/lib/moderate-review.test.ts
Terminal
npx vitest run src/lib/moderate-review.test.ts
Terminal
yarn vitest run src/lib/moderate-review.test.ts
Terminal
bunx vitest run src/lib/moderate-review.test.ts

Expected output:

✓ src/lib/moderate-review.test.ts (7 tests)
Test Files 1 passed (1)
Tests 7 passed (7)

Testing the thresholds this way is separate from checking whether the thresholds are right for your data. For that, run real reviews with known moderation outcomes through Jev and compare its probabilities to what happened.

Copy link to heading7. Set Gateway options, cancel requests, and track usage

Alongside adapter, state, and questions, decide() accepts options for data controls, cancellation, and monitoring:

  • Set modelOptions.gateway to apply controls such as Zero Data Retention and No Training. The adapter forwards these settings as providerOptions.gateway.
  • Pass an abortSignal to cancel an in-flight request when your application aborts it or a timeout expires.
  • Add observe-only middleware to track completion, errors, and cancellation. Its onUsage hook receives the token counts for each call.

Evaluation requests also appear in AI Gateway logs and count toward budgets.

import { decide, boolean } from '@tanstack/ai'
import { vercelGatewayDecider } from '@tanstack/ai-vercel-gateway'
const controller = new AbortController()
setTimeout(() => controller.abort(), 5000)
const result = await decide({
adapter: vercelGatewayDecider('typesafe-ai/jev'),
state: 'Skip this one. Get the real thing 40% off at dealz-outlet dot com with code SAVE40.',
questions: {
isPromotional: boolean({ instructions: 'Is this review promotional content rather than a customer opinion?' }),
},
modelOptions: {
gateway: {
disallowPromptTraining: true,
zeroDataRetention: true,
},
},
abortSignal: controller.signal,
middleware: [
{
name: 'usage-logger',
onUsage: (_ctx, usage) => {
console.log(`prompt tokens: ${usage.promptTokens}`)
},
},
],
})
console.log(result.isPromotional.value)

Jev charges for input tokens only, so promptTokens is what you're billed for. To emit OpenTelemetry spans for evaluate calls instead of logging, pass TanStack AI's otelMiddleware() in the same middleware array.

Copy link to headingOther storefront decisions that fit a typed question

Other e-commerce tasks also involve interpreting customer requests or product information to choose from a defined set of outcomes.

The examples below use Jev to assess the supplied record, while application code applies thresholds and business rules to decide what happens next.

DecisionStateQuestionsYour code then
Route a return requestOrder details and the customer's messagechoice() for reason (defective, wrong item, changed mind, not received); boolean() for whether the item is described as usedAuto-approve within policy, otherwise queue for an agent
Triage a product questionThe question and product metadatachoice() for intent (fit or sizing, compatibility, availability, shipping); boolean() for whether it's a support issueAnswer from product data, or hand off to support
Classify a search queryThe raw query and recent catalog termschoice() for intent (browse a category, find a specific product, check an order); score() for how specific the query isPick the results page layout, or redirect to order lookup
Categorize a new catalog itemSupplier title, description, and attributeschoice() over your leaf categories, or one call per level that passes the previous selection in statePlace the product, or hold it for a merchandiser when confidence is low
Flag a risky order noteOrder note and shipping detailsboolean() for a delivery instruction that conflicts with the address; boolean() for a request to alter the invoicePause fulfillment for review
Score a support chat for handoffThe conversation so farscore() for customer frustration; boolean() for a cancellation requestEscalate to a person before the customer asks

Your application uses Jev's classifications and scores alongside inventory, account history, and business rules to decide whether to approve a return, change an order, or escalate a request. Explore seven practical Jev use cases for patterns outside commerce.

Copy link to headingBest practices

Copy link to headingAsk atomic questions and combine them in code

Jev works best when each question asks one well-scoped thing that a knowledgeable person could answer in a few seconds. If a question requires extended reasoning or weighs several independent factors, split it into one question per factor and combine the answers with your own logic. Each question is evaluated independently, so adding one doesn't change the answers to the others.

Instead ofAskThen
"Rate this pull request"3 score() questions: test coverage, documentation, description clarityWeight the three scores by importance in code
"Is this ticket a priority?"A boolean() for urgency, a score() for business impact, a choice() for customer tierCompute ticket priority from all three answers
"Should the agent run this command?"A boolean() for destructive intent, a boolean() for touching production, a choice() for categoryRequire confirmation when any risk flag exceeds your threshold

Copy link to headingDescribe options and levels instead of labeling them

choice() options are a map of keys to descriptions, and score() levels are ordered descriptions from lowest to highest. Writing 'Mostly negative with some positives' gives the model far more to match against than 'negative'. When an option key needs no description, pass null.

Copy link to headingKeep state focused

Input tokens are the only thing you pay for, so pass the fields the decision depends on rather than an entire record. For a review, that's the product name, title, body, and rating, not the customer's account or order history. state accepts an object, so you can select fields without serializing them yourself.

Copy link to headingSet thresholds per action, not per model

Read-only actions like showing a screen can tolerate a wrong guess, so a probability of 0.7 might be enough. Destructive actions need a higher bar, closer to 0.9 or above. Set the numbers from reviews you've already moderated, weighing that publishing spam costs more than holding a good review, and send anything under the threshold to a moderator rather than rejecting it.

Keep classification separate from authorization. Jev can tell you that a review looks promotional or that a command looks destructive. Whether to publish the review or run the command depends on rules Jev doesn't see, such as account status, policy, and permissions, and those checks apply whatever the probability.

Copy link to headingCreate the adapter once

Construct vercelGatewayDecider('typesafe-ai/jev') at module scope rather than inside a request handler. The adapter holds no per-request state, and reusing it avoids repeating the environment lookup on every call.

Copy link to headingTroubleshooting

Copy link to headingAuthentication errors (401 or 403)

Neither AI_GATEWAY_API_KEY nor VERCEL_OIDC_TOKEN is set, or the OIDC token has expired. Set an API key, or run vercel env pull .env.local to refresh the token, using vercel link first if the directory isn't linked to a project. If a 403 persists, check your access to the linked project and AI Gateway.

Copy link to headingdecide() throws before any request is made

Make sure questions contains at least one entry. Add a question if the map is empty, and rename any entry using the reserved key meta.

Copy link to headingEvaluation is unavailable through an OpenAI-compatible client

For Jev evaluation in TanStack AI, use vercelGatewayDecider with decide(). The vercelGatewayText adapter only handles text generation.

AI Gateway also supports evaluation through AI SDK, the native HTTP API, and the TypeSafe-compatible API. Its OpenAI-, Anthropic-, and Cohere-compatible endpoints don't support evaluation.

Copy link to headingThe request never completes

Evaluation runs on the server and needs a credential, so a decide() call from browser code fails. Wrap the call in a server route and call it over fetch, as in step four. For long-running requests, pass an abortSignal so your handler fails fast instead of holding the connection open.

Copy link to headingAnswers seem overconfident on your data

Run labeled examples through the same questions and compare predicted probabilities with observed outcomes. Inspect where errors cluster, such as reviews about damaged packaging landing in productQuality instead of shipping, revise unclear option and level descriptions, and evaluate the updated questions before choosing new thresholds.

Copy link to headingNext steps

Related documentation

More AI Gateway guides