jev
A small Haskell client for TypeSafe Jev, with direct TypeSafe and OpenRouter support. Define typed questions, send a prompt, and pattern match on your own values. Choice, Score, and Noul can share one request using ordinary Applicative composition.
Textual inputs and metadata use strict Text. JSON encoding and response decoding stay inside the library. Responses retain probabilities, confidence where available, model information, and usage. requestId uses the JSON id when supplied by a gateway, otherwise the x-typesafe-request-id response header.
Add Jev to an application
The supported and tested compiler is GHC 9.10.1 (base-4.20). Dependency
bounds currently target that tested environment; other compiler versions have
not been verified. You can use Cabal directly; Nix is optional for consumers.
To consume a local checkout, place it beside your application and use this
cabal.project in the application directory:
packages: . ../jev
For a Git dependency, instead add a source-repository-package stanza pointing
at https://github.com/realbogart/jev.git and pin tag to a full commit hash you
have reviewed. A local checkout includes your uncommitted changes; a Git pin does not.
A minimal application's .cabal file can be:
cabal-version: 3.4
name: jev-demo
version: 0.1.0.0
build-type: Simple
executable jev-demo
main-is: Main.hs
build-depends: base >= 4.20 && < 4.21, text >= 2.1 && < 2.2, jev == 0.1.0.0
default-language: GHC2021
default-extensions: OverloadedStrings, OverloadedRecordDot
Copy examples/Choice.hs to Main.hs, set JEV_API_KEY, and
run cabal run jev-demo. Add aeson to your application's dependencies if you
use the structured JSON helpers. The postpositive qualified imports in the
examples are enabled by GHC2021 (or explicitly by ImportQualifiedPost).
A single Choice
Enable OverloadedStrings and OverloadedRecordDot in your application. Define an explicit mapping between your constructors and the labels and descriptions Jev sees:
import Jev
import Data.Text qualified as Text
import Data.Text.IO qualified as Text
data Team = Billing | Technical
route :: Question (Choice Team)
route =
choice "Which team should handle this?"
[ Option Billing "billing" (Just "Payments and invoices"),
Option Technical "technical" (Just "Bugs and outages")
]
Given a Text API key, reuse a client across requests:
withClient (defaultConfig TypeSafe key) $ \client -> do
result <- decide client "I was charged twice." route
case result of
Left err -> Text.putStrLn (Text.pack (show err))
Right response -> case response.answers.choice of
Billing -> Text.putStrLn "Send to billing"
Technical -> Text.putStrLn "Send to technical support"
The complete Choice example loads JEV_API_KEY and handles both constructors:
cabal run jev-choice
Choice, Score, and Noul in one call
Use a record constructor with <$> and <*> to describe the result:
data Triage = Triage
{ team :: Choice Team,
urgency :: Score,
human :: Noul
}
triage :: Question Triage
triage =
Triage
<$> route
<*> score "How urgent is this?" ["Routine", "Handle today", "Handle immediately"]
<*> noul "Does this need a human?"
Run decide client prompt triage to receive Either JevError (Response Triage) in one HTTPS request. response.answers.team.choice is a Team; response.answers.urgency.score is a fractional value on the rubric's zero-based scale; response.answers.human.probability is the probability of yes. Choose thresholds in application code. Noul is not a Boolean, and Score is not automatically normalized to 0–1.
The complete Triage example uses OpenRouter, retains routing confidence, reports the score, and escalates when the Noul probability is at least 0.8:
cabal run jev-triage
Set OPENROUTER_API_KEY before running it. Both examples make real, billable requests when executed; building and testing do not.
Configuration and lifecycle
defaultConfig TypeSafe key uses https://api.typesafe.ai/v1/systemone and jev-latest. defaultConfig OpenRouter key uses https://openrouter.ai/api/alpha/decisions and typesafe/jev-1.13. The OpenRouter Decisions API is an alpha endpoint.
Override model, endpoint (a complete URL), or timeoutMicros with record updates:
config :: Text.Text -> Config
config key = (defaultConfig TypeSafe key) {timeoutMicros = 60 * 1000000}
The default deadline is 30 seconds for the entire HTTP operation, including
connection setup, request transmission, and reading the complete response body.
Expiration returns TransportError DeadlineExceeded. Pure input validation and
response decoding are outside this deadline. A custom manager may impose its
own shorter limits. Keys are explicit: the library does not read the environment,
and Config has no Show instance.
Records expose fields for record-dot syntax, record updates, and pattern matching.
They do not generate ordinary selector functions: use response.answers or
Response {answers = value}, rather than answers response.
newClient creates a reusable TLS connection manager; withClient scopes its use. closeClient releases the client's manager reference and prevents new requests through that client. In-flight requests can finish. The HTTP library reclaims connections automatically after the manager becomes unreachable. Closing is idempotent.
For an existing connection pool, use clientWithManager config manager. The caller retains ownership, and closeClient does nothing to a borrowed manager. Its manager settings also control any transport-level retries. Library-owned managers disable retries; application-level retries, caching, and logging are left to callers.
OpenRouter routing and observability settings are optional per request:
-- Import Data.Aeson (object, (.=)) for this example.
routerOptions :: RequestOptions
routerOptions = defaultRequestOptions
{ sessionId = Just "support-session-42",
providerRouting = Just (object ["allow_fallbacks" .= False])
}
-- decideWith client routerOptions "I was charged twice." route
decideWith and decideJSONWith also accept trace objects and a user
identifier. Session and user identifiers are limited to 256 characters. Nonempty
options fail locally on TypeSafe; nested routing and trace fields are checked by
OpenRouter. Defaults send no extra fields.
decideJSON, choiceJSON, scoreJSON, and noulJSON accept Aeson values for structured state, instructions, and criteria. State and instructions support strings, objects, and arrays. Use JsonOption for structured Choice descriptions; Nothing or Just Null encodes an undescribed option as JSON null. Use noulWithCriteria or noulJSON with NoulCriteria to describe both yes and no. With direct TypeSafe, the JSON helpers also accept Null instructions and Noul outcome descriptions. OpenRouter requires non-null instructions and descriptions for both supplied Noul outcomes; incompatible values fail locally. Score levels and state must be non-null on both providers.
Question supports fmap, applicative composition, and traverse. Internal question IDs are assigned automatically. For example, traverse noul prompts describes one request returning [Noul]. There is no Monad instance: questions in one request cannot depend on earlier answers. Make another decide call for dependent decisions.
Question construction is pure; validation happens before any HTTP request. An entirely pure question or empty traversal contains no questions and is rejected. Choice accepts 1–255 options with unique wire labels; Score accepts 2–10 levels. No typeclass instances are required for Choice domain values.
Results expose constructors for pattern matching. Choice distributions pair domain values with probabilities in the supplied option order. Score distributions and legends use IntMap; supplied sparse distributions are preserved. The decoder checks finite probabilities in [0,1], distribution sums within 0.001 of one, a maximal selected Choice probability within 0.001, and a Score within 0.001 * numberOfLevels of its probability-weighted value. Legends must cover every returned probability index. Values are never renormalized or filled in. Confidence is retained as a separate provider measure in [0,1], not recomputed from the distribution. Public constructors allow manually created values that bypass these checks.
Response, Choice, Option, JsonOption, and NoulCriteria have Functor
instances. Mapping a Choice transforms both the selection and the values in its
distribution; mapping a Response changes only its answers.
Validation and testing without HTTP
validateQuestion TypeSafe triage checks question structure without a client or
API key. Failures identify the question's zero-based ID and primitive, for example
q1 (score): Score requires 2 to 10 levels.
prepareRequest provider model state question additionally checks the model and
state, and returns an abstract PreparedRequest a. prepareRequestWith also
accepts per-request options. Use requestBody to inspect the exact JSON bytes,
and decodeResponse to test stored response fixtures against the original typed
question. Neither operation makes a network request or requires credentials:
-- Import Data.Aeson (Value (String)) and qualified Data.ByteString.Lazy as LBS.
decodeRouteFixture :: LBS.ByteString -> Either JevError (Response (Choice Team))
decodeRouteFixture fixture = do
prepared <- prepareRequest TypeSafe "jev-latest" (String "Test state") route
decodeResponse prepared fixture
The fixture decoder does not check HTTP status or attach header metadata. Request
bodies contain no bearer token, but can still contain sensitive application data.
Handling failures
Failures distinguish validation, transport, HTTP status, and decoding:
ValidationError message: no request was made.
TransportError reason: a structured TransportFailure, such as
DeadlineExceeded, ConnectionFailed, or InvalidResponse. Categories contain
no request contents or credentials. A category does not guarantee retry safety;
a timed-out request may already have been processed.
HttpError metadata body: a non-2xx response. metadata.statusCode,
metadata.headers, and metadata.requestId retain diagnostic context.
lookup "Retry-After" metadata.headers retrieves server retry guidance when present.
ResponseDecodeError metadata message: a 2xx response could not be decoded;
the same metadata is retained, including the body ID when readable or header ID.
DecodeError message: decoding a fixture through decodeResponse failed.
Asynchronous cancellation propagates normally. Exceptions from custom manager
hooks or user functions mapped over questions are not generally converted to
JevError. Server response headers and bodies can contain sensitive information;
choose what to retain when logging. See CHANGELOG.md for changes
to error constructor arguments.
Development
Use the repository's Nix development shell, then run:
./scripts/format.sh
./scripts/verify.sh
Verification checks package metadata, compiles the library and both examples,
runs fixture and local HTTP server tests, and builds and tests an unpacked source
distribution using a fresh, minimal Cabal project. Tests require no API credentials.
cabal haddock lib:jev generates API documentation. The default Nix package builds
the library. Nix development-tool overrides live in cabal.project.nix; ordinary
Cabal consumers use the minimal cabal.project without those overrides.
Jev is the convenient public import. Jev.Types, Jev.Question, and Jev.Client separate data, question construction, and transport. The internal protocol module owns JSON encoding and decoding.
API compatibility notes
Checked against the official HTTP reference, question schemas, and response schemas, with targeted live checks on both providers.
The advanced structure guide lists null Score levels, but the HTTP reference, Python schema, and both live endpoints reject them. The library follows the confirmed endpoint behavior. Score legends can contain objects and arrays as well as text, so their values remain Aeson Value.
For Jev 1.13, the model documentation specifies 64k tokens for the complete request and 32k for state plus the longest question. These limits are enforced by the provider, not estimated locally. Pin config.model to a version when your thresholds depend on that version's behavior; jev-latest can change.
TypeSafe documents HTTP 401 for authentication failures, 422 for validation failures, 429 for rate limits, and 529 for overload. The library preserves their status and response body. Callers should use exponential backoff for 429/529. Automatic retries and the model-listing endpoint are outside this library's decision API. The examples intentionally use JEV_API_KEY; TypeSafe's own SDK examples use TYPESAFE_API_KEY.