typesafe

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 19, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

README

typesafe-go

Go Reference CI Go Report Card Zero dependencies License

A community-maintained Go SDK for the TypeSafe System One API and its model, Jev.

Send one state and a map of typed questions. Get one typed answer per question, with calibrated probabilities your code can branch on.

Status: ready for v1.0.0. Everything on the roadmap is built and verified against the live API. See Stability for what v1.0 commits to.

Not affiliated with, endorsed by, or sponsored by TypeSafe AI.

flowchart LR
    S["state<br/><i>the content</i>"] --> API["System One"]
    Q["questions<br/><i>noul · choice · score</i>"] --> API
    API --> A["answers<br/><i>probabilities + confidence</i>"]
    A --> D["your code decides"]

    style API fill:#4a5568,color:#fff
    style D fill:#2d3748,color:#fff

Start here

New to this? User Manual — a linear read, install to production
Want to see code? examples/ — ten runnable programs, each replayed in CI
Picking a primitive? DECISION_GUIDE.md
Coming from Python or JS? MIGRATION.md
Looking up a symbol? pkg.go.dev
Every document in this repository

Guides

docs/MANUAL.md The user manual: install, primitives, decisions, production
docs/DECISION_GUIDE.md Which primitive to use, and how to word the question
docs/LIMITS.md Context budget, rate limits, jaggedness, cost
docs/MIGRATION.md Coming from the official Python or JavaScript SDK
docs/FAQ.md Short answers

Reference

docs/WIRE_CONTRACT.md The verified wire contract, and where the published docs are wrong
docs/PERFORMANCE.md Measured SDK overhead, and the methodology
docs/TESTING.md Mocks, test servers, cassettes, CI recipes
docs/LINTING.md The three analyzers, their rules and their sources
docs/OBSERVABILITY.md Tracing, metrics, caching, and the demo stack
docs/INTEGRATIONS.md HTTP frameworks, LangChainGo, Temporal, MCP

Project

CHANGELOG.md What changed, written by hand
Release_Notes.md The announcement text for each release
CONTRIBUTING.md Clone to passing tests, house rules, the release procedure
SECURITY.md Reporting, supply chain, and what this SDK does with your data
THIRD_PARTY_NOTICES.md Dependency and licence register

Quickstart

package main

import (
    "context"
    "fmt"
    "log"

    typesafe "github.com/nibir1/typesafe-go"
)

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: "Our API has returned 500s for 20 minutes and we cannot process orders.",
        Questions: map[string]typesafe.Question{
            "is_urgent": typesafe.Noul{
                Instructions: "Does this convey urgency?",
            },
            "department": typesafe.Choice{
                Instructions: "Which team should handle this?",
                Criteria: typesafe.Options{
                    "billing":   "Payments, invoicing, refunds",
                    "technical": "Bugs, outages, integrations",
                    "sales":     nil, // interpreted by its name alone
                },
            },
            "severity": typesafe.Score{
                Instructions: "How severe is the incident?",
                Criteria:     typesafe.Levels{"Cosmetic", "Degraded", "Outage"},
            },
        },
    })
    if err != nil {
        log.Fatal(err)
    }

    urgent, _ := resp.Noul("is_urgent")
    team, _ := resp.Choice("department")
    sev, _ := resp.Score("severity")

    level, label := sev.Nearest()
    fmt.Printf("urgent=%.2f  team=%s (%.2f confident)  severity=%d (%v)\n",
        urgent.Noul, team.Choice, team.Confidence, level, label)
}

Real output from the live API:

urgent=0.96  team=technical (1.00 confident)  severity=2 (Outage)

Every question sees the same state and is evaluated in parallel, so asking three costs barely more than asking one. Pack every question about a given state into a single call.


The three primitives

flowchart TD
    Start["What are you asking?"] --> Ordered{"Are the possible<br/>answers ordered?"}
    Ordered -->|Yes| Score["<b>Score</b><br/>severity, urgency<br/>weighted position + confidence"]
    Ordered -->|No| Exclusive{"Is exactly one<br/>of them true?"}
    Exclusive -->|Yes| Choice["<b>Choice</b><br/>which team, which intent<br/>winner + distribution + confidence"]
    Exclusive -->|No| Multiple{"Could several be<br/>true at once?"}
    Multiple -->|Yes| Nouls["<b>Several Nouls</b><br/>one per proposition"]
    Multiple -->|No| Noul["<b>Noul</b><br/>one yes/no proposition<br/>a probability, no confidence"]

    style Score fill:#2c5282,color:#fff
    style Choice fill:#2c5282,color:#fff
    style Noul fill:#2c5282,color:#fff
    style Nouls fill:#2c5282,color:#fff

Full guidance on wording, catch-all options and thresholds in DECISION_GUIDE.md.

Returns Use it for
Noul one probability, 0–1 a yes/no judgment
Choice the winning option + a distribution + confidence picking one of a set you define
Score a weighted position + a distribution + confidence rating against ordered levels
typesafe.Noul{Instructions: "Is this spam?"}

typesafe.Choice{
    Instructions: "What is the tone?",
    Criteria:     typesafe.Options{"calm": "Neutral or polite", "angry": "Upset or hostile"},
}

typesafe.Score{
    Instructions: "How urgent is this?",
    Criteria:     typesafe.Levels{"Can wait", "This week", "Today"},
}

Answers are always members of the set you supplied. There is no parsing step and no prose to recover a value from.

A Noul has no confidence, on purpose

NoulAnswer carries a single probability and nothing else. Near 0.5 is the model saying it does not know, so a separate confidence field would be redundant.

This matters because the obvious accessor would hide it:

conf, ok := resp.Confidence("is_urgent")
// ok == false for a Noul — it has none.

Returning a bare 0 would read as maximally uncertain for a 0.96. Absence is reported, not substituted.


Errors

Every documented failure has a type, and all of them unwrap to *APIError:

resp, err := client.SystemOne(ctx, req)

var rl *typesafe.RateLimitError
if errors.As(err, &rl) {
    time.Sleep(rl.RetryAfter) // 0 when the server sent no preference
}

var ue *typesafe.UnprocessableEntityError
if errors.As(err, &ue) {
    for _, d := range ue.Detail {
        log.Printf("%s: %s", d.Path(), d.Msg)
        // body.questions.frustration.criteria: Field required
    }
}

if errors.Is(err, typesafe.ErrOverloaded) { /* 529, retry later */ }

APIError.RequestID carries x-typesafe-request-id, worth quoting in a support ticket. Credentials are scrubbed from every error string, including when a server echoes your key back in its response body.


What happens on a call

sequenceDiagram
    participant C as your code
    participant I as interceptors
    participant V as validate + estimate
    participant R as retry loop
    participant A as TypeSafe API

    C->>I: SystemOne(ctx, req)
    Note over I: tracing, metrics, cache —<br/>outermost first
    I->>V: validated request
    Note over V: rejects locally if invalid or<br/>over a context ceiling: no round trip
    V->>R: encoded body
    R->>A: attempt 1
    A-->>R: 429 + Retry-After
    Note over R: backoff + jitter,<br/>capped by MaxRetryAfter
    R->>A: attempt 2
    A-->>R: 200
    R-->>I: decoded answers
    I-->>C: typed answers

An interceptor sees one logical call. Retries happen beneath it, so a latency histogram records what the caller waited for rather than one bar per attempt. For per-attempt visibility use WithRetryObserver, which is the layer below.


Retries

On by default, with the same numbers as the official Python and JavaScript SDKs — two retries, 500ms backoff doubling to a 5s ceiling, ±25% jitter, within a 30s overall budget. Porting a working integration should not require re-tuning anything.

client, _ := typesafe.NewClient(
    typesafe.WithMaxRetries(5),                  // adjust one field
    typesafe.WithRetryPolicy(typesafe.NoRetry()), // or turn it off
)

408, 429, 5xx and 529 are retried. 422 never is — a request that failed validation will fail identically, and retrying only delays the error.

Three behaviors worth knowing:

  • Retried requests send byte-identical bytes. The body is marshalled once. Re-doing it per attempt would risk a different request, since Go iterates maps randomly.
  • A retry-after longer than 30s is not waited out. The error is returned at once with the server's requested delay still on it, rather than parking your caller on the server's say-so. Configurable via MaxRetryAfter.
  • Backoff never outlasts the budget. If the next delay would exceed the remaining time, the client stops instead of sleeping toward a deadline it will miss.

ErrRetriesExhausted wraps the terminal failure rather than replacing it, so errors.As still finds the *RateLimitError underneath and existing handling keeps working.

Circuit breaker

Off by default. Retrying helps with a blip and hurts during an outage.

breaker := typesafe.NewCircuitBreaker()          // 5 failures, 30s open
client, _ := typesafe.NewClient(typesafe.WithCircuitBreaker(breaker))

Only retryable failures count toward opening it — a 422 says your request was wrong, not that the service is unwell. Share one breaker per upstream.


Testing without a key

go test ./... in your project should pass on a laptop with no network and no credentials. Three doubles, by how much of the client they exercise:

// Unit test: your branching logic, transport incidental.
m := typesafetest.NewMock().On("is_spam", typesafetest.Noul(0.93))

// Integration test: real client, scripted server.
srv := typesafetest.NewServer(t, typesafetest.RateLimited(3), typesafetest.Answers(...))

// Against real recorded answers, replayed offline forever.
hc := cassette.Open(t, "testdata/cassettes/triage.jsonl", cassette.Recording(*update))

Cassettes replay through an http.RoundTripper, so the SDK's marshalling, status mapping, and error typing all still run. They are newline-delimited JSON, diffable, and byte-deterministic — re-record one and an empty diff means the API has not drifted.

Full guide: docs/TESTING.md.


Deciding, not just asking

TypeSafe's documentation is consistent: decompose a broad judgment into atomic questions, ask them together, and combine the answers with deterministic logic in code. Every SDK hands back a probability distribution and stops there. The decision package is the part that was left as an exercise.

var SpamPolicy = decision.Policy{
    Name: "spam-v3",
    Weights: decision.Weights{
        "asks_for_credentials":  0.4,
        "creates_time_pressure": 0.3,
        "has_suspicious_link":   0.2,
        "generic_greeting":      0.1,
    },
    Normalize:   true,
    ReviewAbove: 0.5,
    BlockAbove:  0.8,
}

result, err := SpamPolicy.Evaluate(resp)
switch result.Verdict {
case decision.Block:  quarantine()
case decision.Review: queueForHuman()
}

A Policy is data, so it round-trips through JSON: thresholds can live in config, be diffed in review, and reload without a deploy. Validate() catches a ReviewAbove above BlockAbove, which makes a verdict unreachable with nothing at runtime to say so.

result.Trace shows which question contributed what, ordered by contribution. A probability in an audit log without its derivation is not evidence of anything.

Probability, done as probability
decision.Or(0.9, 0.8)             // 0.98 — a + b - ab, not a + b
decision.AtLeast(2, 0.5, 0.5, 0.5) // 0.5  — exact Poisson-binomial
decision.MinAll(0.9, 0.9, 0.9)     // 0.9  — no independence assumed

AtLeast answers a question a weighted sum structurally cannot: how likely is it that several of these warning signs are real? A sum of 1.5 cannot distinguish three half-certain signals from one certain and one absent.

MinAll/MaxAny exist because independence is sometimes false — ask "is this urgent?" and "is this time-sensitive?" about the same state and multiplying understates badly.

Confidence as a second axis
flowchart LR
    A["answer"] --> C{"confidence"}
    C -->|"≥ 0.90"| Act["act automatically"]
    C -->|"0.50 – 0.90"| Confirm["act, and verify"]
    C -->|"< 0.50"| Escalate["ask a person"]

    style Act fill:#22543d,color:#fff
    style Confirm fill:#744210,color:#fff
    style Escalate fill:#742a2a,color:#fff

Low confidence means your options overlap for this input, not that the input was vague. Measured: "hey" scores 1.00 for unclear, because unclear is the right answer. Confidence falls when two options both fit. See confidence_routing.

routing := decision.Bands{ActAbove: 0.9, ConfirmAbove: 0.5}
switch routing.Classify(department.Confidence) {
case decision.Act:      route(department.Choice)
case decision.Confirm:  askUser(department.Choice)
case decision.Escalate: routeToHuman()
}

ClassifyAnswer on a Noul returns an error rather than a band — a Noul has no confidence, and banding one would read a zero and escalate every call.

Weights from data, not guesswork
report, err := decision.Calibrate(labeledHistory, questionIDs, decision.CalibrationOptions{})
if !report.Separable {
    return fmt.Errorf("these signals do not predict the label:\n%s", report)
}
policy := decision.NewPolicy("spam-v4", report.Weights)

Logistic regression, stdlib only. Check Separable — a fit on noise still returns weights, and those weights still produce confident-looking verdicts. The bar is the majority-class rate plus two standard errors, so it scales with how much data you have.

Nothing in decision touches the network. Given the same answers it returns the same verdict, which is what makes a decision replayable months later — enforced by an architecture test.


Catching mistakes before they cost you

Two checks with no equivalent in any other TypeSafe client.

Unresolvable state references. TypeSafe's docs recommend pointing a question at part of a structured state by backticked path. The server does not resolve these — the model just sees a path naming nothing and answers anyway. Nothing in the response tells you.

for _, w := range req.CheckReferences() {
    log.Warn(w) // question "q" references `ticket.mesage`, which does not
                // resolve: `ticket` has no key "mesage"
}

Requests that would exceed the context window, caught before the round trip:

est := req.EstimateTokens()
if err := est.Err(); err != nil {
    return err // never sent
}

The API enforces two ceilings — 64k for the whole request and 32k for the state plus any single question — and a request can pass the first and fail the second. The server's error does not say which. The estimator is fitted from live measurement (tokens ≈ 239 + 0.331 × wire_bytes, plus ~240 fixed per call) and deliberately over-reports by ~25%, because an estimate that is sometimes under fails exactly when a request is near a limit.

typesafe.Budget caps requests and tokens per window and refuses before any network I/O, so a runaway loop costs nothing.

Requests the server would reject, caught before the round trip:

warnings, err := req.Validate()
// err      — a Score with 11 levels, a Choice with no options, a missing state
// warnings — legal, but probably not what you meant

The Score ceiling is a good example of why this exists: the API caps a rubric at 10 levels and returns 400 above that. Neither the OpenAPI schema nor the prose documentation mentions the limit. We found it by sending eleven.


Composing it into an application

Interceptors wrap one logical call — retries happen beneath, so a latency histogram records what the caller waited for rather than one bar per attempt:

client, _ := typesafe.NewClient(
    typesafe.WithInterceptor(tracing, metrics, typesafe.WithLogging(slog.Default())),
    typesafe.WithRequestID(uuid.NewString),
    typesafe.WithHooks(typesafe.Hooks{
        OnResponse: func(ctx context.Context, i typesafe.CallInfo) {
            log.Printf("%s: %d tokens in %s", i.RequestID, i.InputTokens, i.Duration)
        },
    }),
)

They compose outermost-first. A panic in an interceptor or hook becomes a *PanicError with the stack intact, rather than taking down the request path.

WithLogging logs the request id, question count, duration, model and usage — and never the state, which is your data and routinely holds personal information.

