Guides
Providers
The provider interface, the built-in Jev provider and its options, retries and timeouts, and how to write a provider of your own.
The public API never names a vendor. is, score and choose talk to a SemanticProvider, and jev() is one implementation of it. The vocabulary the runtime uses (truth, choice, level) is deliberately its own, so a second provider needs no changes at any call site.
The Jev provider#
Jev is a decision model: it scores propositions and returns calibrated probabilities. It generates no text.
import { createSemantic, jev } from "jevascript"
export const semantic = createSemantic({
provider: jev({ model: "jev-1.13.0" }),
})With JEV_API_KEY in the environment, jev() with no arguments is what the zero-config path builds for you. Jev API keys are issued by TypeSafe at console.typesafe.ai/keys.
Options#
| Option | Type | Default | Description |
|---|---|---|---|
| apiKey | string | process.env.JEV_API_KEY | Resolved per request, not at construction, so a module that creates the provider can be imported in tests and builds that have no key. |
| model | string | "jev-1.13.0" | A pinned model version. |
| baseUrl | string | "https://api.typesafe.ai/v1" | API endpoint. |
| fetch | typeof fetch | globalThis.fetch | Swap in an instrumented or proxied |
| maxRetries | number | 3 | Attempts on retryable failures, including the first. |
Precedence: explicit option, then environment variable, then built-in default.
Why the model is pinned#
jev-latest would be an alias that moves. A silent move invalidates cached answers, shifts production behaviour, and detaches dataset evaluations from the model they were measured against. The default is a concrete version, and a provider's model property must always be one.
Retries and timeouts#
- Retried statuses:
408,409,429,500,502,503,504,529. Network failures are retried too. - Backoff is exponential from 250 ms with jitter. A
retry-afterheader is honoured. - The request timeout is
timeoutMsfrom the call, the instance defaults, or 10 s. It is enforced withAbortSignal.timeout, combined with any signal the runtime passes. - After the last attempt, a
ProviderErroris thrown with the HTTP status when there was one.
Capabilities#
Every provider declares what it can express, so impossible requests fail before the network with UnsupportedByProviderError:
import { JEV_CAPABILITIES } from "jevascript"
JEV_CAPABILITIES
// { maxChoiceOptions: 255, levelRange: [2, 10], maxStateTokens: 32_000,
// maxTotalTokens: 64_000, generatesText: false, batching: true }Using two providers#
A question can name its own provider. Questions to different providers in the same turn go out as one request each:
const careful = jev({ model: "jev-1.13.0", maxRetries: 5 })
const t = await semantic(ticket).batch({
urgency: score("A human needs to act on this urgently."),
fraud: score("This order is fraudulent.", { asProposition: true, provider: careful }),
})Or create two instances with createSemantic(), each with its own provider and cache.
Write a provider#
A provider is an object with a name, a pinned model, a capabilities declaration and one method. It is multi-question by construction: a request carries one state and any number of questions, because a single-question interface would make batching impossible to express.
The contract#
interface SemanticProvider {
readonly name: string
readonly model: string // a concrete version, never a moving alias
readonly capabilities: ProviderCapabilities
evaluate(request: SemanticRequest): Promise<SemanticProviderResponse>
}
interface SemanticRequest {
readonly state: State // string | string[] | object
readonly questions: Record<string, SemanticQuestion> // keyed; answer under the same keys
readonly signal?: AbortSignal
readonly timeoutMs?: number
}
interface SemanticProviderResponse {
readonly answers: Record<string, SemanticAnswer>
readonly usage?: { inputTokens?: number; outputTokens?: number }
readonly model?: string
}The three question kinds#
| Kind | Question | Answer |
|---|---|---|
truth | { kind, instructions, trueWhen?, falseWhen? } | { kind: "truth", probability } with probability in [0, 1] |
choice | { kind, instructions, options: Record<string, string | ChoiceOptionSpec> } | { kind: "choice", choice, probabilities, confidence } where probabilities sums to 1 |
level | { kind, instructions, levels: (string | LevelSpec)[] } | { kind: "level", level, probabilities, confidence } with level 0-indexed and possibly fractional |
is() sends a truth question. score() sends truth unless levels is given, then level. choose() sends choice. find() sends a choice plus a truth existence check in one request.
A minimal provider#
This one wraps any HTTP service that returns a probability per question:
import type { SemanticProvider, SemanticRequest, SemanticAnswer } from "jevascript"
import { ProviderError } from "jevascript"
export function mine(options: { apiKey: string; endpoint: string }): SemanticProvider {
return {
name: "mine",
model: "mine-2026-09-01",
capabilities: {
maxChoiceOptions: 32,
levelRange: [2, 5],
generatesText: false,
batching: true,
},
async evaluate(request: SemanticRequest) {
const response = await fetch(options.endpoint, {
method: "POST",
headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
body: JSON.stringify({ state: request.state, questions: request.questions }),
signal: request.signal,
})
if (!response.ok) throw new ProviderError(`mine returned ${response.status}`, response.status)
const raw = (await response.json()) as Record<string, { p?: number; pick?: string; dist?: Record<string, number> }>
const answers: Record<string, SemanticAnswer> = {}
for (const [key, question] of Object.entries(request.questions)) {
const r = raw[key]
if (!r) throw new ProviderError(`mine returned no answer for "${key}"`)
if (question.kind === "truth") {
answers[key] = { kind: "truth", probability: r.p ?? 0.5 }
} else if (question.kind === "choice") {
const probabilities = r.dist ?? { [r.pick!]: 1 }
answers[key] = { kind: "choice", choice: r.pick!, probabilities, confidence: Math.max(...Object.values(probabilities)) }
} else {
const probabilities = Object.values(r.dist ?? {})
answers[key] = { kind: "level", level: r.p ?? 0, probabilities, confidence: Math.max(...probabilities) }
}
}
return { answers, model: "mine-2026-09-01" }
},
}
}Rules a provider must follow#
- Answer every key in
request.questions, under the same key. A missing answer is an error. - Honour
signal. The runtime combines the caller's signal with the timeout. Pass it tofetch. - Declare capabilities honestly. The runtime checks a request against them with
assertSupported()before callingevaluate, so the limits you declare are the ones users see asUnsupportedByProviderError. - Pin
model. The cache key includes it. A moving alias silently serves stale answers. - Throw
ProviderErrorfor transport and upstream failures, with the status when there is one, so application code can distinguish "the model was unavailable" from everything else. - Return
usageif you can. It surfaces in observability events for cost accounting.
Wrapping a text-generating model#
A chat model can be made into a provider by asking it to return a JSON object of probabilities and parsing the result. Two cautions: set generatesText: true so future features can rely on it, and expect worse calibration than a purpose-built decision model. The probabilities a generative model prints are not measured; a 0.9 from it is a style, not a frequency. Use minConfidence with samples to measure stability if you go this route.
An OpenAI-compatible provider and a Vercel AI SDK bridge are on the roadmap but not built. Track progress in the changelog.
Testing a provider#
jevascript/testing ships a provider that answers from a lookup table. It is also a good reference implementation of the contract: read its source for the exact answer shapes. See Testing.