jev

September 17, 2026 · View on GitHub

CI Go Reference Go License

Ask Jev — the decision model behind TypeSafe's System One API — questions whose answers arrive as your own Go types.

An LLM hands you prose and leaves the parsing to you. Jev never leaves the set of answers you defined, and tells you how probable each one was. This package carries that guarantee into the type system rather than stopping at the JSON boundary: a choice over your Intent enum yields an Intent, the compiler checks the switch you write on it, and a question that could not have been answered is an error before the request is sent rather than a surprise after it.

go get github.com/anilsenay/jev

Unofficial. Requires an API key from TypeSafe's early access (TYPESAFE_API_KEY).

Quick start

type Intent string

const (
	Refund   Intent = "refund"
	Shipping Intent = "shipping"
	Other    Intent = "other"
)

// Declare questions once.
var intentQ = jev.Choice("What does the customer want?",
	jev.Opt(Refund, "wants money back"),
	jev.Opt(Shipping, "asks where the order is"),
	jev.Opt(Other, "anything else"),
)

client, err := jev.New() // reads TYPESAFE_API_KEY

ans, err := jev.Ask(ctx, client, ticket, intentQ) // ans is jev.ChoiceAnswer[Intent]
if err != nil {
	return err
}

switch intent, sure := ans.Sure(0.9); {
case sure && intent == Refund:
	return autoRefund(ticket)
case !sure:
	return escalate(ticket, ans.Probs)
default:
	return route(intent)
}

Question kinds

ConstructorAnswerUse for
jev.Noul(q) / jev.Noul(q, jev.NoulCriteria{True, False})NoulAnswer{P}a two-outcome question; ask one each when labels overlap
jev.Choice(q, jev.Opt(v, desc)...) / jev.OneOf(q, v...)ChoiceAnswer[T]{Value, Probs, Confidence}exactly one of a set you define
jev.Score(q, levels...)ScoreAnswer{Value, Probs, Levels, Legend, Confidence}a degree along ordered levels; can land between them

Instructions are optional, as in the official SDKs: a choice or score whose criteria say everything can pass nil. Like those SDKs, this package validates only what it can be sure of before sending — at least one question, at least two score levels, and no duplicate or empty choice labels. The API's own limits (MaxChoiceOptions, MaxScoreLevels) are exported for reference but not enforced, so a raised server limit never blocks a valid request.

Sure(threshold) is available on noul and choice answers. Low confidence is not an error: thresholds are policy and live in your code.

Confidence is the API's own field: how peaked the distribution is. It is not a calibrated probability that the answer is right — use Probs[Value] (or P) for that. A noul carries no separate confidence; its probability is the signal.

Noul — is this true?

A two-outcome judgment where the number itself is the answer. Spell out both outcomes when the line between them is fine. If two labels could be true of the same input, give each its own noul rather than making them compete inside one choice.

spam := jev.Noul("Is this review spam or advertising?", jev.NoulCriteria{
	True:  "promotes an unrelated product, or links to another site",
	False: "a genuine opinion about the product",
})

ans, err := jev.Ask(ctx, client, review, spam) // jev.NoulAnswer
if err != nil {
	return err
}

switch yes, sure := ans.Sure(0.9); {
case sure && yes:
	return hide(review)      // ans.P == 0.99 for the advertising example below
case sure:
	return publish(review)
default:
	return queueForModeration(review, ans.P)
}
state: "BEST DEAL EVER!!! visit cheap-watches-online.example for 90% off luxury watches"
→ P = 0.99   Sure(0.9) = true, true

Sure(t) answers in both directions: (true, true) when P >= t, (false, true) when 1-P >= t, and (false, false) in the uncertain middle. Use a threshold above 0.5.

Choice — which one of these?

One option out of a set you define, returned as your own enum. The model cannot answer outside the set, so include a catch-all when the list might not cover every input.

type Team string

const (
	Billing   Team = "billing"
	Shipping  Team = "shipping"
	Technical Team = "technical"
	Other     Team = "other"
)

route := jev.Choice("Which team should handle this ticket?",
	jev.Opt(Billing, "Charges, invoices, refunds, subscriptions"),
	jev.Opt(Shipping, "Delivery status, delays, lost packages"),
	jev.Opt(Technical, "Bugs, outages, integrations"),
	jev.Opt(Other, nil), // nil when the name speaks for itself
)

ans, err := jev.Ask(ctx, client, ticket, route) // jev.ChoiceAnswer[Team]
if err != nil {
	return err
}