Fan-out
results := client.SystemOneAll(ctx, reqA, reqB, reqC) // positional, one error each
r := <-client.SystemOneAsync(ctx, req)

Every question about one state belongs in a single request — they run in parallel server-side. This is for fanning out over different states.

Batching thousands of states
result := client.SystemOneBatch(ctx, states, typesafe.Questions{
    "is_urgent": typesafe.Noul{Instructions: "Does this convey urgency?"},
    "team":      typesafe.NewChoice("Which team?").Options("billing", "technical"),
}, typesafe.WithConcurrency(16))

if err := result.Err(); err != nil {
    log.Print(err) // "3 of 1000 batch items failed \n 2 x ... \n 1 x ..."
}
for i, item := range result.Items { // input order, always
    if item.Err != nil {
        continue // one failure never aborts the batch
    }
    use(i, item.Response)
}
flowchart TB
    subgraph cheap["Nearly free — same request"]
        Q1["+ question 2"]
        Q2["+ question 3"]
    end
    subgraph expensive["A whole request each"]
        S1["+ state 2"]
        S2["+ state 3"]
    end

    style cheap fill:#22543d,color:#fff
    style expensive fill:#742a2a,color:#fff

A bounded worker pool with per-item error isolation, results in input order, and a summed Usage. SystemOneAll is the unbounded form for a handful of requests; this is the one for thousands.

Concurrency adapts globally: when any worker meets a 429 or 529 the limit for the whole batch halves, and it climbs back one at a time after a run of successes — never above the WithConcurrency ceiling you set. Without a shared limit, every worker independently rediscovers the same rate limit while the batch keeps pushing at the rate that caused it. WithAdaptiveConcurrency(false) pins it.

It batches states, never questions. Jev ingests the state once and evaluates every question against it in parallel, so a second question costs only its own tokens while a second state costs a whole request.

For a batch too large to hold in memory, or when downstream work can start on the first result, range over the stream instead — results arrive in completion order, and ItemResult.Index gives the input position:

for i, item := range client.SystemOneBatchSeq(ctx, states, qs) {
    ...
    if enough { break } // cancels the batch; no goroutine outlives the loop
}

Breaking out is the only thing that drops a pending result — nobody is waiting for it. A cancelled context still yields one item per input, each carrying the context error, so a consumer can tell "cancelled after three" from "cancelled before anything started".

Compile-time typed questions

Declare the option set once, as a Go enum, and the compiler checks both ends:

type Topic string

const (
    TopicBilling   Topic = "billing"
    TopicTechnical Topic = "technical"
    TopicOther     Topic = "other"
)

q := typesafe.TypedChoice[Topic]("Which team should handle this?",
    typesafe.OptionOf(TopicBilling, "Invoices, charges, refunds"),
    typesafe.OptionOf(TopicTechnical, "Bugs, outages, API errors"),
    typesafe.OptionOf(TopicOther, nil), // null description, read by name alone
)

ans, err := q.Answer(resp, "department")
switch ans.Choice {          // Topic, not string
case TopicBilling:   ...
case TopicTechnical: ...
}
ans.Probabilities[TopicBilling] // map[Topic]float64

TypedScore does the same for a rubric, where a level's position is its score:

type Frustration int
const (
    Calm Frustration = iota
    Annoyed
    Angry
)

q := typesafe.TypedScore[Frustration]("How frustrated is the customer?",
    typesafe.LevelOf(Calm, "No sign of irritation"),
    typesafe.LevelOf(Annoyed, "Clearly unhappy, still civil"),
    typesafe.LevelOf(Angry, "Hostile, threatening to leave"),
)

if ans.AtOrAbove(Angry) > 0.8 { escalate() }

A rubric declared out of order would map every answer to the wrong label with nothing in the score to reveal it, so Validate rejects one whose values do not match their positions.

These are wrappers, not a parallel implementation: each embeds the plain question and marshals through the same code, so the JSON is byte-identical and Untyped() converts an answer back losslessly.

Exhaustive catches the one thing types cannot — an answer naming an option the question never declared, which means the request and the enum have drifted apart:

if err := typesafe.Exhaustive(ans, AllTopics...); err != nil { ... }

The question's own Answer method does this for you, against the set it declared.

Generating questions from enums
go install github.com/nibir1/typesafe-go/cmd/typesafe-gen@latest
//go:generate typesafe-gen -type TicketQuestions

// TicketQuestions is everything asked about one support ticket.
type TicketQuestions struct {
    // Which team should handle this ticket?
    Department Topic `typesafe:"choice"`

    // How severe is the problem described here?
    Severity Severity `typesafe:"score,id=severity"`
}

Generates Questions(), a typed constructor and a checked accessor per field, and an AllTopic slice — descriptions taken from each constant's doc comment. The option set otherwise exists twice, as the enum the code switches on and as the criteria map the request carries, and nothing reports it when they drift.

Fluent constructors
typesafe.NewChoice("Which team should handle this?").
    Option("billing", "Payments, invoicing, refunds").
    Option("technical", "Bugs, outages, integrations")

Structs remain the documented default; both forms marshal byte-identically and get the same validation.


Tracing, metrics and caching

Three optional modules, each its own Go module so the core stays dependency-free:

tracer  := typesafeotel.New()
metrics := typesafeprom.New()
cache, _ := typesafecache.New(typesafecache.WithTTL(10 * time.Minute))

client, err := typesafe.NewClient(
    typesafe.WithInterceptor(
        tracer.Interceptor(),   // outermost
        metrics.Interceptor(),
        cache.Interceptor(),    // innermost, so a hit is still traced and timed
    ),
    typesafe.WithRetryObserver(func(ctx context.Context, a typesafe.AttemptInfo) {
        tracer.RetryObserver()(ctx, a)
        metrics.RetryObserver()(ctx, a)
    }),
)

typesafeotel turns a retried call from one unexplained span into a tree:

typesafe.systemone                    412ms
├── typesafe.attempt 1                 38ms  error, 429
├── typesafe.attempt 2                 41ms  error, 429
└── typesafe.attempt 3                310ms  ok

typesafeprom exposes latency, errors by class, retries, tokens, batch outcomes — and answer confidence, which has no equivalent in an ordinary API client. A drift in the confidence distribution is the earliest visible sign that your inputs changed shape; it moves long before latency or errors do.

typesafecache keys on the resolved model id, never the alias you asked for. jev-latest moves without notice, and a cache keyed on the alias would keep serving answers from the previous model version with nothing in the response to reveal it. When an alias starts resolving elsewhere, every entry under the old id becomes unreachable at once — and Event.AliasMoved tells you it happened.

Failures are never cached: a cached error is a cached outage.

deploy/docker-compose.yml runs the whole thing against Jaeger, Prometheus and Grafana with a committed dashboard. Full guide in docs/OBSERVABILITY.md.


Integrations

Seven modules, each with its own go.mod, so importing the SDK drags none of them in:

// net/http, gin, echo, fiber — client injection plus request-id correlation
r.Use(tsgin.Middleware(client), tsgin.Correlation())

// langchaingo — a classification tool whose answer cannot be a value nobody declared
agent := agents.NewOneShotAgent(llm, []tools.Tool{classifier})

// temporal — the API call in an Activity, which is the only replay-safe place for it
tstemporal.Register(w, tstemporal.NewActivities(client))

// mcp — serve TypeSafe to an agent over the Model Context Protocol
srv.Run(ctx, &mcp.StdioTransport{})

The HTTP middlewares are thin by design. The half worth having is correlation: the inbound X-Request-Id becomes the SDK's request id, so one id ties the HTTP request, this SDK's logs and TypeSafe's own records together. Without it, correlating an answer with the request that caused it means joining on timestamps.

evaluate_policy, which no other MCP server offers
go install github.com/nibir1/typesafe-go/integrations/mcp/cmd/typesafe-mcp@latest
typesafe-mcp -policies ./policies -only-policies

A thin MCP proxy lets an agent ask anything. This inverts it: the agent names a policy and supplies text, while the questions, weights and thresholds stay on the server. The agent never sees them, cannot drift from them, and cannot be talked out of them by the text it is judging. What comes back is a verdict and the arithmetic:

{
  "verdict": "review",
  "score": 0.6833,
  "contributions": [
    {"question": "is_abusive", "weight": 2, "value": 0.62, "contribution": 1.24},
    {"question": "is_spam",    "weight": 1, "value": 0.81, "contribution": 0.81}
  ]
}

Full guide in docs/INTEGRATIONS.md.


Static analysis

Three go/analysis analyzers, in a separate module so the core keeps its zero dependencies:

go install github.com/nibir1/typesafe-go/lint/cmd/typesafe-lint@latest
go vet -vettool=$(which typesafe-lint) ./...
Catches
atomicquestion Compound questions — one probability covering two propositions, which no threshold can split apart
jaggededge Questions hitting a documented Jev failure mode: counting, date comparison, hex values, double negatives, generation, inverted Noul criteria
confidencecheck Branching on an answer without reading its confidence; bare threshold literals; discarding the ok from Confidence()

Every jaggededge rule cites a section of TypeSafe's published model-jaggedness notes and repeats its recommended fix. That makes it a conformance checker rather than an opinion:

Noul instructions contain "how many". Jev does not count reliably — it recognizes
the shape of an answer rather than tallying, and the error grows with the size of
the thing being counted. Ask one Noul per item and sum the answers in code
(jaggedness: Math and Numbers: Counting)

//nolint:jaggededge <reason> suppresses a finding, and a suppression without a reason is itself reported.


Configuration

Resolution order, matching the official Python and JavaScript SDKs, so a process already configured for either works here unchanged:

Setting Order Default
API key WithAPIKey → TYPESAFE_API_KEY → error —
Base URL WithBaseURL → TYPESAFE_BASE_URL → default https://api.typesafe.ai
Model per-request → WithDefaultModel → TYPESAFE_DEFAULT_MODEL → default jev-latest
Timeout WithTimeout → default 10s per operation
client, err := typesafe.NewClient(
    typesafe.WithDefaultModel("jev-1.13.0"), // pin a version; aliases move
    typesafe.WithTimeout(5*time.Second),
    typesafe.WithLogger(slog.Default()),
)

The logger never receives your API key or your request state.


Command line

go install github.com/nibir1/typesafe-go/cmd/typesafe@latest
# Ask three questions about a ticket, no file needed
typesafe run --state-text "Payouts have failed for 3 days" \
  --noul urgent="Does this convey urgency?" \
  --choice team="billing,technical,sales" \
  --score severity="Low,Medium,High"

typesafe estimate -f request.json   # tokens and cost, sends nothing
typesafe lint -f request.json       # problems, before you pay for them
typesafe doctor                     # why is my setup not working?
typesafe explain --policy p.json --answer is_spam=0.93

Also models, record, replay, completion, and version. Everything except run/models/doctor/record works offline with no key.

doctor returns a distinct exit code per cause — 3 credential, 4 network, 7 unknown model, 6 rate limited — so a script can branch without parsing stderr.

The binary has no dependencies either: stdlib flag, no CLI framework.


Requirements

Go 1.23+. The core module has zero third-party dependencies, and CI fails if that ever changes.


Repository layout

flowchart TB
    subgraph core["github.com/nibir1/typesafe-go — zero dependencies"]
        C["client · primitives · answers<br/>retries · budget · batching"]
        D["decision"]
        T["typesafetest · cassette"]
        CLI["cmd/typesafe · cmd/typesafe-gen"]
    end

    subgraph opt["Separate modules — you take only what you import"]
        CA["typesafecache<br/><i>also zero deps</i>"]
        OT["typesafeotel"]
        PR["typesafeprom"]
        L["lint<br/><i>the analyzers</i>"]
        IN["integrations/<br/>nethttp · gin · echo · fiber<br/>langchaingo · temporal · mcp"]
    end

    core -.->|"imported by"| opt

    style core fill:#22543d,color:#fff
    style opt fill:#2a4365,color:#fff

Importing github.com/nibir1/typesafe-go pulls in nothing. make deps-graph, a CI job and a depguard lint rule all assert it, because they fail differently.

.                      the typesafe package — client, primitives, answers,
                       retries, budget, middleware, batching
├── decision/          ★ composition: algebra, policies, bands, calibration
├── cassette/          record and replay real API traffic
├── typesafetest/      mock, test server, assertions
├── cmd/typesafe/      the CLI — same module, so still zero dependencies
├── cmd/typesafe-gen/  ★ go:generate questions from Go enums
├── typesafecache/     ★ response cache — SEPARATE module, still zero deps
├── typesafeotel/      OpenTelemetry tracing — SEPARATE module
├── typesafeprom/      Prometheus metrics — SEPARATE module
├── integrations/      ★ nethttp, gin, echo, fiber, langchaingo, temporal, mcp
│                      — SEPARATE modules, one per framework
├── deploy/            docker-compose stack, Grafana dashboard, worked example
├── lint/              ★ the analyzers — a SEPARATE module (needs x/tools)
│   ├── atomicquestion/  jaggededge/  confidencecheck/
│   └── cmd/typesafe-lint/
├── internal/          canonical JSON, state paths, token estimator, fixtures
├── tests/
│   ├── contract/      offline: fixtures vs the locked wire contract
│   ├── typecheck/     negative-compilation tests for the typed API
│   └── integration/   live API, build-tagged
├── testdata/
│   ├── contract/      golden request/response pairs
│   └── spec/          vendored OpenAPI document
├── docs/  scripts/  Makefile

The library lives at the module root so the import path stays github.com/nibir1/typesafe-go rather than stuttering into .../typesafe-go/typesafe.

lint/, typesafeotel and typesafeprom are separate modules because they need golang.org/x/tools, go.opentelemetry.io/otel and client_golang respectively. That split is what keeps the core's zero-dependency guarantee true — importing the SDK pulls in nothing, and make deps-graph asserts it.

typesafecache is separate too, though it has no dependencies of its own: a caller who does not want a cache should not carry one.


Development

make            # list every target
make verify     # the full offline gate — run before pushing
make live       # the above, plus the live API (needs a key)
One workflow

Everything runs from .github/workflows/ci.yml — the code gate, linting, documentation checks, drift detection, the live API and releases. A meta job classifies the run once and every other job states its condition in one line.

flowchart LR
    T["trigger"] --> M["meta<br/><i>classify the run</i>"]
    M -->|"push / PR"| G["code gate<br/>test matrix · lint · docs<br/>licences · examples · modules"]
    M -->|"pull request"| B["benchmark<br/>regression"]
    M -->|"daily"| L["live API"]
    M -->|"Mondays"| D["contract drift"]
    M -->|"tag"| R["gate → govulncheck →<br/>build · sign · attest · publish"]

    style M fill:#4a5568,color:#fff
    style R fill:#22543d,color:#fff
Releasing
make release                                  # dry run
make release VERSION=v1.0.0 CONFIRM=yes       # for real

The release body is the matching section of Release_Notes.md. Dry run is the default and the script asks you to type the version, because pushing a tag is not undoable — the module proxy caches a version within minutes and deleting the tag does not unpublish it. Full procedure in CONTRIBUTING.md.

make verify runs: tidy check, gofmt, go vet under both build tags, the zero-dependency assertion, race tests, the contract suite, fixture validation against the OpenAPI schema, a credential scan over every committed fixture, and a doc-coverage check.

To enable the live targets, put your key where git will not see it:

