TypeSafe System One API for Elixir

Hex.pm Hex Docs Elixir License: MIT

An Elixir client for TypeSafe's System One API, the API behind Jev. It is unofficial and not published by TypeSafe. Its behaviour follows the official Python SDK:

System One models don't generate text. You send some state and a map of typed questions, and get back one typed answer per question, with probabilities your code acts on.

client = TypeSafe.new()   # reads TYPESAFE_API_KEY

{:ok, response} =
  TypeSafe.system_one(client, %{ticket: "I was charged twice. Please fix this ASAP."}, %{
    billing: TypeSafe.noul("Is this ticket about billing?"),
    tone: TypeSafe.choice("What is the customer's tone?", [:calm, :frustrated, :angry]),
    urgency: TypeSafe.score("How urgent is this ticket?", ["Can wait", "This week", "Today"])
  })

response.answers
#=> %{
#     billing: %TypeSafe.NoulAnswer{noul: 0.99},
#     tone: %TypeSafe.ChoiceAnswer{
#       choice: :frustrated,
#       probabilities: %{calm: 0.0, frustrated: 0.86, angry: 0.14},
#       confidence: 0.78
#     },
#     urgency: %TypeSafe.ScoreAnswer{
#       score: 1.99,
#       legend: %{0 => "Can wait", 1 => "This week", 2 => "Today"},
#       probabilities: %{0 => 0.0, 1 => 0.0, 2 => 1.0},
#       confidence: 0.99
#     }
#   }

response.model   #=> "jev-1.13.0"
response.usage   #=> %{input_tokens: 367, output_tokens: 75}

That is a real response from jev-1.13.0.

Installation

def deps do
  [{:typesafe_ex, "~> 0.2"}]
end

It depends on req, jason and telemetry.

Questions and answers

Builder Asks Answer struct
TypeSafe.noul/2 whether a condition holds NoulAnswer: noul, the probability of yes
TypeSafe.choice/2 which one of a defined set ChoiceAnswer: choice, probabilities, confidence
TypeSafe.score/2 how far along ordered levels ScoreAnswer: score, legend, probabilities, confidence
TypeSafe.box/1 where something is in an image BoxAnswer: top, bottom, left, right, confidence

A few details keep answers easy to use from Elixir:

TypeSafe.choice("Which team should own this ticket?",
  billing: "Charges, invoices, refunds",
  technical: "Bugs, outages, integrations",
  unclear: "The ticket does not say enough to tell"
)

TypeSafe.noul("If the user wants to change the lights, do they want them on?",
  true: "Lights on or brighter",
  false: "Lights off or dimmer"
)

Instructions and descriptions can be maps or lists when structure makes a question clearer. A raw map (%{"type" => ..., ...}) is sent as given, and its answer comes back raw. That covers question types this client predates.

Malformed questions raise ArgumentError before anything is sent. That includes a Choice with more than 255 options and a Score with more than 10 levels, which the API refuses.

Images and Box questions

TypeSafe.box/1 finds something in an image and answers with a box. Images work with any question type: a Noul can ask whether a person is in a photo, a Choice what kind of document a scan is.

A TypeSafe.Image is a PNG or JPEG. It stands in a request by its reference, a string. Put the image where it belongs in the state or in a question, and pass it in :images:

photo = TypeSafe.Image.from_file!("kitchen.jpg")

{:ok, response} =
  TypeSafe.system_one(
    client,
    %{photo: photo},
    %{
      mug_present: TypeSafe.noul("Is there a coffee mug in `photo`?"),
      mug: TypeSafe.box("The coffee mug in `photo`")
    },
    model: "jev-1.14.0",
    images: [photo]
  )

if TypeSafe.NoulAnswer.yes?(response.answers.mug_present) do
  TypeSafe.BoxAnswer.pixels(response.answers.mug, 1024, 768)
  #=> {left, top, right, bottom} for a 1024x768 image
end

Code owns the decision

Ask independent questions together: they share one read of the state and run in parallel. Then let ordinary code apply the policy.