team, sure := ans.Sure(0.8) // team is a Team, checked at compile time
if !sure {
	return escalate(ticket, ans.Ranked()) // options, most probable first
}
return assign(ticket, team)
state: "I was charged twice for order A-104 last Friday. Please refund the duplicate."
→ Value = billing   Confidence = 1.00
  Probs  = map[billing:1 other:0 shipping:0 technical:0]
  Ranked = [billing other shipping technical]

jev.OneOf("Which team?", Billing, Shipping, Technical, Other) is the same question without descriptions. Probs always holds every option, including the zero ones.

Score — where on this scale?

A position on a scale whose points you describe, lowest first. The answer is weighted by probability, so it can come to rest short of any single level — and that in-between reading is the useful part, not noise to be rounded away.

severity := jev.Score("How severe is the reported issue?",
	"Cosmetic; no impact to functionality",
	"Broken or degraded feature, but a workaround exists",
	"Blocking issue; no workaround exists",
)

ans, err := jev.Ask(ctx, client, report, severity) // jev.ScoreAnswer
if err != nil {
	return err
}

level, description := ans.Nearest()
log.Printf("severity %.2f — nearest level %d: %s", ans.Value, level, jev.Text(description))

if ans.Value >= 1.5 || ans.Probs[2] > 0.4 { // thresholds are yours
	return page(onCall)
}
state: "The export button crashes the settings page in Safari. Works in Chrome,
        but some customers only use Safari."
→ Value = 1.47   Confidence = 0.30
  Probs = [0 0.53 0.47]
  Nearest = 1, "Broken or degraded feature, but a workaround exists"

That answer sits almost exactly between "has a workaround" and "blocking", and the low Confidence says so. Rounding it to a single level would throw that away; thresholding Value or reading Probs directly keeps it. Scores need at least two levels, and the API accepts at most ten.

Questions can carry structure

instructions, choice option descriptions, score levels and noul criteria are all jev.Content: plain text, or anything that encodes as a JSON object or array. Reach for structure when a sentence keeps failing to separate two options, or when the material is already JSON and flattening it into a sentence would only lose detail.

jev.Choice("Which department does this product belong to?",
	jev.Opt(Sporting, map[string]any{
		"Cycling": []string{"Bike Bottles & Cages", "Helmets"},
		"Fitness": []string{"Yoga Mats"},
	}),
	jev.Opt(Drinkware, map[string]any{
		"what":    "Everyday water bottles, travel mugs, tumblers",
		"not_for": "Sport-specific gear",
	}),
	jev.Opt(Other, nil), // nil description is sent as JSON null
)

jev.Text(c) renders a Content for logging: a string as it is, anything else as compact JSON.

When the API moves first

New API fields and question kinds land before a release of this package does. RawQuestion sends a Spec exactly as given and hands the answer back undecoded, so nothing blocks you in the meantime — the counterpart of the official SDKs' raw question dictionaries and extra_body.

q := jev.RawQuestion(jev.Spec{
	Type:         "rank",                          // a kind this package predates
	Instructions: "Rank these passages by relevance",
	Criteria:     []jev.Content{"passage A", "passage B"},
	Extra:        map[string]any{"beam_width": 4},  // a field it predates too
})

h := jev.Add(b, q)  // *jev.Handle[jev.RawAnswer]
if _, err := b.Run(ctx); err != nil {
	return err
}
raw, err := h.Get() // read raw.Choice, raw.Probabilities, raw.Score yourself

Nothing is validated or narrowed here, so reach for a typed constructor whenever one fits. Extra works on any Spec; type, instructions and criteria are reserved and cannot be overridden from it.

Many questions, one request

Independent questions about the same state should share a batch. They run in parallel on the model and cost one call.

b := client.Batch(review)
spam     := jev.Add(b, jev.Noul("Is this review spam or advertising?"))
intent   := jev.Add(b, intentQ)
severity := jev.Add(b, jev.Score("How severe is the complaint?", "None", "Mild", "Serious", "Severe"))

meta, err := b.Run(ctx) // meta.Model, meta.Usage, meta.RequestID, meta.Latency

s, err := spam.Get()     // jev.NoulAnswer
i, err := intent.Get()   // jev.ChoiceAnswer[Intent]
v, err := severity.Get() // jev.ScoreAnswer
level, name := v.Nearest()

If one answer doesn't fit its question, Run returns an error wrapping jev.ErrMalformedAnswer, but the other handles can still be read.

Models

models, err := client.Models(ctx) // []jev.ModelCard{Name, Description, ReleaseDate}

Needs a provider that implements ModelLister, which the default HTTP provider does; a fake that does not returns ErrNoModelList. Middleware does not hide it.

Errors

One sentinel per status the official SDKs distinguish, matched with errors.Is:

