ExTypesafe

Hex.pm Documentation

Elixir client for the TypeSafe AI API.

TypeSafe evaluates typed questions against a state and returns structured answers your code can branch on directly — no text parsing required. Three question primitives, mixable in a single call:

Primitive Ask Returns
Noul Yes/no question Float 0–1 (probability of "yes")
Choice Pick one from a list you define Chosen option + full probability distribution
Score Rate on a rubric you define Probability-weighted score + rubric legend + probabilities

Installation

Add ex_typesafe to your dependencies in mix.exs:

def deps do
[
{:ex_typesafe, "~> 0.1"}
]
end

Configuration

Set your API key via environment variable (recommended):

export TYPESAFE_API_KEY="ts-..."

Or pass it directly when creating a client:

client = ExTypesafe.Client.new(api_key: "ts-...")

All client options

Option Env Variable Default Description
:api_key TYPESAFE_API_KEY Required. Your TypeSafe API key.
:base_url TYPESAFE_BASE_URL https://api.typesafe.ai API root URL.
:model TYPESAFE_DEFAULT_MODEL jev-latest Default model for requests.
:max_retries 3 Retry attempts on 429/529 responses and transport failures.
:retry_delay_ms 500 Initial retry delay; doubles after each attempt.
:max_retry_delay_ms 5000 Maximum backoff delay and accepted Retry-After value.

Usage

# 1. Create a client (reads TYPESAFE_API_KEY from env)
client = ExTypesafe.Client.new()
# 2. Define typed questions. Atom keys are preserved in the response.
questions = %{
is_urgent: ExTypesafe.Question.noul("Does this message convey urgency?"),
department: ExTypesafe.Question.choice(
"Which team should handle this?",
%{
billing: "Payments, invoices, and refunds",
technical: "Bugs, outages, and integrations",
sales: "Pricing, upgrades, and new accounts"
}
),
frustration: ExTypesafe.Question.score(
"How frustrated does the customer seem?",
["Calm and polite", "Mildly frustrated", "Very angry"]
)
}
# 3. Evaluate against a state
state = "Hi, my payouts have been failing for 3 days. I'm losing sales. Please help ASAP!"
case ExTypesafe.system_one(client, state, questions) do
{:ok, response} ->
response.answers.is_urgent.noul # => 0.97
response.answers.department.choice # => "billing"
response.answers.frustration.score # => 1.92
response.scores.frustration.legend # => %{"0" => "Calm and polite", ...}
response.scores.frustration.probabilities # => %{"0" => 0.01, "1" => 0.11, "2" => 0.88}
response.usage.input_tokens # => 418
response.request_id # => "req_..." when sent by the API
{:error, %ExTypesafe.Error{status: 429}} ->
# Retried automatically; this means retries were exhausted.
:backoff
{:error, error} ->
IO.inspect(error)
end

Question-container structs and response keys

Alongside a map, questions can be a caller-defined struct. Its non-nil fields are treated as questions; optional nil fields are omitted from the request. The struct marker is never sent to the API, and atom field names are restored on the response. A single typed Question.Noul, Question.Choice, or Question.Score is intentionally rejected as a container; define a purpose-built struct whose fields contain valid question values instead. Raw question maps remain forward-compatible:

defmodule TicketQuestions do
defstruct [:is_urgent, :department]
end
questions = %TicketQuestions{is_urgent: ExTypesafe.Question.noul("Urgent?"), department: nil}

Answer keys retain the same form used in the question map:

# Atom input keys provide ergonomic map-dot access.
response.answers.is_urgent.noul
response.choices.department.choice
response.scores.frustration.score
# String input keys remain strings.
questions = %{"is_urgent" => ExTypesafe.Question.noul("Urgent?")}
{:ok, response} = ExTypesafe.system_one(client, state, questions)
response.answers["is_urgent"].noul

response.answers contains all answer kinds. response.nouls, response.choices, and response.scores are typed convenience maps. Unknown answer kinds from a newer API are retained as ExTypesafe.Response.UnknownAnswer rather than causing response parsing to fail.

Structured instructions and criteria

Instructions and criterion descriptions can be strings, maps, or lists. Score levels and Choice descriptions can also be nil when no extra description is needed.

questions = %{
same_person: ExTypesafe.Question.noul(%{
candidate: %{name: "Jane Doe", location: "Austin, TX"},
question: "Is the resume for the same person as `candidate`?"
}),
routing: ExTypesafe.Question.choice(
["Which team should own this?", %{account_tier: "enterprise"}],
%{
billing: %{examples: ["invoice", "refund"]},
technical: nil
}
)
}

Scores require two through ten ordered levels. Choice questions accept one through 255 options. The client returns {:error, %ExTypesafe.Error{status: :validation}} before making a request when those constraints or question-key uniqueness are violated.

Forward-compatible request fields

Raw question maps and :extra_body let you use a newly introduced API feature before this package adds a first-class helper:

{:ok, response} =
ExTypesafe.system_one(
client,
"I was charged twice.",
%{
billing: %{
"type" => "noul",
"instructions" => "Is this about billing?",
"weight" => 2
}
},
extra_body: %{beam_width: 4}
)

The client always controls state, model, and questions; atom- and string-keyed values for those fields in :extra_body are ignored. Unlike question containers, :extra_body must be a plain map (structs are rejected intentionally). Raw question maps pass through without the typed helper validation, so use them deliberately when targeting a newer API feature.

Per-request model and retry overrides

ExTypesafe.system_one(
client,
state,
questions,
model: "jev-latest",
max_retries: 1,
retry_delay_ms: 100,
max_retry_delay_ms: 1_000
)

Retries

429 Too Many Requests, 529 Overloaded, and transport-level connection failures are retried automatically with capped exponential backoff. Numeric Retry-After (delta seconds) and retry-after-ms headers are honored when within the configured cap. Configure retries on the client with :max_retries, :retry_delay_ms, and :max_retry_delay_ms, or override them for an individual call as shown above.

A transport retry can repeat a POST if the connection fails after TypeSafe received the request. Set max_retries: 0 when an at-most-once attempt is more important than automatic recovery.

Testing

Use Req.Test to stub HTTP calls without hitting the network:

defmodule MyApp.ClassifierTest do
use ExUnit.Case, async: true
setup do
client = ExTypesafe.Client.new(
api_key: "ts-test",
plug: {Req.Test, __MODULE__}
)
%{client: client}
end
test "classifies urgent tickets", %{client: client} do
Req.Test.stub(__MODULE__, fn conn ->
body = Jason.encode!(%{
"model" => "jev-1.13.0",
"answers" => %{"is_urgent" => %{"type" => "noul", "noul" => 0.95}},
"usage" => %{"input_tokens" => 296, "output_tokens" => 20}
})
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, body)
end)
{:ok, response} = ExTypesafe.system_one(client, "Help! Urgent!", %{
is_urgent: ExTypesafe.Question.noul("Urgent?")
})
assert response.answers.is_urgent.noul > 0.8
end
end

API Reference

Full TypeSafe API reference: https://docs.typesafe.ai/api

License

MIT