Everything the jevfilter package exports, with the defaults and limits taken from the v0.1.0 source. For a guided introduction, start with the README.
Get started with a prompt
The quickest way to try JevFilter in your own app is to hand the work to an AI coding agent, such as Claude Code, Cursor, or Codex. Open it in your project and paste this prompt. It works for any app with a search or list endpoint that already takes filters. It tells the agent how JevFilter works and asks it to find your existing search before changing anything.
I want to add natural-language search to my app with JevFilter, the npm package `jevfilter` (https://github.com/Karthick-Ramachandran/jevfilter). Read its README and the API reference at https://jevfilter.pages.dev/docs/ before you write code.
How JevFilter works:
- I describe the filters my API already accepts with `defineSearch` and field builders from "jevfilter": `enumField` (fixed values; put the words users say in each value's description), `booleanField`, `numberField({ unit: "USD" })`, `dateField`, and at most one `entityField({ resolve })` for named things looked up in my own data.
- `createNaturalFilter({ schema, provider: jev(), authorize, executor })`, with `jev` imported from "jevfilter/jev". `jev()` reads TYPESAFE_API_KEY. This runs on the server only.
- `prepare(text, { context })` returns one of: ready, needs_clarification, unsupported, blocked, unavailable. Only `ready` has filters to run. Show `result.interpretation` as chips.
- `execute(filters, { context })` validates the filters again, reruns `authorize`, and calls my executor. Filters coming back from the browser are untrusted.
- The model only picks from options built from my schema. It never writes SQL. Code parses numbers and dates.
- Limits in v0.1: no OR between two values of one field, no sorting or "top 10", one entity field per search, English only, at most 16 fields, at most 100 values per enum.
What to build:
1. Find the function in my code that already searches with structured filters, and the filter type it takes. Show me what you found before changing anything.
2. Write a `defineSearch` schema that matches that filter type exactly. Don't invent fields my API doesn't support.
3. On the server, call `createNaturalFilter` with `authorize` from my session and an `executor` that calls my existing search. Tenant and user scope come from the session, never from the filters.
4. Add an endpoint that takes `{ text }`, calls `prepare`, and for `ready` calls `execute` and returns the rows. In the UI, handle every status: show clarification questions, show why a request was refused, and never fall back to searching everything.
5. Set up the Jev key. `jev()` reads TYPESAFE_API_KEY from the server's environment, and I create keys at https://console.typesafe.ai/keys. Add `TYPESAFE_API_KEY=` to .env.example, load .env on the server the way this project already loads environment variables, and check that .env is in .gitignore. Don't ask me to paste the key into this chat, and never put it in client code or commit it. I'll add it to .env myself.
6. Write tests with `keywordProvider()` or `mockProvider()` from "jevfilter", so they run without a key.
Ask me before you change my existing search function or its filter type.
Overview
npm install jevfilter @typesafe-ai/sdk
Set your Jev key
Create a key at console.typesafe.ai/keys, and give it to your server as the TYPESAFE_API_KEY environment variable. jev() reads it there. For local development, a .env file works:
echo "TYPESAFE_API_KEY=your-key" >> .env
echo ".env" >> .gitignore
node --env-file=.env server.js # Node 20.6+; frameworks that load .env do this for you
In production, set it wherever your host keeps secrets. Keep the key out of browser code and out of git. To pass a key some other way, or one key per customer, see jev(options).
The package runs server-side on Node.js 20 or later and ships as ESM only. Its types need TypeScript 5.0 or later. It has two entry points:
jevfilter: the core, with no runtime dependencies.
jevfilter/jev: the Jev provider. It imports @typesafe-ai/sdk, an optional peer dependency pinned to ^0.6.0. You only need it if you use jev().
A search built with createNaturalFilter has two steps. prepare() reads text and never runs a search. execute() runs your search and never calls a model.
prepare(text, options)
Normalize the text (NFC, collapse whitespace, trim) and check its length.
Run authorize(context). A denial ends here, before any model call.
Code finds date and number phrases and computes their values.
One provider call answers closed multiple-choice questions built from your schema.
Every answer is checked against the options it was offered.
Your entity resolver looks up a named record, within the user's scope.
Returns ready, needs_clarification, unsupported, blocked or unavailable.
execute(filters, options)
Treat filters as untrusted input and validate them strictly against the schema.
Reject empty filters unless allowEmptyFilters is set.
Run authorize(context) again.
Re-check entity ids with verify, if you gave one.
Call your executor(filters, context) with a validated copy.
A schema lists the fields your API can already filter on. It is the only source of options the model can choose from. The builders validate their input and throw a TypeError when it is wrong, so mistakes show up when the module loads.
defineSearch(schema)
function defineSearch<const F extends Fields>(schema: {
resource: string;
version?: string;
fields: F;
}): SearchSchema<F>
Property
Type
Default
Meaning
resource
string
required
Plural name used in questions and messages, such as "support tickets". Its words are never treated as entity names.
version
string
"1"
Bump it when field meanings change. Reported as meta.schemaVersion.
fields
F
required
Field name to field, built with the builders below.
Throws when there are no fields, more than 16 fields, a field name that does not match /^[A-Za-z][A-Za-z0-9_]{0,63}$/, or more than one entity field. The returned schema and its fields object are frozen.
enumField(values, options?)
function enumField<const V extends string>(
values: readonly V[] | Readonly<Record<V, string | null>>,
options?: FieldBase,
): EnumField<V>
Pass an array of allowed values, or an object that maps each value to a description. The description is sent to the model, so it is where business synonyms go: { high: "urgent, critical, P1" } is the only reason "urgent" can mean high. Throws with no values, more than 100 values, or a value that is blank or longer than 100 characters.
Filter value: "open" or { not: "closed" }.
booleanField(options?)
function booleanField(options?: FieldBase): BooleanField
A yes/no flag. Filter value: true or false. Use description to say what "yes" means, for example "escalated to a manager".
numberField(options?)
function numberField(options?: FieldBase & { unit?: string }): NumberField
Filter value: a NumberRange such as { lt: 500 }. unit is a currency code or a unit label. When both the field and the request name a unit and they differ (compared case-insensitively), prepare returns unsupported with reason unit_mismatch. The parser only recognizes USD, EUR, GBP and INR, so a field with unit: "replies" accepts plain numbers and refuses currency amounts such as "$5". A request number with no unit is accepted for any field.
dateField(options?)
function dateField(options?: FieldBase): DateField
Filter value: a half-open DateRange of calendar dates, { gte: "2026-08-01", lt: "2026-09-01" }, in the request time zone.
A named record, such as a customer or an owner. The model picks which phrase of the request names it. Your resolve function looks that phrase up within what context may see and returns { id, label } candidates:
exactly one candidate binds, and its id becomes the filter value;
zero candidates give a no_match clarification;
two or more give a choose_entity clarification listing them.
Candidates are deduplicated by id, labels are cut to 200 characters, and the list stops at 20 (LIMITS.maxEntityCandidates). A result that is not an array, or has an item without a non-empty string id and a string label, gives unavailable with reason resolver_error. Candidate labels and ids are never sent to the model.
verify is optional. When present, execute calls it for the entity id in the filters and treats anything other than true, or a throw, as "not found". Throws at build time when resolve is not a function.
interface FieldBase {
label?: string; // shown in chips and questions; defaults to the field key
description?: string; // plain-language meaning, sent to the model
}
interface EnumField<V extends string = string> extends FieldBase {
kind: "enum"; values: Readonly<Record<V, string | null>>;
}
interface BooleanField extends FieldBase { kind: "boolean" }
interface NumberField extends FieldBase { kind: "number"; unit?: string }
interface DateField extends FieldBase { kind: "date" }
interface EntityField<Ctx = unknown> extends FieldBase {
kind: "entity";
resolve: (phrase: string, context: Ctx) => Promise<EntityCandidate[]> | EntityCandidate[];
verify?: (id: string, context: Ctx) => Promise<boolean> | boolean;
}
interface EntityCandidate {
id: string; // opaque handle your executor understands; ends up in filters
label: string; // shown to the user in a chooser; never sent to the model
}
type Field<Ctx = any> = EnumField<string> | BooleanField | NumberField | DateField | EntityField<Ctx>;
type Fields<Ctx = any> = Record<string, Field<Ctx>>;
interface SearchSchema<F extends Fields = Fields> {
resource: string; version: string; fields: F;
}
interface NumberRange { eq?: number; gt?: number; gte?: number; lt?: number; lte?: number }
interface DateRange { gte?: string; lt?: string } // YYYY-MM-DD, half-open
type FilterValue<F> = // enum: V | { not: V }
// boolean: boolean
// number: NumberRange
// date: DateRange
// entity: string (the id)
type Filters<F extends Fields> = { [K in keyof F]?: FilterValue<F[K]> };
Filters is the typed object your executor receives. Every key is optional, and every present bound of a NumberRange must hold.
Field types
Who decides a value depends on the field type. Code computes dates and numbers. The model only chooses among options that code built.
Builder
Filter value
Operators
Who decides
Understands
enumField
"high", { not: "closed" }
is, is not (one value)
The model picks one of your values, or says the field is not mentioned
"open tickets", "everything except closed", "urgent" when a description lists it
booleanField
true, false
is
The model picks yes, no or not mentioned
"escalated tickets", wording from the field's description
numberField
{ lt: 500 }, { gte: 100, lte: 500 }
eq, gt, gte, lt, lte
Code parses the number and comparator. The model only picks the field
"under $500", "at least 3", "between 100 and 500", "over 2k"
dateField
{ gte: "2026-08-01", lt: "2026-09-01" }
gte, lt
Code does the calendar math in your time zone. The model only picks the field
"last week", "since 2026-09-01", "in august", "last 7 days"
entityField
"cus_4" (your id)
is
The model picks a phrase from the request. Your resolver finds the records
"Sam's invoices", "tickets for Acme Corp"
When two phrases land on the same date or number field, their ranges are intersected, so "over 100 and under 500" becomes { gt: 100, lt: 500 }. A range that can never match returns unsupported with reason contradictory.
Date phrases
parseDates finds these phrases and turns each into a half-open range of calendar dates. The outputs below are real, with now = 2026-09-23T10:00:00Z (a Wednesday), time zone UTC and weeks starting on Monday.
A numeric date is read as day/month when the first number is over 12, and as month/day when the second is. When both are 12 or less and differ, it is ambiguous. The same number twice (04/04/2026) is fine.
Without a year, a month and day means the most recent one that is not in the future, and a bare month means the most recent month that has started. That is why "sep 30" and "in december" fall in 2025 above.
"may" alone is ignored because it is usually a verb ("tickets that may be urgent" finds no date). "in may" works.
"this week" and "last week" follow weekStartsOn. With weekStartsOn: 0, "this week" is { gte: "2026-09-20", lt: "2026-09-27" }.
"today" is computed in the request time zone with todayIn. At 20:00 UTC on 23 September it is already 24 September in Asia/Kolkata.
Invalid dates such as 2026-02-30 and backwards ranges are dropped.
A preposition in front of any of the forms above changes the range:
Preposition
Example
Result
since
since last week
{ gte: "2026-09-14" } (start of the period)
after
after 2026-09-01
{ gte: "2026-09-02" } (the day after the period ends)
before
before yesterday
{ lt: "2026-09-22" }
until, till
till 2026-09-10
{ lt: "2026-09-11" } (the named day is included)
on, in, during, from
during last month
{ gte: "2026-08-01", lt: "2026-09-01" }
from or between X to, until, till, through or and Y
from 2026-09-01 to 2026-09-10 between aug 1 and aug 15
Not understood in v0.1 (each of these finds no date): tomorrow, next week and other future-relative phrases, 3 days ago, weekday names (last monday), quarter names (q3 2026), mid august, a bare month with no preposition (august), numbers written as words (last seven days), last 0 days, windows of 1000 or more, and times of day. A bare year (in 2025) is not a date. It is read as the number 2025.
Number phrases
parseNumbers reads a number with its comparator and currency. Numbers inside date phrases are skipped when you pass the date spans as exclude, as prepare does. Real outputs:
k (1,000), m (1,000,000), lakh or lakhs (100,000), crore or crores (10,000,000)
over 2 lakh under 1 crore rupees
{ gt: 200000 } { lt: 10000000 }, unit INR
Grouping and decimals
1,000,000, Indian grouping, decimals
under ₹5,00,000
{ lt: 500000 }, unit INR
Not understood in v0.1: negative numbers (-5 reads as 5), numbers written as words, the symbols ≤ and ≥ (the number is read as eq), ranges without "between" (100 to 200 gives two separate numbers), and units other than the four currencies. A % sign is ignored (under 10% is { lt: 10 }), and m always means million, never minutes. Numbers glued to letters, such as P1, are not read as numbers, and values beyond Number.MAX_SAFE_INTEGER are dropped.
Ctx is your trusted server context, usually the session. R is whatever your executor returns. Throws a TypeError when schema has no fields, when provider has no choose function, or when authorize is missing and allowUnauthenticated is not exactly true.
NaturalFilterConfig
Option
Type
Default
What it does
schema
SearchSchema<F>
required
From defineSearch.
provider
FilterProvider<Ctx>
required
Answers the closed questions. jev(), withCache(...), mockProvider, keywordProvider, or your own.
executor
(filters: Filters<F>, context: Ctx) => Promise<R> | R
none
Your read-only search. It gets validated filters and the trusted context. Apply the tenant and user scope from context as an outer AND, never from filters. Optional for prepare; execute throws Error without it. Errors it throws are not caught.
authorize
(context: Ctx) => Promise<boolean> | boolean
required unless allowUnauthenticated: true
Runs before any model call in prepare and again in every execute. Only a return value of exactly true allows. false, any other value, or a throw denies (a throw is reported to onError with stage "authorize").
allowUnauthenticated
boolean
false
Set to true only for public data anyone may search. It makes skipping authorize a written decision; false does not count as an opt-out.
timeZone
string
"UTC"
IANA time zone for date phrases. PrepareOptions.timeZone overrides it per call.
weekStartsOn
number
1
0 is Sunday, 1 is Monday. Used by "this week" and "last week".
minConfidence
number
0.6
Minimum probability to accept an answer other than "not mentioned" without asking. Below it, the user gets a clarification.
timeoutMs
number
10000
Total budget for one prepare, including the model call and the resolver. When it runs out the result is unavailable with reason timeout. It does not apply to execute.
maxInputLength
number
500
Maximum request length in characters, after whitespace is collapsed and trimmed. A separate, fixed cap of 2048 UTF-8 bytes also applies.
allowEmptyFilters
boolean
false
Allow execute({}), which browses everything within the caller's scope. Without it, empty filters are invalid.
Trusted server context. Passed to authorize, the provider (for example to pick a per-tenant key) and your resolver. Never sent to a model.
now
the current time
Reference time for relative dates.
timeZone
config.timeZone, then "UTC"
Time zone for this call.
signal
none
Combined with the timeoutMs deadline. Aborting ends the call with an unavailable result.
Interprets the text and never calls the executor. It makes one provider call, plus one call to your resolver when the request names an entity. Checks run in this order: empty input, length, authorize, then the count of dates and numbers (more than 4 of either is too_complex). None of these reach the provider. prepare does not throw for bad input; every outcome is a result.
Two thresholds keep conditions from disappearing quietly. If code found a date or number, the model must be at least 90% sure it is not a filter before it is ignored; otherwise the user is asked. If the model says an enum or boolean field is not mentioned but gives some value a probability of 0.15 or more, the user is asked too.
Accepts filters from prepare, from an answered clarification, or from manual edits in your UI, and treats all of them as untrusted. In order, it:
throws Error("createNaturalFilter: no executor configured") when there is no executor;
validates with validateFilters and returns { status: "invalid", errors } on failure;
returns invalid with ["no filters"] for an empty object, unless allowEmptyFilters is set;
runs authorize(context) and returns { status: "blocked", reason: "unauthorized" } on denial, so a permission revoked after the preview blocks the search;
calls verify(id, context) for the entity field if you gave one, returning invalid with ["<field>: not found"] when it does not return true;
calls your executor with the validated copy and returns { status: "ok", filters, results }.
execute never calls a model, so manual filter edits add no tokens and no model latency.
validate(filters)
validate(filters: unknown): ValidationResult<F>
Strict validation only, against this search's schema. The same as validateFilters(schema, filters).
The request is outside what this schema and v0.1 can express.
Show message and offer your manual filters.
blocked
Stopped before any model call.
Show message.
unavailable
The provider or the resolver failed. Nothing falls back to searching everything.
Offer a retry when retryable is true.
Reason codes
Status
Reason
When it happens
Message
blocked
empty_input
The text is empty or only whitespace, or not a string.
Type what you are looking for.
blocked
input_too_long
Longer than maxInputLength characters or 2048 UTF-8 bytes.
Keep searches under maxInputLength characters.
blocked
unauthorized
authorize returned something other than true, or threw.
You can't search here.
unsupported
too_complex
More than 4 date phrases or more than 4 numbers. No model call is made and there is no meta.
That request has too many dates or numbers. Try fewer conditions.
unsupported
out_of_scope
The model says the request asks for something other than a filter: other records, other tenants, permission changes, actions, counting or sorting, instructions to the system, or conditions no field can express.
This search can only filter resource by its listed fields.
unsupported
multiple_values
The request allows or excludes more than one value of an enum field ("open or pending"). Sets field.
Pick one label at a time for now.
unsupported
contradictory
Dates or numbers on one field can never all hold ("over 500 and under 100"). Sets field. Also returned without field if the final filters fail validation.
Those dates (or limits) for label can't all be true.
unsupported
unit_mismatch
The number field has a unit and the request names a different currency. Sets field.
label is in unit, not unit.
unsupported
no_filters
Nothing to filter on and nothing to ask, as in "tickets". JevFilter does not invent a filter.
Add a condition, for example a field or field.
unavailable
provider_error
The provider threw. retryable comes from ProviderError.retryable, and is true for any other error.
Search interpretation is unavailable.
unavailable
timeout
The timeoutMs budget ran out, or the provider or resolver threw an error named TimeoutError or APITimeoutError. Always retryable.
Search interpretation timed out. (Resolver: Couldn't look up label.)
unavailable
invalid_provider_output
Some question got no answer, or an answer that is not one of its offered labels. Not retryable.
Search interpretation returned an invalid answer.
unavailable
resolver_error
Your resolve threw (retryable) or returned an invalid shape (not retryable).
Couldn't look up label.
Clarification
interface Clarification<F extends Fields = Fields> {
kind: "choose_value" | "choose_entity" | "choose_field" | "no_match" | "ambiguous_date";
field?: string;
question: string; // suggested text to show
phrase?: string; // the part of the request that needs clarifying
options: ClarificationOption<F>[]; // empty for no_match and ambiguous_date
}
interface ClarificationOption<F extends Fields = Fields> {
value: string; // enum value, entity id, or field name, depending on kind
label: string;
filters: Filters<F>; // merge into result.filters when picked
}
Kind
When
Options
Question
choose_value
An enum or boolean field: the model said "unclear", its confidence was below minConfidence, or it said "not mentioned" while rating a value at 0.15 or more.
Every allowed value (for booleans, "true" and "false"), plus value: "any" labelled "Any label" with filters: {}.
Which label did you mean?
choose_entity
Your resolver returned 2 to 20 candidates.
One per candidate: { value: id, label, filters: { [field]: id } }.
Which label did you mean?
choose_field
The model is not sure which date or number field a parsed phrase belongs to, or is less than 90% sure it is not a filter. Sets phrase, not field.
One per field of that kind, with the parsed range, plus value: "none" labelled "Don't filter by this" with filters: {}.
Which field should "phrase" apply to?
no_match
Your resolver returned no candidates.
None.
No label matched "phrase".
ambiguous_date
A numeric date such as 03/04/2026 could be either day/month or month/day.
None.
"phrase" could mean two dates. Write it as YYYY-MM-DD.
Answering a question
When the user picks an option, merge its filters into result.filters and call execute. With several questions, merge the chosen option of each. For no_match and ambiguous_date there is nothing to pick, so ask the user to rephrase, or open your manual filters.
The "Any" and "Don't filter by this" options add nothing. If nothing else was understood, the merged object is empty and execute returns invalid unless allowEmptyFilters is set.
FilterChip
interface FilterChip {
field: string;
text: string; // human-readable
source?: string; // the part of the request it came from, when there is one
}
Chip text uses the field's label, or its key:
enum: priority is high, status is not closed
boolean: escalated: yes
date: created from 2026-09-14 to before 2026-09-21, created on or after 2026-09-01, created before 2026-09-22
number: amount < 500 USD, with =, >, ≥, <, ≤ joined by "and"
entity: customer is Sam Wilson (the candidate's label)
On success, filters is a fresh copy that holds only validated keys. It rejects:
Input
Error
Anything but a plain object (arrays, null, class instances)
filters must be a plain object
A key that is not a schema field, including workspaceId or an own __proto__ key
unknown field: "…"
Enum: a value outside the list, or an object other than { not: value } with an allowed value
<field>: must be one of the allowed values, or { not: value }
Boolean: anything but true or false
<field>: must be true or false
Number: not a plain object, or empty
<field>: must be a range like { lt: 500 }
Number: an operator other than eq, gt, gte, lt, lte, or a value that is not a finite number (NaN, Infinity, strings)
<field>: allowed operators are eq, gt, gte, lt, lte with finite numbers
Number: bounds no number can satisfy, such as { lt: 5, gt: 10 }
<field>: range can never match
Date: not a plain object, or empty
<field>: must be a range like { gte: "2026-09-01", lt: "2026-10-01" }
Date: an operator other than gte and lt, or a value that is not a real YYYY-MM-DD date (2026-02-30 fails)
<field>: allowed operators are gte and lt with YYYY-MM-DD dates
Date: gte on or after lt
<field>: range can never match
Entity: not a string, empty, or longer than 256 characters
<field>: must be a non-empty id string
All errors are collected, not just the first. Validation does not check that an entity id exists; that is what verify is for.
Parsers
The deterministic parsers that prepare uses, exported for tests and for your own UI. The phrases they accept are listed under Date phrases and Number phrases.
parseDates(text, opts)
function parseDates(
text: string,
opts: { now: Date; timeZone: string; weekStartsOn?: number }, // weekStartsOn default 1
): DateCandidate[]
interface Span { start: number; end: number; text: string }
interface DateCandidate extends Span {
range?: DateRange; // undefined when the phrase is ambiguous (03/04/2026)
}
parseDates("tickets from last week", { now: new Date("2026-09-23T10:00:00Z"), timeZone: "UTC" })
// [{ start: 8, end: 22, text: "from last week", range: { gte: "2026-09-14", lt: "2026-09-21" } }]
text is the exact source text, including the preposition.
parseNumbers(text, exclude?)
function parseNumbers(text: string, exclude?: Span[]): NumberCandidate[]
interface NumberCandidate extends Span {
range: NumberRange;
unit?: string; // upper-case currency code when the text named one
}
A provider answers closed multiple-choice questions. It never returns a value that becomes a filter directly. Core checks every answer against the options it asked, and code computes every date and number.
FilterProvider
interface FilterProvider<Ctx = unknown> {
readonly name: string;
readonly model?: string; // part of the cache key
choose(request: ProviderRequest, options: ProviderCallOptions<Ctx>): Promise<ProviderResponse>;
}
interface ProviderRequest {
state: { search_request: string }; // the untrusted user text, kept apart from instructions
questions: Record<string, ChoiceSpec>; // question id → question
}
interface ChoiceSpec {
instructions: string; // trusted, compiled from the schema
options: Record<string, string | null>; // option label → description
}
interface ProviderCallOptions<Ctx = unknown> {
context: Ctx; // trusted server context; never send it to a model
signal: AbortSignal; // aborts at the prepare() deadline or with the caller's signal
}
interface ProviderResponse {
answers: Record<string, ProviderAnswer>;
model?: string;
usage?: { inputTokens: number; outputTokens?: number };
cached?: boolean;
}
interface ProviderAnswer {
choice: string; // must be one of the question's option labels
probabilities: Record<string, number>; // label → 0..1; include at least the chosen label
}
The contract:
Answer every question id with one of its option labels. A missing answer or an unknown label makes the whole result unavailable with reason invalid_provider_output. Extra answers are ignored.
Report a probability for at least the chosen label. When it is missing, or not a number from 0 to 1, core treats the answer as unknown confidence (0). Answers other than "not mentioned" then become clarification questions instead of being accepted.
Set model on the provider so a model upgrade changes the cache key.
Throw ProviderError to say whether a retry could help.
Its name is "ProviderError". prepare copies retryable into the unavailable result. Any other error counts as retryable, and an error named TimeoutError or APITimeoutError becomes reason timeout. The message goes to onError only.
An offline provider for tests. Your function answers each question with an option label. undefined picks the first option, and the probability defaults to 1. It makes no network calls and reports no model or usage.
function keywordProvider(): FilterProvider<any> // name "keyword-baseline"
A naive keyword baseline built on mockProvider. It is not a language model. Use it to try the library without a key and as the baseline in evals. It matches enum values and short comma-separated synonyms from their descriptions, treats "not", "except", "excluding", "without" or "non" before a value as an exclusion, gives each date and number to the first field of its kind, and picks a capitalized phrase for an entity. Words such as "ignore", "tenant", "admin", "delete", "permission", "count" or "how many" make it answer other to the intent question.
Jev provider (jevfilter/jev)
jev(options?)
import { jev, DEFAULT_JEV_MODEL } from "jevfilter/jev";
function jev<Ctx = unknown>(options?: JevOptions<Ctx>): FilterProvider<Ctx> // name "jev"
Needs the @typesafe-ai/sdk peer dependency. JevFilter never ships a key or sends requests through a key of its own. Keys come from console.typesafe.ai/keys; see Set your Jev key.
A string (or the environment variable) builds one client on first use and reuses it. A function runs on every prepare with the trusted context, so each tenant can use its own key. The result is trimmed, a fresh client is built for that call, and no key is cached. An empty or non-string result fails the call with a non-retryable ProviderError, without contacting the API.
client
TypeSafeClient
none
A pre-built client (custom fetch, logging, proxies). When set, apiKey, baseURL, timeout, maxRetries and fetch are not used.
model
string
"jev-1.13.0"
Model version, sent with every request and reported as the provider's model.
baseURL
string
TYPESAFE_BASE_URL, then https://api.typesafe.ai
API root.
timeout
number
5000
Timeout per attempt in ms. The core timeoutMs still caps the whole prepare.
jev() // TYPESAFE_API_KEY from the environment
jev({ apiKey: process.env.JEV_API_KEY }) // one key for your app
jev({ apiKey: (session) => session.workspace.jevKey }) // each customer brings their own key
jev({ client: new TypeSafeClient({ /* … */ }) }) // full control
Clients built by jev() have SDK logging turned off, because request bodies contain the user's search text. Errors are mapped like this:
SDK timeout: reason timeout, retryable.
Rate limit, connection error or HTTP 500 and above: provider_error, retryable.
Other API errors, such as HTTP 401: provider_error, not retryable.
No key configured: provider_error, not retryable.
Messages passed to onError are sanitized (for example Jev request failed (HTTP 401)) and never contain keys, headers or request bodies.
DEFAULT_JEV_MODEL
const DEFAULT_JEV_MODEL = "jev-1.13.0";
The pinned default model. Upgrading it changes interpretations, so rerun your evals before you do.
Cache
withCache(provider, options)
function withCache<Ctx>(provider: FilterProvider<Ctx>, options: CacheOptions<Ctx>): FilterProvider<Ctx>
type CacheOptions<Ctx> = {
store: CacheStore;
ttlMs?: number;
storeTimeoutMs?: number;
} & (
| { scope: (context: Ctx) => string; shared?: never }
| { shared: true; scope?: never }
);
Option
Type
Default
What it does
store
CacheStore
required
Where answers live: memoryCache(), or your own { get, set } backed by Redis, KV and so on.
scope
(context: Ctx) => string
one of scope or shared is required
Partitions the cache, usually by tenant: (ctx) => ctx.tenantId. Must return a non-empty string.
shared
true
one of scope or shared is required
One cache for everyone. Public data only. Kept apart from a tenant that happens to be named "shared".
ttlMs
number
600000 (10 minutes)
How long an answer stays valid.
storeTimeoutMs
number
250
Longest a store read may take before it counts as a miss.
Only the provider's answers are cached. authorize, answer validation, entity resolution and your executor run on every search, and dates such as "last week" are recomputed from today, so a hit cannot skip security or go stale on the calendar. The wrapped provider keeps the inner provider's name and model.
The key is a SHA-256 hash of the provider's name and model, the scope (or shared), the search text, and the questions compiled from your schema. Editing a field's description or values changes the questions, so it misses the cache.
Failure rules:
Building throws a TypeError without a store that has get and set, without scope or shared: true, or when ttlMs or storeTimeoutMs is not a finite number of at least 1.
If scope(context) returns anything but a non-empty string, the search fails (unavailable, provider_error) instead of sharing a cache.
A store read that throws, or takes longer than storeTimeoutMs, counts as a miss. Writes run in the background, and a failing write is ignored.
Only complete responses are stored, with a valid option for every question. Errors and invalid answers are never cached. What is stored is the answers and the model.
Identical concurrent searches share one provider call. If that call fails, is abandoned or returns an incomplete answer, a waiting caller makes the call itself instead of inheriting the failure. After 3 such rounds it calls the provider directly.
A hit returns cached: true and zero usage, which reaches you as meta.cached.
With per-tenant keys, a cached answer inside one scope can be served to a user whose search would have been paid with a different key. If each tenant brings its own key, scope by tenant.
function memoryCache(options?: { maxEntries?: number; ttlMs?: number }): CacheStore
Option
Default
Meaning
maxEntries
500
Entries kept before the least recently used is evicted. Rounded down; must be a finite number of at least 1.
ttlMs
no cap
Caps how long any entry may live, whatever withCache asks for. Must be a finite number of at least 1.
An in-process LRU cache, good for a single server or a Worker isolate. Reads and writes copy the value with structuredClone, so a caller that mutates an answer cannot change the cache. A write with a TTL of zero, a negative TTL or NaN is not stored. Invalid options throw a TypeError.
Keys are 64-character hex strings. Return undefined for a miss.
Limits
Limit
Value
What happens
Fields per schema (LIMITS.maxFields)
1 to 16
defineSearch throws
Field name
letter first, then letters, digits or _; 64 characters at most
defineSearch throws
Entity fields per schema
at most 1
defineSearch throws
Values per enum (LIMITS.maxEnumValues)
1 to 100
enumField throws
Enum value length
not blank, 100 characters at most
enumField throws
Request length (maxInputLength)
500 characters by default
blocked, input_too_long
Request size
2048 UTF-8 bytes, fixed
blocked, input_too_long
Date phrases per request
4
5 or more: unsupported, too_complex
Numbers per request
4
5 or more: unsupported, too_complex
Relative window size ("last N days")
1 to 999
outside it, no date is found
Entity phrase candidates sent to the model
60, each 1 to 3 words
the shortest phrases are kept
Entity candidates (LIMITS.maxEntityCandidates)
20
extra candidates are dropped
Entity candidate label
200 characters
cut
Entity id in filters
1 to 256 characters
invalid
Provider calls per prepare
1, plus one resolver call when an entity is named
prepare time budget (timeoutMs)
10000 ms by default
unavailable, timeout
Accept a non-default answer (minConfidence)
0.6 by default
below it, a clarification
Ignore a date or number code found
0.9, fixed
below it, a choose_field clarification
Ask about a value rated plausible
0.15, fixed
a choose_value clarification
Jev request timeout and retries
5000 ms per attempt, 1 retry
Cache TTL, store read timeout
600000 ms, 250 ms
memoryCache entries
500 by default
least recently used evicted
What v0.1 can and can't do
Possible
Conditions on several fields, combined with AND ("urgent billing tickets from last week").
One value per enum field, or excluding one value ({ not: "closed" }).
Boolean flags.
Number comparisons and ranges, including "between" and two bounds on one field.
Relative and absolute dates, in your time zone, with a configurable first day of the week.
Finding a named record through your resolver, with a chooser when several match.
Clarification questions instead of guesses.
Currency checks for USD, EUR, GBP and INR.
Manual filter edits that skip the model and go through the same validation.
A Jev key per app or per tenant.
Caching model answers, per scope.
Custom providers, and offline providers for tests.
Not possible
OR across fields.
"A or B" on one field, or excluding more than one value (multiple_values).
Nested or grouped conditions.
Sorting, ranking or limits ("latest", "biggest", "top 10").
Counting or aggregation.
Free-text search inside content ("the doc where we discussed AWS costs").
Languages other than English. The date and number parsers are English only.
More than one entity field, or more than one named record per request.
More than 4 dates or 4 numbers in one request.
Future-relative dates ("tomorrow", "next week"), weekday names and quarter names.
Unit checks for anything but the four currencies.
Null handling for { not: X }; that is up to your executor.
Also, don't name the resource after one of its values, such as "support tickets" with a support category. And the model can still misread a request it is allowed to make, so show the chips and let users edit them.
Security
The library enforces
authorize is required unless you declare the data public, and runs before any model call and again before every execute.
The model only picks among closed options. Code computes dates and numbers, and your resolver finds records.
Every answer is checked against the options offered. A bad answer gives unavailable and never a broader search.
execute validates filters from the client strictly and passes your executor a fresh copy.
No SQL, ORM code or other executable text is produced.
The search text travels in state, apart from the instructions. Questions quote only the date, number and name phrases that code found in it.
Results never include error details, and the Jev provider keeps keys out of logs, results and errors.
Apply tenant and user scope inside the executor, from context, as an outer AND. Never take scope from filters.
Keep the executor read-only.
Scope the entity resolver to what the user may see, and add verify for ids.
Keep API keys on the server and store customer keys securely.
Rate-limit searches as you would any paid API call.
What the model receives
The search text, plus instructions and options compiled from your schema: the resource name, field names and labels, field kinds, descriptions, enum values and their descriptions, and the date, number and name phrases found in the search text. If your values or descriptions are sensitive, treat them like the search text.
It never receives your records, entity candidate labels or ids, the context, database credentials, or query code.