printf 'TYPESAFE_API_KEY=%s\n' "$YOUR_KEY" > .env.local && chmod 600 .env.local

.env.local is gitignored, and make verify fails if it ever becomes tracked.


Documentation

docs/MANUAL.md The user manual — start here. Install to production, in one read
docs/WIRE_CONTRACT.md The verified wire contract, and every place the published docs are wrong
docs/TESTING.md Testing without a key
docs/LINTING.md The three analyzers, their rules, and CI wiring
docs/OBSERVABILITY.md Tracing, metrics, caching, and the demo stack
docs/INTEGRATIONS.md HTTP frameworks, LangChainGo, Temporal, MCP
docs/DECISION_GUIDE.md Which primitive to use, and how to word the question
docs/LIMITS.md Context budget, rate limits, jaggedness, cost
docs/PERFORMANCE.md Measured overhead and the methodology
docs/MIGRATION.md Coming from the Python or JavaScript SDK
docs/FAQ.md Short answers
CHANGELOG.md What changed, written by hand
Release_Notes.md The announcement text for each release
CONTRIBUTING.md Clone to passing tests, and the house rules
SECURITY.md Reporting, supply chain, and what this SDK does with your data
examples/ Ten runnable programs, each replayed in CI
THIRD_PARTY_NOTICES.md Attribution register

WIRE_CONTRACT.md is worth reading before building anything non-trivial. Several things the official documentation states are contradicted by the live API, including the Score minimum, the Score maximum, the 400 status, the shape of detail, and the format of release_date.


Stability

The API

v1.0.0 follows Semantic Versioning strictly. Everything outside internal/ is public API, and a breaking change to it requires a major version. In practice that means:

  • No exported symbol is removed or renamed in a 1.x release.
  • No function signature changes in a 1.x release.
  • Anything to be removed is marked // Deprecated: for at least one minor cycle first, with the replacement named in the comment.
  • New fields may be added to structs you construct with field names. Construct them that way — an unkeyed struct literal will break, and that is on you.

Three things are explicitly not covered:

  • internal/ is not public, whatever your editor lets you import.
  • Behaviour that depends on the API's answers. A probability is not a contract. If Jev starts answering a question differently, that is a change in the model, not in this SDK.
  • The wire contract corrections in docs/WIRE_CONTRACT.md. They describe what the server does today. If TypeSafe changes it, this SDK follows, and the drift workflow is what notices.
Go versions

The core module supports the current stable Go release and the four before it — today, Go 1.23 through 1.27. Raising the floor is a minor-version event, announced one cycle ahead.

Optional modules sit higher where a dependency forces it: 1.24 for langchaingo, 1.25 for OpenTelemetry, Prometheus, the web frameworks and MCP, 1.26 for Temporal and the analyzers. That constrains those modules, not you — the core and integrations/nethttp build on 1.23, and nothing stops a Go 1.23 program using the SDK without them.

Submodules version independently