StatusSentinelRetried
400ErrBadRequest, ErrInvalidRequestno
401ErrAuthno
403ErrPermissionDeniedno
404ErrNotFoundno
408yes
422ErrUnprocessable, ErrInvalidRequestno
429ErrRateLimityes
529ErrOverloaded, ErrInternalServeryes
other 5xxErrInternalServeryes

Below HTTP: ErrConnection for a request that never got a response, and ErrTimeout (which also matches ErrConnection) for one that ran out of time.

Before any request: ErrInvalidQuestion, ErrNoAPIKey, ErrNotRun, ErrBatchUsed, ErrNoModelList. After one: ErrMalformedAnswer for an answer that does not fit its question.

APIError parses the API's body, so Message carries the human-readable reason and Fields lists the offending fields of a 422:

var apiErr *jev.APIError
if errors.As(err, &apiErr) {
	log.Printf("%s (%s) request=%s", apiErr.Message, apiErr.Type, apiErr.RequestID)
}

Configuration

client, err := jev.New(
	jev.WithAPIKey(key),                 // else $TYPESAFE_API_KEY
	jev.WithBaseURL(url),                // else $TYPESAFE_BASE_URL, else api.typesafe.ai
	jev.WithModel("jev-preview"),        // else $TYPESAFE_DEFAULT_MODEL, else jev-latest
	jev.WithTimeout(2*time.Second),      // bounds one attempt, not the whole call
	jev.WithRetry(jev.DefaultRetry),     // WithRetries(n) changes only the count
	jev.WithHeaders(map[string]string{"X-Tenant": "acme"}),
	jev.WithLogger(slog.Default()),      // silent unless you pass one
	jev.WithHTTPClient(hc),              // used as given, never mutated
	jev.WithCache(jev.NewMemoryCache(10_000)),
	jev.WithMiddleware(metrics),
)

Explicit options win over environment variables, which win over the defaults — the same precedence as the Python and JavaScript SDKs.

Reading configuration from somewhere other than the process environment needs no ceremony:

// godotenv, viper, koanf, a secret manager — anything that hands you a string.
client, err := jev.NewWithKey(cfg.TypeSafeKey, jev.WithTimeout(5*time.Second))

Once WithAPIKey (or NewWithKey) is given a key, TYPESAFE_API_KEY is not read at all, so an empty key is ErrNoAPIKey rather than a silent fall back to whatever the machine happens to hold.

Retries

DefaultRetry matches the official SDKs: two retries, 500ms of backoff doubling to 5s with 25% jitter subtracted, honouring retry-after-ms and Retry-After up to one minute (a longer delay falls back to backoff), retrying 408, 429 and every 5xx, plus connection failures and timeouts.

jev.WithRetry(jev.Retry{
	MaxRetries:        3,
	BackoffInitial:    time.Second,
	BackoffMax:        20 * time.Second,
	BackoffJitter:     0.25,
	Statuses:          []int{429, 503}, // nil means 408, 429 and 5xx
	RespectRetryAfter: true,
	MaxRetryAfter:     time.Minute,
	RetryConnection:   true,
	RetryTimeout:      true,
})

Every request carries User-Agent, X-TypeSafe-SDK and X-TypeSafe-Runtime, and retries carry X-TypeSafe-Retry-Count, as the official clients do.

Provider and Middleware are small interfaces, so logging, metrics, rate limiting or an alternative backend can be plugged in without touching call sites.

Testing

jevtest is a rule-based fake. A question that matches no rule fails the request, so a missing rule becomes a test failure instead of a silent "no".

fake := jevtest.New().
	On(jevtest.Instructions("spam"), jevtest.Yes(0.02)).
	On(jevtest.Kind(jev.KindChoice), jevtest.Pick("refund", 0.93)).
	On(jevtest.State("broken"), jevtest.Level(2, 0.8))

client, _ := jev.New(jev.WithProvider(fake))
// ... exercise your code, then inspect fake.Calls()

Matchers: Any, Instructions, Kind, Option, State, All, Not. Answers: Yes, Pick, Level, Fail.

What the types do not buy you

A ChoiceAnswer[Intent] is guaranteed to hold one of your Intent values. Nothing here guarantees it holds the right one. The compiler checks that an answer fits the question it came from; whether it is right is an empirical question about your inputs, and the only way to settle it is to measure.

So before Sure(0.9) is allowed to decide anything expensive, label a few hundred real inputs, run them through, and count how often the answers above that line were actually right. If 0.9 turns out to mean 70% in your domain, the number that needs changing is in your code, and you can change it without touching a question or paying for another request. That is the whole reason the thresholds live there.

License

MIT