A Go client for the TypeSafe AI API.
TypeSafe's System One model, Jev, evaluates typed questions against a state and returns structured, probabilistic answers instead of generated text:
- Noul (yes/no) → a probability that the answer is yes
- Choice → the selected option, the probability of every option, and a confidence
- Score → a probability-weighted position on an ordered rubric, the probability of every level, and a confidence
Every question in a request is evaluated independently against the same state, so you can ask all the questions your code needs in one call.
The library depends only on the Go standard library.
go get github.com/adibhanna/typesafegoRequires Go 1.24 or later. The package name is typesafe:
import typesafe "github.com/adibhanna/typesafego"package main
import (
"context"
"fmt"
"log"
typesafe "github.com/adibhanna/typesafego"
)
func main() {
client, err := typesafe.NewClient() // reads TYPESAFE_API_KEY
if err != nil {
log.Fatal(err)
}
resp, err := client.SystemOne(context.Background(), typesafe.SystemOneRequest{
State: "My payment failed twice and I still got charged. Fix this now!",
Questions: typesafe.Questions{
"is_urgent": typesafe.Noul("Does the customer convey urgency?"),
"department": typesafe.Choice("Which team should handle this?", map[string]any{
"billing": "Payments, invoices, refunds",
"technical": "Bugs, outages, integrations",
"sales": "Pricing, upgrades, new accounts",
}),
"frustration": typesafe.Score("How frustrated is the customer?",
"Calm", "Frustrated", "Very angry"),
},
})
if err != nil {
log.Fatal(err)
}
urgent, _ := resp.Noul("is_urgent")
department, _ := resp.Choice("department")
frustration, _ := resp.Score("frustration")
fmt.Println(urgent.IsTrue(0.5), urgent.Noul) // true 0.93
fmt.Println(department.Choice, department.Confidence) // billing 0.81
fmt.Println(frustration.Score, frustration.Label()) // 1.7 Very angry
fmt.Println(resp.Model, resp.Usage.InputTokens) // jev-1.13.0 312
}A complete, runnable version lives in examples/triage:
export TYPESAFE_API_KEY=sk-...
go run ./examples/triage "My card was charged twice and support is ignoring me!"State can be any JSON-encodable value: a string, a struct, a map, or a
slice. Question instructions and criteria can be strings or structured JSON
values (objects, arrays), which the model reads as-is.
typesafe.Noul("Is the customer asking for a refund?")
// Optionally spell out what a yes and a no mean.
typesafe.Noul("Is the customer asking for a refund?").WithCriteria(
"The customer explicitly asks for money back or a chargeback.",
"Anything else, including billing questions without a refund request.",
)Answer: *NoulAnswer with Noul (probability of yes) and IsTrue(threshold),
which is Noul >= threshold.
// Options with descriptions. A nil description sends null.
typesafe.Choice("Which team should handle this?", map[string]any{
"billing": "Payments, invoices, refunds",
"technical": map[string]any{"handles": []string{"bugs", "outages"}},
"sales": nil,
})
// Options without descriptions.
typesafe.ChoiceOf("What is the sentiment?", "positive", "neutral", "negative")Answer: *ChoiceAnswer with Choice, Probabilities (option → probability),
Confidence, plus ProbabilityOf(option) and Ranked() (options sorted by
probability, highest first).
typesafe.Score("How frustrated is the customer?",
"Calm", // level 0
"Frustrated", // level 1
"Very angry", // level 2
)Levels are ordered from lowest to highest and each may be a string or a structured value. At least two are required.
Answer: *ScoreAnswer with Score (probability-weighted, may fall between
levels), Legend (level → description), Probabilities (level →
probability), and Confidence, plus:
| Method | Returns |
|---|---|
Level() |
the most probable level (lowest index on a tie) |
NearestLevel() |
Score rounded to the nearest level, clamped to the rubric |
Label() / LabelOf(level) |
the legend entry for Level() / a given level |
ProbabilityOf(level) |
the probability of a level |
Normalized() |
Score / MaxLevel(), a 0..1 value comparable across rubrics of different sizes |
MaxLevel() |
the highest level index |
Level() and NearestLevel() differ when the distribution is split: with
probabilities [0.5, 0, 0.5] the score is 1.0, so NearestLevel() is 1 while
Level() is 0.
RawQuestion sends an arbitrary JSON object for fields or question types
this package does not model. It must contain a string "type".
typesafe.RawQuestion{"type": "noul", "instructions": "Is this spam?"}Answers of a type this package does not know decode to *UnknownAnswer
carrying the raw JSON, so new answer types never break existing code.
SystemOneResponse.Answers is a map[string]Answer. The typed getters
return an error wrapping ErrAnswerNotFound if the id is missing and
ErrAnswerType if the answer has another type:
department, err := resp.Choice("department")
if err != nil {
return err // typesafe: answer not found: "department"
}department, _ := resp.Choice("department")
if department.Confidence < 0.7 {
return routeToHuman(ticket, department.Ranked())
}
return routeTo(department.Choice)Normalized() puts rubrics with different numbers of levels on the same
0..1 scale so they can be weighted together with Noul probabilities:
priority := 0.4*frustration.Normalized() + 0.35*urgent.Noul + 0.25*churn.NoulNewClient reads its defaults from the environment, and explicit options
take precedence:
| Option | Environment variable | Default |
|---|---|---|
WithAPIKey(key) |
TYPESAFE_API_KEY |
required |
WithBaseURL(url) |
TYPESAFE_BASE_URL |
https://api.typesafe.ai |
WithModel(model) |
TYPESAFE_DEFAULT_MODEL |
jev-latest |
WithTimeout(d) |
10s per attempt (0 disables) |
|
WithRetryPolicy(p) / WithMaxRetries(n) |
DefaultRetryPolicy() |
|
WithHTTPClient(c) |
http.DefaultClient |
|
WithHeader(key, value) |
||
WithLogger(l) |
discard |
policy := typesafe.DefaultRetryPolicy()
policy.MaxRetries = 4
client, err := typesafe.NewClient(
typesafe.WithAPIKey(os.Getenv("MY_TYPESAFE_KEY")),
typesafe.WithModel(typesafe.ModelJevPreview),
typesafe.WithTimeout(20*time.Second),
typesafe.WithRetryPolicy(policy),
typesafe.WithLogger(slog.Default()),
)The same options can be applied to a single call without changing the client:
resp, err := client.SystemOne(ctx, req, typesafe.WithModel("jev-1.13.0"), typesafe.WithMaxRetries(0))The model used for a request is, in order of precedence: SystemOneRequest.Model,
a per-call WithModel, the client's WithModel, TYPESAFE_DEFAULT_MODEL,
then jev-latest.
ModelJevLatest (jev-latest) follows the most recent stable release and
ModelJevPreview (jev-preview) the most recent release of any kind. Pin a
versioned ID such as jev-1.13.0 if you have tuned thresholds against a
specific version; the response's Model field always reports the versioned
ID that answered. client.ListModels(ctx) returns the available models.
WithTimeout bounds a single HTTP attempt (default 10s). Each retry gets a
fresh timeout; use the context deadline to bound the whole call. When a
retry's delay would run past that deadline, the client returns the last
error (for example the 429 *APIError) right away instead of waiting only
to fail with context.DeadlineExceeded.
DefaultRetryPolicy() retries up to 2 times with exponential backoff from
500ms doubling to 5s with 25% jitter. It retries HTTP 408, 429, and all 5xx
responses (including TypeSafe's 529 Overloaded), connection errors, and
timeouts, and it honors retry-after-ms and Retry-After headers of up to
60s. Retried attempts carry an X-Typesafe-Retry-Count header. Every field
of RetryPolicy can be changed; WithMaxRetries(0) disables retries.
Errors fall into three groups:
var apiErr *typesafe.APIError
switch {
case errors.As(err, &apiErr):
// Non-2xx response after retries. StatusCode, Message, Body, Header,
// and RequestID are available; RetryAfter() reads the retry headers.
log.Printf("%d %s (request id %s)", apiErr.StatusCode, apiErr.Message, apiErr.RequestID)
case errors.Is(err, typesafe.ErrInvalidRequest):
// Rejected locally before sending: nil state, no questions, a choice
// without options, a score with fewer than two levels, ...
case errors.Is(err, typesafe.ErrInvalidResponse):
// The body could not be decoded.
default:
// Transport failure or context cancellation; errors.Is works on the
// underlying error, e.g. errors.Is(err, context.DeadlineExceeded).
}APIError.Error() reads like
typesafe: POST https://api.typesafe.ai/v1/systemone: 401 Unauthorized: Invalid API key (request id req_123).
FastAPI-style validation errors are flattened to questions.q.criteria: Field required.
The client is an ordinary HTTP client, so point it at an httptest.Server:
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"model":"jev-1.13.0","answers":{"is_urgent":{"type":"noul","noul":0.9}},"usage":{"input_tokens":10,"output_tokens":1}}`)
}))
defer server.Close()
client, err := typesafe.NewClient(
typesafe.WithAPIKey("test"),
typesafe.WithBaseURL(server.URL),
typesafe.WithMaxRetries(0),
)Answer types are plain structs, so code that consumes them can be tested without a server:
answer := &typesafe.ScoreAnswer{Score: 1.6, Legend: []any{"Calm", "Frustrated", "Very angry"}}gofmt -l .
go vet ./...
go test -race ./...