%{team: team, security: security} = response.answers

cond do
  TypeSafe.NoulAnswer.yes?(security) -> {:route, :security}
  team.choice == :unclear or team.confidence < 0.5 -> {:route, :human}
  true -> {:route, team.choice}
end

confidence measures how concentrated the distribution is. It is not the probability that the answer is right. Tune thresholds on your own data, and pin a versioned model such as "jev-1.14.0" once you have. The aliases move when a new release ships.

Model Notes
jev-1.14.0 the newest release (14 October 2026); the only one with Box questions
jev-1.13.0 the previous release
jev-latest the newest stable release; the default. jev-1.13.0 as of 16 October 2026
jev-preview the newest release of any kind. jev-1.14.0 as of 16 October 2026

All models take 64k tokens per request and 32k per question. TypeSafe.models/1 lists what the account can send; versioned ids work whether or not they're listed. For a state that doesn't fit, long_context: true raises both limits to 262k tokens at the same price per token, but slower and less accurate.

For many items, run one call per item concurrently:

tickets
|> Task.async_stream(&TypeSafe.system_one(client, %{ticket: &1}, questions), max_concurrency: 8)
|> Enum.map(fn {:ok, result} -> result end)

Configuration

Option to new/1 Environment variable Default
:api_key TYPESAFE_API_KEY none
:base_url TYPESAFE_BASE_URL https://api.typesafe.ai
:model TYPESAFE_DEFAULT_MODEL jev-latest
:timeout 10_000 ms per response
:retry see below
:compression false; or :gzip, :zstd, see below
:headers extra request headers
:req_options merged into the Req request last

A missing key doesn't stop new/1, so an app can boot without one. Calls then return {:error, %TypeSafe.Error{reason: :no_api_key}} without making a request. TypeSafe.configured?/1 tells you which case you are in.

system_one/4 can override :model, :timeout, :retry, :compression and :headers per call. It also takes :images to send images, :long_context to raise the context limits, and :extra_body for top-level fields the API adds later.

Compression

The API accepts request bodies compressed with gzip or zstd. It pays on large states, such as a long document or a chat log, and saves upload time on a slow link. It is off unless you ask:

client = TypeSafe.new(compression: :gzip)   # every call; or :zstd
TypeSafe.system_one(client, state, questions, compression: false)   # override one call

Errors and retries

Failures return {:error, %TypeSafe.Error{}}, and system_one!/4 raises the same struct. Each error has a reason, the HTTP status, the server's message, and the x-typesafe-request-id as request_id.

reason When
:no_api_key no key configured; no request made
:bad_request, :authentication, :permission_denied, :not_found 400, 401, 403, 404
:payload_too_large 413; the request is over 32 MiB, as it can be with images
:unprocessable_entity 422; the message lists each field, as path: problem
:rate_limited, :overloaded, :server_error, :http_error 429, 529, other 5xx, anything else
:timeout, :connection no response
:invalid_response a 2xx missing a required field; the message names it

Retries follow the official SDKs:

Configure it with retry: [max_retries: 5, budget: 60_000], or turn it off with retry: false, on the client or per call. See TypeSafe.Retry for the options.

Telemetry

Every request runs in a :telemetry.span/3 named [:typesafe, :request].

Testing an app that uses it

Point the client at Req.Test:

# config/test.exs
config :my_app, :typesafe, req_options: [plug: {Req.Test, MyApp.TypeSafe}]

# in a test
Req.Test.stub(MyApp.TypeSafe, fn conn ->
  Req.Test.json(conn, %{
    "model" => "jev-1.14.0",
    "answers" => %{"billing" => %{"type" => "noul", "noul" => 0.97}},
    "usage" => %{"input_tokens" => 100, "output_tokens" => 10}
  })
end)

Developing

mix test                                         # unit tests, no network
TYPESAFE_API_KEY=... mix test --include integration   # also calls the live API

TypeSafe docs: index · primitives · confidence · HTTP API

License

MIT. See LICENSE.