typesafecache, typesafeotel, typesafeprom, lint and every integrations/* module has its own go.mod and its own tag. A breaking change in one does not force a major bump in the rest, and you take only what you import.

The model alias

The SDK defaults to jev-latest, matching the official SDKs, and never pins a version on your behalf. Aliases move without notice. Pin an exact model id if you need reproducibility — and if you use the cache, read the alias section, which is built around this.


How this compares

Six Go clients for this API were downloaded and read on 2026-09-18, not judged by their READMEs. The result is worth stating plainly: the client layer is solved, six times over, by people who read the same documentation.

The Go field (6 SDKs) This SDK
NoulAnswer correctly has no Confidence 6/6 ✓
instructions typed as a union, not string 6/6 ✓
422 handled as the validation error 6/6 ✓
retry-after honoured 6/6 ✓
Retry defaults matching the official SDK 6/6 ✓
Zero third-party dependencies 6/6 ✓
529 handled 5/6 ✓
Ordered Score accessors (numeric key sort) 3/6 ✓
Record/replay cassettes 1/6 partial ✓
Context-budget awareness (64k / 32k) 0/6 ✓
Composition layer (decision) 0/6 ✓
Mock client and test server 0/6 ✓
Static analyzers 0/6 ✓
Batch API 0/6 ✓

Everything in the top half is table stakes, and this SDK pays it. Everything in the bottom half is what it is actually for.

Two of the six — Tangerg/typesafe-sdk-go and zhirschtritt/typesafe-go — are genuinely well built, and two of them cap retry-after at a maximum, which is the sophisticated behaviour. This is not a weak field.

Against the official Python and JavaScript SDKs, no comparison table is offered here. Their defaults and features are theirs to document, a table would go stale, and you are better served by reading them. What is verified — the retry defaults, which this SDK matches deliberately — is in MIGRATION.md.


License

Apache-2.0. See LICENSE and NOTICE.

"TypeSafe", "System One", and "Jev" are names used by TypeSafe AI to identify their API and models, used here only to describe what this software interoperates with.

Documentation

Overview

Package typesafe is a community-maintained Go SDK for the TypeSafe System One API and its model, Jev.

It is not affiliated with, endorsed by, or sponsored by TypeSafe AI.

Status

Phase 0 (contract lock). The wire contract is pinned in docs/WIRE_CONTRACT.md and testdata/spec/openapi.json; golden request/response pairs live in testdata/contract. The client, question primitives, and typed answers arrive in Phases 1 and 2. See docs/Dev_Roadmap.md.

The API in one paragraph

You send one state — a string, object, or array — together with a map of named questions, and receive one typed answer per question in a single call. Three question types cover the decision space: Noul (yes/no, answered with the probability of yes), Choice (select one option from a set you define), and Score (rate against ordered levels you define). Answers are constrained to the options you supplied, so a successful response cannot contain a value your code did not ask for.

Design

The core module has no third-party dependencies and never will. Observability, caching, the CLI, the static analyzers, and framework integrations live in separate modules so that importing this one costs nothing.

Example (Documentation)
package main

// The code in docs/MANUAL.md and README.md, verbatim.
//
// It is an Example with no // Output: comment, so the toolchain compiles and
// type-checks it but never runs it — running would need a key and a network.
// That is the whole point: a signature change makes the documentation fail to
// build instead of quietly making it wrong, which is the failure mode prose
// has and code does not.
//
// When you change a snippet in the manual, change it here too. When this stops
// compiling, the manual is already wrong.

import (
	"context"
	"errors"
	"fmt"
	"log"
	"time"

	typesafe "github.com/nibir1/typesafe-go"
	"github.com/nibir1/typesafe-go/cassette"
	"github.com/nibir1/typesafe-go/decision"
)

type docTopic string

const (
	docTopicBilling   docTopic = "billing"
	docTopicTechnical docTopic = "technical"
)

func docEscalate()    {}
func docRoute(string) {}
func docConfirm()     {}
func docHandOff()     {}

func main() {
	ctx := context.Background()

	client, err := typesafe.NewClient(
		typesafe.WithTimeout(60*time.Second),
		typesafe.WithDefaultModel("jev-1.13.0"),
		typesafe.WithContextLimitCheck(false),
		typesafe.WithRetryPolicy(typesafe.RetryPolicy{
			MaxRetries:        2,
			BackoffInitial:    500 * time.Millisecond,
			BackoffMax:        5 * time.Second,
			BackoffJitter:     0.25,
			RespectRetryAfter: true,
			MaxRetryAfter:     30 * time.Second,
			Timeout:           30 * time.Second,
		}),
		typesafe.WithCircuitBreaker(&typesafe.CircuitBreaker{
			Threshold: 5, OpenFor: 30 * time.Second, HalfOpenProbes: 1,
		}),
		typesafe.WithBudget(typesafe.NewBudget(
			typesafe.MaxRequestsPerMinute(600),
			typesafe.MaxTotalTokens(5_000_000),
		)),
	)
	if err != nil {
		log.Fatal(err)
	}

	req := &typesafe.SystemOneRequest{
		State: "Help! My payouts have been failing for 3 days.",
		Questions: typesafe.Questions{
			"is_urgent": typesafe.Noul{
				Instructions: "Does this message convey urgency?",
				Criteria: &typesafe.NoulCriteria{
					True:  "The sender needs a response today",
					False: "The sender can wait",
				},
			},
			"team": typesafe.Choice{
				Instructions: "Which team should handle this ticket?",
				Criteria: typesafe.Options{
					"billing":   "Payments, invoicing, refunds",
					"technical": "Bugs, outages, integrations",
					"other":     nil,
				},
			},
			"severity": typesafe.Score{
				Instructions: "How severe is the problem described here?",
				Criteria: typesafe.Levels{
					"No impact on the customer",
					"Annoying but there is a workaround",
					"One workflow is blocked",
					"The product is unusable",
				},
			},
		},
	}

	est := req.EstimateTokens()
	if err := est.Err(); err != nil {
		log.Fatal(err)
	}

	resp, err := client.SystemOne(ctx, req)
	if err != nil {
		var rl *typesafe.RateLimitError
		if errors.As(err, &rl) {
			time.Sleep(rl.RetryAfter)
		}
		if errors.Is(err, typesafe.ErrRateLimit) {
			return
		}
		log.Fatal(err)
	}

	urgent, err := resp.Noul("is_urgent")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("urgency: %.2f\n", urgent.Noul)
	if urgent.Bool(0.8) {
		docEscalate()
	}

	team, err := resp.Choice("team")
	if err != nil {
		log.Fatal(err)
	}
	severity, err := resp.Score("severity")
	if err != nil {
		log.Fatal(err)
	}
	if severity.AtOrAbove(2) > 0.8 {
		docEscalate()
	}

	bands := decision.Bands{ActAbove: 0.90, ConfirmAbove: 0.50}
	switch bands.Classify(team.Confidence) {
	case decision.Act:
		docRoute(team.Choice)
	case decision.Confirm:
		docRoute(team.Choice)
		docConfirm()
	case decision.Escalate:
		docHandOff()
	}

	answer, err := resp.Answer("is_urgent")
	if err != nil {
		log.Fatal(err)
	}
	switch a := answer.(type) {
	case typesafe.NoulAnswer:
		_ = a.Noul
	case typesafe.ChoiceAnswer:
		_, _ = a.Choice, a.Confidence
	case typesafe.ScoreAnswer:
		_, _ = a.Score, a.Confidence
	}

	policy := decision.Policy{
		Name: "moderation.v3",
		Weights: decision.Weights{
			"is_solicitation": 3,
			"is_unverifiable": 2,
			"is_hostile":      2,
		},
		Normalize:   true,
		ReviewAbove: 0.55,
		BlockAbove:  0.80,
		OnMissing:   decision.MissingIsError,
	}
	_ = policy

	q := typesafe.TypedChoice[docTopic]("Which team?",
		typesafe.OptionOf(docTopicBilling, "Payments, invoicing, refunds"),
		typesafe.OptionOf(docTopicTechnical, "Bugs, outages, integrations"),
	)
	ans, err := q.Answer(resp, "team")
	if err == nil {
		switch ans.Choice {
		case docTopicBilling:
		case docTopicTechnical:
		}
	}

	states := []any{"a", "b"}
	result := client.SystemOneBatch(ctx, states, req.Questions,
		typesafe.WithConcurrency(16))
	for i, item := range result.Items {
		if item.Err != nil {
			continue
		}
		_ = i
	}

	replayClient, err := typesafe.NewClient(
		typesafe.WithAPIKey("test"),
		typesafe.WithHTTPClient(cassette.MustReplay("testdata/cassettes/triage.jsonl")),
	)
	_, _ = replayClient, err
}

Index

Examples

Constants

View Source
const (
	// DefaultBatchConcurrency is how many requests run at once when nothing
	// else is specified. Deliberately modest: the published rate limits are
	// generous, but they are shared with everything else on the account, and
	// a batch that saturates them starves the interactive traffic beside it.
	DefaultBatchConcurrency = 8

	// MinAdaptiveConcurrency is the floor adaptive backoff will not go below.
	// One worker still makes progress; zero would deadlock.
	MinAdaptiveConcurrency = 1
)

Batch defaults.

View Source
const (
	// MaxContextTokens is the ceiling on state plus every question combined.
	MaxContextTokens = 64_000

	// MaxSingleQuestionTokens is the ceiling on state plus the single longest
	// question. This is a *separate* limit, and the one that is easy to miss:
	// a request can pass the 64k check and fail this one.
	MaxSingleQuestionTokens = 32_000

	// DefaultRequestsPerMinute is the published request rate limit.
	DefaultRequestsPerMinute = 1_200

	// DefaultTokensPerSecond is the published token rate limit.
	DefaultTokensPerSecond = 250_000

	// DefaultInputCostPerMillionTokens is the published price in US dollars.
	// Output tokens are free.
	DefaultInputCostPerMillionTokens = 0.042
)

Jev 1.13 limits, from the published Models page.

Every one of these is a **configurable default, not a constant**. TypeSafe states plainly that rate limits "can change without notice" during early access, so hard-coding them into the client's behavior would mean shipping a new release every time they move. They are starting values for a Budget the caller owns.

View Source
const (
	// DefaultCircuitThreshold is how many consecutive retryable failures open
	// the circuit.
	DefaultCircuitThreshold = 5

	// DefaultCircuitOpenFor is how long it stays open before probing.
	DefaultCircuitOpenFor = 30 * time.Second
)

Circuit breaker defaults.

View Source
const (
	// DefaultBaseURL is the API root. Overridable per client, and by the
	// TYPESAFE_BASE_URL environment variable.
	DefaultBaseURL = "https://api.typesafe.ai"

	// DefaultModel matches the official Python and JavaScript SDKs.
	DefaultModel = "jev-latest"

	// SystemOnePath is the evaluation endpoint.
	SystemOnePath = "/v1/systemone"

	// ModelsPath lists the model names this account may send.
	ModelsPath = "/v1/models"
)

Wire constants, fixed by the published contract. See docs/WIRE_CONTRACT.md.

View Source
const (
	EnvAPIKey       = "TYPESAFE_API_KEY"
	EnvBaseURL      = "TYPESAFE_BASE_URL"
	EnvDefaultModel = "TYPESAFE_DEFAULT_MODEL"
	EnvLogLevel     = "TYPESAFE_LOG_LEVEL"
)

Environment variables, named to match the official SDKs so that a process configured for Python or JavaScript works unchanged here.

View Source
const (
	TypeNoul   = "noul"
	TypeChoice = "choice"
	TypeScore  = "score"
)

Question type discriminators, used as the "type" field on both questions and their corresponding answers.

View Source
const (
	// DefaultMaxRetries is 2 — three attempts in total.
	DefaultMaxRetries = 2

	// DefaultBackoffInitial is the first delay, doubled on each attempt.
	DefaultBackoffInitial = 500 * time.Millisecond

	// DefaultBackoffMax caps any single delay.
	DefaultBackoffMax = 5 * time.Second

	// DefaultBackoffJitter randomizes each delay by ±25%, so that clients
	// that failed together do not retry together.
	DefaultBackoffJitter = 0.25

	// DefaultRetryTimeout is the budget across all attempts, distinct from
	// the per-operation timeout in DefaultTimeout.
	DefaultRetryTimeout = 30 * time.Second

	// DefaultMaxRetryAfter caps how long a server's retry-after will be
	// honored. Without it, a single header could park a request for minutes;
	// see RetryPolicy.MaxRetryAfter.
	DefaultMaxRetryAfter = 30 * time.Second
)

Retry defaults, chosen to match the official Python and JavaScript SDKs exactly rather than to be independently reasonable.

A developer porting a working integration from Python should not have to re-tune anything, and should not discover that this client gives up sooner or hammers harder than the one they came from. Where these numbers look arbitrary, it is because they are the official SDK's numbers.

View Source
const (
	// MinScoreLevels is the fewest rubric levels the API accepts.
	//
	// The prose documentation claims two. It is wrong: a one-level Score
	// returns 200 with score 0 and confidence 1. Degenerate, but legal, and
	// this SDK does not reject requests the server would accept.
	MinScoreLevels = 1

	// MaxScoreLevels is the most rubric levels the API accepts. Exceeding it
	// returns 400 "Too many score levels. Must have at most 10 levels."
	//
	// Documented in neither the OpenAPI schema nor the prose docs. Validate
	// enforces it client-side so the failure costs no round trip.
	MaxScoreLevels = 10
)

Score rating bounds, both established against the live API rather than from documentation.

View Source
const DefaultTimeout = 10 * time.Second

DefaultTimeout bounds each HTTP operation, matching the official SDKs.

View Source
const RequestIDHeader = "x-typesafe-request-id"

RequestIDHeader carries a per-call identifier worth quoting in a support ticket. Present on both successful and failed responses.

View Source
const StatusOverloaded = 529

StatusOverloaded is 529, which net/http does not name. TypeSafe returns it when the service is temporarily saturated.

Variables

View Source
var (
	// ErrNoSuchAnswer means the response carried no answer under that id.
	ErrNoSuchAnswer = errors.New("typesafe: no answer with that id")

	// ErrWrongAnswerType means the answer exists but is a different primitive
	// than the accessor asked for.
	ErrWrongAnswerType = errors.New("typesafe: wrong answer type")
)

Answer errors.

View Source
var (
	// ErrNoAPIKey means no key was supplied and TYPESAFE_API_KEY is unset.
	ErrNoAPIKey = errors.New("typesafe: no API key")

	ErrBadRequest       = errors.New("typesafe: bad request")
	ErrAuthentication   = errors.New("typesafe: authentication failed")
	ErrPermissionDenied = errors.New("typesafe: permission denied")
	ErrNotFound         = errors.New("typesafe: not found")
	ErrInvalidRequest   = errors.New("typesafe: request failed validation")
	ErrRateLimit        = errors.New("typesafe: rate limited")
	ErrOverloaded       = errors.New("typesafe: service overloaded")
	ErrInternalServer   = errors.New("typesafe: server error")
	ErrConnection       = errors.New("typesafe: connection failed")
	ErrTimeout          = errors.New("typesafe: request timed out")
	ErrInvalidResponse  = errors.New("typesafe: malformed response")
	ErrInvalidConfig    = errors.New("typesafe: invalid client configuration")
)

Sentinel errors, for callers who prefer errors.Is over errors.As. Every typed error below matches exactly one of these.

if errors.Is(err, typesafe.ErrRateLimit) { ... }

The typed forms carry more: status, request id, retry-after, and for a 422 the exact field the server rejected. Prefer errors.As when you need those.

View Source
var ErrBatchPartialFailure = errors.New("typesafe: some batch items failed")

ErrBatchPartialFailure matches any BatchError through errors.Is.

View Source
var ErrBudgetExceeded = errors.New("typesafe: budget exceeded")

ErrBudgetExceeded means a Budget refused the request. Nothing was sent.

View Source
var ErrCircuitOpen = errors.New("typesafe: circuit breaker is open")

ErrCircuitOpen means the breaker is open and the request was not attempted.

It is not an API failure: nothing was sent. Treat it as a signal to shed load or serve a fallback, not as evidence about this particular request.

View Source
var ErrRetriesExhausted = errors.New("typesafe: retries exhausted")

ErrRetriesExhausted wraps the last failure when every attempt was used.

The underlying error remains reachable, so errors.As still finds the terminal *RateLimitError or *InternalServerError and errors.Is still matches its sentinel. Code that already handles those does not need changing.

View Source
var ErrUndeclaredOption = fmt.Errorf("typesafe: answer contains an option the question did not declare")

ErrUndeclaredOption means an answer named something outside the declared set.

View Source
var Version = "0.0.0-dev"

Version is the SDK version, reported in the User-Agent header.

A var rather than a const so a release build can stamp it with -ldflags "-X github.com/nibir1/typesafe-go.Version=v1.0.0". The linker's -X flag only writes to variables; as a const this was unsettable, and a released binary would have reported 0.0.0-dev forever.

A module installed with `go install ...@v1.0.0` gets its real version from the build info instead, which is why this is a fallback rather than the source of truth. See VersionString.

Functions

func ContextWithRequestID

func ContextWithRequestID(ctx context.Context, id string) context.Context

ContextWithRequestID puts an existing correlation id on ctx, so a call made with that context reuses it instead of generating a new one.

ctx = typesafe.ContextWithRequestID(ctx, r.Header.Get("X-Request-Id"))
resp, err := client.SystemOne(ctx, req)

For propagating an id that already exists — the one a load balancer put on an inbound request, or a job id from a queue. Correlating an LLM response with the request that caused it is the first thing anyone wants during an incident, and it is impossible if every call invents its own id.

An empty id is ignored, so a missing inbound header falls through to WithRequestID's generator rather than blanking the id out.

func Exhaustive

func Exhaustive[T OptionKey](a ChoiceAnswerOf[T], allowed ...T) error

Exhaustive checks that every option in the answer is one of allowed.

if err := typesafe.Exhaustive(ans, AllTopics...); err != nil {
    return err
}

It returns an error rather than panicking, and it is worth calling: the only way an answer can carry an undeclared option is that the request and the type have drifted apart — a question built somewhere else, or an enum that gained a value the question was never updated with. A type switch handles that by falling through to no branch at all, silently.

Passing no allowed values checks nothing and returns nil, so a caller that has not enumerated its set is not forced to.

func RequestIDFrom

func RequestIDFrom(ctx context.Context) string

RequestIDFrom returns the correlation id this SDK generated for the call, or "" when none was configured.

Useful inside a hook or an interceptor to tie SDK activity to the rest of a trace. Distinct from APIError.RequestID, which is the id *TypeSafe* assigned and is the one to quote in a support ticket.

func VersionString

func VersionString() string

VersionString returns the version this binary or module was built as.

Prefers the version the Go toolchain recorded — which `go install module@version` sets automatically and correctly — and falls back to Version, which a release build stamps with -ldflags. Preferring build info means a user who installed with `go install` sees the version they asked for, not whatever the last person to edit this file typed.

Types

type API

type API interface {
	// SystemOne evaluates one state against a map of named questions.
	SystemOne(ctx context.Context, req *SystemOneRequest) (*SystemOneResponse, error)

	// Models lists the model names this account may use.
	Models(ctx context.Context) ([]ModelCard, error)
}

API is the behavior *Client provides.

Go convention says consumers declare the interfaces they need, and for most code that remains the better habit — depend on a one-method interface you define at the point of use, not on everything a client can do.

This one is provided because writing it out is otherwise the first thing every caller does, and because typesafetest.Mock needs a shared shape to satisfy. It is deliberately small and will not grow: methods added to *Client in later phases stay off this interface unless they are part of the core request path, so that implementing it never becomes a burden.

func triage(ctx context.Context, api typesafe.API, ticket Ticket) (Queue, error) {
    resp, err := api.SystemOne(ctx, &typesafe.SystemOneRequest{ ... })
    ...
}

Both *Client and typesafetest.Mock satisfy it, so the same function can be exercised against a mock, a test server, a cassette, or the live API without changing its signature.

type APIError

type APIError struct {
	// Status is the HTTP status code.
	Status int

	// Body is the raw response body, for statuses whose shape we do not model.
	Body []byte

	// Header is the response header. Never contains request credentials.
	Header http.Header

	// Endpoint is the method and path, without credentials or query.
	Endpoint string

	// RequestID is the x-typesafe-request-id header, or "" if absent. Worth
	// quoting in a support ticket.
	RequestID string

	// Detail is the parsed field-level validation failures from a 422 body.
	// Empty for every other status. See ValidationDetail.
	Detail []ValidationDetail

	// Reason is the server's typed error message, used by 400 and 401.
	//
	// The API returns "detail" in two different shapes: an array of
	// ValidationDetail for schema failures (422), and a single object with
	// error_type and message for everything else. Neither shape is published;
	// both were observed against the live API. A client that assumes one shape
	// silently loses the other, which is why both are modeled here.
	Reason *ErrorDetail
	// contains filtered or unexported fields
}

APIError is any non-2xx response. Every status-specific error below wraps one, so errors.As(err, &apiErr) succeeds for all of them.

func (*APIError) Error

func (e *APIError) Error() string

type Answer

type Answer interface {

	// Type reports the wire discriminator: "noul", "choice", or "score".
	Type() string
	// contains filtered or unexported methods
}

Answer is a decoded answer: NoulAnswer, ChoiceAnswer, or ScoreAnswer.

The interface is sealed, so a type switch over it is exhaustive:

switch a := ans.(type) {
case typesafe.NoulAnswer:   // a.Noul
case typesafe.ChoiceAnswer: // a.Choice, a.Confidence
case typesafe.ScoreAnswer:  // a.Score, a.Confidence
}

type AttemptInfo

type AttemptInfo struct {
	// Attempt is 1 for the first try, 2 for the first retry, and so on.
	Attempt int

	// Err is what the attempt failed with.
	Err error

	// Status is the HTTP status, or 0 when the attempt produced no response.
	Status int

	// Delay is how long the client will wait before the next attempt.
	Delay time.Duration

	// RetryAfterHonored reports whether Delay came from the server's
	// retry-after header rather than from computed backoff.
	RetryAfterHonored bool

	// Elapsed is the time spent since the first attempt began.
	Elapsed time.Duration
}

AttemptInfo describes one completed attempt, passed to a retry observer.

type AuthenticationError

type AuthenticationError struct{ *APIError }

AuthenticationError is a 401: the key is missing or invalid.

func (*AuthenticationError) Is

func (e *AuthenticationError) Is(target error) bool

func (*AuthenticationError) Unwrap

func (e *AuthenticationError) Unwrap() error

type BadRequestError

type BadRequestError struct{ *APIError }

BadRequestError is a 400: the request was well-formed against the schema but the server rejected its content.

Undocumented in both the OpenAPI spec (which declares only 200 and 422) and the prose docs. Observed for an unknown model name and for a Score with more than ten levels. Not retryable.

func (*BadRequestError) Is

func (e *BadRequestError) Is(target error) bool

func (*BadRequestError) Unwrap

func (e *BadRequestError) Unwrap() error

type BatchError

type BatchError struct {
	// Summary is the human-readable grouping.
	Summary string

	// Failed and Total count the outcome.
	Failed, Total int
}

BatchError summarizes partial failure.

It wraps nothing: a batch failure is not one error, and pretending it is would let errors.As pick an arbitrary item's cause and look authoritative. Use BatchResult.Errors to inspect the individual failures.

func (*BatchError) Error

func (e *BatchError) Error() string

func (*BatchError) Is

func (e *BatchError) Is(target error) bool

Is reports whether target is ErrBatchPartialFailure.

type BatchOption

type BatchOption func(*batchConfig)

BatchOption configures a batch.

func WithAdaptiveConcurrency

func WithAdaptiveConcurrency(enabled bool) BatchOption

WithAdaptiveConcurrency turns global rate-limit backoff on or off. On by default.

When a worker meets a 429 or a 529, the limit for the *whole batch* halves, and it recovers by one after a run of successes. Without this, every worker independently rediscovers the same limit, and the batch spends its time in per-request backoff while continuing to push at the rate that caused the problem.

func WithBatchModel

func WithBatchModel(model string) BatchOption

WithBatchModel selects the model for every request in the batch.

SystemOneBatch builds each request itself, so SystemOneRequest.Model is not reachable from the call site; without this the client default is the only option. Leave it unset to use that default.

func WithConcurrency

func WithConcurrency(n int) BatchOption

WithConcurrency bounds how many requests run at once.

Values below 1 are treated as 1. There is no unbounded mode: a batch that launches a goroutine per input is a way to convert a large slice into a rate limit error, and the useful ceiling is set by the account's quota rather than by the size of the input.

func WithItemCallback

func WithItemCallback(fn func(ItemResult)) BatchOption

WithItemCallback registers a function called as each item completes, in completion order.

For progress reporting on a long batch. It runs on the worker's goroutine, so keep it quick and make it safe for concurrent use. For processing results as they land, prefer SystemOneBatchSeq, which does not require that care.

type BatchResult

type BatchResult struct {
	// Items holds one result per input state, **in input order**.
	Items []ItemResult

	// Usage is the summed token usage across successful items.
	Usage Usage

	// Succeeded and Failed count the outcomes.
	Succeeded int
	Failed    int

	// Duration is the wall-clock time for the whole batch.
	Duration time.Duration

	// PeakConcurrency is the highest number of requests in flight at once,
	// and MinConcurrency the lowest the limit fell to. When adaptive
	// concurrency is on, a gap between them means the batch was throttled.
	PeakConcurrency int
	MinConcurrency  int
}

BatchResult is the outcome of a whole batch.

func (BatchResult) Err

func (r BatchResult) Err() error

Err returns a summary error when any item failed, or nil.

Deliberately *not* the first failure. A batch of a thousand where three items failed for two different reasons is badly served by surfacing one of them; the summary names how many failed and why, and Errors gives the rest.

func (BatchResult) Errors

func (r BatchResult) Errors() []error

Errors returns every item failure, in input order.

func (BatchResult) Responses

func (r BatchResult) Responses() []*SystemOneResponse

Responses returns the successful responses, in input order.

type Budget

type Budget struct {
	// contains filtered or unexported fields
}

Budget caps how much a process may spend against the API.

What this is for

A retry loop with a bug, a batch job over the wrong input file, a test that escapes into CI with a live key — each of these can burn a quota in minutes, and the API will cheerfully serve every request until the money or the rate limit runs out. A Budget is the thing that says no first.

It fails **before any network I/O**, so an over-limit call costs nothing and the error arrives immediately rather than after a rate-limit round trip.

budget := typesafe.NewBudget(
    typesafe.MaxRequestsPerMinute(1200),
    typesafe.MaxTokensPerSecond(250_000),
    typesafe.MaxTotalRequests(10_000),   // this process, ever
)
client, err := typesafe.NewClient(typesafe.WithBudget(budget))

The published limits are defaults, not truth

The rate constants this package exposes come from TypeSafe's Models page, which states that they can change without notice during early access. They are starting values. A Budget is configured by you, and the SDK never assumes a limit it was not given.

Budget is safe for concurrent use and is meant to be shared across every client in a process.

func DefaultBudget

func DefaultBudget() *Budget

DefaultBudget returns a Budget set to the published Jev 1.13 rate limits.

A convenience, not a guarantee: the limits are documented as changeable, and a client at exactly the published rate will still meet 429s under contention with other traffic on the same account.

func NewBudget

func NewBudget(opts ...BudgetOption) *Budget

NewBudget builds a Budget. With no options it enforces nothing.

func (*Budget) Reset

func (b *Budget) Reset()

Reset clears all consumption.

func (*Budget) Usage

func (b *Budget) Usage() BudgetUsage

Usage returns current consumption.

type BudgetOption

type BudgetOption func(*Budget)

BudgetOption configures a Budget.

func MaxRequestsPerMinute

func MaxRequestsPerMinute(n int) BudgetOption

MaxRequestsPerMinute caps the request rate. Zero disables the check.

func MaxTokensPerSecond

func MaxTokensPerSecond(n int) BudgetOption

MaxTokensPerSecond caps the estimated token rate. Zero disables the check.

func MaxTotalRequests

func MaxTotalRequests(n int) BudgetOption

MaxTotalRequests caps requests for the lifetime of this Budget. Zero disables the check.

The blunt instrument, and the one that actually stops a runaway loop: a rate limit lets a bug spend all day at exactly the permitted speed.

func MaxTotalTokens

func MaxTotalTokens(n int) BudgetOption

MaxTotalTokens caps estimated tokens for the lifetime of this Budget. Zero disables the check.

type BudgetUsage

type BudgetUsage struct {
	RequestsLastMinute int
	TokensLastSecond   int
	TotalRequests      int
	TotalTokens        int
}

BudgetUsage is a snapshot of consumption.

type CallInfo

type CallInfo struct {
	// RequestID is the client-generated correlation id, when one is
	// configured. See WithRequestID.
	RequestID string

	// Duration is how long the whole call took, retries included.
	Duration time.Duration

	// Questions is how many questions were asked.
	Questions int

	// Model is the versioned model that answered, empty on failure.
	Model string

	// InputTokens and OutputTokens come from the response, zero on failure.
	InputTokens  int
	OutputTokens int

	// Err is the failure, nil on success.
	Err error
}

CallInfo describes a completed call.

type Choice

type Choice struct {
	// Instructions is what the model should decide. Optional per the schema.
	Instructions EntryType

	// Criteria maps each option to a description of when it applies.
	// Required, and must be non-empty.
	//
	// A nil value means "interpret this option by its name alone" and is sent
	// as JSON null.
	Criteria Options
}

Choice selects exactly one option from a set you define.

typesafe.Choice{
    Instructions: "Which team should handle this?",
    Criteria: typesafe.Options{
        "billing":   "Payments, invoicing, refunds",
        "technical": "Bugs, outages, integrations",
        "sales":     nil, // interpreted by its name alone
    },
}

The answer names the winning option and gives a probability for every one, so the result is always a member of the set you supplied. Include a catch-all option when your set may not cover every input — without one, the model must pick from what it was given.

func (Choice) MarshalJSON

func (c Choice) MarshalJSON() ([]byte, error)

MarshalJSON emits the wire form, adding the required type discriminator.

Criteria is always emitted, even when empty, because the API requires the field to be present; Validate rejects an empty one before it is sent.

type ChoiceAnswer

type ChoiceAnswer struct {
	// Choice is the highest-probability option.
	Choice string `json:"choice"`

	// Probabilities gives every option's probability. They sum to 1.
	Probabilities map[string]float64 `json:"probabilities"`

	// Confidence in [0,1], derived from the shape of the distribution. A flat
	// distribution means no option clearly won, which usually means the
	// options overlap or the state does not contain enough to decide.
	Confidence float64 `json:"confidence"`
}

ChoiceAnswer is the answer to a Choice.

func (ChoiceAnswer) Margin

func (a ChoiceAnswer) Margin() float64

Margin is the gap between the top two options.

It answers a different question than Confidence: a wide margin means the winner beat its nearest rival clearly, even if probability is spread across the remaining options. Returns the winner's probability when there is only one option.

func (ChoiceAnswer) ProbabilityOf

func (a ChoiceAnswer) ProbabilityOf(option string) (float64, bool)

ProbabilityOf returns the probability assigned to an option, and whether the option was part of the answer at all.

func (ChoiceAnswer) Ranked

func (a ChoiceAnswer) Ranked() []RankedOption

Ranked returns every option ordered by descending probability.

Ties break by option name so the order is deterministic across runs and machines — Go map iteration is not, and a non-deterministic ranking would make snapshot tests and audit logs unstable.

func (ChoiceAnswer) Type

func (ChoiceAnswer) Type() string

type ChoiceAnswerOf

type ChoiceAnswerOf[T OptionKey] struct {
	// Choice is the highest-probability option, as a T.
	Choice T

	// Probabilities gives every option's probability, keyed by T.
	Probabilities map[T]float64

	// Confidence in [0,1], derived from the shape of the distribution.
	Confidence float64
}

ChoiceAnswerOf is a ChoiceAnswer whose option keys are values of T.

func TypedChoiceAnswer

func TypedChoiceAnswer[T OptionKey](r *SystemOneResponse, id string) (ChoiceAnswerOf[T], error)

TypedChoiceAnswer decodes the answer under id with T as the option type.

ans, err := typesafe.TypedChoiceAnswer[Topic](resp, "department")

This is the free-standing form, for a response you did not build the question for. It converts whatever came back into T without checking it against a declared set — nothing here knows what that set was. When you have the question, prefer its Answer method, which checks; otherwise pass your enum's values to Exhaustive.

func (ChoiceAnswerOf[T]) Margin

func (a ChoiceAnswerOf[T]) Margin() float64

Margin is the gap between the top two options.

func (ChoiceAnswerOf[T]) ProbabilityOf

func (a ChoiceAnswerOf[T]) ProbabilityOf(option T) (float64, bool)

ProbabilityOf returns an option's probability, and whether it was part of the answer at all.

func (ChoiceAnswerOf[T]) Ranked

func (a ChoiceAnswerOf[T]) Ranked() []RankedOptionOf[T]

Ranked returns every option ordered by descending probability, ties broken by name so the order is stable across runs.

func (ChoiceAnswerOf[T]) Untyped

func (a ChoiceAnswerOf[T]) Untyped() ChoiceAnswer

Untyped returns the plain ChoiceAnswer, for the accessors that do not need the type parameter and for code that has not been converted yet.

type ChoiceBuilder

type ChoiceBuilder struct {
	// contains filtered or unexported fields
}

ChoiceBuilder builds a Choice.

func NewChoice

func NewChoice(instructions EntryType) ChoiceBuilder

NewChoice starts a Choice.

typesafe.NewChoice("Which team should handle this?").
    Option("billing", "Payments, invoicing, refunds").
    Option("technical", "Bugs, outages, integrations").
    Option("other", nil)

func (ChoiceBuilder) Build

func (b ChoiceBuilder) Build() Choice

Build returns the question.

func (ChoiceBuilder) MarshalJSON

func (b ChoiceBuilder) MarshalJSON() ([]byte, error)

MarshalJSON delegates to the built question.

func (ChoiceBuilder) Option

func (b ChoiceBuilder) Option(name string, description EntryType) ChoiceBuilder

Option adds an option and its description.

Pass nil for the description when the option's name says everything; it is sent as JSON null, which the API reads as "interpret this by its name alone".

func (ChoiceBuilder) Options

func (b ChoiceBuilder) Options(names ...string) ChoiceBuilder

Options adds several options that need no description, for the common case where the names are self-explanatory.

typesafe.NewChoice("What is the tone?").Options("calm", "angry", "excited")

type CircuitBreaker

type CircuitBreaker struct {
	// Threshold is the number of consecutive retryable failures that open the
	// circuit. Zero uses DefaultCircuitThreshold.
	Threshold int

	// OpenFor is how long the circuit stays open before allowing a probe.
	// Zero uses DefaultCircuitOpenFor.
	OpenFor time.Duration

	// HalfOpenProbes is how many requests may pass while half-open. Zero
	// means one.
	HalfOpenProbes int

	// OnStateChange, if set, is called whenever the state changes. It runs on
	// the calling goroutine, so keep it quick.
	OnStateChange func(from, to CircuitState)
	// contains filtered or unexported fields
}

CircuitBreaker stops a client from hammering a service that is already failing.

Retries help with a blip and hurt during an outage: every caller politely backing off and trying again still multiplies load on something that cannot serve it. The breaker converts sustained failure into immediate, cheap rejection, which both protects the service and lets a caller fail over quickly instead of waiting out a full retry budget per request.

It is off by default. Enable it with WithCircuitBreaker, and share one breaker per upstream — a breaker per goroutine observes nothing useful.

Only failures the retry policy considers retryable count toward opening it. A 422 means the request was wrong, not that the service is unwell, and tripping on those would open the circuit for one caller's bad input.

func NewCircuitBreaker

func NewCircuitBreaker() *CircuitBreaker

NewCircuitBreaker returns a breaker with the default settings.

func (*CircuitBreaker) Reset

func (b *CircuitBreaker) Reset()

Reset returns the breaker to closed and clears its counters.

func (*CircuitBreaker) State

func (b *CircuitBreaker) State() CircuitState

State reports the current state, moving an expired open circuit to half-open as a side effect.

type CircuitState

type CircuitState int

CircuitState is a breaker's current state.

const (
	// CircuitClosed passes every request through. The normal state.
	CircuitClosed CircuitState = iota

	// CircuitOpen rejects immediately, without attempting the request.
	CircuitOpen

	// CircuitHalfOpen lets a limited number of probes through to discover
	// whether the service has recovered.
	CircuitHalfOpen
)

Breaker states.

func (CircuitState) String

func (s CircuitState) String() string

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client calls the TypeSafe System One API. It is safe for concurrent use and is meant to be created once and shared.

func NewClient

func NewClient(opts ...Option) (*Client, error)

NewClient builds a client.

Configuration resolves in this order, each falling back to the next:

API key    WithAPIKey        -> TYPESAFE_API_KEY        -> error
Base URL   WithBaseURL       -> TYPESAFE_BASE_URL       -> https://api.typesafe.ai
Model      WithDefaultModel  -> TYPESAFE_DEFAULT_MODEL  -> jev-latest
Timeout    WithTimeout       -> 10s

This mirrors the official Python and JavaScript SDKs, so a process already configured for either works here unchanged.

client, err := typesafe.NewClient()
if err != nil {
    return err
}

func (*Client) BaseURL

func (c *Client) BaseURL() string

BaseURL reports the API root this client targets.

func (*Client) DefaultModel

func (c *Client) DefaultModel() string

DefaultModel reports the model used when a request leaves Model empty.

func (*Client) Models

func (c *Client) Models(ctx context.Context) ([]ModelCard, error)

Models lists the model names and aliases this account may send in SystemOneRequest.Model.

Versioned ids such as jev-1.13.0 are accepted by the API whether or not they appear here, so this is a discovery aid rather than an allow-list.

func (*Client) SystemOne

func (c *Client) SystemOne(ctx context.Context, req *SystemOneRequest) (*SystemOneResponse, error)

SystemOne evaluates one state against a map of named questions and returns one answer per question.

Every question sees the same state and is evaluated independently and in parallel, so asking several costs little more than asking one. Pack every question about a given state into a single call.

Returns a typed error for every documented failure: AuthenticationError, UnprocessableEntityError, RateLimitError, OverloadedError, and so on. Use errors.As to reach the detail, or errors.Is against the sentinels.

func (*Client) SystemOneAll

func (c *Client) SystemOneAll(ctx context.Context, requests ...*SystemOneRequest) []Result

SystemOneAll runs several requests concurrently and returns their results in input order.

results := client.SystemOneAll(ctx, reqA, reqB, reqC)
for i, r := range results {
    if r.Err != nil { ... }
}

Results are positional: results[i] belongs to requests[i], regardless of which finished first. A failure in one does not affect the others — each carries its own error — because a batch where one bad input discards the other ninety-nine results is not useful.

This is the unbounded form, appropriate for a handful of requests. For thousands, with a worker pool and rate-limit awareness, use the batch API.

func (*Client) SystemOneAsync

func (c *Client) SystemOneAsync(ctx context.Context, req *SystemOneRequest) <-chan Result

SystemOneAsync starts a call and returns a channel that will carry its one result.

a := client.SystemOneAsync(ctx, reqA)
b := client.SystemOneAsync(ctx, reqB)
ra, rb := <-a, <-b

For fanning out over several states without writing the goroutine and channel plumbing each time. Every question about *one* state belongs in a single request — they are evaluated in parallel server-side and cost only the extra tokens — so this is for fanning out over different states, not different questions.

It cannot leak

The channel is buffered with room for the single result, so the goroutine always completes its send and exits even if nobody ever reads. That matters more than it sounds: the obvious unbuffered implementation leaks a goroutine for every abandoned call, and abandoning calls is exactly what happens when a caller takes the first of several results and returns.

The channel is closed after the send, so a range over it terminates and a second receive yields the zero Result rather than blocking.

Canceling ctx does not close the channel early — the in-flight request is canceled, and the resulting error arrives on the channel as a normal result. A caller waiting on the channel is therefore always woken exactly once, whether the call succeeded, failed, or was canceled.

func (*Client) SystemOneBatch

func (c *Client) SystemOneBatch(ctx context.Context, states []any, qs Questions, opts ...BatchOption) BatchResult

SystemOneBatch evaluates the same questions against many states.

result := client.SystemOneBatch(ctx, states, typesafe.Questions{
    "is_spam":  typesafe.Noul{Instructions: "Is this spam?"},
    "sentiment": typesafe.Choice{ ... },
}, typesafe.WithConcurrency(16))

if err := result.Err(); err != nil {
    log.Printf("%v", err) // a summary, not one arbitrary failure
}
for _, item := range result.Items { // input order
    ...
}

It batches states, never questions

Jev ingests the state once and evaluates every question against it in parallel, so a second question costs only its own tokens while a second state costs a whole request. Pack every question about one state into a single call and use this to fan out across states — batching questions would be strictly more expensive and slower.

Per-item error isolation

One failure never aborts the batch. Each item carries its own error, and the rest continue at full speed. A batch where one bad input discards ninety-nine good results is not useful, and retrying the whole thing to recover them is worse.

Canceling ctx stops the batch: items already running finish or fail, and items not yet started are returned with the context error. Every result slot is filled either way, so Items always has one entry per input.

func (*Client) SystemOneBatchSeq

func (c *Client) SystemOneBatchSeq(ctx context.Context, states []any, qs Questions, opts ...BatchOption) iter.Seq2[int, ItemResult]

SystemOneBatchSeq is SystemOneBatch as a stream, yielding results as they complete rather than when the whole batch finishes.

for i, item := range client.SystemOneBatchSeq(ctx, states, qs) {
    if item.Err != nil { ... }
}

Use it when the batch is large enough that holding every response in memory matters, or when downstream work can start on the first result. Results arrive in **completion** order; ItemResult.Index gives the input position.

Breaking out of the range stops the batch: the remaining work is canceled and every goroutine exits before the loop returns.

type ConnectionError

type ConnectionError struct {
	Endpoint string
	Err      error
}

ConnectionError is a transport failure: the request never produced a response. Safe to retry.

func (*ConnectionError) Error

func (e *ConnectionError) Error() string

func (*ConnectionError) Is

func (e *ConnectionError) Is(target error) bool

func (*ConnectionError) Unwrap

func (e *ConnectionError) Unwrap() error

type CostEstimate

type CostEstimate struct {
	// Tokens is the underlying token estimate.
	Tokens TokenEstimate

	// Questions is how many questions the request asks.
	Questions int

	// InputCostUSD is the estimated charge. Output tokens are free.
	//
	// Derived from a conservative token estimate, so it over-reports. Use it
	// to size a workload, never to reconcile a bill.
	InputCostUSD float64

	// RatePerMillionUSD is the price used.
	RatePerMillionUSD float64
}

CostEstimate is a pre-flight guess at what a request will cost in money.

func (CostEstimate) String

func (c CostEstimate) String() string

String renders the cost estimate, always flagged as approximate.

type EntryType

type EntryType = any

EntryType is the union the API uses for every human-readable field:

string | object | array | null

It appears as a question's Instructions, as a Choice option's description, as a Score level's description, and as a Noul's true/false criteria.

Structured values are a supported feature, not a quirk. A schema, a taxonomy, or a database row is already JSON; passing it directly is clearer than flattening it into a sentence, and the model is trained to read structure.

Instructions: "Does this convey urgency?"

Instructions: map[string]any{
    "field":    map[string]any{"name": "amount_due", "unit": "USD"},
    "question": "How large is the `field` value in `source_text`?",
}

A nil EntryType is omitted from the request. The API treats an absent field and an explicit null identically.

type ErrorDetail

type ErrorDetail struct {
	ErrorType string `json:"error_type"`
	Message   string `json:"message"`
}

ErrorDetail is the object form of the "detail" field, returned for errors that are not per-field schema violations.

{"detail": {"error_type": "api_usage_error", "message": "Unknown model: x"}}

Observed error_type values include "authentication_error" and "api_usage_error". The set is not published, so treat it as open.

func (ErrorDetail) String

func (d ErrorDetail) String() string

type Handler

type Handler func(ctx context.Context, req *SystemOneRequest) (*SystemOneResponse, error)

Handler performs one logical SystemOne call.

Interceptors wrap a Handler to observe or alter a call. The signature deliberately matches Client.SystemOne, so the client itself is a Handler and an interceptor chain composes onto it without adaptation.

type Hooks

type Hooks struct {
	// OnRequest runs before a request is sent.
	OnRequest func(ctx context.Context, req *SystemOneRequest)

	// OnResponse runs after a successful call.
	OnResponse func(ctx context.Context, info CallInfo)

	// OnError runs after a failed call.
	OnError func(ctx context.Context, info CallInfo)
}

Hooks are callbacks at fixed points in a call, for code that wants to observe without composing an interceptor.

Every hook is optional. They run synchronously on the calling goroutine, so keep them quick; a slow hook is latency the caller pays. A panic in one is recovered and returned as a *PanicError.

typesafe.WithHooks(typesafe.Hooks{
    OnResponse: func(ctx context.Context, i typesafe.CallInfo) {
        log.Printf("%s took %s, %d tokens", i.RequestID, i.Duration, i.InputTokens)
    },
})

type Interceptor

type Interceptor func(next Handler) Handler

Interceptor wraps a Handler, in the style of a gRPC unary interceptor.

func timing(next typesafe.Handler) typesafe.Handler {
    return func(ctx context.Context, req *typesafe.SystemOneRequest) (*typesafe.SystemOneResponse, error) {
        start := time.Now()
        resp, err := next(ctx, req)
        metrics.Observe(time.Since(start), err)
        return resp, err
    }
}

What an interceptor sees

One *logical* call. Retries, backoff and the circuit breaker all happen beneath the innermost handler, so an interceptor observes a single attempt from the caller's point of view no matter how many HTTP requests it took. That is almost always what instrumentation wants: a latency histogram should record what the caller waited for, not one bar per retry. When you do want per-attempt visibility, use WithRetryObserver, which is the layer below.

func WithLogging

func WithLogging(l *slog.Logger) Interceptor

WithLogging returns an interceptor that logs each call through slog.

A ready-made alternative to WithLogger for callers who want request-level logging composed with their other interceptors rather than emitted from inside the client.

It logs the request id, question count, duration, model and token usage. It does **not** log the state, the questions, or anything derived from them: state is the caller's data and routinely contains personal information, and a logging helper that leaks it by default is worse than none.

type InternalServerError

type InternalServerError struct{ *APIError }

InternalServerError is a 5xx other than 529.

func (*InternalServerError) Is

func (e *InternalServerError) Is(target error) bool

func (*InternalServerError) Unwrap

func (e *InternalServerError) Unwrap() error

type ItemResult

type ItemResult struct {
	// Index is the position in the input slice. Results are returned in input
	// order, so this is redundant there — it matters in the streaming view,
	// where results arrive as they finish.
	Index int

	// State is the input this result belongs to, carried through so a caller
	// handling a failure does not have to index back into the original slice.
	State any

	// Response is the answer set, nil on failure.
	Response *SystemOneResponse

	// Err is the failure, nil on success.
	Err error

	// Duration is how long this item took, retries included.
	Duration time.Duration
}

ItemResult is the outcome for one state in a batch.

type LevelKey

type LevelKey interface {
	~int | ~int8 | ~int16 | ~int32 | ~int64
}

LevelKey constrains a Score's level type: any named integer type.

A Score level's position is its score, so the enum's values must be its indices — the usual iota declaration, lowest level first. TypedScore checks that and reports a mismatch through Validate rather than silently mapping answers to the wrong label.

type Levels

type Levels []EntryType

Levels is the ordered rubric. Index is the score.

type ModelCard

type ModelCard struct {
	// Name is the id or alias accepted by SystemOneRequest.Model.
	Name string `json:"name"`

	// Description says what the model is for.
	Description string `json:"description"`

	// ReleaseDate is when the model or alias was released.
	ReleaseDate string `json:"release_date"`
}

ModelCard describes one model or alias the account may use.

type NotFoundError

type NotFoundError struct{ *APIError }

NotFoundError is a 404.

func (*NotFoundError) Is

func (e *NotFoundError) Is(target error) bool

func (*NotFoundError) Unwrap

func (e *NotFoundError) Unwrap() error

type Noul

type Noul struct {
	// Instructions is the yes/no question or statement to evaluate.
	//
	// Phrase it positively. The model reads instructions literally, and a
	// double negative costs accuracy — see the "Indirection" entry in
	// TypeSafe's published model-jaggedness notes.
	Instructions EntryType

	// Criteria optionally describes what a yes and a no mean. Nil is omitted.
	//
	// Keep it aligned with Instructions. A Noul whose true describes a "no"
	// performs measurably worse than one phrased consistently.
	//
	// A nil True or False is omitted from the request rather than sent as an
	// explicit null. The schema marks both optional and nullable, so the two
	// encodings are equivalent to the server and this SDK emits the shorter
	// one. Contrast Choice, where a nil option description must be sent as
	// null — there the key itself carries the option name, so omitting it
	// would remove the option.
	Criteria *NoulCriteria
}

Noul is a yes/no question, answered with the probability that the answer is yes.

typesafe.Noul{Instructions: "Does this convey urgency?"}

The answer is a single number in [0,1]. Near 1 is a strong yes, near 0 a strong no, and near 0.5 is the model saying it does not know — which is why a Noul answer carries no separate confidence field. The probability is the uncertainty.

Both fields are optional per the API schema, though a Noul with neither says nothing about what to judge; Validate warns about that.

func (Noul) MarshalJSON

func (n Noul) MarshalJSON() ([]byte, error)

MarshalJSON emits the wire form, adding the required type discriminator.

type NoulAnswer

type NoulAnswer struct {
	// Noul is the probability that the answer is yes, in [0,1].
	Noul float64 `json:"noul"`
}

NoulAnswer is the answer to a Noul.

It has no Confidence field, and that is deliberate on the API's part rather than an omission here: the probability already expresses the uncertainty. Code that reaches for a confidence on a Noul is reading a zero that means nothing.

func (NoulAnswer) Bool

func (a NoulAnswer) Bool(threshold float64) bool

Bool collapses the probability to a decision at the given threshold.

The threshold is your policy, not the model's: there is no universally correct value, and the right one depends on the cost of each kind of mistake in your domain. Name the constant you pass rather than inlining a literal.

if ans.Bool(spamThreshold) { ... }

func (NoulAnswer) Type

func (NoulAnswer) Type() string

func (NoulAnswer) Uncertain

func (a NoulAnswer) Uncertain(delta float64) bool

Uncertain reports whether the probability sits within delta of 0.5, the region where the model is expressing genuine ignorance rather than a weak opinion.

if ans.Uncertain(0.1) { routeToHuman() }

type NoulBuilder

type NoulBuilder struct {
	// contains filtered or unexported fields
}

NoulBuilder builds a Noul.

The methods return a copy rather than mutating, so a partially built question can be shared as a base without one caller's additions leaking into another's.

func NewNoul

func NewNoul(instructions EntryType) NoulBuilder

NewNoul starts a Noul.

typesafe.NewNoul("Does this convey urgency?").
    Means("Explicitly time-sensitive", "No urgency expressed")

func (NoulBuilder) Build

func (b NoulBuilder) Build() Noul

Build returns the question.

func (NoulBuilder) MarshalJSON

func (b NoulBuilder) MarshalJSON() ([]byte, error)

MarshalJSON delegates to the built question, so a builder used in place of a question produces byte-identical output.

func (NoulBuilder) Means

func (b NoulBuilder) Means(yes, no EntryType) NoulBuilder

Means describes what a yes and a no mean.

Keep the two aligned with the instructions. TypeSafe documents that a Noul whose true describes a "no" performs measurably worse, and nothing in the resulting probability reveals the mistake.

type NoulCriteria

type NoulCriteria struct {
	// True is what a value near 1 means.
	True EntryType `json:"true,omitempty"`

	// False is what a value near 0 means.
	False EntryType `json:"false,omitempty"`
}

NoulCriteria describes what each end of the 0-to-1 range means.

type Option

type Option func(*config) error

Option configures a Client. Options are applied in order, so a later one overrides an earlier one.

func WithAPIKey

func WithAPIKey(key string) Option

WithAPIKey sets the key explicitly, taking precedence over TYPESAFE_API_KEY.

Prefer the environment variable in deployed code: a key in a source file is a key in your git history.

func WithBaseURL

func WithBaseURL(raw string) Option

WithBaseURL overrides the API root, for a proxy, a gateway, or a test server. Takes precedence over TYPESAFE_BASE_URL.

func WithBudget

func WithBudget(b *Budget) Option

WithBudget attaches a Budget to a client.

Share one Budget across every client in a process that talks to the same account: a per-client budget enforces nothing, since the account is what has the quota.

func WithCircuitBreaker

func WithCircuitBreaker(b *CircuitBreaker) Option

WithCircuitBreaker attaches a breaker, which is off by default.

Share one breaker across every client that talks to the same upstream. A breaker observes consecutive failures, and one that sees only a fraction of the traffic will not trip when it should.

breaker := typesafe.NewCircuitBreaker()
breaker.OnStateChange = func(from, to typesafe.CircuitState) {
    log.Warn("typesafe circuit", "from", from, "to", to)
}
client, err := typesafe.NewClient(typesafe.WithCircuitBreaker(breaker))

When open, SystemOne returns ErrCircuitOpen without sending anything.

func WithContextLimitCheck

func WithContextLimitCheck(enabled bool) Option

WithContextLimitCheck controls whether the client refuses requests whose estimated size exceeds a documented ceiling.

On by default. The estimate is conservative and over-reports by roughly 25%, so it will occasionally refuse a request the server would have accepted. That trade is deliberate — the alternative is a wasted round trip and a 400 whose message does not say which of the two limits was hit — but turn it off if you would rather let the server decide.

func WithDefaultModel

func WithDefaultModel(model string) Option

WithDefaultModel sets the model used when a request leaves Model empty. Takes precedence over TYPESAFE_DEFAULT_MODEL.

func WithHTTPClient

func WithHTTPClient(hc *http.Client) Option

WithHTTPClient supplies your own *http.Client — for a custom transport, connection pool, proxy, or instrumentation.

The client's Timeout is respected as given. If it is zero, WithTimeout (or the 10s default) is applied to a shallow copy, so that supplying a client never silently removes the timeout.

func WithHeader

func WithHeader(key, value string) Option

WithHeader sets a header sent on every request.

Authorization cannot be set this way — it is derived from the API key, and allowing an override here would make credential handling ambiguous.

func WithHooks

func WithHooks(h Hooks) Option

WithHooks installs callbacks at fixed points in a call.

Hooks are implemented as an interceptor, so they nest with any other interceptors in the order everything was declared. Use Hooks for the common case of observing a call; use WithInterceptor when you need to alter one.

func WithInterceptor

func WithInterceptor(interceptors ...Interceptor) Option

WithInterceptor adds interceptors to a client.

They run outermost-first, in the order given, so the first interceptor listed is the first to see a request and the last to see its response — the same nesting as gRPC and as net/http middleware.

client, err := typesafe.NewClient(
    typesafe.WithInterceptor(tracing, metrics, logging),
)
// tracing -> metrics -> logging -> the API

Why this is an option and not a Use method

The roadmap sketched `client.Use(...)`. A method mutating a live client is a data race waiting to happen: Client is documented as safe for concurrent use and is meant to be built once and shared, so a Use call from one goroutine while another is mid-request would be exactly the bug this SDK should not ship. Composing at construction makes the chain immutable, which costs nothing — the set of interceptors is a deployment decision, not a per-call one.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger attaches a structured logger.

The SDK never logs the API key, the Authorization header, or the request state at any level: state is the caller's data and frequently contains personal information. Request ids, statuses, and timings are logged.

func WithMaxRetries

func WithMaxRetries(n int) Option

WithMaxRetries adjusts only the retry count, leaving the rest of the default policy alone. Zero disables retrying.

func WithRequestID

func WithRequestID(fn func() string, header ...string) Option

WithRequestID generates a correlation id for every call.

The id is placed on the context, where hooks and interceptors can read it with RequestIDFrom, and is logged alongside every message about the call.

It is **not** sent to the API by default. TypeSafe documents no request header for a client-supplied id, and inventing one risks colliding with a meaning the server later assigns. Pass a header name to send it anyway:

typesafe.WithRequestID(uuid.NewString)                    // local only
typesafe.WithRequestID(uuid.NewString, "x-correlation-id") // also sent

func WithRetryObserver

func WithRetryObserver(fn func(context.Context, AttemptInfo)) Option

WithRetryObserver registers a callback invoked after each failed attempt that will be retried, before the backoff begins.

Useful for metrics and for surfacing retry behavior in traces. It is called synchronously on the calling goroutine, so keep it quick and do not block.

typesafe.WithRetryObserver(func(ctx context.Context, a typesafe.AttemptInfo) {
    metrics.Retries.WithLabelValues(strconv.Itoa(a.Status)).Inc()
})

The context is the one the call was made with. Tracing integrations need it: without it an attempt cannot be attached to the span of the call it belongs to, and a per-attempt span would be an orphan.

func WithRetryPolicy

func WithRetryPolicy(p RetryPolicy) Option

WithRetryPolicy replaces the whole retry policy.

The default matches the official Python and JavaScript SDKs: two retries, 500ms initial backoff doubling to a 5s ceiling, ±25% jitter, retrying 408, 429 and 5xx, honoring retry-after, within a 30s overall budget.

typesafe.WithRetryPolicy(typesafe.NoRetry())

p := typesafe.DefaultRetryPolicy()
p.MaxRetries = 5
typesafe.WithRetryPolicy(p)

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout bounds each HTTP operation. The default is 10s, matching the official SDKs.

This is per operation, not per call: a single SystemOne may span several operations when retrying, bounded separately by RetryPolicy.Timeout. Whichever fires first wins.

func WithUserAgentSuffix

func WithUserAgentSuffix(suffix string) Option

WithUserAgentSuffix appends an identifier to the SDK's User-Agent, for applications that want their own name in TypeSafe's logs. The SDK's own identity is always sent first and cannot be replaced.

type OptionKey

type OptionKey interface {
	~string
}

OptionKey constrains a Choice's option type: any named string type.

type Topic string

type Options

type Options map[string]EntryType

Options maps an option name to its description. A nil value is sent as JSON null, meaning the option is interpreted by its name alone.

type OverloadedError

type OverloadedError struct {
	*APIError
	RetryAfter time.Duration
}

OverloadedError is a 529: TypeSafe is temporarily overloaded. Distinct from InternalServerError because it is explicitly transient and expected under load, not a defect.

func (*OverloadedError) Is

func (e *OverloadedError) Is(target error) bool

func (*OverloadedError) Unwrap

func (e *OverloadedError) Unwrap() error

type PanicError

type PanicError struct {
	// Value is whatever was passed to panic.
	Value any

	// Stack is the stack trace captured at the moment of recovery.
	Stack []byte
}

PanicError is a panic recovered from an interceptor or a hook.

A panic in instrumentation should not take down a request path. Recovering it and returning it as an error keeps the caller's error handling in charge, and the captured stack points at the interceptor rather than at the recovery site — which is the difference between a two-minute fix and an afternoon.

func (*PanicError) Error

func (e *PanicError) Error() string

func (*PanicError) Unwrap

func (e *PanicError) Unwrap() error

Unwrap returns the panic value when it was itself an error.

type PermissionDeniedError

type PermissionDeniedError struct{ *APIError }

PermissionDeniedError is a 403.

func (*PermissionDeniedError) Is

func (e *PermissionDeniedError) Is(target error) bool

func (*PermissionDeniedError) Unwrap

func (e *PermissionDeniedError) Unwrap() error

type Question

type Question interface {
	// contains filtered or unexported methods
}

Question is one of the three System One primitives: Noul, Choice, or Score.

The interface is sealed — only this package can implement it — so the set of question types stays closed and a type switch over them is exhaustive. RawQuestion is the escape hatch for anything this SDK does not yet model.

type Questions

type Questions = map[string]Question

Questions is a named set of questions, for readability at a call site where the map type would otherwise dominate the line.

type RankedOption

type RankedOption struct {
	Option      string
	Probability float64
}

RankedOption is one option and its probability.

type RankedOptionOf

type RankedOptionOf[T OptionKey] struct {
	Option      T
	Probability float64
}

RankedOptionOf is one typed option and its probability.

type RateLimitError

type RateLimitError struct {
	*APIError

	// RetryAfter is the delay the server asked for, or 0 if it did not send a
	// retry-after header. Zero does not mean "retry immediately" — it means
	// the server expressed no preference, so use your own backoff.
	RetryAfter time.Duration
}

RateLimitError is a 429.

func (*RateLimitError) Is

func (e *RateLimitError) Is(target error) bool

func (*RateLimitError) Unwrap

func (e *RateLimitError) Unwrap() error

type RawQuestion

type RawQuestion map[string]any

RawQuestion is an arbitrary JSON-encodable question body, sent exactly as given.

It exists so a caller is never blocked by this SDK lagging the API: a question shape added after this release can be sent today. Nothing is validated and nothing is defaulted — including the required "type" field, which you must set yourself.

typesafe.RawQuestion{"type": "noul", "instructions": "Is this spam?"}

Prefer the typed primitives. They validate before the request leaves the process, and their answers decode into typed accessors.

type RefWarning

type RefWarning struct {
	// QuestionID is the question containing the reference.
	QuestionID string

	// Path is the reference, without its backticks.
	Path string

	// Reason says which segment failed and why.
	Reason string
}

RefWarning is a backticked state reference in a question that does not resolve against the state being sent.

func (RefWarning) String

func (w RefWarning) String() string

type ResponseValidationError

type ResponseValidationError struct {
	Endpoint  string
	RequestID string
	Body      []byte
	Err       error
}

ResponseValidationError is a 2xx whose body does not match the contract. Getting one means either the API changed or this SDK's reading of it is wrong; both are worth reporting.

func (*ResponseValidationError) Error

func (e *ResponseValidationError) Error() string

func (*ResponseValidationError) Is

func (e *ResponseValidationError) Is(target error) bool

func (*ResponseValidationError) Unwrap

func (e *ResponseValidationError) Unwrap() error

type Result

type Result struct {
	// Response is the answer set, nil on failure.
	Response *SystemOneResponse

	// Err is the failure, nil on success.
	Err error
}

Result is the outcome of an asynchronous call: exactly one of Response and Err is non-nil.

type RetryPolicy

type RetryPolicy struct {
	// MaxRetries is the number of retries *after* the first attempt. 0
	// disables retrying entirely.
	MaxRetries int

	// BackoffInitial is the first delay. Each subsequent delay doubles.
	BackoffInitial time.Duration

	// BackoffMax caps any single delay.
	BackoffMax time.Duration

	// BackoffJitter randomizes each delay by this fraction, in [0,1].
	// 0.25 means the delay lands uniformly within ±25% of its nominal value.
	BackoffJitter float64

	// HTTPStatuses are the response codes worth retrying. The default is
	// {408, 429, 500–599}, which includes 529.
	//
	// 422 is deliberately absent: a request that failed validation will fail
	// it again, identically, and retrying only delays the error.
	HTTPStatuses map[int]bool

	// RespectRetryAfter honors a retry-after header in place of the computed
	// backoff, subject to MaxRetryAfter and the remaining budget.
	RespectRetryAfter bool

	// MaxRetryAfter caps a server-supplied delay. A retry-after longer than
	// this is treated as "too long to wait" and the error is returned
	// immediately, rather than blocking the caller for an unbounded time on
	// the server's say-so.
	MaxRetryAfter time.Duration

	// RetryOnConnection retries when the request produced no response.
	RetryOnConnection bool

	// RetryOnTimeout retries when an attempt exceeded the per-operation
	// timeout. A caller's own canceled context is never retried.
	RetryOnTimeout bool

	// Predicate, when set, overrides every rule above. Return true to retry.
	//
	// It sees the error from the attempt, including the typed API errors, so
	// a caller can express policies this struct does not cover.
	Predicate func(error) bool

	// Timeout bounds the whole operation across every attempt and every
	// backoff. Zero means no overall budget, and only MaxRetries limits the
	// work.
	Timeout time.Duration
	// contains filtered or unexported fields
}

RetryPolicy configures retry behavior.

The zero value is not usable — use DefaultRetryPolicy and adjust, or WithMaxRetries for the common case.

func DefaultRetryPolicy

func DefaultRetryPolicy() RetryPolicy

DefaultRetryPolicy returns the policy the client uses when none is set.

func NoRetry

func NoRetry() RetryPolicy

NoRetry returns a policy that makes exactly one attempt.

type Score

type Score struct {
	// Instructions is what the model should rate. Optional per the schema.
	Instructions EntryType

	// Criteria is the ordered level descriptions, lowest first. Required.
	// Must hold between MinScoreLevels and MaxScoreLevels entries.
	Criteria Levels
}

Score rates the state against ordered levels you define.

typesafe.Score{
    Instructions: "How frustrated is the customer?",
    Criteria:     typesafe.Levels{"Calm", "Frustrated", "Very angry"},
}

Order is meaning: a level's position is its score, starting at zero. The answer is a probability-weighted value that may land between levels.

Do not read a magnitude out of a score

TypeSafe documents that Jev's score levels are weakly calibrated numerically. Thresholding is sound — "is this at least Frustrated?" — but interpolating a real-world quantity between two levels is not. If you need a number, extract it as a Choice over enumerated parts and compute in code.

func (Score) MarshalJSON

func (s Score) MarshalJSON() ([]byte, error)

MarshalJSON emits the wire form, adding the required type discriminator.

type ScoreAnswer

type ScoreAnswer struct {
	// Score is the probability-weighted position across the levels. It may
	// fall between two of them. Treat it as ordinal, not as a magnitude.
	Score float64 `json:"score"`

	// Legend maps each level index to its description. Values carry whatever
	// shape you supplied as criteria — a structured rubric returns structured
	// legend entries, so this is EntryType and not string.
	Legend map[string]EntryType `json:"legend"`

	// Probabilities maps each level index to its probability. They sum to 1.
	Probabilities map[string]float64 `json:"probabilities"`

	// Confidence in [0,1], derived from the distribution's shape.
	Confidence float64 `json:"confidence"`
}

ScoreAnswer is the answer to a Score.

Legend and Probabilities are keyed by the level index as a string — "0", "1", and so on. Use the ordered accessors rather than ranging over the maps: Go map order is random, and these keys are integers wearing string clothes.

func (ScoreAnswer) AtOrAbove

func (a ScoreAnswer) AtOrAbove(level int) float64

AtOrAbove returns the total probability of landing at level or higher.

This is the safe way to threshold a Score: it works on the distribution the model actually produced, rather than on an interpolated magnitude the levels do not support.

if ans.AtOrAbove(2) > 0.8 { escalate() }

func (ScoreAnswer) AtOrBelow

func (a ScoreAnswer) AtOrBelow(level int) float64

AtOrBelow returns the total probability of landing at level or lower.

func (ScoreAnswer) LevelProbabilities

func (a ScoreAnswer) LevelProbabilities() []float64

LevelProbabilities returns the distribution in index order.

func (ScoreAnswer) Levels

func (a ScoreAnswer) Levels() []EntryType

Levels returns the legend in index order.

Keys are parsed as integers rather than sorted as strings. With the current ten-level ceiling the two orderings coincide, so this is insurance rather than a fix — but it is the ordering the data actually means, and it stays correct if the ceiling is ever raised.

func (ScoreAnswer) MostLikely

func (a ScoreAnswer) MostLikely() (int, float64)

MostLikely returns the single highest-probability level and its probability.

This is not always Nearest: a bimodal distribution — heavy at both ends, light in the middle — has a weighted mean that sits in a level the model considers unlikely. When the two disagree, the distribution is telling you the question has more than one reading.

func (ScoreAnswer) Nearest

func (a ScoreAnswer) Nearest() (int, EntryType)

Nearest returns the level index closest to Score, with its description.

Rounds half away from zero. Returns -1 and nil for an empty legend.

func (ScoreAnswer) NumLevels

func (a ScoreAnswer) NumLevels() int

NumLevels is the size of the rubric this answer was scored against.

func (ScoreAnswer) Type

func (ScoreAnswer) Type() string

type ScoreAnswerOf

type ScoreAnswerOf[L LevelKey] struct {
	// Score is the probability-weighted position across the levels. It is
	// ordinal, and may fall between two levels — which is why it stays a
	// float64 rather than becoming an L.
	Score float64

	// Level is the nearest declared level to Score.
	Level L

	// Legend maps each level to its description.
	Legend map[L]EntryType

	// Probabilities maps each level to its probability. They sum to 1.
	Probabilities map[L]float64

	// Confidence in [0,1], derived from the distribution's shape.
	Confidence float64
}

ScoreAnswerOf is a ScoreAnswer whose level indices are values of L.

func TypedScoreAnswer

func TypedScoreAnswer[L LevelKey](r *SystemOneResponse, id string) (ScoreAnswerOf[L], error)

TypedScoreAnswer decodes the answer under id with L as the level type.

Level indices come back as strings on the wire — "0", "1" — and are parsed as integers, not sorted as strings. An index that does not parse is an error rather than a silently dropped level.

func (ScoreAnswerOf[L]) AtOrAbove

func (a ScoreAnswerOf[L]) AtOrAbove(level L) float64

AtOrAbove returns the total probability of landing at level or higher.

This is the safe way to threshold a Score: it works on the distribution the model produced rather than on an interpolated magnitude the levels do not support.

if ans.AtOrAbove(Angry) > 0.8 { escalate() }

func (ScoreAnswerOf[L]) AtOrBelow

func (a ScoreAnswerOf[L]) AtOrBelow(level L) float64

AtOrBelow returns the total probability of landing at level or lower.

func (ScoreAnswerOf[L]) MostLikely

func (a ScoreAnswerOf[L]) MostLikely() (L, float64)

MostLikely returns the single highest-probability level and its probability.

This is not always Level: a bimodal distribution has a weighted mean sitting in a level the model considers unlikely. When the two disagree, the question has more than one reading.

func (ScoreAnswerOf[L]) Untyped

func (a ScoreAnswerOf[L]) Untyped() ScoreAnswer

Untyped returns the plain ScoreAnswer.

type ScoreBuilder

type ScoreBuilder struct {
	// contains filtered or unexported fields
}

ScoreBuilder builds a Score.

func NewScore

func NewScore(instructions EntryType) ScoreBuilder

NewScore starts a Score.

typesafe.NewScore("How frustrated is the customer?").
    Levels("Calm", "Frustrated", "Very angry")

Order is meaning: a level's position is its score, lowest first.

func (ScoreBuilder) Build

func (b ScoreBuilder) Build() Score

Build returns the question.

func (ScoreBuilder) Level

func (b ScoreBuilder) Level(description EntryType) ScoreBuilder

Level appends one level. Call it in order, lowest first.

func (ScoreBuilder) Levels

func (b ScoreBuilder) Levels(descriptions ...string) ScoreBuilder

Levels appends several string levels in order, lowest first.

func (ScoreBuilder) MarshalJSON

func (b ScoreBuilder) MarshalJSON() ([]byte, error)

MarshalJSON delegates to the built question.

type SystemOneRequest

type SystemOneRequest struct {
	// State is the content every question refers to: a string, or any value
	// that marshals to a JSON object or array.
	//
	// Prefer an object for anything beyond a single passage, so each part of
	// the state has a descriptive name. Jev accepts text only — pre-process
	// images, audio, and binaries into text or structured fields first.
	State any `json:"state"`

	// Model selects which model handles the request. Leave empty to use the
	// client's default (see WithDefaultModel and TYPESAFE_DEFAULT_MODEL).
	//
	// Aliases such as jev-latest move without notice. The response reports
	// the versioned id that actually answered.
	Model string `json:"model"`

	// Questions must contain at least one entry.
	Questions map[string]Question `json:"questions"`
}

SystemOneRequest is one evaluation: a single state, and a map of named questions asked about it.

All three fields are required by the API. Question ids are yours to choose; answers come back under the same ids. The documentation notes that ids are not sent to the model and play no part in inference, so name them for your own code's benefit and put nothing load-bearing in them.

func (*SystemOneRequest) CheckReferences

func (r *SystemOneRequest) CheckReferences() []RefWarning

CheckReferences resolves every backticked state reference in every question against the request's state, and reports those that name nothing.

TypeSafe's documentation recommends pointing a question at the relevant part of a structured state by path:

"Does `ticket.messages[0].text` request a refund?"

The server does not resolve these. The model is trained to read them, which means a typo is silent: `ticket.mesage` names nothing, the model answers anyway, and the answer is quietly worse. Nothing in the response indicates that it happened.

This catches it before the request is sent. It is advisory — a reference may legitimately point outside the state, and backticks are also used for ordinary emphasis — so the result is warnings, never an error.

for _, w := range req.CheckReferences() {
    log.Warn(w.String())
}

func (*SystemOneRequest) EstimateCost

func (r *SystemOneRequest) EstimateCost(ratePerMillionUSD float64) CostEstimate

EstimateCost estimates the input charge for a request.

ratePerMillionUSD is the price per million input tokens. Pass 0 to use DefaultInputCostPerMillionTokens, which is the published Jev 1.13 rate — but pass your own if you have negotiated terms, because a hard-coded price is wrong the moment anyone's contract differs.

cost := req.EstimateCost(0)
log.Printf("%s", cost) // never present this as authoritative

func (*SystemOneRequest) EstimateTokens

func (r *SystemOneRequest) EstimateTokens() TokenEstimate

EstimateTokens returns a conservative estimate of the request's input cost.

est := req.EstimateTokens()
if err := est.Err(); err != nil {
    return err // caught before a round trip
}

Both documented ceilings are checked. The single-question one is the trap: a request comfortably inside the 64k whole-request budget can still be rejected for a state plus one long question exceeding 32k, and nothing in the error the server returns explains which limit was hit.

func (*SystemOneRequest) Validate

func (r *SystemOneRequest) Validate() ([]Warning, error)

Validate checks a request before it is sent.

The error is returned for anything the server is known to reject; every such rule below was confirmed against the live API, not inferred from the docs. Warnings are for the legal-but-suspicious, and are advisory.

warnings, err := req.Validate()
if err != nil {
    return err
}
for _, w := range warnings {
    log.Warn(w.String())
}

SystemOne applies the error half automatically. Call this yourself when you want the warnings, or to check a request you build ahead of time.

type SystemOneResponse

type SystemOneResponse struct {
	// Model is the versioned model id that answered, which may differ from
	// the alias you requested. Log it: aliases move, and a confidence
	// threshold tuned against one version is not calibrated for another.
	Model string `json:"model"`

	// Answers holds one answer per question id.
	//
	// Phase 1 leaves these undecoded. The typed accessors — Noul, Choice,
	// Score — arrive in Phase 2, at which point the raw form remains
	// available for anything this SDK does not model.
	Answers map[string]json.RawMessage `json:"answers"`

	// Usage is the token accounting for this request.
	Usage Usage `json:"usage"`
}

SystemOneResponse is one answer per question, keyed by the ids you sent.

func (*SystemOneResponse) All

func (r *SystemOneResponse) All() map[string]Answer

All decodes every answer, keyed by question id.

An answer whose type this SDK does not recognize is skipped rather than failing the whole call, so a new primitive added server-side degrades to "not visible here" instead of breaking existing code. Reach it through SystemOneResponse.Answers, which always holds the raw JSON.

func (*SystemOneResponse) Answer

func (r *SystemOneResponse) Answer(id string) (Answer, error)

Answer decodes the answer under id into its concrete type, discovered from the wire discriminator.

Use this when the question type is not known statically; use Noul, Choice, or Score when it is.

func (*SystemOneResponse) Choice

func (r *SystemOneResponse) Choice(id string) (ChoiceAnswer, error)

Choice decodes the answer under id as a ChoiceAnswer.

func (*SystemOneResponse) Choices

func (r *SystemOneResponse) Choices() map[string]ChoiceAnswer

Choices decodes every Choice answer. Answers of other types are omitted.

func (*SystemOneResponse) Confidence

func (r *SystemOneResponse) Confidence(id string) (float64, bool)

Confidence returns the confidence of the answer under id.

The second result is false for a Noul, which carries no confidence — the probability itself expresses the uncertainty. Callers that treat a missing confidence as zero would read every Noul as maximally uncertain, so this reports absence rather than substituting a number.

func (*SystemOneResponse) Noul

func (r *SystemOneResponse) Noul(id string) (NoulAnswer, error)

Noul decodes the answer under id as a NoulAnswer.

Returns ErrNoSuchAnswer if no such answer exists, or ErrWrongAnswerType if it is a different primitive. Both are returned, never panicked: a wrong accessor is a coding mistake worth an error, not a crashed process.

func (*SystemOneResponse) Nouls

func (r *SystemOneResponse) Nouls() map[string]NoulAnswer

Nouls decodes every Noul answer, mirroring the Python SDK's grouped accessors. Answers of other types are omitted.

func (*SystemOneResponse) Score

func (r *SystemOneResponse) Score(id string) (ScoreAnswer, error)

Score decodes the answer under id as a ScoreAnswer.

func (*SystemOneResponse) Scores

func (r *SystemOneResponse) Scores() map[string]ScoreAnswer

Scores decodes every Score answer. Answers of other types are omitted.

type TimeoutError

type TimeoutError struct {
	Endpoint string
	Err      error
}

TimeoutError is a deadline exceeded while waiting for a response.

A caller's own canceled context surfaces as context.Canceled, not as this: deliberate cancellation is not a timeout, and conflating them makes shutdown paths log spurious failures.

func (*TimeoutError) Error

func (e *TimeoutError) Error() string

func (*TimeoutError) Is

func (e *TimeoutError) Is(target error) bool

func (*TimeoutError) Unwrap

func (e *TimeoutError) Unwrap() error

type TokenEstimate

type TokenEstimate struct {
	// State is the estimated cost of the state alone.
	State int

	// PerQuestion is the estimated cost of each question, by id.
	PerQuestion map[string]int

	// Overhead is the fixed per-request cost, independent of content.
	Overhead int

	// Total is the whole request: compare against MaxContextTokens.
	Total int

	// LongestSingle is state plus the most expensive single question:
	// compare against MaxSingleQuestionTokens.
	LongestSingle int

	// LongestQuestionID names the question driving LongestSingle.
	LongestQuestionID string

	// ExceedsTotal reports that Total is over MaxContextTokens.
	ExceedsTotal bool

	// ExceedsSingle reports that LongestSingle is over
	// MaxSingleQuestionTokens. A request can pass ExceedsTotal and fail this.
	ExceedsSingle bool

	// Approximate is always true.
	Approximate bool
}

TokenEstimate is a conservative guess at what a request will cost.

It is an estimate, and it says so

TypeSafe publishes no tokenizer. This is fitted from measurements against the live API: token count tracks serialized JSON size closely and linearly, with a large fixed overhead of roughly 250 tokens per call regardless of content. The model carries 25% headroom on the marginal rate so that it errs high rather than low — an estimate that is sometimes under is worse than none, because it fails exactly when a request is near the limit.

Expect it to over-report by roughly a quarter. Do not use it for billing.

func (TokenEstimate) Err

func (e TokenEstimate) Err() error

Err returns a descriptive error when a limit is crossed, or nil.

The message names which ceiling, by how much, and which question is responsible — because "request too large" sends the reader back to count bytes by hand.

func (TokenEstimate) String

func (e TokenEstimate) String() string

String renders the estimate for a terminal.

func (TokenEstimate) WouldExceedLimits

func (e TokenEstimate) WouldExceedLimits() bool

WouldExceedLimits reports whether either documented ceiling is crossed.

type TypedChoiceQuestion

type TypedChoiceQuestion[T OptionKey] struct {
	Choice
	// contains filtered or unexported fields
}

TypedChoiceQuestion is a Choice whose options are values of T.

It embeds Choice, so it is a Question, it marshals identically, and every plain-Choice field remains reachable.

func TypedChoice

func TypedChoice[T OptionKey](instructions EntryType, opts ...TypedOption[T]) TypedChoiceQuestion[T]

TypedChoice builds a Choice whose options are values of T.

type Topic string
const (
    TopicBilling   Topic = "billing"
    TopicTechnical Topic = "technical"
    TopicOther     Topic = "other"
)

q := typesafe.TypedChoice[Topic]("Which team should handle this?",
    typesafe.OptionOf(TopicBilling, "Invoices, charges, refunds"),
    typesafe.OptionOf(TopicTechnical, "Bugs, outages, API errors"),
    typesafe.OptionOf(TopicOther, nil),
)

A duplicate option name is a programming error that would silently drop an option from the request; Validate reports it rather than sending a question with fewer options than the code appears to declare.

func (TypedChoiceQuestion[T]) Answer

Answer decodes the answer under id as a ChoiceAnswerOf[T], and checks it against this question's declared option set.

ans, err := q.Answer(resp, "department")
switch ans.Choice {      // Topic, not string
case TopicBilling:   ...
case TopicTechnical: ...
}

An answer naming an option the question did not declare is an error. That can only happen if the request and the type have drifted apart, and it is exactly the case a switch would handle by silently falling through.

func (TypedChoiceQuestion[T]) Options

func (q TypedChoiceQuestion[T]) Options() []T

Options returns the declared option set, in declaration order.

type TypedLevel

type TypedLevel[L LevelKey] struct {
	// Level is the rubric level, as a value of your named type. Its numeric
	// value must equal its position, lowest first.
	Level L

	// Description says what this level means.
	Description EntryType
}

TypedLevel is one level of a TypedScore: a typed level and its description.

func LevelOf

func LevelOf[L LevelKey](level L, description EntryType) TypedLevel[L]

LevelOf builds one typed level.

typesafe.LevelOf(FrustrationCalm, "No sign of irritation")

type TypedOption

type TypedOption[T OptionKey] struct {
	// Name is the option, as a value of your named type.
	Name T

	// Description says when the option applies. nil is sent as JSON null,
	// meaning the option is read by its name alone.
	Description EntryType
}

TypedOption is one option of a TypedChoice: a typed name and its description.

func OptionOf

func OptionOf[T OptionKey](name T, description EntryType) TypedOption[T]

OptionOf builds one typed option.

typesafe.OptionOf(TopicBilling, "Invoices, charges, refunds")
typesafe.OptionOf(TopicOther, nil) // read by its name alone

Named OptionOf rather than Option because Option is already this package's client-configuration type (WithAPIKey and the rest return one).

type TypedScoreQuestion

type TypedScoreQuestion[L LevelKey] struct {
	Score
	// contains filtered or unexported fields
}

TypedScoreQuestion is a Score whose levels are values of L.

func TypedScore

func TypedScore[L LevelKey](instructions EntryType, levels ...TypedLevel[L]) TypedScoreQuestion[L]

TypedScore builds a Score whose levels are values of L.

type Frustration int
const (
    Calm Frustration = iota
    Annoyed
    Angry
)

q := typesafe.TypedScore[Frustration]("How frustrated is the customer?",
    typesafe.LevelOf(Calm, "No sign of irritation"),
    typesafe.LevelOf(Annoyed, "Clearly unhappy, still civil"),
    typesafe.LevelOf(Angry, "Hostile, threatening to leave"),
)

Position is meaning: the first level scores 0, the second 1, and so on. The enum's values must therefore match their positions, which an ordinary iota declaration gives you. Validate reports a mismatch — passing levels out of order would map every answer to the wrong label, and nothing in the resulting score would reveal it.

func (TypedScoreQuestion[L]) Answer

Answer decodes the answer under id as a ScoreAnswerOf[L].

func (TypedScoreQuestion[L]) Levels

func (q TypedScoreQuestion[L]) Levels() []L

Levels returns the declared levels, in rubric order.

type UnprocessableEntityError

type UnprocessableEntityError struct{ *APIError }

UnprocessableEntityError is a 422: the request body failed validation.

This is never retried — the same bytes will fail the same way. Read Detail to find out which field the server rejected.

func (*UnprocessableEntityError) Is

func (e *UnprocessableEntityError) Is(target error) bool

func (*UnprocessableEntityError) Unwrap

func (e *UnprocessableEntityError) Unwrap() error

type Usage

type Usage struct {
	InputTokens  int `json:"input_tokens"`
	OutputTokens int `json:"output_tokens"`
}

Usage reports token accounting for a request. Only input tokens are billed; TypeSafe does not charge for output.

type ValidationDetail

type ValidationDetail struct {
	// Loc is the path to the offending field, e.g.
	// ["body", "questions", "frustration", "criteria"]. Elements are strings
	// or integers.
	Loc []any `json:"loc"`

	// Msg is the server's human-readable explanation.
	Msg string `json:"msg"`

	// Type is the machine-readable error kind, e.g. "missing".
	Type string `json:"type"`

	// Input is the value that failed validation, when the server includes it.
	Input any `json:"input,omitempty"`

	// Ctx carries kind-specific context, when the server includes it.
	Ctx map[string]any `json:"ctx,omitempty"`
}

ValidationDetail is one entry from a 422 response body. The server reports a path to the offending field, so a caller can see precisely which question and which field were rejected rather than re-reading their whole request.

func (ValidationDetail) Path

func (d ValidationDetail) Path() string

Path renders Loc as a dotted path with bracketed indices, e.g. "body.questions.frustration.criteria" or "body.questions.q.criteria[0]".

func (ValidationDetail) String

func (d ValidationDetail) String() string

type Warning

type Warning struct {
	// QuestionID is the question this concerns, or "" for the request itself.
	QuestionID string

	// Message describes what looks wrong.
	Message string
}

Warning is something legal that is probably not what you meant.

Warnings never block a request. The rule this SDK follows: if the server would accept it, we send it. Rejecting a request the API would have answered is a worse failure than passing through something odd, because the caller can see an odd answer and cannot see a request we refused to make.

func (Warning) String

func (w Warning) String() string

Directories

Path Synopsis
Package cassette records real API interactions to a file and replays them offline, so that an integration test needs a live key exactly once.
Package cassette records real API interactions to a file and replays them offline, so that an integration test needs a live key exactly once.
cmd
typesafe command
Command typesafe is a command-line client for the TypeSafe System One API.
Command typesafe is a command-line client for the TypeSafe System One API.
typesafe-gen command
Command typesafe-gen generates typed TypeSafe questions from Go enums.
Command typesafe-gen generates typed TypeSafe questions from Go enums.
Package decision composes System One answers into decisions.
Package decision composes System One answers into decisions.
integrations
echo module
fiber module
gin module
langchaingo module
mcp module
nethttp module
temporal module
internal
canonical
Package canonical produces a deterministic byte form of a JSON value.
Package canonical produces a deterministic byte form of a JSON value.
fixtures
Package fixtures locates and loads the golden contract fixtures.
Package fixtures locates and loads the golden contract fixtures.
genexample
Package genexample is the worked example typesafe-gen generates from.
Package genexample is the worked example typesafe-gen generates from.
statepath
Package statepath resolves the backticked state references that TypeSafe's documentation recommends writing inside question instructions.
Package statepath resolves the backticked state references that TypeSafe's documentation recommends writing inside question instructions.
tokens
Package tokens estimates how many input tokens a request will cost.
Package tokens estimates how many input tokens a request will cost.
lint module
typesafeotel module
typesafeprom module
Package typesafetest provides doubles for testing code that calls the TypeSafe API: a programmable HTTP server, canned responses for every documented failure, and assertion helpers.
Package typesafetest provides doubles for testing code that calls the TypeSafe API: a programmable HTTP server, canned responses for every documented failure, and assertion helpers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL